# Theme contract
What the engine promises a theme, and the only thing this repository is bound to (ADR-0023). A theme's
markup, layout and styling are not the engine's business; a theme's *inputs* are.
**STATUS: partly live.** The fields under *Live today* exist and are gated; everything else is the shape
the contract takes when the feature arrives. All of it is engine obligation, not theme instruction — a
theme may ignore any of it.
## Live today
A bundle page receives:
| Field | Contents |
|---|---|
| `.Title` | may be empty; a template falls back to `.Key` rather than failing |
| `.Lang` | the locale of this variant, always set |
| `.Key` | the bundle's identity, without language or extension. **Not its address** — a `slug` moves the address and leaves the key alone, so link with `.URL` or `.Canonical` and never by assembling a key (ADR-0035) |
| `.HTML` | the rendered body, already escaped |
| `.Extra` | every frontmatter key the parser does not name (ADR-0002) |
| `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route |
| `.Canonical` | the permalink of the variant actually served — not the URL requested, which differs when the fallback chain supplied another language. **Absolute** when the site declares `base`, since a canonical link is resolved by machines rather than by the page (ADR-0039) |
| `.Alternates` | every language this key exists in: `.Lang`, `.URL` (absolute, for `hreflang`) and `.Path` (root-relative, for a *visible* language link — `.URL` would send a reader to the canonical host) |
| `.Site` | what the site declared about itself: `.Site.Base` and `.Site.Title`, either possibly empty |
| `.Sections` | every section that holds something, for navigation. Only `.Title` and `.URL` are set, and the list is empty on a bare site (ADR-0049) |
| `.Sequence` | the series this page sits in, absent when it sits in none (ADR-0033) |
| `.Tags` | this bundle's own terms, each with the URL of its listing; empty when it carries none (ADR-0049) |
| `.ExtrasURL` | this bundle's supporting files, empty when it has none — so a theme can offer them without guessing (ADR-0047) |
`.Sequence` carries the reading order and this page's place in it:
| Field | Contents |
|---|---|
| `.Sequence.Title`, `.Sequence.URL` | the series' title (may be empty) and its permalink |
| `.Sequence.Members` | every entry in reading order, each as an `.Items` entry — *ascending*, unlike a dated listing |
| `.Sequence.Index`, `.Sequence.Count` | this page's 1-based position and the total; `Index` is 0 when this page is the series landing itself |
| `.Sequence.Prev`, `.Sequence.Next` | the neighbours, absent at the ends and on a landing page. `Prev` is the **earlier** entry — the opposite sense of a listing's `.PrevURL` |
| `.Sequence.First`, `.Sequence.Last` | the ends of the series, present whenever it has members |
A landing page therefore renders an archive from `.Members` and a chapter renders navigation from
`.Prev`/`.Next`, both from one field. Membership and order are the engine's business
(`content-model.md`); a theme never sorts.
Two named templates: `base` is executed for every page; `main` is the block each kind of page defines and
a theme redefines. There is one parsed set per kind — bundle and listing today — so two kinds may both
define `main` without colliding (ADR-0019).
Every kind of page receives `.Title`, `.Lang`, `.Canonical`, `.Style`, `.Site`, `.Sections` and `.Alternates` —
the document shell. A listing page adds:
| Field | Contents |
|---|---|
| `.Items` | entries on this page: `.Title`, `.Key`, `.URL`, `.Date`, `.Section` |
| `.Page`, `.Pages` | 1-based position and total, `Pages` at least 1 |
| `.PrevURL`, `.NextURL` | empty at the ends; *newer* is `prev`, because the order is newest first |
| `.Groups` | the same entries partitioned by section, each with `.Name` and `.Items`. A tag listing supplies **both** shapes and the theme picks: templates cannot group, so the engine offers the partition, but whether a listing looks grouped is markup (ADR-0046) |
## Chrome text and formatting
Three functions, available in every template including a site root's own blocks. They exist so no template
hardcodes English: the words the engine supplies are the engine's to localise (ADR-0034).
| Call | Gives |
|---|---|
| `{{t .Lang "newer"}}` | one phrase in that language. Extra arguments fill `%s` placeholders in order |
| `{{num .Lang .Page}}` | an integer in that language's digits — `12`, `১২` |
| `{{day .Lang .Date}}` | a date as that language reads it — `8 March 2026`, `৮ মার্চ ২০২৬`; empty for a zero date |
Phrase keys today: `newer`, `older`, `empty`, `page-of` (two arguments), `position` (two arguments), `first`,
`last`, `extras`, `back-to-page`, `contents`, `note`, `warn`, `tip`, `details`. An
unknown language falls back to the default locale and an unknown key returns itself, so a missing
translation can never blank a page or fail a render.
Three rules a theme must keep: put the machine-readable form in the attribute and the localised form in the
text — ``; never localise
`.Title`, `.HTML` or anything else the author wrote; and never pass a URL, key or path segment through
these functions. An address is not chrome (`content-model.md`).
A site root cannot add or override a phrase yet. A theme needing its own words writes them in its own
block; site-supplied strings wait for the settings cascade (`ideas/deferred-decisions.md`).
## Shortcode fragments
Fragments live in `templates/shortcodes.html`, or in `templates/shortcodes/*.html`, or both — one named
template per shortcode, and that is where a shortcode's markup lives. The engine parses the call and supplies
its data, never any HTML (ADR-0036).
Parse order is embedded file, embedded directory, site file, site directory, and the last definition of a name
wins (ADR-0071). So **the directory overrides the file** within one source, and a site overrides the binary
either way. The reference theme ships the directory: `figure`, `gallery`, `icon`, `admonitions` (note, warn,
tip), `details`, `aside`, `toc`. Override one by defining that name anywhere the engine looks; everything you
leave alone is inherited.
### Assets a fragment needs
A shortcode that needs CSS or JS declares it as a second fragment named `assets:` (ADR-0079):
```
{{define "lightbox"}}…{{end}}
{{define "assets:lightbox"}}{{end}}
```
The engine renders `assets:lightbox` **once** into `Page.Assets` when the page called `::lightbox` at all —
however many times — and renders nothing when it did not. A shortcode with no `assets:` fragment is the
normal case and costs nothing to declare. Frontmatter `use: [lightbox]` pulls the same fragment in without
a call, for a page whose script is not tied to one shortcode.
The fragment receives an empty `Fragment`: it emits fixed markup, not per-call markup. Two calls with
different arguments still share one asset, which is what makes deduplication meaningful. Order is
first-call, and nothing promises an order *between* two assets.
Where the files live is yours. `/static/` is served verbatim and needs no engine change, which is why the
reference theme's examples point there. The reference theme itself defines no `assets:` fragment and ships
no asset (ADR-0063) — this is a mechanism, not an invitation.
Templates in the same set may call each other — `{{template "picture" .}}` from `figure.html` reaches a block
defined in `gallery.html`, because both are parsed into the partials set. The pipeline is all that passes, and
there is no way to build a value in a template, so inclusion is useful where the callee needs exactly what the
caller has. Across sets it does not work: `base`, `page`, `list`, `extras` and the fragments are five separate
sets, which is what stops a listing's `main` leaking into a bundle page (ADR-0019).
Every fragment receives the same two fields (ADR-0037):
| Field | Contents |
|---|---|
| `.Args` | the call's `key=value` pairs, exactly as the author wrote them |
| `.Pictures` | images the *engine* gathered: one for a figure, many for a gallery, none when the call names nothing it recognises (ADR-0042) |
| `.Lang` | the language being served, so a fragment can localise words of its own through `t` (ADR-0067) |
| `.Body` | a container call's content, already rendered to HTML. Empty for every leaf call (ADR-0064) |
| `.Headings` | the document's headings for a `::toc` call: `.Level`, `.Text`, `.ID`. Empty for every other call (ADR-0065) |
Each picture carries:
| Field | Contents |
|---|---|
| `.Src` | the author's own file, relative to the bundle — always usable on its own |
| `.Srcset` | the generated widths, closed by the original at its own width; empty when nothing was worth generating, or when the format has no decoder |
| `.Width`, `.Height` | the original's intrinsic size, for reserving the box; zero when the file could not be read |
**A theme emitting `.Srcset` must emit `sizes` too.** The engine cannot supply it: `sizes` says how wide the
picture will *be*, which is a fact about the layout and therefore the theme's (ADR-0068). Without it a browser
assumes `100vw` and fetches the widest variant for a thumbnail, which costs more bandwidth than serving no
derivatives at all. The reference theme states its own measure — 32rem for a figure, 16rem for a gallery
column above 36rem — and adds `loading="lazy"` to gallery entries but never to a figure, which is often the
first thing on the page.
| Shortcode | Template | Receives |
|---|---|---|
| `::figure{src=… alt="…" caption=…}` | `figure` | `.Args.src`, `.Args.alt`, `.Args.caption` |
| `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
| `:name:` | `icon` | `.Args.name` — the name as written, nothing else |
| `:::note{title=…}` … `:::` | `note` | `.Args.title`, `.Body` — the rendered content |
| a fenced code block | `code` | `.Args.lang`, `.Args.title`, and `.Body` — chroma's token spans, already classed (ADR-0075) |
| `::toc{depth=N}` | `toc` | `.Headings` — the document's headings in order, no deeper than `depth` if given |
| `:::details{summary=… group=… open=…}` … `:::` | `details` | `.Args`, `.Body`. Siblings sharing a `group` open one at a time, through `` and no script |
| `:::aside{title=…}` … `:::` | `aside` | `.Args.title`, `.Body`. Beside the text where there is room, in the flow where there is not |
**A fragment supplying its own words must localise them.** `.Lang` is there for exactly that: the reference
theme labels an untitled admonition with `{{t .Lang "warn"}}` and a contents list with `{{t .Lang "contents"}}`,
so a Bengali page reads সতর্কতা and সূচিপত্র rather than English (ADR-0067). Words the *author* wrote —
`.Args.title`, `.Body` — are never touched.
**A code block is highlighted by the engine and framed by the theme.** `.Body` is `
` with
token classes — `k` keyword, `s` string, `c` comment, `m` number, `nf` function, `ln` line number, and a
`line hl` for a highlighted row. The palette is a stylesheet's, never inline, so dark mode is the theme's as
usual. The reference theme frames it in a `
` with the title as a ``, and
stops there: a copy button needs a script, and the reference theme is gated script-free (ADR-0026), so that
belongs in a theme of your own.
A **container** call wraps content: `:::name{…}`, a body, then `:::`. It renders through the fragment of that
name with `.Body` already HTML, and one level only — a `:::` inside closes the one it is in. A theme with no
template for the kind renders nothing, and the engine writes the body out unwrapped, so an unknown kind costs
an author styling and never paragraphs (ADR-0064). The reference theme defines `note`, `warn` and `tip`.
The `icon` fragment is one template for the whole set: **which names exist is the theme's decision**, and the
engine holds no list of them (ADR-0063). Render nothing for a name you do not know — the engine then writes
the author's original `:name:` text back, so an unrecognised icon is never dropped out of a sentence. The
reference theme maps six names to Unicode characters and ships no sprite, font or asset; a theme wanting drawn
icons defines a sprite in its own `base.html` and emits `