# Templates and themes

Two halves: the themes you can use as they are, and how to write one of your
own — the file layout, the values in scope, and which template renders what.

Go template helper signatures are in [TEMPLATE_HELPERS.md](TEMPLATE_HELPERS.md);
image functions are in [IMAGES.md](IMAGES.md).

## Ready-made themes

Four themes ship with SSG. `simple` and `krowy` are embedded in the binary and
scaffolded into an empty theme directory the first time you name one; `imd` and
`ssgtheme` live in the repository, so copy the directory you want into your own
`templates/`.

The screenshots below are each theme's own demo content, built and captured from
this repository.

<ul class="theme-cards">
  <li class="theme-card">
    <img src="/img/themes/simple.webp" alt="The simple theme: a dark hero, a three-card post listing and a slim footer" loading="lazy" width="1600" height="1125">
    <div class="theme-card__body">
      <h3><code>simple</code> — a minimal blog</h3>
      <p>Dark by default, one accent colour, cards for the post listing. The smallest theme in the set and the one to start from when writing your own.</p>
      <span class="theme-card__tag">Embedded · scaffolded by <code>ssg init</code></span>
    </div>
  </li>
  <li class="theme-card">
    <img src="/img/themes/krowy.webp" alt="The krowy theme: a wood-texture background, a photo strip and a sidebar with recent posts and categories" loading="lazy" width="1600" height="1125">
    <div class="theme-card__body">
      <h3><code>krowy</code> — a fuller blog with a sidebar</h3>
      <p>A wider layout with a sidebar for recent posts and categories, a photo strip in the header and a textured background. The one to look at for a content-heavy site.</p>
      <span class="theme-card__tag">Embedded · used by the examples</span>
    </div>
  </li>
  <li class="theme-card">
    <img src="/img/themes/imd.webp" alt="The imd theme: a light agency landing page with a network illustration and a statistics panel" loading="lazy" width="1600" height="1125">
    <div class="theme-card__body">
      <h3><code>imd</code> — a corporate landing page</h3>
      <p>A marketing layout rather than a blog: hero with a call to action, a statistics panel, a partner strip.</p>
      <span class="theme-card__tag">In the repository · copy <code>templates/imd/</code></span>
    </div>
  </li>
  <li class="theme-card">
    <div class="theme-card__body">
      <h3><code>ssgtheme</code> — documentation sites</h3>
      <p>The theme this documentation is built with: card grids, a guide layout with the sidebar you are reading beside, a colour-scheme switch and shared chrome in <code>partials/</code>. You are looking at it.</p>
      <span class="theme-card__tag">In the repository · <a href="../templates/ssgtheme/README.md">README</a></span>
    </div>
  </li>
</ul>

## Selecting a theme

The second positional argument names a directory below `templates_dir`:

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

With default paths, the theme is `templates/my-theme/`.

## Downloading a theme

A theme can be fetched before generation:

```bash
ssg my-blog some-theme example.com \
  --online-theme=https://github.com/user/some-theme
```

GitHub, GitLab and direct ZIP URLs are accepted. Only ZIP archives are
extracted — a tar URL is rejected up front rather than downloaded in full and
then failing.

