diff --git a/cmd/khosra/main.go b/cmd/khosra/main.go index 4720b0e..22aff17 100644 --- a/cmd/khosra/main.go +++ b/cmd/khosra/main.go @@ -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 { diff --git a/docs/decisions.md b/docs/decisions.md index 8152c88..e4f77a9 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/state.md b/docs/state.md index 93b5433..4d1c54c 100644 --- a/docs/state.md +++ b/docs/state.md @@ -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 diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 6862193..74b39fe 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -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. diff --git a/internal/render/chrome.go b/internal/render/chrome.go index fa1a461..cd3c7e1 100644 --- a/internal/render/chrome.go +++ b/internal/render/chrome.go @@ -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. diff --git a/internal/render/render.go b/internal/render/render.go index d05110f..04eb3e8 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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) } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 0d1d588..0a050bb 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -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) + } +} diff --git a/internal/render/templates/base.html b/internal/render/templates/base.html index c3c7854..c3d2fa7 100644 --- a/internal/render/templates/base.html +++ b/internal/render/templates/base.html @@ -22,9 +22,32 @@ +{{- if or .Sections .Site.Title}} +
+{{- if .Site.Title}} +

{{.Site.Title}}

+{{- end}} +{{- if .Sections}} + +{{- end}} +
+{{- end}}
{{- template "main" .}}
+{{- if gt (len .Alternates) 1}} + +{{- end}} {{- end}} diff --git a/internal/render/templates/page.html b/internal/render/templates/page.html index 68bcdc4..10df3b2 100644 --- a/internal/render/templates/page.html +++ b/internal/render/templates/page.html @@ -10,6 +10,12 @@ {{if .Title}}{{.Title}}{{else}}{{.URL}}{{end}} {{t $.Lang "position" (num $.Lang .Index) (num $.Lang .Count)}} {{- with .Next}}{{end}} +{{- if gt .Count 2}} + +{{- end}} {{- else if .Members}} {{- end}} {{- end}} +{{- if or .Tags .ExtrasURL}} + +{{- end}} {{- end}} diff --git a/internal/render/templates/theme.css b/internal/render/templates/theme.css index 8182e35..62cf16a 100644 --- a/internal/render/templates/theme.css +++ b/internal/render/templates/theme.css @@ -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; } } diff --git a/internal/render/view.go b/internal/render/view.go index 4c2d278..c8f5a75 100644 --- a/internal/render/view.go +++ b/internal/render/view.go @@ -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 } diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 6a5e39b..473b223 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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, `