chroma at render time, emitting CSS classes rather than inline colour, handed to a `code` theme fragment. Highlighting works with scripting off, in a feed reader, in a browser that never runs JavaScript. No lighter pure-Go option exists — every "alternative to chroma" is JavaScript, which the reference theme is gated against. A fence's info string carries the rest: title, numbers, start, hl=3,7-9, and file=name lines=A-B, which reads the snippet out of a file beside the bundle and numbers it by that file's own lines. So a post quotes several parts of one program without the copies drifting from it, and a reader can find what they are looking at. Verified on the real binary: the same file at lines 5-10 and 12-14, each numbered as it really is, with different lines tinted. Not a new package: a new one could not import the key=value parser this repo already has, because ADR-0069 forbids a feature importing its sibling, and a second parser for the same syntax is what §6 stops. Two costs, both stated in the ADR rather than buried. The binary goes from ~15MB to 19MB, for a project whose story is one small binary. And the reference theme now carries a token palette — the first thing in it that is a taste rather than a demonstration — kept to eight classes for that reason. The demo quotes a shell file, not a Go one: a .go file under examples/ joins the module and has to compile, which the build gate caught before it shipped. 6 of 9 modules, ext 2188/3500.
19 KiB
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 — <time datetime="{{.Date.Format "2006-01-02"}}">{{day .Lang .Date}}</time>; 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.
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 <details name> 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 <pre class="chroma"> 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 <figure class="code"> with the title as a <figcaption>, 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 <use> from this fragment.
::include{file=…} has no fragment: an included file is content, so it renders as Markdown in
place and a theme has nothing to style about it (ADR-0038).
Authored and engine-supplied data are kept apart so a src argument can never be confused with a src the
engine found. Arguments are escaped by html/template like any other data, in whichever context the
template puts them — which is what keeps an author's text out of the markup. A call whose template is
missing renders nothing and logs; it never fails the page.
A gallery entry has no alt text: nothing in a filename supplies one, and inventing it would be worse than an
empty alt on a picture the page has already introduced. A figure's alt is the author's, from .Args.alt.
Argument names are contract, added but never renamed.
Feed discovery
The engine serves Atom at /feed.xml, per section and per tag (content-model.md). A theme advertises it from
the document head, which is what a reader looks for:
<link rel="alternate" type="application/atom+xml" href="{{.Site.Base}}/feed.xml" title="{{.Site.Title}}">
Only with a declared base: without one there is no feed to point at.
What is yours and what is the engine's
The engine supplies facts it alone can produce — which bundles exist, what a picture measures, where a
permalink points, what a month is called in Bengali. Everything about how those facts look is yours, and the
engine is audited against that line (ADR-0046, docs/architecture.md). Two consequences worth knowing:
- Where a listing offers more than one shape of the same data —
.Itemsand.Groups— pick one. The engine is not telling you which. - If something you want is purely visual, it is CSS or a template, and asking the engine for it is the wrong request. Widow control used to be an engine feature and is now one line of stylesheet (ADR-0045).
Typography is the theme's
The engine transforms text where no stylesheet could — smart quotes, dashes, ellipses — and stops there. How
text lays out is the theme's, done in CSS: the reference stylesheet sets text-wrap: pretty on body copy and
text-wrap: balance on headings, which is where widow and orphan control belongs (ADR-0045). The engine will
not insert characters into an author's prose to influence line breaking.
Markup a body may now contain
.HTML is Markdown output, so the dialect decides which elements a theme has to be ready to style
(content-model.md). Beyond CommonMark's own, since ADR-0058: <table> with <thead>/<tbody>,
<dl>/<dt>/<dd>, <del>, <sub>, <sup>, <mark>, <abbr title=…>, and goldmark's footnote markup — <sup id="fnref:N"> in the text and a
<div class="footnotes"> carrying an <ol> of notes. Headings arrive with an id.
None of it is optional and none of it is configurable: a theme that styles none of these still renders a correct page, which is the point of the contract. The reference stylesheet does the minimum — a rule under each row, an indent for definitions, smaller footnotes — and nothing more (ADR-0026).
The stability rule
Fields and names are added, never renamed or removed. Absence is always legal: a template reading a
field that does not exist gets the zero value and must not crash, and the engine must not make a missing
field fatal at request time (ADR-0002, invariant 1). This is the View-layer freeze from
architecture.md, arriving as soon as a theme exists rather than at Arc 2.
Breaking the contract is not a feature — it is a new contract version, and it needs an ADR.
What the engine provides
| Provides | Detail |
|---|---|
| the page object | known fields plus an Extra bag carrying unknown frontmatter (ADR-0002) |
| the resolved cascade | settings after site → section → bundle resolution (ideas/deferred-decisions.md) |
| queries the page needs | its sequence neighbours, its taxonomy terms, its section's members |
| named template lookup | per-type sets; a theme redefines a named block and inherits the rest (ADR-0019) |
| URLs | every path the engine emits, so a theme never constructs one by hand |
| per-page assets | the styles / scripts frontmatter lists, resolved relative to the bundle |
| chrome strings | looked up by key and language, never hardcoded English in a template — live, see above |
| validity windows | a template that renders time-dependent output declares one |
Extras view
A request under a bundle's extras directory renders templates/extras.html, whose main block receives
(ADR-0047):
| Field | Contents |
|---|---|
.Bundle |
the page these files belong to, as an .Items entry — so a theme has its title and a way back |
.Entries |
the tree, sorted by path: .Name, .Path, .Kind, .Size, .IsDir |
.Selected |
absent on the bare listing; otherwise the entry the URL named |
.Selected.HTML |
the rendered file for a markdown or text entry, empty for anything else |
.Selected.RawURL |
always present — for <img src>, <object data>, or a download link |
.Kind is one of markdown, text, image, pdf, audio, video, other, dir, classified by
extension. An entry's .Path is relative to the extras listing, so a template can link it directly.
Selecting an entry is an ordinary link and a full page, so a sidebar-plus-pane layout needs no JavaScript. Swapping the pane client-side is a later enhancement over working markup, never a requirement.
What the engine does not provide
Layout, class names, CSS, client-side behaviour, and any choice about how something looks — including how media is embedded, how a listing is arranged, and whether something appears in a sidebar. Those are theme decisions, and a request touching them is split: a contract extension here, a change there.
The engine also does not provide a component library, a CSS build step, or a JavaScript runtime.
conventions.md holds the output floor the engine itself meets — semantic HTML, zero-JS, every image
carrying width, height and alt — and a theme is expected to stay inside it, but the engine does not
enforce a theme's markup.
The reference theme
The binary embeds a reference theme — templates plus one small stylesheet — so a bare site root renders (ADR-0026). It exists to make this document executable: it implements every field and block named here and nothing else, and a golden-file test through it catches contract regressions before a real theme does.
It is not a design. Legibility only, no branding, no visual opinions, no JavaScript — verify.sh fails on
a <script tag in it. If it starts accumulating taste, it has stopped being a reference.
It demonstrates the contract; it is not the contract. Changing it does not change what a theme may rely on
— which is why verify.sh also fails when it changes without this document changing, since in practice the
two drift together.
Overriding it
A site root's templates/ is parsed after the embedded set, and the last definition of a name wins, so
a theme redefines one named block and inherits the document (ADR-0019). Each set is built from named files
and only those are overlaid — base.html plus that kind's block file (page.html or list.html), and the
fragments on their own (shortcodes.html and shortcodes/*.html, ADR-0071). Overlaying every site template into every set would let a listing's main
leak into bundle pages, which is the collision per-kind sets exist to prevent.
templates/theme.css in the site root replaces the reference stylesheet entirely; there is no merging.
static/ in the site root is served verbatim under /static/. Directory paths answer 404 rather than
listing their contents.
Splitting a request
When a feature spans engine and theme, this repo delivers:
- the contract extension — new fields, new named blocks, new queries, new URLs;
- a note stating what a theme must do to use it, precise enough to act on without this conversation;
- embedded defaults updated far enough to prove the extension works.
It does not deliver the theme. Claiming otherwise is the same error as claiming to have migrated content in a site root this repo cannot see.