**Themes laid out for other generators often work.** An archive whose templates
live under `layouts/` alongside `static/` or `assets/` — the conventional
layout for Hugo themes — is converted into SSG's structure during extraction, so
a theme written for that layout can be a starting point rather than a rewrite.
How much of it works depends on the template syntax it uses: see
[template engines](#template-engines) for the alternatives SSG can render.

Downloaded code and templates should be reviewed before use. A theme is
executable template code, and extraction is bounded (100 MB per entry, 10 000
entries) but not a sandbox.


The rest of this guide is for writing or modifying a theme.

## Theme structure

```text
templates/my-theme/
├── base.html
├── index.html
├── page.html
├── post.html
├── category.html
├── tag.html               # optional; falls back to category.html
├── author.html            # optional; falls back to category.html
├── series.html            # optional; falls back to category.html
├── layouts/
│   └── landing.html       # optional page layout
├── partials/              # theme-owned organisation/assets
├── shortcodes/
│   └── promo.html
├── css/
├── js/
└── images/
```

Standard template roles:

| File | Renders |
|---|---|
| `index.html` | Homepage and paginated homepage pages |
| `page.html` | Normal pages |
| `post.html` | Posts |
| `category.html` | Categories and fallback for other archives |
| `tag.html` | Tag archive when present |
| `author.html` | Author archive when present |

**Define names must match file names.** Templates are selected by their
*define* name, not the file name. If your theme wraps templates in
`{{define "…"}}` blocks, copying `category.html` to `author.html` is not
enough — the copy still defines `category.html`. Rename the define to
`author.html` to activate it. Since v1.8.5 such a "shell" file falls back
gracefully (never a blank page) and the build prints a warning naming the fix.
Themes without `{{define}}` blocks are matched by file name and need no
renaming.
| `series.html` | Series archive when present |
| `layouts/<name>.html` | A page with frontmatter `layout: <name>` — the file may define either `<name>.html` or `layouts/<name>.html`; both resolve |
| `<name>.html` | A page with frontmatter `template: <name>` |

Custom `layout` and `template` selection currently applies to pages. Posts use
`post.html`. If a page's selected custom template is absent, SSG falls back to
`page.html`.

For the Go engine, when the theme root contains no `.html` files, SSG creates
the five standard templates. If any root HTML template exists, the theme is
treated as intentional and missing files are not individually scaffolded.
Non-Go engines never receive generated templates and must ship all templates
they need.

### Theme assets and their output URLs

A theme's `css/`, `js/` and `images/` directories are copied to the output root
under the **same name**, so a template references them by an absolute path:

| Theme directory   | Copied to        | Reference in a template as              |
|-------------------|------------------|-----------------------------------------|
| `<theme>/css/`    | `output/css/`    | `/css/<file>`   (e.g. `/css/style.css`) |
| `<theme>/js/`     | `output/js/`     | `/js/<file>`    (e.g. `/js/main.js`)    |
| `<theme>/images/` | `output/images/` | `/images/<file>` (e.g. `/images/logo.svg`) |

So a theme whose files are `templates/my-theme/css/style.css`,
`.../js/main.js` and `.../images/logo.svg` links them as:

```html
<link rel="stylesheet" href="/css/style.css">
<script src="/js/main.js" defer></script>
<img src="/images/logo.svg" alt="Logo">
```

The subdirectory name is preserved verbatim — nothing is renamed or flattened,
and only these three directories are treated as theme assets (other top-level
theme directories such as `partials/`, `shortcodes/` and `layouts/` are template
sources, not copied as assets).

The `media/` directory in the generated-output tree is **not** a theme
directory: it holds media copied from the content source
(`content/<source>/media/` → `output/media/`), independent of the theme.

Build transforms (SCSS compilation, bundling, minification, fingerprinting) run
*after* this copy, operating on the files already sitting at `output/css/` etc.

**Precedence with `static_dir`.** The project's static directory (`static/` by
default, or whatever `static_dir:` names) is copied **verbatim to the output
root after the theme assets**. When a static file and a theme asset resolve to
the same output path — e.g. `static/css/style.css` and `<theme>/css/style.css`
both land at `output/css/style.css` — **the static file wins**: it is copied
last and overwrites the theme's copy. Use this to override a single theme asset
per project; keep static paths clear of the theme's `css/`, `js/` and `images/`
if you do not intend to shadow it.

## Template loading and sharing

Three directories are parsed, in this order, into **one** template set:

| Parsed | Contents |
|---|---|
| `<theme>/*.html` | the role templates above |
| `<theme>/layouts/*.html` | per-page layouts selected by frontmatter `layout:` |
| `<theme>/partials/*.html` | shared `{{define}}` blocks, callable from any of the above |

