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.
intandfloatconvert a string to a number, so[reviews limit="3"]can feedfirst (int .Attrs.limit).add/sub/mul/divalso accept a numeric string now, so{{ add 1 .Attrs.offset }}works with no conversion.- A shortcode can read
.SiteData(thedata/files) and.ExternalData, call the theme's partials with{{ template "card" . }}, and use the collection helpers..Datainside a shortcode is unchanged — it is still theshortcodes:entry's owndata:map, which is why the site-wide tree arrived under a different name. - A
static_sourcesentry takessitemap: true(and an optionalpriority), which lists a verbatim HTML document insitemap.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
_headerscaches/and/*.htmlfor 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, andsanitize_output: warnreports 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 repairnow finds fenced markup, not only indented markup. A page whose exported body carries a stray code fence renders as source from that line on, andrepairused to walk past it.ssg repair(dry run) tells you which pages;ssg repair --fixremoves 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_markupnames the cause, "indented as code" or "fenced as code". Oncheck_markup: stricta page with a swallowing fence now fails the build where it previously passed. Drop towarnif 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 nosticky: truein any post is byte-identical. date_archives: truestarts 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 isfalse.- 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 do —
snap 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 setai.cache_direxplicitly to keep one location). - Once a build has run,
.ai-cache/contents are duplicated and the old directory can be deleted:ssg cache statsshows it asai (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.
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.
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.
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:
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.