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:
@@ -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
|
||||
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
|
||||
|
||||
1. **Open page object.** Known fields as struct members plus a `Meta`/`Extra` bag. Absence equals
|
||||
|
||||
@@ -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
|
||||
layer test is meant to draw.
|
||||
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
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
## 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/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 `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/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 |
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `*_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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Build queue
|
||||
|
||||
+13
-2
@@ -46,10 +46,10 @@ A listing page receives `.Title`, `.Lang`, `.Canonical`, `.Style` as above, plus
|
||||
|
||||
| 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 |
|
||||
| `.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -81,8 +81,8 @@ type List struct {
|
||||
Page, Pages int
|
||||
// PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
|
||||
PrevURL, NextURL string
|
||||
// Groups is set instead of Items when entries are grouped — a tag listing groups by section, so one
|
||||
// busy term stays readable (ADR-0018).
|
||||
// Groups is the same entries partitioned by section, set alongside Items for a tag listing. Both shapes
|
||||
// are offered because which one to show is the theme's decision, not the engine's (ADR-0046).
|
||||
Groups []Group
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ type Item struct {
|
||||
Key string
|
||||
URL string
|
||||
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.
|
||||
@@ -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,
|
||||
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 {
|
||||
sec := b.Section()
|
||||
if n := len(l.Groups); n > 0 && l.Groups[n-1].Name == sec {
|
||||
l.Groups[n-1].Items = append(l.Groups[n-1].Items, r.item(b, lang))
|
||||
item := r.item(b, lang)
|
||||
l.Items = append(l.Items, item)
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -416,7 +424,7 @@ func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence {
|
||||
|
||||
// item is one listing entry.
|
||||
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.
|
||||
|
||||
@@ -161,3 +161,38 @@ func TestWithoutABaseEverythingStaysRelative(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user