Because it is one set, a `{{define "site-header"}}` written in
`partials/chrome.html` is callable from `index.html`, `post.html` or a layout —
that is how a theme keeps its `<head>`, header and footer in one place instead
of copying them into every role file:

```gotemplate
{{/* partials/chrome.html */}}
{{define "site-header"}}
  <header>…{{ .Domain }}…</header>
{{end}}

{{/* page.html */}}
{{ template "site-header" . }}
```

Pass a computed context with `dict` when a partial needs values the caller
knows:

```gotemplate
{{ template "site-head" (dict
    "Title" (printf "%s — %s" .Page.Title .Domain)
    "Canonical" (printf "/%s/" .Page.Slug)
    "Ctx" .) }}
```

Notes that save a debugging session:

- **Only `.html` files are parsed.** Other files under `partials/` are neither
  parsed nor copied to the output; public assets belong in `css/`, `js/` or
  `images/`, which are the only theme directories copied verbatim.
- **A file whose defines do not match its filename renders nothing on its own.**
  That is exactly what makes `partials/chrome.html` work — and why a
  `category.html` copied to `author.html` still needs its define renamed (see
  the warning described above).
- **`base.html`** is part of the scaffolded starter themes and is only used by
  a theme that chooses to define and call it; it is not a required file and SSG
  never invokes it implicitly.
- Templates are parsed once per build, after content is loaded, so site data is
  fully available to every helper.
- **An HTML comment written in a template does not reach the output.** Go's
  `html/template` strips comments while parsing — long-standing and reasonable
  on its own terms, and silent: no error, no warning, output missing something
  you wrote. It matters because every comment-based *host directive* is affected,
  not just a note to yourself:

  ```gotemplate
  <!--email_off-->            {{/* Cloudflare Email Obfuscation opt-out — VANISHES */}}
  {{ "<!--email_off-->" | safeHTML }}   {{/* survives */}}
  ```

  The same applies to SSI/ESI markers (`<!--#include …-->`), CDN and tag-manager
  markers, and any build-provenance comment a theme wants to emit. Wrap the
  comment in `safeHTML` whenever it is meant for something downstream to read.

  **It then survives `minify_html` too.** Until 1.8.59 minification deleted every
  comment but the conditional `<!--[if …]>`, so the `safeHTML` form worked with
  minification off and silently did nothing with it on — which is how most
  production builds run. The directives kept by default are `<!--[if`,
  `<!--email_off`, `<!--/email_off`, `<!--#` (SSI), `<!--esi`, `<!--googleoff`,
  `<!--googleon`, `<!--noindex`, `<!--/noindex` and `<!--htmlmin:keep`. A host
  that reads something else is covered by `minify_html_keep_comments`:

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

  An ordinary comment — a note to yourself — is still removed, which is the
  point of minifying.

The bundled `ssgtheme` is the reference implementation of this layout:
`partials/chrome.html` holds the head, header and footer; the four role
templates hold only what is unique to them. See
[`templates/ssgtheme/README.md`](../templates/ssgtheme/README.md).

## Marking editable regions

A theme says which parts of a page may be edited in the browser
(`ssg --http --watch --edit`, see [EDITING.md](EDITING.md)) with one attribute:

```html
<h1 data-ssg-edit="frontmatter:title">{{ .Post.Title }}</h1>
<time data-ssg-edit="frontmatter:date" datetime="{{ .Post.Date.Format "2006-01-02" }}">…</time>
<p data-ssg-edit="frontmatter:description">{{ .Page.Description }}</p>
```

The value names the source of the text: `frontmatter:<key>` opens that field's
control, and `body` marks the region holding the page's content, inside which a
click on a paragraph, heading, list item or quotation opens that block:

```html
<div class="post-body" data-ssg-edit="body">{{ .Post.Content | safeHTML }}</div>
```

Put it on an element the theme already has rather than adding a wrapper, so a
published page keeps the markup it had. A region with no attribute is not editable, which is the point — most
text on a rendered page did not come from a file's body, and guessing where it
came from is how an editor changes the wrong paragraph.

