Files
khosra/docs/theme-contract.md
T
Claude Opus 5andbdeshi 67defae912 highlight code server-side, and let a block quote a file
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.
2026-08-02 00:16:32 +06:00

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 — .Items and .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:

  1. the contract extension — new fields, new named blocks, new queries, new URLs;
  2. a note stating what a theme must do to use it, precise enough to act on without this conversation;
  3. 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.