# Content guide

This document is the canonical reference for local Markdown content in SSG. For
configuration keys, see [CONFIGURATION.md](CONFIGURATION.md). For values exposed
to themes, see [TEMPLATES.md](TEMPLATES.md).

## Content source

A normal build selects one source directory:

```bash
ssg my-blog simple example.com
```

With the default paths, `my-blog` resolves to `content/my-blog/`. The source may
also come from `source:` in a configuration file. MDDB is an alternative remote
source and is documented in [CONFIGURATION.md](CONFIGURATION.md#mddb-content).

### Extra sources (`content_sources`)

Content that already lives elsewhere — a `docs/` folder, notes beside the code,
a package's guides in a monorepo — does not have to be copied into the source
tree. `content_sources` lists additional **flat Markdown roots** (loaded
recursively) that are merged into the site:

```yaml
content_sources:
  - path: docs                  # relative to the working directory, or absolute
    type: page                  # page (default) | post
    category: Documentation     # optional; created if metadata.json lacks it
  - path: ../shared/notes
    type: post
```

- Extra sources join the site **before** finalize, so they get the same URL,
  permalink, i18n, taxonomy and collision treatment as native content.
- `category` applies only to files whose own frontmatter names no category.
- A directory that does not exist, is a file, or has an unsupported `type`
  fails the build with a message naming the path; an empty directory warns.
- With at least one extra source, the primary `source` becomes **optional** —
  a site can consist of extra sources alone, and then no `metadata.json` is
  required either.
- Watch mode watches these directories too.

CLI: `--content-source=DIR`, repeatable, path-only (`type`/`category` need the
config file):

```bash
ssg --content-source=docs ssgtheme example.com --watch
```

An unset `source` with no `content_sources` is a build error that names the
missing settings — and the config loader warns about unknown keys, so a config
written for a newer ssg does not fail silently.

## Directory contract

```text
content/
└── my-blog/
    ├── metadata.json
    ├── pages/
    │   ├── about.md
    │   └── legal/
    │       └── privacy.md
    ├── posts/
    │   ├── general/
    │   │   └── hello.md
    │   └── 2026/
    │       └── 07/
    │           └── release.md
    └── media/
        └── hero.jpg
```

The loader follows these rules:

1. `metadata.json` is required for a local source. Unknown JSON fields are
   ignored, which allows direct use of larger export metadata files.
2. Pages are loaded recursively from `pages/`.
3. Posts must be below at least one directory inside `posts/`. A Markdown file
   placed directly in `posts/` is ignored — but the build now names it rather
   than counting to zero, because a skipped post is otherwise indistinguishable
   from a frontmatter problem (pages at the top level of `pages/` load fine).

   Set `flat_posts: true` to load them where they are. It is off by default:
   those files have always been skipped, so a site with a published one it never
   saw rendered would gain a page nobody asked to publish on its next build.
   `ssg init` scaffolds its example post directly in `posts/` and turns the key
   on in the config it writes, so a fresh site builds with the post it was
   given.
4. After that first grouping directory, post directories are recursive.
5. Directories organise files only. Post categories come from frontmatter.
6. A supported non-Markdown file beside a page or post is a co-located asset.
   It is copied when that content references its filename (only files directly beside
   the Markdown file are supported; nested subdirectories next to content files are skipped).

The `pages/` and `posts/` names can be changed with `pages_path` and
`posts_path`. The source root can be changed with `content_dir`.

## metadata.json

The metadata file supplies categories, authors and exported media records:

```json
{
  "categories": [
    {
      "id": 1,
      "count": 3,
      "description": "General articles",
      "link": "/category/general/",
      "name": "General",
      "slug": "general",
      "parent": 0
    }
  ],
  "users": [
    { "id": 1, "name": "Ada Lovelace", "slug": "ada" }
  ],
  "media": []
}
```

Only the following shapes are consumed:

| Collection | Recognised fields |
|---|---|
| `categories` | `id`, `count`, `description`, `link`, `name`, `slug`, `parent` |
| `users` | `id`, `name`, `slug` |
| `tags` | `id`, `name`, `slug` — resolves numeric ids in `tags:` frontmatter and supplies canonical archive slugs (v1.8.6) |
| `media` | `id`, `slug`, `title.rendered`, `media_type`, `mime_type`, `source_url`, `media_details.width`, `media_details.height`, `media_details.file` |

Additional export fields are allowed but are not exposed as site metadata.

## Author archives

Every author referenced by at least one **post** gets an automatic archive at
`/author/<slug>/`:

- The `author` frontmatter field accepts an ID (`1`), a name (`Ada Lovelace`)
  or a slug (`ada`); all three resolve through the `users` block above. An ID
  with no `users` entry falls back to `author-<id>`.
- Archives list **posts only** — pages never join an author archive, even with
  an `author` field.
- Templates: `author.html` renders the archive (context: `.Name`, `.Posts`
  newest-first, `.Kind` = `author`); missing `author.html` falls back to
  `category.html`. Archives appear in the sitemap.
- The author archive stays on this fixed pipeline; it is not configurable via
  `taxonomies:` (migrating it onto the generic registry is a documented
  deferred item), and `author` is a reserved path custom taxonomies cannot
  claim.

**Explicit content wins (v1.8.5).** If a page, post or alias already owns an
archive URL — say a hand-written profile with `link: /author/ada/` — the
auto-generated archive for that URL is skipped with a build warning instead of
silently overwriting your page, and the suppressed archive stays out of the
sitemap and feeds. The same rule protects `/category/…`, `/tag/…` and
`/series/…` URLs.

## Markdown and frontmatter

For predictable output, authored content should use YAML frontmatter:

```markdown
---
title: Understanding WebP
slug: understanding-webp
status: publish
type: post
date: 2026-07-14
modified: 2026-07-15
categories: [Guides]
author: ada
tags: [images, performance]
excerpt: Why WebP can reduce image size.
featured_image: /media/webp-hero.jpg
---

The full Markdown article starts here.
```

Files with frontmatter are included only when `status` is exactly `publish`.
Any other value, including an omitted status, is treated as a draft.

A plain `.md` file without frontmatter is accepted and treated as published.
Frontmatter is still recommended for anything other than imported plain
content, but two values are inferred so such a file is not blank everywhere it
is listed:

| Value | Inferred from | When |
|---|---|---|
| `title` | the document's first `# heading` (or a Setext `Title` / `====`) | always, when frontmatter has no `title` |
| `excerpt` | the opening paragraph, capped at 200 characters on a word boundary | only with `auto_excerpt: true` |

The title fallback is unconditional because an untitled page is broken in every
listing, menu and `<title>`. The excerpt fallback is opt-in because it changes
card text, feed summaries and meta descriptions on an existing site. Derivation
skips headings, fenced code, tables, block quotes, images, list markers and
Liquid guards (`{%` … `%}`), so the excerpt starts at the first real sentence.

### Frontmatter fields

| Field | Type | Applies to | Behaviour |
|---|---|---|---|
| `id` | integer | both | Optional source identifier |
| `title` | string | both | Display title |
| `slug` | string | both | URL segment; defaults to the source filename |
| `status` | string | both | Only `publish` is rendered when frontmatter exists |
| `type` | string | both | Use `page` or `post`; affects URL and template behaviour |
| `date` | date | post | Publication date and default date-based URL |
| `modified` | date | both | Last modification date |
| `link` | string | both | Explicit URL path; highest URL precedence. A value that already names a file (`/validator.html`) is **final** — `page_format` does not decorate it |
| `author` | integer/string | post | Author ID, numeric string, name or slug |
| `categories` | list | post | Category IDs, numeric strings, names or slugs |
| `category` | string | post | Single free-form category value exposed to templates |
| `tags` | list | post | Creates tag listings at `/tag/<slug>/` |
| `series` | string | post | Creates a series listing and previous/next navigation |
| `sticky` | bool | Pin the post to the top of date-ordered listings: the front page, the posts page, `.Site.Posts` and every term archive. Pinned posts keep their own order among themselves; reaches templates as `.Sticky`. Feeds are deliberately **not** pinned — `/feed/` reports what was published when, as WordPress does. |
| `excerpt` | string | both | Listing, feed and metadata summary |
| `description` | string | both | SEO description; themes may fall back to `excerpt` |
| `keywords` | string | both | SEO keywords |
| `canonical` | string | both | Explicit canonical value exposed to templates |
| `aliases` | list | both | Old paths that `301` here (and, by default, get a stub copy) |
| `alias_stubs` | bool | both | Override the site-wide default: `false` = 301 only, no stub copy; `true` = force a stub |
| `schema` | map | both | Override/extend this page's generated JSON-LD (deep-merged, per-page wins) |
| `featured_image` | string | both | Hero image; also `og:image`, `twitter:image` and JSON-LD `image` |
| `layout` | string | page | Theme layout; `redirect` also marks an item for sitemap exclusion |
| `paginate` | int / map | page | Split a collection over this page and `/page/N/` siblings, each rendered through the page's own layout with `.Pager` and `.Posts` in scope. `paginate: 10` pages the site's posts ten at a time; the map form takes `over` (`posts`, default, or `pages`), `size` (else the site's `paginate`, else 10) and `source` (a content root, as in `feeds:`). Only the first page enters the sitemap. See [Paginating a page](#paginating-a-page) |
| `template` | string | page | Specific page template filename override |
| `robots` | string | both | Robots directive; `noindex` excludes from sitemap |
| `sitemap` | string | both | `no` excludes the item from `sitemap.xml` |
| `lang` | string | both | Language used in multilingual output |

Unknown frontmatter fields are retained and flattened into the template's root
context. They never replace standard fields with the same name.

### Author and category resolution

IDs, names and slugs are supported:

```yaml
author: 1
categories: [1, 5]
```

```yaml
author: Ada Lovelace
categories: [Guides, Performance]
```

```yaml
author: ada
categories: [guides, performance]
```

Name and slug matching is case-insensitive. Numeric strings are converted to
IDs. Values that cannot be resolved through `metadata.json` are ignored.

## Content dimensions

A page is described by more than "post or page". Type, language, taxonomy and
source have been dimensions here for a while — a custom taxonomy is a dimension,
and `audience:` works today with no new code, see below. Three more arrived in
1.8.60.

### Relations

```yaml
---
title: API Authentication
slug: api-auth-v4
relations:
  supersedes: [api-auth-v3]
  see_also: [oauth-setup, api-keys]
---
```

A relation is a link the author names. The build already knew about series,
translations and a "related posts" heuristic; it could not know that this page
replaces that one, because only the person writing it does.

Values are slugs, resolved in the page's own language, falling back to the
default one for a page that has not been translated yet. In a template:

```gotemplate
{{ range relationsOf .Page "see_also" }}
  <a href="{{ .GetURL }}">{{ .Title }}</a>
{{ end }}

{{/* The inverse — which pages point HERE — is the half nobody can write down,
     because the new version does not know what it replaced until the whole
     site is loaded. */}}
{{ range relatedBy .Page "supersedes" }}
  <a href="{{ .GetURL }}">Superseded: {{ .Title }}</a>
{{ end }}
```

Relations also become edges in [`site-graph.json`](AI-AGENTS.md#site_graph), so
an agent reading the site sees them.

A relation naming a slug the site does not have is a broken link with a name on
it, and follows `check_links`: silent when link checking is off, a warning when
it is on, and a build failure under `strict`.

### Versions

```yaml
---
title: API Authentication
slug: api-auth-v4
version: 4
version_of: api-auth
---
```

`version_of` groups a chain; `version` orders it. Numbers sort as numbers, so
10 comes after 2.

The highest version is the **latest**, and every earlier one gets a canonical
pointing at it. That is the part worth having: without it, a document's own old
revisions compete with the current one for the same search query, and a site
quietly ranks its out-of-date documentation above the page it wants people to
read.

Templates get the whole chain:

```gotemplate
{{ if not .Page.IsLatest }}
  <p class="notice">There is a newer version of this page.</p>
{{ end }}
<ul>
  {{ range .Page.Versions }}
    <li{{ if .IsLatest }} class="current"{{ end }}><a href="{{ .GetURL }}">Version {{ .Version }}</a></li>
  {{ end }}
</ul>
```

```yaml
versions:
  noindex_old: true    # also removes them from the sitemap
```

`noindex_old` is off by default: a superseded page is still a page someone may
have linked to. Turning it on removes those pages from the sitemap as well,
through the rule that already drops any noindex page — rather than through a
second switch that could disagree with the first.

**URLs are not rewritten.** A version lives wherever its file says it does. The
build does not invent `/v4/` paths, because a URL that moves on its own is a URL
that breaks.

### Outputs per page

```yaml
---
title: API reference
outputs: [html, json]
---
```

Overrides the site's `outputs:` for this page, in both directions: a reference
page can publish JSON while the rest of the site does not, and a page can opt
out of what the site publishes.

### Audience

There is no `audience:` key, and there does not need to be one — it is a
taxonomy, and custom taxonomies already do this:

```yaml
taxonomies:
  audience:
    multiple: true
```

```yaml
---
title: Deploying
audience: [developer, operator]
---
```

That gives archives at `/audience/developer/`, filtering in templates
(`pagesByTerm "audience" "developer"`) and a term index, all of it working
today. See [TAXONOMIES.md](TAXONOMIES.md).

Rendering *different content* for different audiences under one URL is a
deliberately separate thing, and is not part of this.

### Nothing changes for frontmatter that does not use them

Every one of these keys is optional, and a file without them behaves exactly as
it did — the golden corpora check it. They also stay readable at `.Extra.version`
and `.Extra.relations`, so a template that already reads them there keeps
working. That is deliberate: promoting a key to a first-class field would
otherwise take it out of `.Extra` and break such a template silently.

## Excerpts and section markers

SSG supports WordPress-export-style section markers:

```markdown
## Excerpt
A short summary.

## Content
The full article.
```

The markers must match exactly and are removed from rendered content. Without
them, everything after frontmatter becomes content. Markers inside fenced code
blocks are treated as code. A top-level `# Title` line in content is discarded
as an export artifact; use the frontmatter `title` for the document title.

## Slugs and URLs

When `slug` is absent, it is derived from the filename without `.md` and
lowercased:

```text
API.md → api → /api/
```

Set `preserve_slug_case: true` to preserve filename or explicit slug casing.

Default URLs are:

| Content | Default URL |
|---|---|
| Page | `/<slug>/` |
| Post | `/<year>/<month>/<day>/<slug>/` |

URL precedence, from highest to lowest, is:

1. Frontmatter `link`
2. Configured `permalinks.post` or `permalinks.page`
3. `post_url_format` for posts
4. Default URL

Permalink patterns support `:year`, `:month`, `:day`, `:slug` and `:category`:

```yaml
permalinks:
  post: /:year/:month/:slug/
  page: /:slug/
```

Expanded paths are sanitised so they cannot escape the output directory.

### A clean `/blog/` (and why you might see `/category/blog/`)

SSG does not create a blog section for you, and it never forces posts under
`/category/blog/`. That URL appears for exactly one reason: a post was given a
**category** named "blog", and every category auto-generates an archive at
`/category/<slug>/`. If you want the industry-standard `/blog/` and a single
canonical URL per post, don't categorise posts as "blog" — build the section
explicitly instead:

1. **Put posts under `/blog/`** with a permalink pattern:

   ```yaml
   permalinks:
     post: /blog/:slug/
   ```

2. **Add a blog index page** that lists the posts. Create `pages/blog.md` and
   point it at a layout that ranges over the site's posts:

   ```yaml
   ---
   title: "Blog"
   slug: "blog"
   link: "/blog/"
   layout: "blog"       # renders templates/<theme>/layouts/blog.html
   type: page
   hide_from_lists: true
   ---
   ```

   The bundled `ssgtheme` ships a `layouts/blog.html` that does this; in a custom
   theme, a layout that iterates `.Site.Posts` is all it takes. (`hide_from_lists`
   is an `ssgtheme` convention read from a page's custom frontmatter to keep the
   index itself out of the nav — omit it in a theme that doesn't use it.)

3. **Leave posts uncategorised.** With no `category:`/`categories:` on a post,
   no `/category/blog/` archive is generated, so `/blog/` is the only listing and
   there is no duplicate-content pair. (Tags and series still work — they live at
   `/tag/<slug>/` and `/series/<slug>/`, separate from the main section.)

This is exactly how this documentation site is built. Reach for a category
archive only when you genuinely want a *taxonomy* facet, not as the primary blog
index.

`page_format` controls filesystem form:

| Value | Result for `about` |
|---|---|
| `directory` | `about/index.html` (default) |
| `flat` | `about.html` |
| `both` | Both forms |

## Aliases

Use aliases to preserve old inbound URLs:

```yaml
aliases:
  - /old/permalink/
  - /2019/legacy-path/
```

Each alias is **always** added as a `301` to the `_redirects` file (chains are
flattened). By default it *also* gets a `noindex` HTML redirect stub — a
meta-refresh + canonical fallback for hosts without server redirects. Aliases are
excluded from the sitemap; a collision with real content is skipped with a
warning.

### Redirect only, no duplicate copy

On a platform that serves `_redirects` (Cloudflare Pages, Netlify) the stub copy
is redundant: the old path already `301`s at the edge. To emit **only** the 301 —
no 200-serving duplicate for crawlers to spend budget on — turn stubs off. Set it
site-wide in the config (`alias_stubs: false`) or per page in frontmatter:

```yaml
aliases:
  - /hotel-term-condition/
alias_stubs: false   # this page's aliases 301 only; no stub copy
```

Per-page `alias_stubs` overrides the site-wide default either way — set it `true`
on a page to keep its stub even when the site disables stubs. The `301` is
emitted regardless; only the stub copy is affected.

**Trailing slash.** The `301` is written for the alias path exactly as declared,
while the stub copy is a directory index (`/old-slug/index.html`). On a host that
serves `_redirects`, a hand-written rule for `/old-slug` and the stub at
`/old-slug/` can therefore answer differently per trailing slash (one `301`s, the
other serves the 200 copy). If that split matters, set `alias_stubs: false` so
the alias is a pure `301` and there is no copy to disagree with the redirect.

## Relative Markdown links

Links to source Markdown files are rewritten to their generated URLs. This is
**on by default** (since 1.8.15) — a raw `.md` link otherwise 404s or serves the
source file; set `rewrite_md_links: false` to opt out. The behaviour is
unchanged, only the default:

```markdown
See [Authentication](AUTHENTICATION.md) and [API](../reference/API.md).
```

SSG resolves the source filename and final slug. Unknown `.md` links remain
unchanged. The feature is disabled by default for sites that intentionally
publish raw Markdown.

## Assets and static files

- Referenced images, media, archives and documents beside a Markdown file are
  copied as co-located assets (only files directly beside the Markdown source are supported;
  nested subdirectories next to content files are skipped). Unreferenced siblings are skipped.
- `content/<source>/media/` contains source media records/files.
- Project-level `static/` is copied recursively and verbatim to the output.
- Theme CSS, JavaScript and images are copied from the selected template.

Use `static/` for favicons, downloads, manifests and files SSG should not parse.
See [IMAGES.md](IMAGES.md) for generated image variants and template image
helpers.

## Data-driven content features

### Tags and series

`tags` generates `/tag/<slug>/` listings. `series` generates
`/series/<slug>/` and exposes previous/next series values to templates. A theme
may provide `series.html`; otherwise the category template is used.

### Multilingual content

When `languages` is configured, the default language stays at the site root and
other languages are emitted under `/<lang>/`. Set `lang` on each item to select
its language. Templates receive language, translation and `hreflang` context;
see [TEMPLATES.md](TEMPLATES.md).

### Dates

`timezone` and `language_timezones` affect content dates used by templates and
date permalink tokens. Feeds and sitemap timestamps remain UTC. With
`lastmod_from_git`, sitemap modification dates come from the source file's last
Git commit and fall back to frontmatter/file dates when unavailable — for a
verbatim `static_sources` document, which has no frontmatter, the fallback is the
file's modification time.

**`git` has to be on `PATH`.** When it is not, every date quietly comes from the
fallback instead, which looks like the setting working until the dates are
compared. The build now says so once. The case that hits it in practice is the
**snap**: strict confinement shows a snap only its own rootfs, and the snap
bundles `cwebp` and `avifenc` for exactly that reason but not `git`, so
`lastmod_from_git` cannot reach a repository there. Use the DEB, the tarball or
the Docker image on a site that depends on it.

## Paginating a page

`paginate` in the site config pages every *generated* listing — the post index,
category, tag, author, date and type archives. A page you wrote could not: it
had no `.Pager`, and a template can slice `.Site.Posts` but cannot write a
second file, so `/blog/page/2/` was a 404 whatever it linked to.

The case that needs it is a page that renders a listing **and something else**
— an aggregated feed above the site's own posts, an introduction, a call to
action — which is exactly why it is a page and not the generated listing.
Setting `posts_page` would hand the URL to the generator and lose the rest.

A page opts in from its own frontmatter:

```yaml
---
title: Blog
slug: blog
layout: blog
paginate: 10          # the site's posts, ten per page
---
```

or, naming what it pages over:

```yaml
paginate:
  over: posts         # posts (default) or pages
  size: 10            # else the site's `paginate`, else 10
  source: blog        # optional: one content root, matched as feeds: does
```

The build writes `/blog/`, `/blog/page/2/`, … through the page's own layout.
Every page of it gets `.Posts` (that page's slice) and `.Pager` (`Current`,
`Total`, `PrevURL`, `NextURL`, `Pages`), the same shape an archive gets, so one
pager partial serves both. Page 2 canonicalises to itself, in `.CanonicalURL`
and in the `og:url` and JSON-LD the SEO pass injects.

Two rules carry over from the generated listings: only the first page enters
`sitemap.xml` (the tail is a slice of one document, not more documents), and the
page's `.md`/`.json` outputs and aliases belong to page 1 alone. A page whose
`link:` names a file (`/blog.html`) has nowhere to put a `/page/2/` and is
rendered whole, with a warning that says so.

## WordPress-compatible media shortcodes

When migrating content from a WordPress site, the content might contain legacy media shortcodes. SSG has native, built-in support for parsing and sanitizing the following bracket shortcodes:

* **`[youtube]VIDEO_ID[/youtube]`** — Renders the standard YouTube video embed responsive iframe.
* **`[embed]VIDEO_URL[/embed]`** — Renders the responsive video player iframe.

These shortcodes are automatically protected from the HTML sanitizer (`sanitize_html: true`) via a secure token bypass mechanism, preventing the iframe elements from being stripped out during the build phase while still neutralising other malicious HTML.

To strip these shortcodes from your text in lists or feeds, use the `stripShortcodes` template helper. To extract the YouTube thumbnail image from a post's content, use the `thumbnailFromYoutube` helper.

## Publication checklist

- `metadata.json` exists and contains every referenced author/category.
- Posts are not placed directly in `posts/`.
- Frontmatter content has `status: publish`.
- `type` is correct and posts have a valid `date`.
- Explicit `link` and aliases do not collide.
- `ssg ... --check-links=strict` succeeds before deployment.