The attribute is free to leave in: a build without `--edit` removes it, byte for
byte, so a published page carries none of it. `ssgtheme`, `simple` and `krowy`
already have it on their titles.

## Template engines

| Engine | Configuration | Aliases | Syntax |
|---|---|---|---|
| Go | `engine: go` | default | `html/template` |
| Pongo2 | `engine: pongo2` | `jinja2`, `django` | Jinja2/Django-like |
| Mustache | `engine: mustache` | — | Logic-less Mustache |
| Handlebars | `engine: handlebars` | `hbs` | Handlebars |

CLI example:

```bash
ssg my-blog my-theme example.com --engine=pongo2
```

Syntax comparison:

```gotemplate
{{ range .Posts }}
  <h2>{{ .Title }}</h2>
{{ end }}
```

```django
{% for post in Posts %}
  <h2>{{ post.Title }}</h2>
{% endfor %}
```

```mustache
{{#Posts}}
  <h2>{{Title}}</h2>
{{/Posts}}
```

```handlebars
{{#each Posts}}
  <h2>{{Title}}</h2>
{{/each}}
```

Non-Go engines receive the same data model adapted to their renderer. Their
theme files must be written in the selected engine's syntax, and Go template
inheritance (`{{define}}`/`{{template}}`) does not apply. Rendered Markdown
content is provided as HTML for these engines.

### Helper support across engines (GO-054)

SSG hands every engine the same helper library. Pongo2 exposes helpers as
**filters** (`{{ value|helper }}`, `{{ value|helper:arg }}`); Handlebars
exposes them as **helpers** (`{{helper value}}`). Mustache is logic-less and
cannot call helpers at all. Anything an engine cannot express is reported once
at build time — never silently ignored.

| Helper group | Go | Pongo2 | Handlebars | Mustache |
|---|:--:|:--:|:--:|:--:|
| Classic (`safeHTML`, `formatDate`, `stripHTML`, `default`, `dict`, …) | ✅ | ✅¹ | ✅ | ❌ |
| Conditionals (`in`, `contains`, `startsWith`, `ternary`, `matches`, …) | ✅ | ✅ | ✅ | ❌ |
| Image (`imageResize`, `imageSrcSet`, `imageInfo`, …) | ✅ | ✅ | ✅ | ❌ |
| External sources (`getExternal`, `getExternalMeta`) | ✅ | ✅ | ✅ | ❌ |
| i18n (`t`) | ✅ | ✅ | ✅ | ❌ |
| Collection (`where`, `filter`, `sort`, `groupBy`, `pluck`, …) | ✅ | ⚠️² | ⚠️² | ❌ |

¹ Helpers returning HTML are marked safe automatically; pipe through pongo2's
own `|safe` only if you compose further. ² Helpers with more than two arguments
(pongo2) or more than three (Handlebars), and variadic helpers, cannot be
adapted — calling one raises a visible error (pongo2) or renders a
`[helper X error: …]` marker (Handlebars) plus a build warning, so the failure
is never silent.

### Engine limitations

- **Mustache**: logic-less by design — no helpers, filters, or expressions.
  Prepare any derived values in front matter or switch to Pongo2/Handlebars.
- **Pongo2**: helper results that are already HTML are returned as safe values;
  plain strings are auto-escaped like any pongo2 output.
- **Partials/inheritance**: alt-engine themes load each template file
  independently; use that engine's native include mechanism, not Go's
  `{{define}}`.

## Rendering contexts

SSG uses different root contexts for individual content and collection pages.
Rely on the tables below rather than assuming every value exists everywhere.

### Homepage

| Value | Type | Meaning |
|---|---|---|
| `.Site` | site data | All pages, posts, categories, media and authors |
| `.Posts` | list | Posts on the current pagination page |
| `.Pages` | list | All pages |
| `.Domain` | string | Canonical host |
| `.Vars` | map | Custom configuration variables |
| `.Data` | map | YAML/JSON data files |
| `.Pager` | pager | Pagination state |
| `.BuildTime` | time | When this build ran — one value for the whole build |

