# Upgrading

Every release is a drop-in replacement unless this page says otherwise. Replace
the binary, rebuild, and the site you had is the site you get.

Where that is not true — where a default changed, a flag moved, or a setting
started meaning something new — it is listed below, with the version it landed
in and what to do about it. Of the 66 releases so far, 53 need nothing at
all, which is why this page is organised by **what changed**, not by release
number: a section per version would be mostly empty and would bury the handful
that matter.

## The compatibility promise

A setting that stops being the right way to do something keeps working. It is
not removed in the release that replaces it — it starts warning in the build
log, naming what to use instead, and stays accepted for roughly five releases
after that. So an upgrade never fails silently: either it just works, or the
build tells you what to change while still producing your site.

Deprecation warnings are worth reading rather than filtering out. They are the
only notice you get, and they expire.

## How to upgrade

The install method decides the command; none of them need the site touched.

```bash
# One-liner (Linux/macOS) — same command installs and upgrades
curl -sSL https://raw.githubusercontent.com/spagu/ssg/main/install.sh | bash

# Debian/Ubuntu, if you added the apt repository
sudo apt update && sudo apt install --only-upgrade ssg

# macOS
brew upgrade ssg

# Docker — pull the tag you want rather than reusing a cached latest
docker pull ghcr.io/spagu/ssg:latest
```

Confirm what you actually have before and after, because a stale binary earlier
in `PATH` is the most common reason an upgrade appears to do nothing:

```bash
ssg --version
```

