Guide
Static site generator format
-f ssg writes a drop-in content source for SSG and other static site generators. Where markdown is a faithful dump of what WordPress returned, ssg is a content source: one name per concept, paths that mirror the site, and body HTML cleaned of the old theme's scaffolding.
A drop-in content source for spagu/ssg and other static site
generators. Where markdown is a faithful dump of what WordPress returned, ssg is a
content source: one name per concept, paths that mirror the site, and body HTML cleaned of
the old theme's scaffolding.
1wpexportjson export --url https://example.com -f ssg -o export/site \
2 --assisted-crawl --crawl-content
📂 Layout
export/site/
├── metadata.json categories / tags / users / media
├── comments.json reader comments, addressed by page URL
├── pages/
│ ├── about.md /about/
│ └── baby-water-instructor/
│ └── cost.md /baby-water-instructor/cost/
├── posts/
│ └── swimming/
│ └── swimming-lesson.md posts sit at least one level below posts/
└── media/images/…
Pages are nested to mirror their URL, so the site's information architecture stays visible
in the file tree. Posts sit under their category; one with no resolvable category lands in
posts/uncategorized/.
📝 Front Matter
Single-spelled — a generator reads one name per concept, not three:
| Key | Source |
|---|---|
title |
seo_title if the site rendered one, else the post title |
slug, status, type |
as reported by WordPress |
date, modified |
RFC 3339 |
link |
root-relative by default (--link-style absolute to change). A permalink that carries no path — /?modula-gallery=1289 from a type with no rewrite rule — is replaced by the address the document is filed at, because / is the front page |
author |
resolved to a name via metadata.json users[] |
category |
the post's first named category |
description |
meta_description, else og_description, else the excerpt |
excerpt |
plain text, theme "Continue reading" chrome removed |
featured_image |
localised media path |
source_template |
the page template WordPress drew it with, when it is not the default — see The page template |
Empty values emit no key at all, so a generator sees an absent key rather than an empty string.
🧹 Content Cleanup
Applied to the body of every ssg document:
| Transform | Why |
|---|---|
HTML entities → UTF-8 (– → –, … → …) |
The file is UTF-8; the entities are noise that survives into the rendered page. <, >, &, " and ' stay encoded — decoding those would turn escaped markup into live markup |
alt filled from the media library's alt_text |
WCAG 2.2 SC 1.1.1 Non-text Content. An existing alt is never overwritten |
WordPress classes dropped (wp-image-*, size-*, align*, attachment-*, wp-block-*) |
They refer to the old theme's stylesheet. Authored classes are kept |
title dropped when it merely repeats the filename |
Carries no information a reader can use |
loading, decoding, sizes dropped |
Browser hints the generator emits itself |
The markdown format keeps its existing output, with two exceptions that were plainly bugs:
entities are decoded there too, and the excerpt no longer carries the "Continue reading" anchor.
The page template
source_template carries the page template WordPress drew a page with —
people-content-page.php, page-services-template.php — and is absent where
WordPress reports none, which is what it reports for the default one.
It is there because a theme is often two designs rather than one: a page opening with a photographic banner and a page opening with a plain title band, an author profile laid out unlike anything else on the site. Which of them a page gets is decided by its page template, and no other field in an export says so. A consumer rebuilding the theme was otherwise left guessing from the content, or fetching every page again to look at it. Measured on one site being migrated, the pair (page template, featured image) predicted the layout on every page checked — and the template was the half that could not be had (#81).
The name is not template on purpose. That one is the target's field: it
names the template a generator should render a document with, and a WordPress
file name written there would send the build looking for a template it does not
have. source_template says what the source used; what to do about it is the
consumer's decision.
A pinned post
sticky: true marks a post the editor pinned to the top of the blog, and is
absent otherwise. Sort on it before the date, or the post the site owner
deliberately put first arrives wherever its date puts it (#51).
Term addresses
category and the tags a document carries are display names. The address the
site publishes their archives under is the slug, and the two disagree often
enough to matter: on one migration 48 tag archives and 9 category archives 404'd
because a generator had made slugs out of names (#45). So the addresses travel
too:
1category: "Pasta & Rice"
2category_slug: "pasta-rice"
3category_path: "recipes/pasta-rice" # only when the taxonomy is nested
4tag_slugs:
5 - "hand-made-pasta-3"
Use category_path where it is present — it is the chain WordPress published,
/category/recipes/pasta-rice/ — and category_slug otherwise. The names are
unchanged and still the thing to display.
A page that lists posts
A /blog/ whose body is a page-builder element carries two extra keys, because
its content is generated at render time and cannot be exported (#41):
1lists: posts
2lists_hint: "fusion_blog"
lists_hint names the element that was matched, so a wrong guess is visible
rather than silent. Point the generator's listing at this page's address —
in SSG, posts_page — instead of letting the migrated page occupy it.
Front matter through a flat metadata store
Two values are not flat: the meta map and the hreflangs list. That is the
right shape for a generator reading these files, and the wrong one for a store
whose metadata model is key → list of strings — mddb
is one, deliberately. A pipeline through such a store loses both, and loses them
silently when the loader stringifies with Go's %v.
--frontmatter-style flat writes them as single JSON strings instead:
1meta: '{"recipe:yield":"8","twitter:label1":"Prep time"}'
2hreflangs: '[{"lang":"en","href":"https://x.test/focaccia/"}]'
Lossless, decodable by anything that reads JSON, and stable between runs. The
default is unchanged, and json_ld already travelled as text, so it needs
nothing. Lists of plain strings — categories, tags, category_slugs,
tag_slugs, category_paths — are already what such a store holds natively.