`.Pager` contains `Current`, `Total`, `PerPage`, `PrevURL` and `NextURL`:

```gotemplate
{{ if gt .Pager.Total 1 }}
  {{ if .Pager.PrevURL }}<a href="{{ .Pager.PrevURL }}">Previous</a>{{ end }}
  <span>{{ .Pager.Current }} / {{ .Pager.Total }}</span>
  {{ if .Pager.NextURL }}<a href="{{ .Pager.NextURL }}">Next</a>{{ end }}
{{ end }}
```

### Page and post

Individual content fields are flattened at the root:

| Value | Meaning |
|---|---|
| `.Site`, `.Domain`, `.Vars`, `.Data` | Global site/configuration data |
| `.BuildTime` | When this build ran (see below) |
| `.ID`, `.Title`, `.Slug`, `.Status`, `.Type` | Identity fields |
| `.Date`, `.Modified` | Dates converted to the configured content timezone |
| `.Content`, `.Excerpt`, `.Description`, `.Keywords` | Body and metadata text |
| `.URL`, `.CanonicalURL`, `.OutputPath` | Computed destinations |
| `.Link`, `.Canonical`, `.Robots`, `.Sitemap` | Explicit URL/SEO values |
| `.Author` | `int` — a metadata ID, **not** a name. Resolve with `getAuthorName .Author` |
| `.Categories` | `[]int` — metadata IDs. Resolve each with `getCategoryName` / `getCategorySlug` |
| `.Category` | `string` — the primary category name |
| `.Tags` | `[]string` — names already |

### `.BuildTime` — the copyright year that stays current

```gotemplate
© 2007-{{ .BuildTime.Year }} {{ .Domain }}
```

`.BuildTime` is a `time.Time` set **once per build** and handed to every page
and archive, so two documents of one build can never straddle midnight and
disagree. It is a value, not a function: rendering never reads a clock, which is
what keeps a rebuild of unchanged sources byte-identical.

It honours **`SOURCE_DATE_EPOCH`**, the reproducible-builds convention. Pin it
and the build is a pure function of its input:

```bash
SOURCE_DATE_EPOCH=1700000000 ssg content simple example.com   # always 2023
ssg content simple example.com                                # the real clock
```

A malformed value is ignored rather than fatal — the variable is often set by a
surrounding toolchain the site owner does not control, and someone else's typo
should not break a deploy. The build stays internally consistent either way.

A site that rebuilds on a schedule, or on a `republish-trigger` webhook, then
never shows last year's footer without anyone editing anything.

> ⚠️ The four do not behave alike. An exporter writes **names** in frontmatter
> (`author: "Zonqor"`, `categories: ["Marsaskala"]`), but `.Author` and
> `.Categories` hold the **IDs** those names resolved to. So
> `{{ .Author }} · {{ range .Categories }}{{ . }}{{ end }}` renders `2 · 3, 4`.
> It is not an error, just wrong output, which is why it survives review — use
> `getAuthorName`, `getCategoryName` and `getCategorySlug`.