Full release notes for every version live in
[CHANGELOG.md](https://github.com/spagu/ssg/blob/main/CHANGELOG.md). This page
covers only the steps; the changelog covers everything else.

## What applies to you

<div class="upgrade-picker" hidden>
  <label for="upgrade-from"><strong>I am upgrading from</strong></label>
  <select id="upgrade-from">
    <option value="">— choose your current version —</option>
    <optgroup label="1.8.x">
      <option value="1.8.59">1.8.59 — 2026-09-08</option>
      <option value="1.8.58">1.8.58 — 2026-09-08</option>
      <option value="1.8.57">1.8.57 — 2026-09-07</option>
      <option value="1.8.56">1.8.56 — 2026-09-06</option>
      <option value="1.8.55">1.8.55 — 2026-09-02</option>
      <option value="1.8.54">1.8.54 — 2026-09-01</option>
      <option value="1.8.53">1.8.53 — 2026-09-01</option>
      <option value="1.8.52">1.8.52 — 2026-08-27</option>
      <option value="1.8.51">1.8.51 — 2026-08-25</option>
      <option value="1.8.50">1.8.50 — 2026-08-25</option>
      <option value="1.8.49">1.8.49 — 2026-08-24</option>
      <option value="1.8.48">1.8.48 — 2026-08-24</option>
      <option value="1.8.47">1.8.47 — 2026-08-21</option>
      <option value="1.8.46">1.8.46 — 2026-08-19</option>
      <option value="1.8.45">1.8.45 — 2026-08-19</option>
      <option value="1.8.44">1.8.44 — 2026-08-18</option>
      <option value="1.8.43">1.8.43 — 2026-08-17</option>
      <option value="1.8.42">1.8.42 — 2026-08-17</option>
      <option value="1.8.41">1.8.41 — 2026-08-16</option>
      <option value="1.8.40">1.8.40 — 2026-08-16</option>
      <option value="1.8.39">1.8.39 — 2026-08-16</option>
      <option value="1.8.38">1.8.38 — 2026-08-16</option>
      <option value="1.8.37">1.8.37 — 2026-08-16</option>
      <option value="1.8.36">1.8.36 — 2026-08-16</option>
      <option value="1.8.35">1.8.35 — 2026-08-15</option>
      <option value="1.8.34">1.8.34 — 2026-08-14</option>
      <option value="1.8.33">1.8.33 — 2026-08-14</option>
      <option value="1.8.32">1.8.32 — 2026-08-14</option>
      <option value="1.8.31">1.8.31 — 2026-08-13</option>
      <option value="1.8.30">1.8.30 — 2026-08-12</option>
      <option value="1.8.29">1.8.29 — 2026-08-12</option>
      <option value="1.8.28">1.8.28 — 2026-08-12</option>
      <option value="1.8.27">1.8.27 — 2026-08-12</option>
      <option value="1.8.26">1.8.26 — 2026-08-11</option>
      <option value="1.8.25">1.8.25 — 2026-08-11</option>
      <option value="1.8.24">1.8.24 — 2026-08-09</option>
      <option value="1.8.23">1.8.23 — 2026-08-08</option>
      <option value="1.8.22">1.8.22 — 2026-08-08</option>
      <option value="1.8.21">1.8.21 — 2026-08-07</option>
      <option value="1.8.20">1.8.20 — 2026-08-07</option>
      <option value="1.8.19">1.8.19 — 2026-08-06</option>
      <option value="1.8.18">1.8.18 — 2026-08-04</option>
      <option value="1.8.17">1.8.17 — 2026-08-04</option>
      <option value="1.8.16">1.8.16 — 2026-08-02</option>
      <option value="1.8.15">1.8.15 — 2026-08-01</option>
      <option value="1.8.14">1.8.14 — 2026-08-01</option>
      <option value="1.8.13">1.8.13 — 2026-07-24</option>
      <option value="1.8.12">1.8.12 — 2026-07-22</option>
      <option value="1.8.11">1.8.11 — 2026-07-22</option>
      <option value="1.8.10">1.8.10 — 2026-07-21</option>
      <option value="1.8.9">1.8.9 — 2026-07-21</option>
      <option value="1.8.8">1.8.8 — 2026-07-20</option>
      <option value="1.8.7">1.8.7 — 2026-07-15</option>
      <option value="1.8.6">1.8.6 — 2026-07-15</option>
      <option value="1.8.5">1.8.5 — 2026-07-15</option>
      <option value="1.8.4">1.8.4 — 2026-07-14</option>
      <option value="1.8.3">1.8.3 — 2026-07-14</option>
      <option value="1.8.2">1.8.2 — 2026-07-11</option>
      <option value="1.8.1">1.8.1 — 2026-07-10</option>
      <option value="1.8.0">1.8.0 — 2026-07-10</option>
    </optgroup>
    <optgroup label="1.7.x">
      <option value="1.7.15">1.7.15 — 2026-07-09</option>
      <option value="1.7.14">1.7.14 — 2026-07-08</option>
      <option value="1.7.13">1.7.13 — 2026-04-08</option>
      <option value="1.7.12">1.7.12 — 2026-04-08</option>
      <option value="1.7.11">1.7.11 — 2026-04-06</option>
      <option value="1.7.10">1.7.10 — 2026-04-06</option>
      <option value="1.7.9">1.7.9 — 2026-04-06</option>
      <option value="1.7.8">1.7.8 — 2026-04-06</option>
      <option value="1.7.7">1.7.7 — 2026-04-01</option>
      <option value="1.7.6">1.7.6 — 2026-04-01</option>
      <option value="1.7.4">1.7.4 — 2026-04-01</option>
      <option value="1.7.3">1.7.3 — 2026-03-31</option>
      <option value="1.7.2">1.7.2 — 2026-03-31</option>
      <option value="1.7.1">1.7.1 — 2026-03-30</option>
      <option value="1.7.0">1.7.0 — 2026-03-05</option>
    </optgroup>
    <optgroup label="1.6.x">
      <option value="1.6.2">1.6.2 — 2026-03-05</option>
      <option value="1.6.1">1.6.1 — 2026-03-05</option>
      <option value="1.6.0">1.6.0 — 2026-03-05</option>
    </optgroup>
    <optgroup label="1.5.x">
      <option value="1.5.4">1.5.4 — 2026-02-04</option>
      <option value="1.5.3">1.5.3 — 2026-02-04</option>
      <option value="1.5.2">1.5.2 — 2026-02-03</option>
      <option value="1.5.1">1.5.1 — 2026-02-03</option>
      <option value="1.5.0">1.5.0 — 2026-02-03</option>
    </optgroup>
    <optgroup label="1.4.x">
      <option value="1.4.9">1.4.9 — 2026-01-29</option>
      <option value="1.4.8">1.4.8 — 2026-01-29</option>
      <option value="1.4.7">1.4.7 — 2026-01-29</option>
      <option value="1.4.6">1.4.6 — 2026-01-23</option>
      <option value="1.4.5">1.4.5 — 2026-01-23</option>
      <option value="1.4.4">1.4.4 — 2026-01-18</option>
      <option value="1.4.3">1.4.3 — 2026-01-18</option>
      <option value="1.4.2">1.4.2 — 2026-01-18</option>
      <option value="1.4.1">1.4.1 — 2026-01-18</option>
      <option value="1.4.0">1.4.0 — 2026-01-18</option>
    </optgroup>
    <optgroup label="1.3.x">
      <option value="1.3.4">1.3.4 — 2026-01-17</option>
      <option value="1.3.3">1.3.3 — 2026-01-17</option>
      <option value="1.3.2">1.3.2 — 2026-01-17</option>
      <option value="1.3.1">1.3.1 — 2026-01-17</option>
      <option value="1.3.0">1.3.0 — 2026-01-17</option>
    </optgroup>
    <optgroup label="1.2.x">
      <option value="1.2.0">1.2.0 — 2026-01-16</option>
    </optgroup>
    <optgroup label="1.1.x">
      <option value="1.1.0">1.1.0 — 2026-01-13</option>
    </optgroup>
    <optgroup label="1.0.x">
      <option value="1.0.0">1.0.0 — 2026-01-13</option>
    </optgroup>
  </select>
  <p class="upgrade-count" role="status"></p>
</div>

Pick your current version and the list below narrows to the steps between it and
today. Without JavaScript the selector stays hidden and every step is shown,
which is a longer read but never a wrong one.

<div class="upgrade-steps">

<!--
  The version numbers below are historical facts: each names the release a
  change actually landed in, and the selector filters on data-since. A bulk
  find-replace during a version bump has rewritten them once already, which made
  the guide claim the wrong release and the filter show the wrong steps. Add new
  entries; never renumber existing ones.
-->


<div class="upgrade-step" data-since="1.8.60">

### 1.8.60 — `--watch` rebuilds less, and one external-source mode is stricter

**`outputs:` grew a second shape.** The flat list you have means exactly what it
meant. A map per content type is new, and so are the `txt` format and
`outputs_custom`. `markdown_publish: true` is unchanged.

**The new frontmatter keys are additive.** `relations:`, `version:`,
`version_of:` and `outputs:` do nothing unless a file uses them, and they stay
readable at `.Extra.*` as well, so a template already reading `.Extra.version`
keeps working. One behaviour to know if you adopt versions: superseded pages
gain a canonical pointing at the latest.

**Render hooks are opt-in by config.** Without `render_hooks:` the Markdown
renderer is untouched and the output is byte for byte what it was. With one, the
named node kind is rendered by your template instead. See docs/RENDER_HOOKS.md.

**Components are opt-in by a directory.** If your project has a `components/`
directory it is now loaded (see docs/COMPONENTS.md); if it does not, nothing
changes. One thing to know either way: content that quotes `{{< something >}}`
is safe — an unknown call is left as written, and calls inside code blocks are
not calls.

**A new flag, off by default.** `ssg --http --watch --edit` opens the browser
editor (docs/EDITING.md). Nothing changes for a build that does not pass it: the
theme attributes it uses are stripped from every published page, which the
golden corpora check.

**One validation is stricter, deliberately.** An external source with
`mode: content` that is not a CMS used to load and silently stay data. It now
needs a `content_map` and is refused without one, because with GO-098 that mode
finally does what it says. If you have such a source, either add the mapping
(docs/EXTERNAL_SOURCES.md, "Records as pages") or drop the `mode: content` line
that was doing nothing.

**`--watch` now rebuilds incrementally.** After its hash check says something
changed, it renders only the pages that change can reach, instead of the whole
site. The output is the same tree either way — a property test asserts that byte
for byte over random sequences of edits — so there is nothing to change in a
project. What you will notice is a line naming the decision, either
`🧩 Incremental: 1 changed file(s) affect 1 output(s)` or `🧩 Full build: …`
with the reason.

A build is full whenever the answer is not certain: a changed template or
partial, a changed config, a file the last build never saw, `--clean`, or
content from MDDB, external sources or a CMS import. `ssg graph` prints which of
those applies to your site, and `ssg --incremental` asks for the same narrowing
in a one-shot build. The graph lives in `.ssg-cache/graph/`, is listed by
`ssg cache stats`, and deleting it costs one full build. See docs/INCREMENTAL.md.

If you script around the watcher's output, note that `ssg profile page /url/`
now ends with a `built from` list read from that graph, where it used to say the
dependency tree required GO-094.

**Otherwise purely additive.** `ssg config view|set|unset` reads and edits the config from
the command line. One behaviour change worth knowing: the MCP `designer_config_set`
tool now writes through the same editor, which splices the change into the text
instead of re-encoding the document — so an edited config keeps its blank lines
and comment alignment where it used to lose them. Nothing about which keys that
tool may write has changed.

`--profile` (or `profile: text`) reports where the build's
time went, and `--profile=json` writes `build-profile.json` beside the project
for `ssg profile page /url/` and for CI to archive. Off by default, and it
changes no output.

`ssg mcp` gains `site_dependencies`, which answers what a page was built from
or what editing a file rebuilds. It reads `.ssg-cache/graph/` rather than the
published site graph, so it needs no configuration beyond a completed build,
and dependencies stay out of the public `site-graph.json` on purpose.

`site_graph: true` publishes a `site-graph.json` describing
the whole site and adds `site_*` tools to `ssg mcp`; off by default. Under the
hood `routes.json` and `llms.txt` are now derived from that same model — their
output is byte-identical, which the golden corpora check.

A content page may now carry `paginate:` in its frontmatter
and be written as `/slug/`, `/slug/page/2/`, … through its own layout, with
`.Pager` and `.Posts` in scope. A page that does not set it renders exactly as
before, and `paginate` in the site config keeps meaning what it meant for the
generated listings. The golden baseline is byte-identical.

One thing to know if you write a shared layout: `.Pager` is nil on an ordinary
page, so guard it — `{{ with .Pager }}…{{ end }}` — the way `category.html`
already does.

</div>

<div class="upgrade-step" data-since="1.8.59">

### 1.8.59 — shorter meta descriptions, and comments that survive minification

**Your meta descriptions may get shorter, and that is the fix.** A page with no
`excerpt:` of its own gets one derived from its first paragraph, and that was
capped at 200 characters while ssg's own `check_meta` flags anything over 160.
The cap is 160 now, so the default path passes the default check. If you were
relying on the longer form, set `excerpt:` explicitly — it is unchanged and
still wins.

**If you emit a host directive through `safeHTML`, check it is in your output.**
It was being deleted by `minify_html`, so `<!--email_off-->` and friends worked
locally and vanished in production. They survive now. If you worked around it
with `<!--htmlmin:ignore-->`, that still works and you can drop it. A host that
reads a directive ssg does not know about:

```yaml
minify_html_keep_comments: [acme:begin, acme:end]
```

**You can finally declare a social card without a migration.** `marketing:` is a
config block:

```yaml
marketing:
  og_image: "/img/card.png"     # 1200×630
  og_site_name: "Example"
```

Values win over anything `ssg migrate` recorded, field by field. If you use
`ssgtheme`, it now emits `og:image` and `twitter:image` from a page's
`featured_image` or that default — **and drops `twitter:card` to `summary` when
there is no image anywhere**, which is a visible change if you had it set to
`summary_large_image` with nothing to show. Set `marketing.og_image` to get the
large card back.

</div>

<div class="upgrade-step" data-since="1.8.58">

### 1.8.58 — two settings start doing what they said, and the sitemap can be split

**Nothing to configure.** Both changes make an existing setting take effect, so
the only sites that see a difference are the ones that had already asked for it.

**`taxonomies: { tag: { sitemap: false } }` now works.** It parsed and did
nothing on the built-in `category` and `tag`, so if you set it and moved on,
those archives have been in your `sitemap.xml` ever since — and they will
disappear from it on this build. That is the behaviour you asked for; if you had
worked around the old bug by making the archives indexable, you can undo that.
`archive` is still the separate switch that decides whether an archive is written
at all, and `author` is not configurable this way.

**A site using `series:` gains sitemap entries.** Series archives have always
been written and linked, and were in no sitemap — the only built-in taxonomy
with no code path listing it. They appear now, one entry per series, honouring
`taxonomies: { series: { sitemap: false } }` if you would rather they did not.
Nothing else about them changes.

**Sub-sitemaps are new and entirely opt-in.** `sitemaps:` declares files with
their own selection and turns `sitemap.xml` into the index; `sitemap_max_urls`
lowers the per-file ceiling. A site that sets neither writes exactly the file it
wrote before, byte for byte. The one behaviour that changed without asking: a
site whose sitemap exceeded **50,000 URLs** was publishing a file crawlers
reject, and is now split and indexed instead. See
[CONFIGURATION.md](CONFIGURATION.md#splitting-the-sitemap-sitemaps-sitemap_max_urls).

**A `static_sources` entry with `sitemap: true` now carries a `<lastmod>`.** It
never did, because the date came only from git. It now falls back to the source
file's modification time.

**A new warning may appear if you use `lastmod_from_git`:**

```text
   ⚠️  lastmod_from_git is on, but git could not date app/index.html — using the file's modification time instead
      git has to be on PATH; a strictly confined snap cannot see the host's copy.
```

If you see it, your sitemap dates have **not** been coming from Git — they were
falling back all along, silently. The usual cause is running ssg from the snap,
which cannot see `git` at all. Switch to the DEB, the tarball or the Docker image
on a site that depends on commit dates.

</div>

<div class="upgrade-step" data-since="1.8.57">

### 1.8.57 — one file may change shape, and three things become possible

**Nothing to configure**, and nothing that was working stops.

**`_routes.json` may lose rules — deliberately.** Cloudflare rejects a routes
file whose rules overlap, so a rule already covered by a splat in the same list
is now folded into it: `/api/contact` and `/api/consent/*` beside `/api/*` are
published as `/api/*` alone. The build names what it absorbed. If your site
deployed before, it still deploys and routes identically; if it did **not**
deploy — *"Overlapping rules found"* — this is the fix, and you can drop the
workaround of repeating every specific route on the middleware worker.

**Three things a theme could not do before.** All additive; no existing template
changes meaning.

- `int` and `float` convert a string to a number, so `[reviews limit="3"]` can
  feed `first (int .Attrs.limit)`. `add`/`sub`/`mul`/`div` also accept a numeric
  string now, so `{{ add 1 .Attrs.offset }}` works with no conversion.
- A shortcode can read `.SiteData` (the `data/` files) and `.ExternalData`, call
  the theme's partials with `{{ template "card" . }}`, and use the collection
  helpers. **`.Data` inside a shortcode is unchanged** — it is still the
  `shortcodes:` entry's own `data:` map, which is why the site-wide tree arrived
  under a different name.
- A `static_sources` entry takes `sitemap: true` (and an optional `priority`),
  which lists a verbatim HTML document in `sitemap.xml`. Opt-in: nothing you
  publish today is listed unless you ask.

**If a comment you wrote is missing from your output, it always was.**
`html/template` strips HTML comments while parsing, which silently removed
Cloudflare's `<!--email_off-->`, SSI/ESI markers and anything similar. This
release documents it rather than changing it; emit such a comment through
`safeHTML`:

```gotemplate
{{ "<!--email_off-->" | safeHTML }}
```

</div>

<div class="upgrade-step" data-since="1.8.56">

### 1.8.56 — your sitemap gains entries, `id 1` stops being special, and empty canonicals get reported

**Nothing to configure.** Three fixes change what a rebuild publishes, and each
one adds something that was missing rather than taking anything away.

**`sitemap.xml` now names the post listing.** A site using `posts_page: blog`
publishes `/blog/` — a real, indexable document, and usually the most linked
page after the home page — and the sitemap never mentioned it. It appears now,
at priority `0.9`, first page only. A listing whose rendered HTML says `noindex`
still stays out. Without `posts_page` nothing changes: the listing is the site
root, which the front-page entry already named.

**Category id 1 is an ordinary id.** The sitemap, the per-category Atom feeds
and the `isValidCategory` / `hasValidCategories` template helpers all used to
skip category **id 1**, on the assumption that it is WordPress's *Uncategorized*.
Any exporter numbering categories from 1 puts a real term there, and that term's
archive was rendered and linked but silently absent from the sitemap and given
no feed. The catch-all is now recognised by what it is — slug or name
`uncategorized`, `bez-kategorii` and their translations — so:

- an archive at id 1 gains a sitemap entry and a `feed.xml`;
- a catch-all term at any id loses them, if it had them;
- `{{ if hasValidCategories . }}` in a theme starts rendering the category block
  for posts filed only under a real id-1 category, and stops for posts filed only
  under a catch-all that used to slip through on a different id.

If your `metadata.json` was renumbered to work around the old behaviour, you can
put the ids back.

**A category feed moves if its archive is not at `/category/<slug>/`.** The
per-category feeds were addressed by slug while the archive is written where the
source site served it, so a category with its own `link:` had its feed at
`/category/<slug>/feed.xml` next to no archive. The feed now sits beside the
archive — `/projects-archive/feed.xml`, `/category/rooms/kitchens/feed.xml` — and
a term whose archive was never rendered no longer gets one. Only sites using
category `link:` or nested categories see any file move; if you linked a feed URL
by hand, check it.

**A new warning may appear, and it is worth reading.** Every build now says so
when a page ships `<link rel="canonical" href=""/>`, or an empty `og:url` or
`twitter:url`:

```text
   ⚠️  10 page(s) name their own URL with an empty value
      category/air-conditioning/index.html → <link rel="canonical">, og:url
```

It is a warning, never a build failure, and it needs no configuration. If it
fires on your archives, the canonical fix above is the answer; elsewhere it means
a template names a value its context does not carry.

**Archives carry `.CanonicalURL`.** Themes had no way to get an archive's own
URL, so `<link rel="canonical" href="{{ .CanonicalURL }}"/>` in `category.html`
silently rendered `href=""` — Go templates resolve a missing map key to empty. If
your archives shipped an empty canonical, this release fills it in with no theme
change. Themes reconstructing the URL by hand can drop that code; the built-in
value is the path the archive was actually written to, which is also right for a
category with its own `link:` and for a nested one. `authorURL` is new alongside
it, for bylines that were hardcoding `/author/<slug>/`.

</div>

<div class="upgrade-step" data-since="1.8.47">

### 1.8.47 — check one thing if you run `ssg mcp --listen`

**Nothing to configure**, and the golden baseline is byte-identical. Two changes
are worth a minute of attention, though.

**The MCP endpoint is now always authenticated.** `ssg mcp --listen=…` mints a
bearer token when neither `--token` nor `$SSG_MCP_TOKEN` supplies one —
including on a loopback listener, which used to be the one case that got none.
If you were relying on an unauthenticated loopback endpoint, your client now
needs the token; read it from the startup line, or better, set it yourself:

```sh
SSG_MCP_TOKEN=… ssg mcp --listen=127.0.0.1:7823 --no-stdio
```

A minted token is new on every start, so a long-running deployment should set
the variable rather than copy a printed value. The startup line says which
happened — *"Minted for this run"* means the secret you thought you passed did
not arrive, and in a supervised deployment that is the line to alert on.

**The preview now serves your `_redirects` and `_headers`.** `ssg --http` reads
both files out of the output directory, so a redirect finally answers locally
the way it will in production. Two consequences on a site that has rules:

- a path covered by a redirect rule now redirects in the preview instead of
  serving the file that sits there, matching Cloudflare Pages;
- the default `_headers` caches `/` and `/*.html` for an hour, so the preview
  reports that too. Pages carrying the live-reload script are exempt.

If you would rather the preview not honour a rule, the rule is the thing to
change — the preview is now telling you what the deployment will do.

</div>

<div class="upgrade-step" data-since="1.8.46">

### 1.8.46 — nothing to do

Built with Go 1.27 and a little less work per page. No configuration, no output
change: the golden baseline is byte-identical.

</div>

<div class="upgrade-step" data-since="1.8.45">

### 1.8.45 — two defaults changed, and both change output

Nothing to configure, but **two things are now on by default** and both alter
what is published. Neither touches a site that was already clean — the golden
baseline is byte-identical across every corpus — but a site carrying either
problem will see its output change:

- **Invisible characters are removed from generated HTML.** Zero-width spaces,
  bidi overrides, tag characters, soft hyphens and exotic-width spaces. If your
  content deliberately publishes one *outside* a code block, it will now be
  taken out; put it in a code span, or set `sanitize_output: off`. Run a build
  first — it reports exactly what it would remove, and `sanitize_output: warn`
  reports without changing anything.
- **EXIF, IPTC and XMP are stripped from published JPEGs.** If you publish
  photographs and want their camera settings visible, set
  `image_metadata: keep`. Otherwise this removes GPS coordinates and serial
  numbers you probably did not mean to publish.

`ssg mcp` also speaks the 2026-07-28 protocol shape now, **alongside** the older
one. A client that has not moved is answered exactly as before; nothing to do.

</div>

<div class="upgrade-step" data-since="1.8.44">

### 1.8.44 — nothing to do

`ssg mcp` still speaks stdio by default and still declares the version a client
asks for; the network transport runs only with `--listen`. The migrate flags are
additive. The one visible change is that a **newly scaffolded** theme now
contains `css/style.css` and `js/main.js` — files its templates always linked and
nothing wrote. An existing theme is untouched: an asset already on disk is never
overwritten, so a theme you have edited keeps its own stylesheet.

If you have been living with an unstyled scaffolded site, delete the theme
directory and rebuild to get the new one, or copy the two files across.

</div>

<div class="upgrade-step" data-since="1.8.43">

### 1.8.43 — nothing to do

`ssg daemon` is new and runs nothing unless you write a `.ssg_projects` file, and
the build may now report Markdown it never read — a directory under your source
that is neither `pages/` nor `posts/`. That report names the `content_sources:`
block that loads it. Nothing that was building stops building, and nothing that
was silent starts failing.

</div>

<div class="upgrade-step" data-since="1.8.42">

### 1.8.42 — `repair` and `check_markup` may report pages they used to pass

Nothing to configure. Two things can newly speak up, and in both cases what they
report has been shipping:

- **`ssg repair` now finds fenced markup**, not only indented markup. A page
  whose exported body carries a stray code fence renders as source from that line
  on, and `repair` used to walk past it. `ssg repair` (dry run) tells you which
  pages; `ssg repair --fix` removes the fence markers and leaves the markup, the
  same shape as the existing dedent. A fence you wrote on purpose — ```` ```html ````,
  or any fence in a prose document — is never touched.
- **`check_markup` names the cause**, "indented as code" or "fenced as code". On
  `check_markup: strict` a page with a swallowing fence now fails the build where
  it previously passed. Drop to `warn` if you need the build green while you fix
  the sources.

**`type_archives` is new and off**, so a custom post type's section is not built
unless you ask — with one exception worth knowing: if your
`content/<source>/metadata.json` carries `custom_types[].has_archive`, the
archives it declares are built automatically. **wpexporter 1.8.15 writes it**, so
re-exporting a migrated site gives you those sections without touching the
config. On an older export, declare them yourself:

```yaml
type_archives:
  realizacje: true
```

</div>

<div class="upgrade-step" data-since="1.8.41">

### 1.8.41 — check your `domain` if the build now warns about it

Nothing to change unless your `domain` carries a scheme or a trailing slash. If
it does, the build now says so and corrects it:

```
⚠️  domain "https://example.com" is not a bare host — using "example.com".
```

**The URLs your site publishes will change** — from `https://https://example.com/…`
to `https://example.com/…` in the canonical tag, `og:url`, the sitemap, the
JSON-LD `@id` and the feed. That is the correction, not a regression: the old
ones were never reachable addresses. Worth knowing anyway, because a sitemap
already submitted to Search Console carried them.

Fix the value in `.ssg.yaml` (or the positional argument) to silence the
warning:

```yaml
domain: "example.com"   # not https://example.com
```

A port (`example.com:8080`) and a subdirectory deploy (`example.com/blog`) are
left alone — the first is part of the host, the second is deliberate.

</div>

<div class="upgrade-step" data-since="1.8.40">

### 1.8.40 — `check_schema` may report pages it used to pass

Nothing to change unless you use `schema_defaults` **and** your theme writes
JSON-LD of its own. If it does, `check_schema` will now name every page in a
section whose declared `@type` reached the output nowhere — which is most likely
a real gap that has been shipping, not a new false positive: a theme that emits
any `application/ld+json` block turns auto-injection off for the whole page.

Emit the derived data beside your own block:

```gotemplate
<script type="application/ld+json">{{ toJSON .Schema }}</script>
{{ partial "schema-faq" . }}
```

`.Schema` is new in this release: the merged structured data SSG would have
injected, in precedence order, so the theme does not have to rebuild it.

On `check_schema: strict` this turns a previously passing build into a failing
one. If you need the build green before you can fix the theme, drop to
`check_schema: warn` for the interim — the finding is the same, it just does not
stop the build.

</div>

<div class="upgrade-step" data-since="1.8.39">

### 1.8.39 — the front page honours pinned posts, and date archives exist

Two things that were supposed to work in earlier releases now do, so output can
change **for sites that asked for them**:

- **A pinned post moves to the top of the front page** and of `.Site.Posts`. In
  1.8.38 it already led every category and tag archive; it stood wherever its
  date fell on the front page. If your theme marks the pinned entry with
  `{{if .Sticky}}`, it will now appear first there too. A site with no
  `sticky: true` in any post is byte-identical.
- **`date_archives: true` starts writing `/YYYY/` and `/YYYY/MM/`.** The key was
  accepted and nothing was generated, so a site that set it has been building
  without those pages. They appear on the next build. A page of your own that
  already owns such a URL keeps it, and the build says so. If you do not want
  them, remove the key — the default is `false`.
- **Feeds go back to chronological order.** Pinning reached `/feed/` in 1.8.38,
  which WordPress does not do. Nothing to change; a pinned older post simply
  stops appearing at the top of the feed.

Nothing to do for the engine change: `ssg migrate` now runs the newest wpexporter
it can reach. Snap users who want an engine ahead of the snap's rebuild cadence
can install one into their home directory and it will be used:

```bash
go install github.com/tradik/wpexporter/cmd/wpexporter@latest
```

`--engine /path/to/wpexporter` (or `SSG_WPEXPORTER`) picks one explicitly.

</div>

<div class="upgrade-step" data-since="1.8.38">

### 1.8.38 — `ssg migrate` needs wpexporter 1.8.11

An older engine is now refused before the migration starts, instead of producing
an export that looks complete and is not. **Snap users: nothing to do** —
`snap refresh static-site-generator` carries a current engine. Otherwise:

```bash
go install github.com/tradik/wpexporter/cmd/wpexporter@latest
```

Nothing else in this release requires action: pinned posts, the numbered pager
and the `<title>`-in-`<script>` warning are all additive.

</div>

<div class="upgrade-step" data-since="1.8.37">

### 1.8.37 — paginated category archives, and posts_page takes its address

If you set `paginate`, category archives are now paginated too: an archive with
more posts than the page size grows `/category/<slug>/page/2/` and so on, where
before it was one long file. **Nothing to do** — the archive's first page keeps
its address; a site without `paginate` is untouched.

If you set `posts_page` **and** have a page document at that same address, the
listing now takes the URL and the page is not written. That is what WordPress
does with its assigned "Posts page", and the build names both documents so you
can rename the page or change the key if you wanted the page instead.

</div>

<div class="upgrade-step" data-since="1.8.36">

### 1.8.36 — the dev server stops inventing directory indexes

A directory with no `index.html` now answers **404** instead of listing its file
names. That matches every host this project deploys to; the listing only ever
appeared locally, and it made a missing page look like a working one. Nothing to
do — unless you relied on browsing `output/` through the dev server, in which
case use a file manager or `ls`.

Date archives (`/YYYY/`, `/YYYY/MM/`) are **opt-in**: set `date_archives: true`
to publish them. Existing sites are untouched.

</div>

<div class="upgrade-step" data-since="1.8.35">

### 1.8.35 — category archives follow the source site's own address

If your `metadata.json` records a `link` for a category (every WordPress
migration does), its archive now renders at that address rather than under
`/category/`. **This changes archive URLs for migrated sites** — which is the
point: those are the addresses the content, the menu and the search results
already point at. The built-in path is emitted as a 301, so nothing that
pointed at the old form breaks. A site whose categories carry no `link` is
untouched.

The bundled themes now render `.Site.Menus.primary` when a migration brought
navigation across, and the comment thread on posts. Your own theme is
unaffected; both are opt-in by virtue of being in the theme you choose.

</div>

<div class="upgrade-step" data-since="1.8.34">

### 1.8.34 — nested categories move to their real address

A category with a `parent` now renders at `/category/<parent>/<child>/`, the
address WordPress served, instead of a flat `/category/<child>/`. **If your
`metadata.json` nests categories, those archive URLs change** — the flat path
is emitted as a 301 in `_redirects`, so existing links keep working on hosts
that honour it (Cloudflare Pages, Netlify). A site whose categories are all
top-level is untouched.

### 1.8.34 — migrated sites can keep their navigation

`ssg migrate` now accepts `--auth-user`/`--auth-pass` (or `--auth-token`) and
brings the source site's menus with the content — WordPress refuses them to an
anonymous caller, which is why migrated sites came up with no navigation at
all. **Nothing to do** for an existing site; to gain the navigation, re-run the
migration with credentials (an application password).

Themes read menus as `.Site.Menus.<location>`. The bundled themes start
rendering them in 1.8.35 — a theme file is read by whichever ssg you run, so it
may only use fields the previous release already had; your own theme can use
them now.

</div>

<div class="upgrade-step" data-since="1.8.33">

### 1.8.33 — comments migrate, and the dev server takes the next free port

`--content` now accepts **`comments`** for real: with **wpexporter 1.8.5 or
newer** they land in `content/<source>/comments.json`, addressed by page URL.
Older engines do not know `--no-comments`, so a `--content` selection that
leaves comments out will fail against them — **upgrade wpexporter** alongside
ssg (`snap refresh wpexporter`, or `go install
github.com/tradik/wpexporter/cmd/wpexporter@latest`).

While you are there: `--content` opts every **unlisted** kind out. If your
migration command says `--content pages,posts,media`, it has been leaving the
theme's own post types *and* the comments behind. The full list:

```bash
ssg migrate wordpress https://example.com --content pages,posts,media,custom,comments
```

`--http` no longer stops when the port is taken: it walks forward (8888 → 8889
→ …) and prints where it landed. **Nothing to do** — but a script that assumed
the exact port should read the announced address, or pin the port by freeing it
first.

</div>

<div class="upgrade-step" data-since="1.8.32">

### 1.8.32 — a migration takes only the media the content uses

`ssg migrate` now downloads the files your pages and posts actually reference
(featured images and in-content media, with their size variants) instead of the
source site's entire media library — which on a long-lived WordPress holds every
crop of every upload plus the leftovers of removed plugins.

**Nothing to do** for a site you have already migrated: this changes what a new
migration fetches, not what is on disk. Re-running one will pull fewer files
than before; pass **`--all-media`** to keep the old behaviour.

</div>

<div class="upgrade-step" data-since="1.8.31">

### 1.8.31 — builds now report unrenderable markup

`check_markup` is on (`warn`) by default — the only check that is. It names
source Markdown whose markup is indented four columns or more, which
CommonMark renders as a literal code block, so the visitor reads `</div>`
instead of the page. **You may see new warnings on content you have had for
months**; they describe what your site already serves. Fix them with
`ssg repair --fix`, or silence the check with `check_markup: ""` (or
`--no-check-markup`). It never fails a build unless you set `strict`.

Nothing else changes: `title`, `description` and `colors` are new optional
config keys, and `ssg migrate` filling them in only ever writes keys your
config does not already have.

</div>

<div class="upgrade-step" data-since="1.8.30">

### 1.8.30 — migrated sites keep their menus, and tracking stays opt-in

`ssg migrate --content …` no longer disables the site's metadata: tags, users
and menus now always ship (exclude them with `no-menus` / `no-tags` /
`no-users`). If a migration of yours came up without navigation, re-run it.

The tracking ids a migration records are rendered only when you set
`analytics: true` — they are never injected because content moved.

</div>

<div class="upgrade-step" data-since="1.8.29">

### 1.8.29 — the snap carries the migration engine

`ssg migrate wordpress` works in the snap out of the box: the engine
(`wpexporter`) ships inside it, because strict confinement cannot reach the
host's copy. **Do nothing** beyond `snap refresh static-site-generator`.

</div>

<div class="upgrade-step" data-since="1.8.28">

### 1.8.28 — `migrate` becomes a reserved subcommand

`ssg migrate <provider> <url>` migrates a live site into an SSG project
([docs/MIGRATE.md](MIGRATE.md)). **Do nothing** — unless a content source
directory of yours is literally named `migrate`: the bare positional form
`ssg migrate <template> <domain>` now runs the subcommand instead, so build
that source with `--source=migrate`.

</div>

<div class="upgrade-step" data-since="1.8.27">

### 1.8.27 — the AI cache moved under .ssg-cache/

All disk caches now live under one root: `.ssg-cache/images/`,
`.ssg-cache/external-sources/` and — newly — `.ssg-cache/ai/` (previously
`.ai-cache/`). **Do nothing**: existing AI answers are found in the old
location and adopted by copy, so nothing is re-generated and no generated
text changes. Image cache keys are unchanged (golden-tested) — no
reconversion happens on upgrade.

Two follow-ups, both optional:

- If you **committed `.ai-cache/`** for reproducible CI builds, keep it —
  it stays readable. New answers land in `.ssg-cache/ai/`; commit that path
  going forward (or set `ai.cache_dir` explicitly to keep one location).
- Once a build has run, `.ai-cache/` contents are duplicated and the old
  directory can be deleted: `ssg cache stats` shows it as `ai (legacy)`
  while it exists.

New CLI for all of it: `ssg cache stats`, `ssg cache clean
[--namespace=images|external-sources|ai]`, `ssg cache gc [--dry]`.

</div>

<div class="upgrade-step" data-since="1.8.26">

### 1.8.26 — `{{ .Content }}` renders, and frontmatter `excerpt:` is read

Two long-standing bugs are fixed, and each shows up as a diff rather than an error:

**Root `.Content` now renders.** In `page.html`/`post.html` the root `.Content`
was raw Markdown wrapped as HTML, so a theme printing `{{ .Content }}` shipped
literal `## headings` and `**bold**`, while `{{ .Content | safeHTML }}` raised a
type error. Now `.Content` is the rendered HTML and `safeHTML` also accepts an
already-rendered value, so both forms work. The bundled themes use
`{{ .Post.Content | safeHTML }}` and are unaffected; a theme that carried the
workaround keeps working.

**Frontmatter `excerpt:` is read.** A frontmatter `excerpt:` used to vanish into
`Extra` and never reach `page.Excerpt` — empty meta descriptions, card summaries
and feed summaries on WordPress migrations. It now fills the excerpt (a `##
Excerpt` section still wins), and `auto_excerpt` derives one from content that
opens with a raw-HTML block.

**Do nothing** — but expect summaries, meta descriptions and feed entries that
were blank to gain text. If you had worked around the empty excerpt by writing a
`## Excerpt` section, that section still takes precedence.

On the snap, `webp: true` now works under strict confinement (cwebp is bundled) —
refresh the snap.

</div>

<div class="upgrade-step" data-since="1.8.25">

### 1.8.25 — the dev server reloads the browser itself

`ssg --http --watch` now injects a tiny live-reload client into every served
page: after a successful rebuild the tab refreshes on its own, and a failed
build shows an error bar with the message instead of silently serving the stale
page. Nothing is written to `output/` for this — the script is added only to the
served HTML, never to the files on disk or to the published `.md` copies.

**Do nothing** — it is a development convenience and production output is
unchanged. Pass `--no-auto-reload` (or set `auto_reload: false`) if you would
rather refresh by hand.

The rest of 1.8.25 is opt-in and needs no action: `markdown_publish` (a Markdown
copy of every page plus `llms.txt`, for AI agents), `clean_special_chars`,
`output_encoding`, `robots_rules` and the home-page card limits are all off or
default-compatible until you enable them — see
[CONFIGURATION.md](CONFIGURATION.md) and [AI-AGENTS.md](AI-AGENTS.md).

</div>

<div class="upgrade-step" data-since="1.8.24">

### 1.8.24 — the home page now emits structured data

`seo: true` injects JSON-LD, OpenGraph and hreflang for posts and pages, but the
index was rendered without a page context, so the SEO block was skipped there
entirely — the page a crawler reaches first had none of it, and the `WebSite`
type it was supposed to carry was unreachable code.

**Do nothing**, but expect a diff: your home page gains a JSON-LD block, an
`og:url` and a canonical it did not have. If you were injecting those from your
own theme, check you are not now emitting them twice — SSG skips its own block
when the page already provides one, so a theme that emits `og:title` is left
alone.

Site-wide `schema:` defaults reach the home page for the first time too.

</div>

<div class="upgrade-step" data-since="1.8.23">

### 1.8.23 — rebuild if you minify JavaScript

`--minify-js` stripped comments with a regex, which cannot tell a comment from
the same characters inside a string. So this

```js
function f() { return "/*" + "x" + "*/"; }
```

minified to `return "";` — the scan ran from the `/*` in the first literal to
the `*/` in the third and took the closing quote with it. **The damaged output
often still parses**, so the build reported success and nothing in it warned.

**If you use `--minify-js` (or `--minify-all`), rebuild and redeploy.** Anything
already published may be silently altered, and the shapes most at risk are
vendored libraries — a CSS-comment parser holds `/*` and `*/` in strings, which
is how this was found. Grepping your deployed JS for an unterminated string is
not practical; rebuilding is.

**If you disabled JS minification to avoid this, you can turn it back on.**

CSS was affected the same way (`content: "/*"` is legal), and is fixed too.

</div>

<div class="upgrade-step" data-since="1.8.23">

### 1.8.23 — `.md` link rewriting got narrower and follows `pretty_urls`

Three things changed, all of them output:

**Absolute URLs are no longer rewritten.** Any href ending in `.md` used to be
matched on its filename, so a link to a page's own history on a code host became
a link to the page containing it. `check_links` passed, because the target
existed. **Check any external `.md` link you have** — it was pointing at the
wrong place and now points where you wrote it.

**Rewritten links follow `pretty_urls`.** With `pretty_urls: strip` a rewritten
`[CONTRIBUTING.md](./CONTRIBUTING.md)` now emits `/contributing`, not
`/contributing.html`. If `check_redirects` was reporting those, it will stop.

**`check_orphans` stops reporting false orphans.** With `pretty_urls` set and a
nav linking `/validator`, every page was reported as an orphan while
`check_links` resolved the same links. If you switched the check off because of
that noise, it is worth switching back on.

**Do nothing** for any of these — they are corrections. The first is the one to
look at, because it changed where a link goes.

</div>

<div class="upgrade-step" data-since="1.8.22">

### 1.8.22 — `related`'s three-argument form is now `relatedIn`

Two different functions were registered under the name `related`, and the
two-argument one won. So the three-argument form the reference documented never
ran: a theme using it failed with *"wrong number of args for related"*, every
post was skipped, and the build still reported success — which on a first build
reads as though the content never loaded.

**If you use `related page n`, do nothing** — that is the form that always ran,
and it is unchanged. **If you followed the old reference and passed a
collection**, the name is now `relatedIn`:

```gotemplate
{{ .Site.Posts | relatedIn .Page 3 }}
```

The two rank differently, which is why both survive rather than one being
dropped: `related` scores shared tags and keywords over the site's own posts,
`relatedIn` scores shared tags (3) > categories (2) > same author (1) over the
collection you hand it.

</div>

<div class="upgrade-step" data-since="1.8.22">

### 1.8.22 — `formatDate` actually formats

It never did: every non-string fell through to Go's `%v`, and `Page.Date` is a
`time.Time`, so themes rendered `2017-05-13 20:36:46 +0000 UTC` — including
inside `datetime` attributes, where it is not valid HTML.

**Your dates will change appearance.** The default is now `13 May 2017`, and a
layout is accepted:

```gotemplate
{{ formatDate .Date }}                {{/* 13 May 2017 */}}
{{ formatDate .Date "2006-01-02" }}   {{/* 2017-05-13 */}}
```

A zero date now renders empty instead of `1 January 0001`. Strings are still
passed through untouched, so a theme that pre-formats its dates is unaffected.

</div>

<div class="upgrade-step" data-since="1.8.22">

### 1.8.22 — a `404.html` is generated

Static hosts answer an unmatched path by falling back to `index.html` **with a
`200`** unless the output contains a `404.html`, so every dead URL read to a
crawler as another live copy of the home page.

**Do nothing** — a minimal one is generated, and a page slugged `404` still
takes precedence. If you deliberately want none:

```yaml
not_found_off: true
```

Note the new file in your output; a deploy diff will show it once.

</div>

<div class="upgrade-step" data-since="1.8.22">

### 1.8.22 — `pretty_urls` now decides what a page says about itself

It used to feed link checking only, so a site on a host that strips extensions
published canonical tags, `og:url`, JSON-LD and a sitemap naming URLs that
`308` — the one thing a canonical must not do. Those now name the URL the host
actually answers.

**Check which host you have.** `pretty_urls: true` still means what it always
meant — strip `.html` *and* add a trailing slash. Cloudflare Pages does **not**
add the slash, so on Pages the accurate setting is:

```yaml
pretty_urls: strip        # /docs/intro.html → /docs/intro
```

| Value | Host behaviour |
|---|---|
| `false` / `off` | Serves files literally |
| `strip` | Drops `.html`, no trailing slash (Cloudflare Pages) |
| `true` / `strip-slash` | Drops `.html`, adds the slash |

If you leave `true` on a host that does not add the slash, your canonical tags
will name URLs that redirect — the situation this release exists to fix. Feed
entry IDs deliberately keep the raw form, so subscribers are not re-delivered
every post.

</div>

<div class="upgrade-step" data-since="1.8.22">

### 1.8.22 — the bundled theme reads `variables.gtm_id`

`ssgtheme` had the Tag Manager container ID hardcoded, so using GTM meant
editing the theme — which put the ID in the theme rather than the site, lost it
on every theme update, and made two sites sharing the theme impossible.

**If you edited the theme to insert your container ID, move it to the config**,
or Tag Manager will stop loading:

```yaml
variables:
  gtm_id: GTM-XXXXXXX
```

When `variables.cookie_consent` is also set the loader is consent-gated rather
than live, since the container request is itself a third-party call made before
any choice.

</div>

<div class="upgrade-step" data-since="1.8.18">

### 1.8.18 — `sitemap.xml` drops pages marked `noindex`

A page whose rendered HTML carries a `noindex` robots directive is no longer
listed in the sitemap. Submitting a URL you have asked search engines to ignore
is a contradiction, so the two now agree.

**Do nothing** unless you were relying on those URLs being listed for something
other than search — a link checker or a warm-up crawler reading `sitemap.xml`
will see fewer URLs than before.

In the same release, `seo: true` fills a missing meta description from the
front-matter excerpt instead of leaving it out. If you deliberately shipped
pages with no description, they now have one.

</div>

<div class="upgrade-step" data-since="1.8.17">

### 1.8.17 — builds render in parallel by default

Rendering now uses a worker pool sized to the machine. Output is byte-identical
to the sequential build, which is enforced by `make determinism` on every
change, so this is a speed change and not an output change.

**Do nothing.** If you need the old behaviour — a constrained CI box, or
debugging — turn it off:

```bash
ssg --workers=0 …          # or build_workers: 0 in the config
```

Unset means "one worker per CPU"; `0` means sequential; any other number is
taken exactly.

</div>

<div class="upgrade-step" data-since="1.8.15">

### 1.8.15 — `rewrite_md_links` defaults to on

A relative `.md` link in your content is rewritten to the final output URL. It
had to be switched on before; now it is on unless you switch it off.

**Do nothing** in almost every case: a raw `.md` link in a built site either
404s or serves the Markdown source, so the old default was rarely what anyone
wanted. Opt out only if you were deliberately shipping `.md` links:

```yaml
rewrite_md_links: false
```

The rewriting itself did not change — only whether it runs by default.

</div>

<div class="upgrade-step" data-since="1.8.7">

### 1.8.7 — `seo_off` does what it says again

Between 1.8.2 and 1.8.6, `seo_off` and `--seo-off` were accepted but did
nothing, because SEO injection had become opt-in and there was nothing left to
turn off. From 1.8.7 the key is honoured again and forces SEO off.

**Check for a stale `seo_off: true`** left in a config from before 1.8.2. It was
harmless for five releases and is not any more: combined with `seo: true` it
wins, and your OpenGraph tags disappear.

Also in this release, every value flag accepts both spellings — `--flag=value`
and `--flag value` — which undoes the 1.8.1 restriction below.

</div>

<div class="upgrade-step" data-since="1.8.2">

### 1.8.2 — SEO injection became opt-in

**This is the one most likely to surprise you.** The generator-level OpenGraph,
Twitter and JSON-LD partial is off by default. SSG will not rewrite your
rendered `<head>` unless asked.

The reasoning is that SEO injection *modifies your HTML*, unlike the sitemap and
`robots.txt`, which write separate files and stay on. Anything that edits your
markup should be something you asked for.

**If you relied on automatic OpenGraph tags, turn them back on:**

```yaml
seo: true
```

```bash
ssg --seo …
```

Skip this and the build still succeeds — it simply stops emitting the tags, so
the symptom shows up later as bare link previews on social platforms rather than
as a failure at build time. It is worth checking one page's `<head>` after
upgrading.

</div>

<div class="upgrade-step" data-since="1.8.1">

### 1.8.1 — boolean and simple string flags wanted `--flag=value`

For six releases, boolean and simple string options had to be written joined:
`--flag=value`, not `--flag value`.

**Applies only if you are landing on 1.8.1 through 1.8.6.** Going to 1.8.7 or
later, both spellings work and there is nothing to do.

</div>

<div class="upgrade-step" data-since="1.8.0">

### 1.8.0 — the mddb API key is not sent over plaintext

The HTTP client refuses to attach `Authorization: Bearer` over `http://` to a
non-loopback host; `https://` and loopback addresses are unaffected. The gRPC
client picks transport security from the scheme — `grpcs://` and `https://` get
TLS, `grpc://` and `http://` do not, a bare host gets TLS unless it is loopback
— and likewise refuses to send a key over an insecure channel to a non-loopback
host.

**If your mddb URL is `http://` on a remote host, move it to `https://`** (or
`grpcs://`). The key is otherwise dropped, and mddb answers as if you were
unauthenticated — so the failure looks like missing content, not like an auth
error.

Local development against `127.0.0.1` or `localhost` keeps working untouched.

</div>

<div class="upgrade-step" data-since="1.7.15">

### 1.7.15 — the dev server binds loopback only

The built-in server listens on `127.0.0.1` instead of `0.0.0.0`. A development
server reachable from the whole network by default is a hazard, so exposing it
is now a deliberate act.

**If you reached the dev server from another machine** — a phone on the same
Wi-Fi, a container, a colleague — say so explicitly:

```bash
ssg --http --host=0.0.0.0 --port=8888 …
```

There is a matching `host:` config key, defaulting to `127.0.0.1`.

</div>

<div class="upgrade-step" data-since="1.7.14">

### 1.7.14 — Go 1.26.5 or newer to build from source

The module's `go` directive was raised to go1.26.5 to pick up a `crypto/tls` fix
(GO-2026-5856).

**Only affects building from source.** Released binaries, packages, the Docker
image and the GitHub Action are all built for you and carry no toolchain
requirement.

</div>

<div class="upgrade-step" data-since="1.3.1">

### 1.3.1 — WebP conversion needs the `cwebp` binary

1.3.0 briefly used a native Go library; 1.3.1 went back to `cwebp`, which is
what makes static builds and cross-compilation possible without CGO.

**Install the WebP tools** if you generate WebP images:

```bash
sudo apt install webp      # Debian/Ubuntu
brew install webp          # macOS
```

From 1.3.4 the GitHub Action installs them for you, so Action users can skip
this.

</div>

</div>

## Everything else

The releases not listed above changed nothing you have to act on: new features
you can ignore, fixes, performance work, security hardening internal to SSG, and
documentation. They are still worth skimming in the
[changelog](https://github.com/spagu/ssg/blob/main/CHANGELOG.md) — features
arrive far more often than steps do.

## If an upgrade goes wrong

Releases are not deleted. Every previous version stays downloadable from the
[releases page](https://github.com/spagu/ssg/releases), so going back is a
matter of installing the older binary. Your content and config are untouched by
an upgrade, so a rollback needs no cleanup.

If a build breaks in a way this page does not explain, that is a bug worth
reporting rather than working around —
[open an issue](https://github.com/spagu/ssg/issues) with the version you came
from, the version you went to, and what the build printed.

<style>
.upgrade-picker { margin: 2rem 0; }
.upgrade-picker select { display: block; margin-top: 0.5rem; padding: 0.5rem; max-width: 22rem; width: 100%; }
.upgrade-count { margin-top: 0.5rem; font-weight: 600; }
.upgrade-step[hidden] { display: none; }
</style>

<script>
/* Progressive enhancement: the picker is hidden until this runs, so with no
   JavaScript every step is shown and the page is still complete and correct.
   Filtering only ever hides steps that do not apply. */
(function () {
  var picker = document.querySelector('.upgrade-picker');
  var select = document.getElementById('upgrade-from');
  var count = document.querySelector('.upgrade-count');
  var steps = Array.prototype.slice.call(document.querySelectorAll('.upgrade-step'));
  if (!picker || !select || !steps.length) return;

  /* Compare dotted numeric versions of any length — 1.7.8.1 sorts after 1.7.8
     and before 1.7.9, so a shorter version is not treated as larger. */
  function cmp(a, b) {
    var x = a.split('.').map(Number), y = b.split('.').map(Number);
    for (var i = 0; i < Math.max(x.length, y.length); i++) {
      var d = (x[i] || 0) - (y[i] || 0);
      if (d) return d < 0 ? -1 : 1;
    }
    return 0;
  }

  function apply() {
    var from = select.value;
    if (!from) {
      steps.forEach(function (s) { s.hidden = false; });
      count.textContent = '';
      return;
    }
    var shown = 0;
    steps.forEach(function (s) {
      /* A step applies when it landed after the version you are on. */
      var applies = cmp(s.getAttribute('data-since'), from) > 0;
      s.hidden = !applies;
      if (applies) shown++;
    });
    count.textContent = shown === 0
      ? 'Nothing to do — upgrading from ' + from + ' is a drop-in replacement.'
      : shown + (shown === 1 ? ' step applies' : ' steps apply') + ' when upgrading from ' + from + '.';
  }

  select.addEventListener('change', apply);
  picker.hidden = false;
})();
</script>
