audit every feature against the layer test

Deleting the widows feature answered one question; the human asked the general one.
So every feature built so far is now audited, with the verdicts as a table in
ADR-0046, and each remaining queue entry carries a layer note before anyone writes
code for it.

The codebase turned out to be otherwise clean, and I checked rather than remembered:
no Go file writes a tag, a class or a style. ADR-0036's rule that all markup comes
from a fragment had already forced that.

One real finding. A tag listing received only `.Groups`, so the engine had decided
that a tag listing *looks* grouped — ADR-0032's own reasoning was "so a busy term
stays readable", which is a readability judgement. It now receives both shapes: the
partition, because a template cannot group for itself, and the flat list, because
choosing between them is markup. `Item` gained `.Section` so a flat listing can still
say where an entry came from.

Two judgement calls recorded rather than left implicit. The typographer stays: turning
`--` into an en dash is a character transformation no stylesheet can express. Chrome
strings stay: translations are data, and the alternative is every theme hardcoding
Bengali month names. The inlined stylesheet is accepted with its cost written down —
bytes per page, no caching — and a trigger for revisiting it.

Two patterns worth reusing came out of this: offer the shape rather than choosing it,
and a split feature is normal — search will be an engine-built index queried by the
browser, not one or the other.
This commit is contained in:
Claude Opus 5
2026-07-31 12:51:31 +06:00
committed by bdeshi
parent ab882980ad
commit bc8c26d8f4
6 changed files with 109 additions and 12 deletions
+5
View File
@@ -91,6 +91,11 @@ non-breaking space into an author's prose. It worked, it had tests, and it was w
`text-wrap: pretty` does it better with no bytes in the content, and the engine could never see the line box `text-wrap: pretty` does it better with no bytes in the content, and the engine could never see the line box
it was guessing about. it was guessing about.
Every feature built so far has been audited against this, with the verdicts in ADR-0046. Two patterns came out
of it worth reusing. **Offer the shape, do not choose it**: templates cannot group, so the engine supplies a
partitioned listing — but it supplies the flat one too, because which to show is markup. And **a split feature
is normal**: search will be an engine-built index and a browser-side query, not one or the other.
## Invariants — violating one is a stop condition ## Invariants — violating one is a stop condition
1. **Open page object.** Known fields as struct members plus a `Meta`/`Extra` bag. Absence equals 1. **Open page object.** Known fields as struct members plus a `Meta`/`Extra` bag. Absence equals
+34
View File
@@ -634,3 +634,37 @@ that is a smaller cost than editing prose, and it improves on its own as browser
because turning `--` into an en dash is a text transformation no stylesheet can express — the distinction the because turning `--` into an en dash is a text transformation no stylesheet can express — the distinction the
layer test is meant to draw. layer test is meant to draw.
Revisit if: nothing. If widows matter more than this, the answer is a better stylesheet. Revisit if: nothing. If widows matter more than this, the answer is a better stylesheet.
## ADR-0046 — Layer audit of everything built, and the rule for everything next
Date: 2026-07-31 · Status: accepted (amends ADR-0032's grouping clause)
Decision: every feature was re-examined against the layer test in `architecture.md`, and the verdicts are
recorded below. One was wrong and is fixed here: a tag listing now receives **both** the flat list and the
section partition, so the theme decides whether it looks grouped. `Item` gains `.Section` so a flat listing can
still say where an entry came from. Everything else stays where it is, for the reasons given.
| Feature | Layer | Why it is right there |
|---|---|---|
| Scanning, keys, permalinks, aliases, redirects | engine | Only the engine reads the disk, and a URL is a promise it makes |
| Queries: sections, tags, sequences, pagination | engine | Needs the whole index. Page size sets URLs, so it cannot be presentation (ADR-0028) |
| Sequence neighbours, index, count | engine | Ordering needs frontmatter and the index; a template cannot sort |
| Grouping a tag listing | **engine offers, theme decides** | Templates cannot group, so the partition is data — but *whether to show it* is markup. Fixed here |
| Typographer: quotes, dashes, ellipses | engine | A *character* transformation no stylesheet can express: `--` cannot become an en dash in CSS |
| Chrome strings, digits, month names | engine | Translations are data. The alternative is every theme hardcoding Bengali months; where they appear is still the theme's |
| Widow and orphan control | **browser** | Deleted from the engine (ADR-0045). CSS knows the line box |
| Shortcode markup | theme | Engine parses the call and supplies data; every tag comes from a fragment (ADR-0036) |
| Image derivatives, `srcset`, `width`/`height` | engine | Resampling and file generation cannot happen in a browser, and `srcset` is markup the browser needs to be given |
| Gallery order, alt text | engine / theme | Order needs the directory; `alt=""` is written by the fragment, because only the theme knows the picture's role |
| Absolute canonical, `hreflang`, OpenGraph values | engine | Needs the declared origin. The *tags* are emitted by the theme's `base.html` |
| `robots.txt`, `sitemap.xml`, Atom | engine | Machine contracts with absolute URLs; no layer below can produce them |
| Draft and future-dated visibility | engine | A visibility rule enforced anywhere else is not enforced (ADR-0024) |
| Inlined reference stylesheet | engine, accepted | Costs bytes per page and forgoes caching; buys a site root that renders with no asset route. Revisit when the stylesheet is big enough for caching to beat the round trip |
Why: the widows mistake was not a coding error, it was a missing question, and one deleted feature is not
evidence the rest are sound. Auditing found the codebase otherwise clean — no Go file writes a tag, a class or
a style, which the ADR-0036 fragment rule already forced — but it did find one place where the engine had
quietly chosen how something looks.
Consequence: cheap — the verdicts are written down, so the next feature argues with a table instead of a
memory, and future entries carry a layer note before they are built. Expensive — "engine offers both shapes"
is a slightly larger contract than "engine decides", and a theme that ignores `.Groups` now has to know
`.Items` exists.
Revisit if: a verdict here is contradicted by a feature that cannot be built under it. Then the verdict was
wrong, and it is amended by name rather than worked around.
+7 -3
View File
@@ -1,6 +1,6 @@
# State # State
**Verified against:** `0231dae` on 2026-07-30 — update this line every change. **Verified against:** `8a7560a` 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. If this file disagrees with the code, the code is right and this file is a bug.
## Inventory ## Inventory
@@ -13,7 +13,7 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/content/clock.go` | the one place the engine reads the wall clock, which `verify.sh` enforces by filename | 12 | | `internal/content/clock.go` | the one place the engine reads the wall clock, which `verify.sh` enforces by filename | 12 |
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) | 59 | | `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/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 `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 409 | | `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the `Partial`/`Origin` seams features render and resolve through, `Page`/`List`/`Sequence`/`head` | 459 |
| `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/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`, `shortcodes.html`, `theme.css` (ADR-0026) | — | | `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
| `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/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 |
@@ -26,7 +26,7 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 | | `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`, `-dev`), wiring, startup including the derivative pass — the only place things are assembled | 92 | | `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`, `-dev`), wiring, startup including the derivative pass — the only place things are assembled | 92 |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 | | `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 |
| `*_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 | 2608 | | `*_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 | 2643 |
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the 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 key (ADR-0035) — a paginated listing per section, tag listings global and
@@ -91,6 +91,10 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
None. Nothing blocks Arc 1 or the first deploy. None. Nothing blocks Arc 1 or the first deploy.
Every feature has been audited against the layer test (ADR-0046). One was wrong and was deleted (widow
prevention, ADR-0045); one quietly decided presentation and now offers both shapes (tag listing grouping). The
verdict table is in the ADR, and remaining queue entries carry a layer note before they are built.
Every ADR in `decisions.md` is accepted; none is open or proposed. Every ADR in `decisions.md` is accepted; none is open or proposed.
## Build queue ## Build queue
+13 -2
View File
@@ -46,10 +46,10 @@ A listing page receives `.Title`, `.Lang`, `.Canonical`, `.Style` as above, plus
| Field | Contents | | Field | Contents |
|---|---| |---|---|
| `.Items` | entries on this page: `.Title`, `.Key`, `.URL`, `.Date` | | `.Items` | entries on this page: `.Title`, `.Key`, `.URL`, `.Date`, `.Section` |
| `.Page`, `.Pages` | 1-based position and total, `Pages` at least 1 | | `.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 | | `.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` | | `.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 ## Chrome text and formatting
@@ -125,6 +125,17 @@ the document head, which is what a reader looks for:
Only with a declared `base`: without one there is no feed to point at. 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 ## Typography is the theme's
The engine transforms text where no stylesheet could — smart quotes, dashes, ellipses — and stops there. How The engine transforms text where no stylesheet could — smart quotes, dashes, ellipses — and stops there. How
+15 -7
View File
@@ -81,8 +81,8 @@ type List struct {
Page, Pages int Page, Pages int
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first. // PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
PrevURL, NextURL string PrevURL, NextURL string
// Groups is set instead of Items when entries are grouped — a tag listing groups by section, so one // Groups is the same entries partitioned by section, set alongside Items for a tag listing. Both shapes
// busy term stays readable (ADR-0018). // are offered because which one to show is the theme's decision, not the engine's (ADR-0046).
Groups []Group Groups []Group
} }
@@ -98,6 +98,9 @@ type Item struct {
Key string Key string
URL string URL string
Date time.Time Date time.Time
// Section is the entry's top-level section, so a flat listing can label where an entry came from without
// the engine deciding that it must be grouped (ADR-0046).
Section string
} }
// Alternate is one language a bundle exists in. // Alternate is one language a bundle exists in.
@@ -373,13 +376,18 @@ func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page in
} }
l, window := r.paginate(title, lang, content.TagURL(section, slug, lang, page), all, page, l, window := r.paginate(title, lang, content.TagURL(section, slug, lang, page), all, page,
func(p int) string { return content.TagURL(section, slug, lang, p) }) func(p int) string { return content.TagURL(section, slug, lang, p) })
// Both shapes, always: the flat list in query order, and the same entries partitioned by section. A
// template cannot group for itself, so the engine offers the partition — but it does not decide that a tag
// listing must look grouped, which is a readability judgement belonging to whoever writes the markup
// (ADR-0046, amending ADR-0032).
for _, b := range window { for _, b := range window {
sec := b.Section() item := r.item(b, lang)
if n := len(l.Groups); n > 0 && l.Groups[n-1].Name == sec { l.Items = append(l.Items, item)
l.Groups[n-1].Items = append(l.Groups[n-1].Items, r.item(b, lang)) if n := len(l.Groups); n > 0 && l.Groups[n-1].Name == item.Section {
l.Groups[n-1].Items = append(l.Groups[n-1].Items, item)
continue continue
} }
l.Groups = append(l.Groups, Group{Name: sec, Items: []Item{r.item(b, lang)}}) l.Groups = append(l.Groups, Group{Name: item.Section, Items: []Item{item}})
} }
return r.execute(r.list, l, "tag "+slug) return r.execute(r.list, l, "tag "+slug)
} }
@@ -416,7 +424,7 @@ func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence {
// item is one listing entry. // item is one listing entry.
func (r *Renderer) item(b content.Bundle, lang string) Item { func (r *Renderer) item(b content.Bundle, lang string) Item {
return Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Route, lang), Date: b.Date} return Item{Title: b.Title, Key: b.Key, URL: content.URL(b.Route, lang), Date: b.Date, Section: b.Section()}
} }
// paginate builds the shell of a listing page and returns the slice of entries it shows. // paginate builds the shell of a listing page and returns the slice of entries it shows.
+35
View File
@@ -161,3 +161,38 @@ func TestWithoutABaseEverythingStaysRelative(t *testing.T) {
t.Error("no declared title means no site_name tag, rather than an empty one") t.Error("no declared title means no site_name tag, rather than an empty one")
} }
} }
func TestATagListingOffersBothShapesAndLetsTheThemeChoose(t *testing.T) {
// The engine may not decide that a tag listing looks grouped (ADR-0046). It supplies the partition, because
// a template cannot group for itself, and the flat list beside it, because choosing is markup.
r, err := New(nil, content.Settings{}, nil)
if err != nil {
t.Fatal(err)
}
var bundles []content.Bundle
for _, spec := range []struct{ name, section string }{
{"posts/essay", "posts"}, {"comics/strip", "comics"}, {"posts/other", "posts"},
} {
b, err := content.Parse(spec.name+".md", []byte("---\ntitle: "+spec.name+"\n---\n"))
if err != nil {
t.Fatal(err)
}
b.Route = b.Key
bundles = append(bundles, b)
}
out, err := r.Tag("", "monsoon", "en", bundles, 1)
if err != nil {
t.Fatal(err)
}
// The reference theme renders groups, so the sections appear as headings.
got := string(out)
for _, want := range []string{"<h2>posts</h2>", "<h2>comics</h2>"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// A theme preferring a flat list must have one, with each entry able to say where it came from.
if !strings.Contains(got, "posts/essay") || !strings.Contains(got, "comics/strip") {
t.Errorf("every entry should be present whichever shape is used:\n%s", got)
}
}