> There is no `urlize` or `slugify` helper. To build a term URL use
> `termURL "category" "Marsaskala"` — see
> [TAXONOMIES.md](TAXONOMIES.md#template-helpers) for the taxonomy helper set,
> which [TEMPLATE_HELPERS.md](TEMPLATE_HELPERS.md) cross-references.
| `.FeaturedImage` | Hero/social image |
| `.Layout`, `.Template` | Content template selection fields |
| `.WordCount`, `.ReadingTime` | Computed reading statistics |
| `.HasMath`, `.TOC` | Optional authoring output |
| `.Series` | Series name |
| `.SeriesPrevURL`, `.SeriesPrevTitle` | Previous series item |
| `.SeriesNextURL`, `.SeriesNextTitle` | Next series item |
| `.Lang`, `.Languages`, `.DefaultLanguage` | Language state |
| `.Translations`, `.Hreflang` | Language switching/alternate links |

The `models.Page` struct is also available as a nested value — but the key differs
by content type: **`.Page` in `page.html`, `.Post` in `post.html`.** A post
template reaching for `.Page.Title` gets nothing, silently: no error, just an empty
heading and empty taxonomy lists.

**The nested value is the struct, not the table above.** The table lists what the
template *root* provides, and the two are not the same set — several root values
are computed for the template and have no struct field behind them. Reaching for
one under `.Page` fails at render with a message that does not hint at the fix,
and the page is then skipped with a warning, so on a first build it looks like the
content never loaded:

```
executing "page.html" at <.Page.TOC>: can't evaluate field TOC in type interface {}
```

| Value | Where it lives |
|---|---|
| `.URL`, `.CanonicalURL`, `.OutputPath`, `.TOC`, `.Hreflang` | **root only** — computed for the template, no struct field behind them |
| `.Site`, `.Domain`, `.Vars`, `.Data`, `.ExternalData`, `.Languages` | **root only** — site-level, not per-page |
| `.Pager`, `.Posts` | **only on a page with `paginate:` in its frontmatter** — this page's pager and slice, the same shape an archive gets; nil / absent elsewhere, so guard with `{{ with .Pager }}` in a shared layout |
| `.Title`, `.Slug`, `.Date`, `.Description`, `.Tags`, `.Content`, `.Translations`, … | both root and `.Page`/`.Post` |
| custom frontmatter (`lead:`) | root as `.lead`, or **`.Page.Extra.lead`** |

Under the nested struct the computed ones have method equivalents:
`{{ .Page.GetURL }}`, `{{ .Page.GetCanonical .Domain }}`, `{{ .Page.GetOutputPath }}`.

Custom frontmatter keys are flattened to the root (so `{{ .lead }}` works) but
cannot overwrite a standard value; under the nested struct they live in the
`Extra` map.

```gotemplate
{{/* post.html */}}  <h1>{{ .Post.Title }}</h1>{{ .Post.Content | safeHTML }}
{{/* page.html */}}  <h1>{{ .Page.Title }}</h1>{{ .Page.Content | safeHTML }}
```

Note the `| safeHTML` in both: a content field is the **raw Markdown source**, and
`safeHTML` is what converts it to HTML (see
[docs/TEMPLATE_HELPERS.md](TEMPLATE_HELPERS.md)). Printing `{{ .Content }}`
directly ships unrendered Markdown to the reader.

### Category, tag, author and series archives

| Value | Meaning |
|---|---|
| `.Site` | Complete site data |
| `.Category` | Category-compatible name/slug object |
| `.Kind` | `category`, `tag`, `author` or `series` |
| `.Name` | Display name |
| `.Series` | Series name for compatibility |
| `.Posts` | Posts in the archive |
| `.Pager` | Pagination for this archive (`.Current`, `.Total`, `.Pages`) |
| `.CanonicalURL` | Absolute URL of **this** archive page |
| `.Lang`, `.BuildTime` | Language code and build timestamp |
| `.Domain`, `.Vars`, `.Data` | Global values |

Category, tag and author posts are newest first. Series posts are oldest first
to preserve reading order.

`.CanonicalURL` is the same name pages and posts carry, so one expression writes
the canonical everywhere:

```gotemplate
<link rel="canonical" href="{{ .CanonicalURL }}"/>
<meta property="og:url" content="{{ .CanonicalURL }}"/>
```

It names the path the archive was actually written to, which matters in two
cases a template cannot reconstruct from `.Category.Slug`: a category with its
own `link:` is served away from `/category/`, and a nested category is served at
`/category/<parent>/<child>/`. Page 2 canonicalises to itself rather than
claiming to be page 1.

Author archives are addressed with `authorURL` (see
[docs/TEMPLATE_HELPERS.md](TEMPLATE_HELPERS.md)); `termURL` covers registered
taxonomies only.

## Site data

`.Site` contains:

```text
.Site.Domain
.Site.Pages
.Site.Posts
.Site.Categories   # map keyed by integer ID
.Site.Media        # map keyed by integer ID
.Site.Authors      # map keyed by integer ID
```

Examples:

```gotemplate
{{ range .Site.Pages }}
  <a href="{{ .GetURL }}">{{ .Title }}</a>
{{ end }}
```

```gotemplate
{{ with index .Site.Authors .Author }}
  <a href="/author/{{ .Slug }}/">{{ .Name }}</a>
{{ end }}
```

### What a category and a media item carry

These entries hold more than the generator reads. The extra fields exist for
themes, and are listed here so a theme author does not have to guess (GO-045):

| `.Site.Categories[id]` | What it is |
|---|---|
| `.ID`, `.Name`, `.Slug` | identity; `.Slug` is what the archive URL uses |
| `.Description` | the term's own text, for an archive header |
| `.Count` | how many entries the source CMS had in it — "12 posts" beside a term |
| `.Parent` | the id of the parent term, for a nested menu; `0` at the top |
| `.Link` | the URL the **source** site served this archive at, useful on a migrated site |

| `.Site.Media[id]` | What it is |
|---|---|
| `.ID`, `.Slug`, `.Title.Rendered` | identity and caption |
| `.MediaType`, `.MimeType` | `image`, `file`; the MIME type picks an icon for a document link |
| `.SourceURL` | the original URL, for a site that did not copy its media across |
| `.MediaDetails.File` | the path the generator itself resolves a featured image through |
| `.MediaDetails.Width`, `.Height` | the intrinsic size — write both attributes and the page stops shifting as images load |

```gotemplate
{{ with index .Site.Media .FeaturedMediaID }}
  <img src="{{ .SourceURL }}" alt="{{ .Title.Rendered }}"
       width="{{ .MediaDetails.Width }}" height="{{ .MediaDetails.Height }}">
{{ end }}
```

## Data and variables

`data/authors/ada.yaml` is exposed as `.Data.authors.ada`. Configuration:

```yaml
variables:
  analytics_id: G-XXXX
  api:
    endpoint: https://api.example.com
```

becomes:

```gotemplate
{{ .Vars.analytics_id }}
{{ .Vars.api.endpoint }}
```

See [CONFIGURATION.md](CONFIGURATION.md#data-and-variables) for environment
resolution and hook exports.

## Go template helpers

The Go engine includes standard `html/template` operations plus SSG functions.
A small example:

```gotemplate
{{ $recentGuides := .Site.Posts
    | where "Type" "post"
    | sort "Date" "desc"
    | first 5
}}

{{ range $recentGuides }}
  <article><a href="{{ .GetURL }}">{{ .Title }}</a></article>
{{ end }}
```

Helper groups include:

- collection operations: `where`, `sort`, `first`, `last`, `groupBy`, `uniq`,
  `pluck`, `reverse`, `limit` and `offset`;
- conditionals: `in`, `notIn`, `contains`, `startsWith`, `matches`, `isNil`,
  `isEmpty` and `ternary`;
- content helpers: `latest`, `published`, `byTag`, `byCategory`, `byAuthor` and
  `related`;
- Markdown, URL, date and WordPress migration helpers;
- build-time image helpers.

Invalid collection helper use fails rendering with a descriptive error. Inputs
are not mutated. Full signatures and comparison rules are documented in
[TEMPLATE_HELPERS.md](TEMPLATE_HELPERS.md).

## Image helpers

Go templates and shortcodes can inspect and process images:

```gotemplate
{{ $img := imageResize "images/hero.jpg"
    (dict "width" 1200 "height" 630 "mode" "fill" "format" "webp") }}
<img src="{{ $img.URL }}"
     width="{{ $img.Width }}"
     height="{{ $img.Height }}"
     alt="">
```

Generated variants use a deterministic content-addressed cache below
`processed_images/`. WebP output requires `cwebp`. See [IMAGES.md](IMAGES.md).

## Shortcode templates

Shortcode template paths are relative to the selected theme. A configured
`shortcodes/promo.html` can use:

```gotemplate
<aside class="promo promo--{{ .Type }}">
  {{ if .Logo }}<img src="{{ .Logo }}" alt="">{{ end }}
  <h2>{{ .Title }}</h2>
  <p>{{ .Text }}</p>
  <a href="{{ .Url }}">Learn more</a>
  {{ if .Legal }}<small>{{ .Legal }}</small>{{ end }}
</aside>
```

### What is in scope inside a shortcode template

A shortcode template is executed against the **shortcode itself**, not against
the page — `.` and `$` are the same object. In scope:

| Expression | Source |
|---|---|
| `.Name` `.Type` `.Title` `.Text` `.Url` `.Logo` `.Legal` `.Ranking` `.Tags` | the `shortcodes:` entry |
| `.Data.key` | the entry's `data:` map (values are strings) |
| `.Attrs.key`, `.InnerContent` | the invocation: `[name key="v"]inner[/name]` |
| `.Vars.key`, `$.Vars.key` | site-wide `variables:` (same map page templates see) |
| `.SiteData.key` | the `data/` files — the same tree page templates read as `.Data` |
| `.ExternalData.key` | `external_sources` namespaces |

`.SiteData`, not `.Data`: inside a shortcode `.Data` has always meant the
`shortcodes:` entry's own `data:` map, and repointing it would break every theme
using it. The site-wide namespaces are admitted for the same reason `.Vars` is —
one map shared by every invocation, with nothing page-specific about it.

**Not** in scope: `.Page`, `.Site`, `.Posts`, `.Categories` or anything else
from a page template's context. A shortcode has no page — the same instance may
render on many pages — so reaching for page data is a template error.

**Theme partials are callable.** A shortcode is parsed into a copy of the
theme's namespace, so `{{ template "card" . }}` reaches `partials/card.html`
like any role template does, and a block both a page template and a shortcode
need is written once. The copy is private to the shortcode: a `{{ define }}` in
it wins inside it and cannot change what a page renders.

**The collection helpers work here too** — `filter`, `sort`, `first`, `where`,
`pluck` and the rest — because there is now site-wide data to use them on. With
`int`/`float` converting an attribute, a data-driven block is expressible in
full:

```gotemplate
{{/* shortcodes/reviews.html — [reviews min="4" limit="3"] */}}
{{ $picked := filter "rating" "ge" (float .Attrs.min) .SiteData.reviews }}
{{ range first (int .Attrs.limit) (sort "date" "desc" $picked) }}
  {{ template "review-card" . }}
{{ end }}
```

A template error does not stop the build by default: the shortcode is dropped
from the page and a warning is printed. Set `shortcode_errors` (or
`--shortcode-errors=`) to change that:

| Mode | Result |
|---|---|
| `drop` (default) | warning; the shortcode is removed from the page |
| `keep` | warning; the shortcode's **raw source** stays in the page, so the gap is visible |
| `strict` | as `keep`, and the build fails after rendering |

`keep` and `strict` are the ones to use in CI: a page that quietly lost its
payment widget still looks fine, whereas one showing `[stripe_form]` does not.
The raw source also survives HTML minification, which an HTML comment marker
would not.

Configuration and supported forms are documented in
[CONFIGURATION.md](CONFIGURATION.md#shortcodes).

## Creating a theme

1. Create `templates/<name>/`.
2. Add all five standard templates, even though the Go engine can scaffold an
   entirely empty theme. Explicit files make the theme portable and reviewable.
3. Start with `index.html`, `page.html`, `post.html` and `category.html` using
   only the documented context for each.
4. Put public assets below `css/`, `js/` or `images/`.
5. Test empty collections, content without optional metadata, pagination and a
   production build with minification.
6. Run strict link validation:

```bash
ssg my-blog my-theme example.com --clean --check-links=strict
```

Do not edit generated files in `output/`; change the theme or source instead.
