finish the reference theme, and the contract holes it found

The theme was supposed to need no engine work. It needed four things, which is
exactly what the audit said would happen if the contract had gaps rather than the
theme (ADR-0046) — a reader could not reach, from any page: another section, the
tags on the page they were reading, the extras beside it, or the same page in
another language.

So the contract grew three fields, all additive: `.Sections` for navigation,
`.Tags` with each term's listing URL, and `.ExtrasURL`, empty when a bundle has
none so a theme never links a 404. Sections arrive through `Renderer.Navigation`, a
callback, because sections change when content does and a copy would go stale — the
nav updates on a rebuild along with everything else.

One flaw only visible by looking at a rendered page: the language switcher pointed
at the canonical host, because `.Alternates` went absolute for hreflang. Those are
two needs, so an Alternate now carries `.URL` (absolute, for machines) and `.Path`
(relative, for a link a person clicks).

The theme now demonstrates every field it is given, including `.First`/`.Last`,
which existed and were never rendered. Still no JavaScript, still one stylesheet.

A test that asserted a page had no sequence nav was matching the inlined
stylesheet rather than the markup, and now matches the element. That is the third
time a loose assertion has passed for the wrong reason.
This commit is contained in:
Claude Opus 5
2026-07-31 19:48:32 +06:00
committed by bdeshi
parent 9100ce4876
commit 96313e4eda
12 changed files with 188 additions and 13 deletions
+3
View File
@@ -74,6 +74,8 @@ func runServe() {
slog.Warn("dev mode: drafts and future-dated bundles are visible, and templates reload")
}
// Navigation reads the live index, so a section that appears with a rebuild appears in the nav too, and the
// renderer never holds a stale copy (ADR-0049).
// One atomic pointer, swapped whole: a request reads the index that was current when it arrived, never one
// being rebuilt underneath it (ADR-0022).
var live atomic.Pointer[content.Site]
@@ -82,6 +84,7 @@ func runServe() {
if count < 0 {
fatal("cannot read content", nil)
}
renderer.Navigation(func() []string { return live.Load().Sections() })
derivedFS, err := content.OpenSite(*cache)
if err != nil {
+19
View File
@@ -712,3 +712,22 @@ would want notifications after all; and the watcher never stops, because the pro
which means no test can assert its shutdown.
Revisit if: a site grows big enough that polling shows up in a profile, or an operator wants a rebuild on
demand — a signal handler or an endpoint, not a shorter interval.
## ADR-0049 — Completing the reference theme found four holes in the contract
Date: 2026-07-31 · Status: accepted (extends `theme-contract.md`, additively)
Decision: a page now also receives `.Sections` (the site's sections, for navigation), `.Tags` (this bundle's own
terms with their listing URLs) and `.ExtrasURL` (empty when the bundle has none). Sections arrive through
`Renderer.Navigation`, a callback set at wiring time, because sections change when content does and a copy held
by the renderer would go stale. The reference theme uses all three, plus visible `.Alternates` links and the
sequence's `.First`/`.Last`, which existed and were never rendered.
Why: the theme was supposed to need no engine work, and the audit said that if it did, the gap was in the
contract rather than in the theme (ADR-0046). It did. Four things a reader could not reach from a page: any other
section, the tags on the page they were reading, the extras beside it, and the same page in the other language.
Each is a fact only the engine has, so each belongs in the contract — and none of them could be worked around in
a template, which is exactly the test.
Consequence: cheap — the contract grew by three fields, all additive and all zero-valued when absent, and the
reference theme is now a complete demonstration rather than a partial one. Expensive — `Navigation` is a second
set-once callback beside `Reload`, so the renderer has two pieces of state that are wired rather than passed;
`.Tags` and `.ExtrasURL` cost one Stat per bundle render.
Revisit if: a fourth set-once callback appears. Three would say the renderer wants a construction options
struct rather than a constructor plus setters.
+6 -6
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `b6484e2` on 2026-07-30 — update this line every change.
**Verified against:** `66a9fdb` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
@@ -14,10 +14,10 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) | 96 |
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) | 59 |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility | 424 |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods | 422 |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` | 117 |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods | 447 |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `Picture`, `Origin` | 130 |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 |
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
| `internal/render/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) | — |
| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044) | 564 |
| `internal/ext/scaffold/` | writes a new bundle into the site root through `os.Root`: a directory bundle, a draft, never an overwrite | 102 |
| `internal/ext/watch/` | polls the site root, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048) | 129 |
@@ -29,10 +29,10 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) | 125 |
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 |
| `internal/web/web.go` | handler: `serve` dispatches by kind, `serveBundle` answers the commonest one; listings, `/static/`, `/derived/`, degrade on failure | 206 |
| `cmd/khosra/main.go` | flags, wiring, startup, the derivative pass, and the atomic swap a rebuild goes through. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike | 147 |
| `cmd/khosra/main.go` | flags, wiring, startup, the derivative pass, and the atomic swap a rebuild goes through. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike | 150 |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 |
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing | 42 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection | 2990 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach | 3045 |
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
+8 -3
View File
@@ -20,9 +20,12 @@ A bundle page receives:
| `.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 |
| `.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:
@@ -42,7 +45,8 @@ Two named templates: `base` is executed for every page; `main` is the block each
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:
Every kind of page receives `.Title`, `.Lang`, `.Canonical`, `.Style`, `.Site`, `.Sections` and `.Alternates`
the document shell. A listing page adds:
| Field | Contents |
|---|---|
@@ -62,7 +66,8 @@ hardcodes English: the words the engine supplies are the engine's to localise (A
| `{{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
Phrase keys today: `newer`, `older`, `empty`, `page-of` (two arguments), `position` (two arguments), `first`,
`last`, `extras`, `back-to-page`. 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.
+2
View File
@@ -24,6 +24,8 @@ var chrome = map[string]map[string]string{
"position": {"en": "%s of %s", "bn": "%s / %s"},
"extras": {"en": "Extras", "bn": "অতিরিক্ত"},
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
"first": {"en": "First", "bn": "প্রথম"},
"last": {"en": "Last", "bn": "শেষ"},
}
// months are Gregorian month names per language, indexed by [time.Month]-1.
+27 -2
View File
@@ -40,6 +40,9 @@ type Renderer struct {
files fs.FS
// settings are the site's declarations, constant for the life of the process.
settings content.Settings
// sections reports the site's sections when asked. A callback, because sections change when content does and
// the renderer must not hold a stale copy (ADR-0049).
sections func() []string
// reload reparses the theme before each render, for `-dev`: editing a template should not need a restart.
// Off in a serving build, where parsing once is the point (conventions.md).
reload bool
@@ -155,13 +158,19 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
// canonical arrives as a path and leaves absolute when the site declared a base: a canonical link and an
// hreflang are read by machines that resolve neither against the page (ADR-0039).
func (r *Renderer) head(title, lang, canonical string) head {
return head{
h := head{
Title: title,
Lang: lang,
Canonical: r.absolute(canonical),
Style: r.style,
Site: r.settings,
}
if r.sections != nil {
for _, name := range r.sections() {
h.Sections = append(h.Sections, Item{Title: name, Key: name, URL: content.URL(name, lang)})
}
}
return h
}
// absolute is the site's own URL for a path the engine emitted, or the path itself when no base is declared.
@@ -169,6 +178,12 @@ func (r *Renderer) absolute(path string) string {
return content.Absolute(r.settings.Base, path)
}
// Navigation tells the renderer where to find the site's sections.
//
// Set once at wiring time, like Reload: a page needs to offer navigation, and only the index knows which
// sections exist. A callback rather than a slice, because content changes and a copy would go stale.
func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
// Reload makes every render reparse the theme first. For `-dev` only: it trades the parse-once rule for the
// ability to edit a template and refresh.
func (r *Renderer) Reload() { r.reload = true }
@@ -311,7 +326,17 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
Sequence: r.sequence(seq, served),
}
for _, l := range variants {
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(content.URL(b.Route, l))})
path := content.URL(b.Route, l)
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(path), Path: path})
}
for _, tag := range b.Tags {
p.Tags = append(p.Tags, Item{Title: tag, Key: content.TagSlug(tag), URL: content.TagURL("", content.TagSlug(tag), served, 1)})
}
// One Stat rather than a walk: a page only needs to know whether there is anything to link to (ADR-0047).
if assets, hasAssets := b.Assets(); hasAssets && r.files != nil {
if _, err := fs.Stat(r.files, path.Join(assets, content.ExtrasDir)); err == nil {
p.ExtrasURL = content.ExtrasURL(b.Route, served, "")
}
}
return r.execute(r.page, p, b.Key)
}
+53
View File
@@ -196,3 +196,56 @@ func TestATagListingOffersBothShapesAndLetsTheThemeChoose(t *testing.T) {
t.Errorf("every entry should be present whichever shape is used:\n%s", got)
}
}
func TestAPageCanReachTheRestOfTheSite(t *testing.T) {
// The four things a reader could not get to before the reference theme was finished (ADR-0049): another
// section, this page's tags, its extras, and the same page in another language.
fsys := fstest.MapFS{
"content/posts/one/index.md": {Data: []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n")},
"content/posts/one/extras/note.md": {Data: []byte("a note\n")},
}
r, err := New(fsys, content.Settings{Title: "Khosra"}, nil)
if err != nil {
t.Fatal(err)
}
r.Navigation(func() []string { return []string{"posts", "comics"} })
b, err := content.Parse("posts/one/index.md", []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n"))
if err != nil {
t.Fatal(err)
}
b.Path, b.Route = "content/posts/one/index.md", b.Key
out, err := r.Bundle(b, "en", []string{"en", "bn"}, nil)
if err != nil {
t.Fatal(err)
}
got := string(out)
for _, want := range []string{
`href="/posts/"`, // navigation
`href="/comics/"`, // a section this page is not in
`rel="tag" href="/tags/monsoon/"`, // its own tags, at the term's listing
`href="/posts/one/extras/"`, // its extras, offered only because they exist
`href="/bn/posts/one/" hreflang="bn"`, // the other language, as a visible relative link
} {
if !strings.Contains(got, want) {
t.Errorf("a reader cannot reach %s:\n%s", want, got)
}
}
}
func TestAPageOffersNoExtrasLinkWhenThereAreNone(t *testing.T) {
// Guessing would give every page a link to a 404.
fsys := fstest.MapFS{"content/posts/bare/index.md": {Data: []byte("---\ntitle: Bare\n---\nx\n")}}
r, err := New(fsys, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
b, _ := content.Parse("posts/bare/index.md", []byte("---\ntitle: Bare\n---\nx\n"))
b.Path, b.Route = "content/posts/bare/index.md", b.Key
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "/extras/") {
t.Errorf("no extras exist, so nothing should link them:\n%s", out)
}
}
+23
View File
@@ -22,9 +22,32 @@
<style>{{.Style}}</style>
</head>
<body>
{{- if or .Sections .Site.Title}}
<header>
{{- if .Site.Title}}
<p class="site"><a href="/">{{.Site.Title}}</a></p>
{{- end}}
{{- if .Sections}}
<nav class="sections">
{{- range .Sections}}
<a href="{{.URL}}">{{.Title}}</a>
{{- end}}
</nav>
{{- end}}
</header>
{{- end}}
<main>
{{- template "main" .}}
</main>
{{- if gt (len .Alternates) 1}}
<footer>
<nav class="languages">
{{- range .Alternates}}
<a href="{{.Path}}" hreflang="{{.Lang}}" lang="{{.Lang}}">{{.Lang}}</a>
{{- end}}
</nav>
</footer>
{{- end}}
</body>
</html>
{{- end}}
+20
View File
@@ -10,6 +10,12 @@
<span><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.URL}}{{end}}</a> {{t $.Lang "position" (num $.Lang .Index) (num $.Lang .Count)}}</span>
{{- with .Next}}<a rel="next" href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a>{{end}}
</nav>
{{- if gt .Count 2}}
<nav class="ends">
{{- with .First}}<a href="{{.URL}}">{{t $.Lang "first"}}</a>{{end}}
{{- with .Last}}<a href="{{.URL}}">{{t $.Lang "last"}}</a>{{end}}
</nav>
{{- end}}
{{- else if .Members}}
<nav class="sequence">
<ol class="archive">
@@ -21,4 +27,18 @@
</nav>
{{- end}}
{{- end}}
{{- if or .Tags .ExtrasURL}}
<footer class="about-this-page">
{{- if .Tags}}
<nav class="tags">
{{- range .Tags}}
<a rel="tag" href="{{.URL}}">{{.Title}}</a>
{{- end}}
</nav>
{{- end}}
{{- if .ExtrasURL}}
<p><a href="{{.ExtrasURL}}">{{t .Lang "extras"}}</a></p>
{{- end}}
</footer>
{{- end}}
{{- end}}
+10
View File
@@ -8,8 +8,18 @@ a { color: #1a4d7a; }
img { max-width: 100%; height: auto; }
pre, code { font-family: ui-monospace, monospace; font-size: 0.9em; }
pre { overflow-x: auto; padding: 0.75rem; background: #f3f2ee; }
/* Enough structure to read the demonstration; a real theme starts over (ADR-0026). */
header, footer { max-width: 34rem; margin: 1.5rem auto; padding: 0 1rem; font-size: 0.9em; }
header .site { font-weight: 600; margin: 0 0 0.25rem; }
nav.sections a, nav.languages a, nav.tags a { margin-right: 0.75rem; }
nav.sequence, nav.ends { display: flex; gap: 1rem; justify-content: space-between; margin: 1.5rem 0; }
ul.extras, ol.archive { padding-left: 1.25rem; }
.kind { color: #6b6b6b; font-size: 0.85em; }
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 0.75rem; }
.gallery figure { margin: 0; }
@media (prefers-color-scheme: dark) {
html { color: #e8e6e1; background: #16161a; }
a { color: #8ab4dd; }
pre { background: #22222a; }
.kind { color: #9a9a9a; }
}
+14 -1
View File
@@ -27,6 +27,9 @@ type head struct {
Style template.CSS
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
Site content.Settings
// Sections are the site's sections, for navigation: only .Title and .URL are set. Empty until the engine is
// told where to find them, which cmd does at wiring time (ADR-0049).
Sections []Item
}
// Page is one bundle rendered.
@@ -40,6 +43,11 @@ type Page struct {
Extra map[string]any
// Sequence is the series this page sits in, nil when it sits in none.
Sequence *Sequence
// Tags are this bundle's own terms, each with the URL of its listing. Empty when it carries none.
Tags []Item
// ExtrasURL links this bundle's supporting files, empty when it has none — so a theme can offer them
// without guessing whether they exist (ADR-0047).
ExtrasURL string
}
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
@@ -113,5 +121,10 @@ type Item struct {
// Alternate is one language a bundle exists in.
type Alternate struct {
Lang string
URL string
// URL is absolute when the site declares a base, because hreflang is read by machines that resolve nothing
// against the page.
URL string
// Path is the same address, root-relative — what a *visible* language link wants. Found by looking at a
// rendered page: using URL sent a reader from a local server to the canonical host (ADR-0049).
Path string
}
+3 -1
View File
@@ -387,7 +387,9 @@ func TestPagesOutsideASeriesGetNoSequence(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if body := rec.Body.String(); strings.Contains(body, "sequence") {
// Matched on the element, not the word: the stylesheet is inlined into every page, so "sequence" appears in
// the CSS whether or not the nav does.
if body := rec.Body.String(); strings.Contains(body, `<nav class="sequence">`) {
t.Errorf("an unrelated page must render no sequence nav:\n%s", body)
}
}