Files
khosra/docs/theme-contract.md
T
bdeshi 12f9a31779 delete the widows feature; line breaking is CSS
The human asked whether widow prevention belonged in the backend at all. It did
not, and it broke two rules already written down: the theme contract says the
engine decides nothing about how something looks, and ADR-0034 says authored body
text is the author's — while this inserted U+00A0 into that text.

The practical harm follows from the layer error rather than from a coding mistake.
The engine cannot see the line box, so joining the last two words is a guess that
can overflow a narrow viewport, and a reader copying the paragraph gets a
non-breaking space in their clipboard. `text-wrap: pretty` and `text-wrap: balance`
in the reference stylesheet know the line box and need no bytes in the content.

108 lines of engine deleted for one CSS declaration. The typographer stays: turning
`--` into an en dash is a text transformation no stylesheet can express, which is
exactly the distinction the new layer test draws.

Also worth recording: this took the Extensions counter from 3 back to 2. A threshold
reached by a feature that should not have existed was never a threshold.
2026-07-31 12:47:03 +06:00

219 lines
12 KiB
Markdown

# 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, as `.Lang` and `.URL`, for `hreflang`. Absolute on the same terms |
| `.Site` | what the site declared about itself: `.Site.Base` and `.Site.Title`, either possibly empty |
| `.Sequence` | the series this page sits in, absent when it sits in none (ADR-0033) |
`.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).
A listing page receives `.Title`, `.Lang`, `.Canonical`, `.Style` as above, plus:
| Field | Contents |
|---|---|
| `.Items` | entries on this page: `.Title`, `.Key`, `.URL`, `.Date` |
| `.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` | set instead of `.Items` when entries are grouped — a tag listing groups by section, each `.Name` and `.Items` |
## 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). 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
`templates/shortcodes.html` holds 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).
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) |
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 |
| 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 |
`{{< 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`.
The set is overlaid the same way as the page kinds: a site's `templates/shortcodes.html` is parsed after
the embedded one, so redefining `figure` replaces it and any fragment left alone is inherited. 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.
## 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.
## 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
For a request under a bundle's extras directory (`ideas/deferred-decisions.md`) the engine additionally provides:
| Field | Contents |
|---|---|
| `.Extras.Entries` | the tree: `Name`, `Path`, `URL`, `RawURL`, `Kind`, `Size`, `IsDir`, `ModTime` |
| `.Extras.Selected` | nil on the bare listing; otherwise the chosen entry |
| `.Extras.Selected.HTML` | rendered output for `markdown` and `text` kinds; empty otherwise |
| `.Extras.Selected.RawURL` | always present — for `<img src>`, `<object data>`, or a download link |
`.Bundle` is the parent, so a theme has its title, language and breadcrumb. Selecting an entry is an
ordinary link and a full re-render, 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
`shortcodes.html` on its own. 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.