Guide

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.

 1# One-liner (Linux/macOS) — same command installs and upgrades
 2curl -sSL https://raw.githubusercontent.com/spagu/ssg/main/install.sh | bash
 3
 4# Debian/Ubuntu, if you added the apt repository
 5sudo apt update && sudo apt install --only-upgrade ssg
 6
 7# macOS
 8brew upgrade ssg
 9
10# Docker — pull the tag you want rather than reusing a cached latest
11docker 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:

1ssg --version

Full release notes for every version live in CHANGELOG. This page covers only the steps; the changelog covers everything else.

What applies to you

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.

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.

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:

1minify_html_keep_comments: [acme:begin, acme:end]

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

1marketing:
2  og_image: "/img/card.png"     # 1200×630
3  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.

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.

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:

1   ⚠️  lastmod_from_git is on, but git could not date app/index.html — using the file's modification time instead
2      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.

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:

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

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:

1   ⚠️  10 page(s) name their own URL with an empty value
2      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>/.

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:

1SSG_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.

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.

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.

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.

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.

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:

1type_archives:
2  realizacje: true

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:

1domain: "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.

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:

<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.

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:

1go install github.com/tradik/wpexporter/cmd/wpexporter@latest

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

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 dosnap refresh static-site-generator carries a current engine. Otherwise:

1go 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.

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.

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.

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.

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.

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:

1ssg 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.

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.

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.

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.

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.

1.8.28 — migrate becomes a reserved subcommand

ssg migrate <provider> <url> migrates a live site into an SSG project (docs/MIGRATE). 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.

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].

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 <strong>bold</strong>, 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.

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 and AI-AGENTS.

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.

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

1function 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.

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.

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:

{{ .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.

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:

{{ 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.

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:

1not_found_off: true

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

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:

1pretty_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.

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:

1variables:
2  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.

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.

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:

1ssg --workers=0# or build_workers: 0 in the config

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

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:

1rewrite_md_links: false

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

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.

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:

1seo: true
1ssg --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.

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.

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.

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:

1ssg --http --host=0.0.0.0 --port=8888

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

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.

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:

1sudo apt install webp      # Debian/Ubuntu
2brew install webp          # macOS

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

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 — 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, 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 with the version you came from, the version you went to, and what the build printed.