main
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
30c26bd1ac
|
resolve relative links against the disk, serve them as addresses
Item 2 of the order of work. An author writes `../day-01.en.md` — the path an editor preview resolves — and the engine emits `/posts/day-01/`. The larger effect is durability. Resolution goes through key → route, and a slug moves the route while never moving the key (ADR-0035), so a relative link survives a rename that a hand-written /posts/a-better-name/ does not. The demo proves it: `../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote the filename and got the slugged address. This is the engine altering authored markup, which ADR-0045 polices, so the test that matters is what it declines to touch. Fourteen cases must survive exactly as written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative path, a bare fragment, a bare query, a name climbing out of content/, and every relative path whose extension is not .md. That last line is what keeps cover.jpg working — a bundle's assets already resolve because its URL mirrors its directory, so rewriting them would break what works. Nine rewrite cases sit beside them. Key derivation goes through content.KeyFromName, exported for this: the language-suffix rule is the part that would drift between two copies, so it lives in one place while the five lines of joining are duplicated in check. khosra check now reports a relative .md link resolving to no bundle, as fatal — verified by mistyping one and watching exit 1. Only the .md form: an extensionless relative path may be an asset, and a checker that calls a working link broken gets ignored wholesale. Two debts this change paid rather than deferred. render.go reached the file-length advisory, so theme parsing moved to theme.go — 414 and 105 lines, one topic each, since parsing runs per rebuild and rendering runs per request. Not a _helpers.go shard. And the demo's coverage test bound its renderer with a *copy* of the rebuilder's wiring, so it missed this feature entirely while the real binary served it correctly. Navigation had already drifted the same way. Both now call one bind(), which is exactly what ADR-0072 was written about — and the test failing is the only reason the copy was found. Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is in ext where it belongs. 18 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c3125e7ad2
|
log every request, and make error mean something again
Item 1 of the order of work, and two gaps rather than one polish item. There was no access log. Nothing recorded that a request happened, and the Dockerfile ships the binary alone with the site root mounted (ADR-0010), so a bare deployment produced none at all — which mattered because offline log analysis is this project's answer to analytics: no counter on the read path, no third-party script. web.Logged now writes one Info line per request with method, path, status, bytes and duration. And slog was never configured. conventions.md makes importing `log` instead of log/slog a hard failure while nothing ever set a level, a handler or a format. Two flags now do, rejected at startup if unusable, because a logger quietly less verbose than asked for hides exactly the lines somebody changed the flag to see. JSON is the half that matters: it is what makes a log parseable. The re-levelling was the larger half. Counts were 38 error, 7 warn, 4 info, 0 debug; they are now 7, 40, 6, 0. Almost every one of those errors was ADR-0029's "logged, not fatal" category — a misspelled directive, an asset path climbing out of its bundle, an unreadable picture — where the engine coped and the reader still got a good page. Error used as "somebody should see this" means an operator cannot tell a broken build from a typo. The seven that remain are the five requests that answer 500 and the two inside fatal. A successful rebuild now says so. It swapped silently before, so an operator could see a failed rebuild and never a successful one, which leaves the failures with nothing to be read against. No wrapper package: log/slog is the module, and a layer over it would be an abstraction with one caller. Duration comes from content.Now, since the clock is confined to one file and verify.sh enforces it by filename. The recorder does not forward Flusher or ReaderFrom — nothing here streams, so the cost is one io.Copy fast path on static files, and implementing interfaces no caller needs is the speculation rule 6 forbids. Two things this change owed and paid. content.go's comment still said content problems were "logged at error level", which the re-levelling made false. And the new flags pushed runServe past the function-length advisory, so the site-opening block became opened() — a warning that fires on correct code gets acted on, not tolerated, and that is the whole reason the advisory exists. Evidence, demo site, JSON: rebuilt bundles=31, then serving, then one request line each for a 200, a 404 and robots.txt with real byte counts and durations. At -log-level warn, request lines disappear. An invalid level exits with the reason. 12 files. Core 2913 → 2959 of 3400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
69a7eb4733
|
move robots and sitemap out of core, and raise the ceiling on purpose
Item 0 of the roadmap's order of work, and it blocked everything after it: core
sat at 2965 of 3000 while the review scheduled four core-bound items, the first
of which — logging — wanted the whole remainder.
/robots.txt and /sitemap.xml are exact paths somebody else's software asks for by
name. They own no core concept and pass every test the architecture applies to a
feature; they lived in internal/web only because a feature could not own a route
until ADR-0081. internal/ext/discover/ now holds them. Core 2965 → 2913.
The seam gained one parameter to make it possible: a func() *content.Site, since a
sitemap must list what is served now and the index is swapped whole on every
rebuild (ADR-0077). A captured pointer would have frozen the site at startup —
which is the kind of bug that only shows up after a rebuild, in production.
The ceiling rises to 3400 as well as the move, because the move alone could not buy
the room. feed.go and web/extras.go cannot follow discover out: a feed lives at
/{section}/feed.xml and extras under a bundle's own URL, so both are resolver cases
while the seam mounts exact paths only. Raising by the minimum that unblocks one
item produces a ceiling nobody believes, so 3400 fits the View cluster with
headroom. HARNESS.md asks that a raise be read as evidence something belongs in
ext before evidence the number was small; both readings were true, so both actions
were taken.
web no longer reserves those two paths, so a clash between features is wire.go's:
it merges route maps in declaration order, keeps the earlier claim, logs the loser.
Verified — a site shipping root/robots.txt starts, serves the engine's robots.txt,
and logs the passthrough claim, where an unguarded mux.Handle would have panicked.
Evidence: robots.txt and sitemap.xml are byte-identical before and after the move
against the demo site (67 and 2701 bytes, cmp clean), and the sitemap keeps its
application/xml type.
One real cost, recorded in both places rather than hidden. internal/web's
visibility test asserted that a listing, a feed *and* a sitemap all hide
unpublished bundles — one property, one test, because all three share a Query. The
sitemap half moved to the feature instead of a web test importing ext, which would
invert the one-way layering the architecture gate enforces. That property is now
asserted twice, once per package owning a surface.
Three gates caught real mistakes on the way: the staged-tree check found a partial
stage where git rm had staged a deletion while the caller edits were unstaged, the
coupling gates demanded state.md and HARNESS.md, and the nesting advisory rejected
a closure that put the merge loop one level too deep — fixed by making it a plain
function rather than tolerated.
Extensions 6 → 7. Routing cases unmoved: exact paths are mux entries, never
resolver cases, which is what that counter's exclusion column already said.
13 files. Core 2913/3400, ext 2495/3500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ec6e9838f0
|
rename docs/ to harness/, and reserve docs/ for the reader
docs/content-model.md opens with "Engine specification". It is also where the rule lives that a leading underscore makes a file unaddressable — and the human who owns this site did not know that rule, because nothing in this repository is addressed to an author. Twelve documents named docs/ while being exclusively about building the parser is a signpost pointing at the wrong room. Naming the directory for its audience makes the gap visible instead of hiding it. docs/ is now reserved and deliberately absent: an empty docs/ is an honest statement that end-user documentation does not exist, where docs/ full of parser specs was a claim that it did. HARNESS.md stays at the root. Root holds the three entry points — README.md for a human, CLAUDE.md for an agent, HARNESS.md for whoever maintains the machine — and harness/README.md is the map of the directory, so moving the guide inside would have collided with it for nothing. Mechanical and wide: 100 path references across 24 files. Every verify.sh gate that names a doc by path, the directory lists the dangling-path and ADR-number gates scan, surface.sh's output target, the Makefile, CLAUDE.md's read order, the skill, four commands, and two Go package comments. A first pass with a shell loop silently edited only four files and the rest still said docs/; the fix was to write the file list out and check the remaining count was zero rather than trust the loop's exit status. No rule, threshold, gate or obligation moved — this is a rename, and the gates demonstrated it twice: they stayed green on the new paths, and the ADR-number gate caught ADR-0082 before the entry existed. Deferred, both on the human's call: the end-user documentation site itself, which wants its own decision about where it lives and whether its claims are gated; and moving examples/ under docs/, since demo-site is a live site root that verify.sh, the coverage test and make demo all point at, and moving it would couple a rename to a design nobody has made. 31 files, +146/-106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9349c54d2e
|
let a feature own a route, and serve the site's own files at exact paths
Addresses like /.well-known/security.txt are fixed by somebody else's spec. None is a bundle, none belongs under /static/, and core had no way to serve one. This is the trigger the extension registry has been held for, in those words: ADR-0042 called core's generic derived-file route "the seam to revisit when a second feature wants output of its own", and state.md's counter note said to build the registry "when a feature wants a route". Raw passthrough is that feature, so the seam is built rather than worked around. Only Routes, not the seven-field Extension struct extensions.md describes. Five of the other six fields have no implementor and building them would be the speculation rule 6 forbids. It also kept the change inside the core budget, which had 65 lines left: the seam is ~30 core lines and the feature's own code lands in internal/ext/, where there is room. Core is 2965/3000. A feature returns map[string]http.Handler; core mounts each as an exact pattern and learns nothing about who owns it. A path core already answers is skipped with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so a site shipping root/robots.txt would otherwise take the server down at startup. Verified: server alive, engine keeps /robots.txt, warning logged, zero panics. Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and the file is rendered with text/template — never html/template, which would turn an ampersand in a contact address into & and a JSON quote into ". Opt-in by name rather than by sniffing the type, because a key or a signature may contain anything and a pass choosing for itself which files to rewrite would eventually eat one. The data is the site's own declarations and nothing more, which is the point: a security.txt naming its canonical URL should not repeat what site.yaml already says. Headers come from root/_headers.yaml, exact paths only. Globs are a second-use feature and the concrete need is a handful of .well-known names. The manifest is not served, by the leading-underscore rule that already means "not addressable" everywhere else — no special case was added for it. A manifest that will not parse is logged and ignored; the files still serve. Found while counting: the Extensions row read 4 while five packages existed. notation landed in ADR-0061/0062 and was never counted, though the prose beside the number already named all five. Corrected to 6. That is the latent item about counters having no mechanical check, demonstrating itself. Not done, and logged as latent: khosra check cannot report a root/ file shadowing an engine path, because verify.sh fails a feature that imports a sibling and the reserved paths live in passthrough. The startup warning fires on every boot, which is louder than a check finding. Evidence against the demo with a fresh binary: /pubkey answers with its declared text/plain despite having no extension; /.well-known/security.txt answers with Canonical filled from site.yaml's base, plus the declared CORS header; /humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are untouched. Eight unit tests cover layout, absence, interpolation, non-escaping, declared and derived headers, a broken template, and a broken manifest. 24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact paths are mux entries, not resolver cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
64e53f28c8
|
give a page the assets its own content asked for, and write down the rule
Two decisions and one mechanism. The human wants demos, games and runnable
embeds to carry real CSS and JS while every ordinary page stays scriptless, and
wants adding an asset to be theme work rather than a rebuild.
The mechanism reuses what already had that property. A theme defines
`assets:<name>` beside its other fragments; shortcodes record their own name as
they are opened; after conversion the engine renders each matching fragment once
into Page.Assets. So a gallery calling one shortcode forty times carries its
stylesheet once, and a page that called nothing carries nothing. Frontmatter
`use:` reaches the same fragments without a call.
Considered and rejected: templates/assets.yaml, which reads more declaratively
and buys a parser, a contract shape and a rebuild for conditional markup; and a
table in Go mapping shortcode to files, which would hardcode exactly what was
deliberately made data-driven.
Collection is parse-phase, so no transform counter moves — goldmark's extender
list is already the ordered pipeline for parse work, which state.md's counter
says in its "does not count" column.
Separately, styles/scripts are lifted at last. They sat in content-model.md's
table unread, and the theme contract listed them under "what the engine
provides", which was aspirational rather than true. Both are bundle-relative: a
name with .. or a leading / is dropped and logged, the refusal ::include and a
code block's file= already make. The engine builds the URLs because a theme must
not construct an address.
ADR-0080 writes the antifeature list down, with its single exception inside it.
An antifeature nobody recorded does not bind anything, and each of these dies to
one reasonable-looking request at a time. The exception is author-invoked and
cannot fire by accident.
The reference theme emits the stylesheets and no script element at all. That was
the human's correction to a first attempt which had page.html emitting the tag
and verify.sh narrowed to permit it — narrowing the gate to fit the code was
backwards, and the narrowing was also wrong, passing a probe with a hardcoded src
because it filtered whole lines and every line carries {{define}}. verify.sh is
untouched. examples/demo-site redefines the head block instead, so the JavaScript
half is demonstrated by a site rather than built into the binary, which is a
better demonstration and a stronger property.
Evidence, against the demo site with a freshly built binary: the sandbox page
carries its own css and js at bundle-relative URLs; colophon calls ::tally twice
and carries tally.css once with zero scripts; about calls it never and carries
neither; listings unaffected. Plus a table test for the escape refusal, which
until now had only the running server behind it.
19 files, +355/-86. No counter moves. Demo is 31 bundles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
69f43e5791
|
enable task lists, superseding the decision that excluded them
ADR-0058 kept task lists out as "a note-taking affordance, not a publishing one". That reasoning measured the wrong axis: a checklist inside a published technical piece — setup steps, a runbook, a what-I-tried list — is publishing, and nothing else in the dialect expresses "this item is done" without the author hand-writing an entity. ADR-0078 supersedes that half and records why. The half of ADR-0058 that mattered is untouched: extension.GFM stays refused, because the bundle drags linkify in with the tables it is wanted for, and linkify rewrites an author's plain text into markup — the line ADR-0034 draws. One named extension is not a bundle, and wire.go now says so where the temptation to reach for GFM will next appear. ADR-0058's Status line names its successor, so a reader arriving there learns the task-list sentence no longer holds. Same in-place Status annotation the mutability rule allows, Decision text untouched. Checkboxes render disabled: static markup, nothing clickable, nothing stored. A reader with scripting off sees the same page, which is the property the whole theme is built on. goldmark adds no class to the list, so theme.css finds it with :has rather than the engine inventing markup to be styled by. Demo carries the case ADR-0051 requires — a colophon checklist of what this build does and does not do, including the unticked "ship a single byte of JavaScript", which is true of that page and asserted by the test's absent list. No counter moves: state.md's counters are explicit that an upstream extension enabled in the list is dialect, not a feature of this engine — only a package under internal/ext/ counts. 8 files, +55/-10. 1 line of engine code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
be92b4b957
|
correct five claims about behaviour this engine does not have
All five were found by checking content-model.md against the parser rather than by any gate, which is the point: verify.sh catches a dangling path and a missing ADR number, never a sentence that is merely untrue. The Scaffolding section documented `khosra demo <empty dir>` writing a generated site. ADR-0051 deleted that generator eleven commits ago in favour of the tracked site in examples/, so the paragraph described a subcommand main.go never dispatched — and justified itself with "nothing in the engine repository is content", which the committed demo site contradicts. Deleted rather than rewritten: what the demo is belongs to state.md's inventory, and restating it here would have broken the single-source rule to fix a smaller problem. ADR-0050 is where that claim originated and it still read "Status: accepted", so a reader arriving there had no way to learn the generator was gone. Its Status line now names ADR-0051 as superseding that half. This deviates from decisions.md being append-only, so the exception is recorded in the mutability column where the convention lives: a Status line may be annotated in place, the Decision text never. "The parser does not read `slug` yet" was false and contradicted by the frontmatter table twelve lines above it in the same file. Deleted. The Sequences section and the Sequence doc comment both said `draft` is not honoured "because no bundle carries it yet". Two errors: the demo site carries one, and draft is honoured — membership resolves through Lookup, which is the single place ADR-0024 hides unpublished bundles. The doc now says what actually happens, including that prev/next closes over the gap. state.md said draft "lands in Extra unread". It is lifted to Bundle.Draft and deleted from Extra, and content_test.go already asserted it. Evidence: a three-chapter series with a draft middle, served by a freshly built binary. The landing lists First Rain and Third Rain; chapter one's next points at three/, not two/; the draft's own URL is 404. That absence has no test, so it is now a latent item with the reason it is safe by construction. 6 files, +25/-25. No counter moves. surface.md regenerated: the comment gained a line and shifted ten declaration numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b8c62d38fd
|
swap the index and the theme as one snapshot
Two stores meant a request landing between them saw a new theme with the previous index: each half coherent, the pair a state that never existed on disk. Microseconds, which is why it waited — it closes now because the fix removes machinery instead of adding it. web.Snapshot holds both behind one atomic.Pointer that a rebuild stores once. A Renderer never changes after New, so Refresh and the atomic inside the renderer are gone; an immutable renderer is the simpler object, and the one place a change is applied is now the one place it is observed. web.Handler lost its renderer argument and twenty test construction sites moved with it. Live reload verified on the real binary through the new path: a template edit appeared and reverted, two rebuilds for two edits. Also answers the session's open question: no subagents for fan-out reads. A verdict arriving without the reading behind it cannot be audited, which is the thing this harness exists to make possible. Latent list: 6. |
||
|
|
aa9bd2a489
|
make merging the default include model
Composing a page from several files is why includes exist, and one endnote list at its end is what that page wants. ADR-0066 kept the old default only so existing content would not re-render; both models have now been seen and the human chose. `include: embed` still asks for the other thing. One-level needed enforcing on the new path: a spliced fragment's own include line would otherwise be parsed as a call and expanded a second time, which a test caught. It is dropped during the splice, matching what embed already did. |
||
|
|
67defae912
|
highlight code server-side, and let a block quote a file
chroma at render time, emitting CSS classes rather than inline colour, handed to a `code` theme fragment. Highlighting works with scripting off, in a feed reader, in a browser that never runs JavaScript. No lighter pure-Go option exists — every "alternative to chroma" is JavaScript, which the reference theme is gated against. A fence's info string carries the rest: title, numbers, start, hl=3,7-9, and file=name lines=A-B, which reads the snippet out of a file beside the bundle and numbers it by that file's own lines. So a post quotes several parts of one program without the copies drifting from it, and a reader can find what they are looking at. Verified on the real binary: the same file at lines 5-10 and 12-14, each numbered as it really is, with different lines tinted. Not a new package: a new one could not import the key=value parser this repo already has, because ADR-0069 forbids a feature importing its sibling, and a second parser for the same syntax is what §6 stops. Two costs, both stated in the ADR rather than buried. The binary goes from ~15MB to 19MB, for a project whose story is one small binary. And the reference theme now carries a token palette — the first thing in it that is a taste rather than a demonstration — kept to eight classes for that reason. The demo quotes a shell file, not a Go one: a .go file under examples/ joins the module and has to compile, which the build gate caught before it shipped. 6 of 9 modules, ext 2188/3500. |
||
|
|
6447a995d1
|
bound the picture memo, evicting the least recently used
The memo held one entry per picture ever rendered, for the life of the process. Correct for one author's laptop, wrong for what this engine is meant to be: a server that runs for months and serves a whole site to many readers. Least-recently-used rather than clearing when full, because a site has pages nobody opens for months and a front page opened every minute — discarding wholesale throws away exactly the entries about to be asked for again. A map into a recency-ordered list: reads promote, evictions take the back, both constant time. 1024 entries is a few hundred kilobytes. Generous enough that a normal site never evicts, bounded enough that no site can grow the process without limit. The number is a constant and not a setting, because a knob with one user is a knob nobody asked for. Eviction, replacement and the bound are tested, including under -race, since requests are concurrent and the store is shared. Latent item cleared. ext 1975/2000. |
||
|
|
751ab9c06f
|
move the demo's coverage test beside the wiring it proves
The test rebuilt the feature list by hand, because a package cannot import a main, and it drifted three times in one session — the dialect, notation, Compose — each caught by a failing case rather than by the copy. The obvious fix was a composition package under internal/ext, and it was the wrong one: ADR-0069 forbids a feature importing its sibling, so that package would have failed the gate on its first build and the invariant would have been weakened one commit after becoming mechanical. Moving the test is smaller and points the other way — cmd stays the only place a feature is named, which is what extensions.md asks for, and wire_test.go was already here for the same reason. runServe and the test now call one function for the renderer, so there is no list to keep in step. Four files named a feature before; three do now, all of them package main, plus a benchmark that deliberately wires one extension to measure the render path and never claimed to be the shipped list. The core ceiling paid for it rather than being raised a third time: `given` was a helper with one caller and is now inlined into the only function that used it. core 2842/2850. Latent item cleared — the one that was marked due. |
||
|
|
1b898fdfc3
|
split the theme's fragments into a directory, keeping the file form
One file held every fragment, and it gained one per feature all session: figure, gallery, icon, three admonitions, details, aside, contents. A theme author overriding one had to copy the file or redefine into it, and a diff of the theme became a diff of everything. Both forms are supported, because a small theme is happier with one file and the contract should not force a directory on it. Parse order is embedded file, embedded directory, site file, site directory, and the last definition wins — so the directory overrides the file within one source and a site overrides the binary either way. Verified with a site root using both at once: its shortcodes.html supplied `icon`, its shortcodes/note.html supplied `note`, the embedded directory supplied the rest, and with the same name in both the directory won. The embedded theme ships the directory only, seven files, so nothing is defined twice. parseSet takes globs now and lost a branch doing it. Its old guard — a literal embedded name must exist — had to go, since shortcodes.html is deliberately absent; the replacement is stronger, failing at startup when a set matches nothing anywhere, which also catches a renamed base.html. |
||
|
|
4f706f1c35
|
delete date from Extra, like every other lifted key
Extra holds what the parser does not name (ADR-0002), and title, aliases, tags, order, slug and draft are all deleted once lifted. date was the one exception, so a template reading .Extra.date got the raw YAML value sitting beside the parsed time — two spellings of the same fact, which is how they drift. One line, and a test that names every lifted key rather than just this one, so the next field added to Bundle has somewhere to fail if it forgets. Latent item cleared. It had been waiting on "whatever next reads Extra generically"; nothing does yet, but the fix was smaller than the row describing it. |
||
|
|
ccce537ae4
|
tell the browser how wide a picture will be
srcset without sizes means a browser assumes 100vw, so a gallery thumbnail in a 16rem column was fetching the 1600px original — the derivative pass was costing bandwidth on the page it exists to save it on. It now picks the 480w variant. sizes is the one part of responsive images the engine cannot supply: it states how wide the picture will *be*, which is a fact about the layout and therefore the theme's. The reference theme states its own measure and nothing more. Lazy loading goes on gallery entries and not on a figure, because a figure is often the first thing on the page and deferring it delays what the reader came for. Both have demo cases, including that the figure is *not* deferred. Queue entry G2, which had been sitting unfixed through the whole Markdown arc. |
||
|
|
7136f6e2d9
|
let a shortcode fragment speak the reader's language
Three fragments added this session needed a word the author did not write: an untitled :::warn told the reader nothing about being a warning, an untitled panel fell back to whatever the browser calls <details>, and the contents list had no label at all — an accessibility gap as much as an untranslated one. None of them could be fixed, because `t` needs a language and Fragment had none, so those words could only ever have been English on a site that serves Bengali. Fragment gains Lang, captured on each call at parse time — a node renderer never receives the parse context, the same constraint that put pictures and headings on the node. Five phrase keys follow, and a Bengali page now reads সূচিপত্র, সতর্কতা and বিস্তারিত where an English one reads Contents, Warning and Details. The demo's own list.html was the better example of the problem and now shows the answer: a site's own sentences are not in the engine's phrase table, so a template needing its own words branches on the language it was given. That is what theme-contract.md has always told a theme to do, demonstrated rather than asserted, and a case proves the Bengali listing carries no English. Two files crossed the size advisory on the way. render.go shed the contract types to view.go, where state.md already claimed they lived and where the file's own header said they belonged; shortcodes_test.go split to mirror its sources, which the one-file-per-source convention already asked for. Both are pure moves. |
||
|
|
b5be77498e
|
give the author three controls the engine was deciding alone
`include: merge` in frontmatter splices a bundle's includes before the parse, so
a page assembled from several files is one document: one footnote list at its
end, numbered straight through, and an abbreviation defined anywhere reaching
every part. Moving the rendered block afterwards would have meant editing
goldmark's own markup; handing the parser one source gets the right answer from
it instead. Without the flag nothing changes — each fragment stays its own
document with namespaced ids, so no existing content re-renders.
Heading ids are unique under either model. Merging gets that free, because one
parse means one id set, but embedding did not: three `## Description`s across a
page and its fragments produced three identical anchors, and every link to them
landed on the first. A nested parse now shares the parent's id set, so the
second becomes #description-1 — goldmark's own suffixing, reaching across files
because they finally share the set it counts in.
Auditing for other policies the author could not reach found two more.
A heading may declare its anchor: `## Title {#stable-anchor}`. This is the one
that mattered most and nobody had asked for it — a derived id changes when the
text does, so rewording a heading silently broke every link to that anchor,
which is indefensible in an engine whose first value is that published addresses
are permanent.
`::toc{depth=2}` shortens a contents list, because a theme cannot know per page
how deep is useful and the author can.
Deliberately not added: a typographer toggle, a per-picture "do not resample",
icon overrides. No second user for any of them.
The hand-copied wiring in example_test.go drifted for the third time this
session — Compose this time, after the dialect and notation — each caught by a
demo case rather than by the copy. The latent row is now marked due, with what
moving the list would require.
|
||
|
|
d1c9179d6c
|
frame the contents list, and give the highlight its own colours
Two defects only a screenshot could find, both mine. The table of contents did not look like one: list-style:none stripped the markers and nothing replaced them, so the entries read as two loose links in the prose. Hairlines above and below make it a block again. <mark> was using the browser default, black on yellow, which fights a dark page. It has explicit colours now, and a muted pair in dark mode. Also records a third, which is not a defect but reads like one: an included file's footnotes render where the include sits, so the page appears to end and restart halfway down. That is ADR-0038 working as documented — a fragment is converted on its own bytes — and the ids are correctly namespaced. Only the placement is unfortunate, so it goes on the latent list with the trigger that would justify changing it. Verified in the browser this time rather than by curl: the aside floats at 1280px and flows inline at 375px, grouped panels close each other with no script, and dark mode is readable throughout. |
||
|
|
91c733a0e0
|
spread the demo's features across the pages that would carry them
notes-on-water had become a kitchen sink: every feature from the last eight loops on one page, which demonstrates the engine and misrepresents the site. Now each lands where a real site would put it. The text marks, the abbreviation, authored <kbd> and the margin note go to writing/typography, which is already the page about what the engine does to words. Icons and the grouped panels go to pages/colophon, which is the page about the theme — install instructions are what tabs are actually for. about gets a lone expando, the-flood gets a chapter note, and notes-on-water keeps what it is really for: the include and its namespaced footnotes, the gauge table, the glossary and a contents list over its own headings. Seven bundles carry features now instead of two, and the demo cases moved with them, so each still names the page it proves. |
||
|
|
fdc76ba1b9
|
add panels and margin notes, with no engine code at all
Expandable sections, tabs and asides — three of the things parked earlier — turn out to be two theme fragments and nine lines of CSS. That is what the container mechanism bought: they needed no Go. Tabs without script were the only real problem, and containers do not nest, so :::tabs wrapping :::tab was never available. Sibling <details> elements sharing a name attribute are natively mutually exclusive, which is what tabs are, so grouping is one argument on the same fragment an expando already uses. A browser too old for grouping opens them independently — the content is never hidden, which is the failure mode worth caring about. The aside is beside the text where the viewport has room and in the flow where it does not, in one media query. No JS anywhere, and the demo case asserts the page contains no <script> at all. No ADR: nothing here is expensive to reverse, and the contract grew additively as its stability rule allows. |
||
|
|
8bfacc7e98
|
build a table of contents from the document's headings
`::toc` renders through a `toc` fragment receiving level, text and the id goldmark already assigns. The engine collects, the theme decides whether that is a list, a sidebar or nothing — the reference theme emits a flat <ol> with a level class per entry, so indentation is CSS rather than markup. A theme cannot enumerate headings, because templates cannot parse HTML, so the engine is the only thing that can supply them. Collection lives in ext; only the Heading type and one Fragment field are core. That is what needed the ceiling: CORE_LOC_MAX 2800 -> 2850, the second raise. ADR-0041 said to read a second raise as evidence something belongs in ext, and the test was applied rather than waived. feed.go and discover.go are the features that should leave core, and they cannot, because an ext feature cannot own a route until the extension registry exists — which the counter says buys nothing yet. The thing that should move is blocked on a different decision, so the honest fix was the ceiling and an ADR saying exactly that. When routes become ownable, they leave and this comes back down. Entry text is the heading's words with markup stripped: a link inside a link is not markup a browser accepts. An entry whose heading has no id is skipped rather than linked nowhere, and a page with no headings renders no nav at all. core 2804/2850, ext 1825/2000, 34 gates green, 0 warnings. |
||
|
|
78b8c51dff
|
add container directives, and admonitions as their first user
`:::name{…}`, a body of Markdown, then `:::`. This spends the form reserved by
ADR-0059 rather than leaving it a promise — and building it back then would have
been a mechanism with no user, which is what the reserve was avoiding.
The body renders first and reaches the theme fragment as .Body, already HTML, so
emphasis, links, subscripts and icons all work inside an admonition. That is one
addition to the theme contract, additive as the stability rule requires, and two
lines of core — which is what the remaining budget allowed.
Rendered by a transformer rather than the node renderer, for the same reason an
include is: rendering a subtree needs the document, and a node renderer never
gets one.
When the theme has no template for a kind, the engine writes the body out
unwrapped. Same principle as an unknown icon keeping its text, and it matters
more here: a theme not knowing one name must never cost an author paragraphs,
and an unstyled aside is a far smaller failure than a missing one.
The leaf parser was parameterised by prefix rather than copied — a second copy of
parsing logic is a stop condition, and the two forms differ by one colon.
Containers do not nest: a `:::` inside closes the one it is in, the same limit an
include carries. Stated in the ADR and the contract rather than left to be found.
core 2796/2800, ext 1743/2000, 34 gates green, 0 warnings.
|
||
|
|
df9335df33
|
parse :name: as an icon, and let the theme decide what one is
The engine's half is one call: parse the name, hand it to a single `icon` fragment, decide nothing else. No icon table in Go, ever. The surveyed engines split three ways — Unicode (Hugo, Pandoc), a remote image per icon (Jekyll's jemoji, which fails the sovereignty test outright), and inlined SVG from a bundled set (MkDocs Material) — and all three are decisions about markup, which ADR-0036 puts in the theme. A theme wanting Font Awesome ships a sprite in its own base.html and redefines one fragment: no webfont, no request, no script. The boundary rules are the whole difficulty, because the colon is the commonest punctuation in technical prose. A name must start with a letter, hold only letters, digits, hyphens and underscores, and neither colon may touch an alphanumeric. Verified on the real binary that 10:30:15, key:value:pair, "Note: this", a URL and a code span all come through untouched — each of them would otherwise be a silent edit to someone's sentence. The literal fallback closes the same hole from the other side: when the theme renders nothing, the engine writes the author's `:name:` back, so an unrecognised icon is never deleted from the middle of a paragraph. An icon needed its own inline node rather than the block one — goldmark distinguishes the two by type — which also avoids the type switch CLAUDE.md §6 forbids: two kinds, two renderer functions. The reference theme maps six names to Unicode and ships no sprite, font or asset. core 2794/2800, ext 1615/2000, 34 gates green. |
||
|
|
661fb469e8
|
expand abbreviations from a definition line
`*[TERM]: expansion` on its own line, PHP Markdown Extra's form, and every whole-word use in the document becomes <abbr title="…">. A transformer rather than an inline parser, because a definition may appear after the use it explains and a parser only ever sees what it has already read. A block parser rather than a pattern found later, because the line has to stop being content — an author would notice that going wrong before anything else. Whole-word matching is the part that would have bitten: without it a definition of HTML quietly rewrites HTMLish and xHTML too, so both have cases. Code spans, autolinks, raw HTML and an already-expanded term are skipped, the longest definition wins where two could match, and the expansion is escaped into the attribute so a quoted phrase cannot end it. Definitions are document-scoped. A term defined in a page does not reach an included fragment, which is parsed on its own bytes exactly as footnotes are — stated in content-model.md rather than left to be discovered. The nesting gate caught firstMatch four levels deep; the inner search is its own function now, which reads better than it did before the warning. core 2794/2800, ext 1483/2000, 34 gates green, 0 warnings. |
||
|
|
53e9ef473a
|
add inline notation, and take the tilde back from strikethrough
~sub~, ^sup^, ==mark==, and ~~strike~~ moved in from goldmark. Not a preference: goldmark's strikethrough claims a single tilde as well as a double, so with it enabled H~2~O rendered as H<del>2</del>O — measured before the change. Two features cannot share a byte and both be correct, so notation owns it and the authored syntax stays exactly as ADR-0058 documented. The second failure was worse and only showed up under test. Under delimiter rules `x^2 + y^2 = z^2` pairs its carets across the whole expression and renders x<sup>2 + y</sup>2 — prose silently becoming markup, in exactly the content this engine is for. So a single run is scanned rather than paired, and may not cross whitespace: a subscript holds a formula, never a phrase. Pandoc draws the same line. The cost is that a single run takes its content literally, so there is no emphasis inside a subscript, which the ADR states rather than leaving to be discovered. New package under internal/ext, which is a stop condition and was asked. It takes the extensions counter to 4, past its threshold, and the answer is still no: four features attach in three unrelated ways, and two goldmark extenders compose in goldmark's own extender list, which is already the registry for that shape. The example site's hand-copied extender list drifted, exactly as the latent row added last loop predicted — the demo case failed and named it. Both are now in step again. core 2794/2800, ext 1236/2000, 34 gates green, 0 warnings. |
||
|
|
1f168d973b
|
render the HTML an author writes, and narrow the gate to one call site
Dropping raw HTML was silently destructive. H<sub>2</sub>O rendered as "H2O",
10<sup>6</sup> as "106", <kbd>Ctrl</kbd> as "Ctrl", and khosra check reported
nothing — an author lost meaning with no signal anywhere. Measured on the real
binary before and after.
Invariant 2 already says content from the site root is trusted, so the old gate
was defending the half of the boundary that was never in question while the
untrusted half has no code to defend yet. Chemistry, units, exponents and
keystrokes are what a hard-science site needs and what no Markdown dialect
expresses, so html.WithUnsafe() goes on in internal/render/render.go.
The gate does not disappear; it narrows. verify.sh used to fail on WithUnsafe
appearing anywhere and now fails unless it appears in exactly that one file —
watched doing both, accepting one call site and naming both files when a second
appears. A second pipeline trusting its input is the failure ADR-0003 exists to
prevent, and when comments arrive they get their own goldmark without it. The
gate is the reminder that the split has to be built rather than assumed.
The security test that asserted "raw HTML must still be dropped" now asserts the
property that actually holds and matters more: a shortcode argument stays data
whatever the page around it is allowed to do. ::figure{alt=<b>bold</b>} still
arrives as <b> while the <span> beside it renders.
core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
|
||
|
|
a893ab1821
|
replace the shortcode syntax with generic directives
`::name{key=value}` alone on a line, quotes only where a value has spaces,
braces omitted when there are none. The old form cost eleven characters of
punctuation per call and could not carry a body, which admonitions will need.
Generic directives are an existing convention — remark-directive, MyST,
Docusaurus — so this is a syntax authors and tools already know rather than one
more invention, and it reserves `:::name` for containers and `:name[…]` for the
inline dynamic calls that come later.
Retired outright rather than aliased: two syntaxes is two parsers and two test
sets forever. `khosra check` reports every leftover call as fatal and names the
replacement, so migrating a site root is running it until it exits zero — proven
on an unmigrated root, which exits 1 with the file and the fix.
The trigger byte moves from `{` to `:`, which prose uses constantly, so the
parser refuses `3::4`, `: a definition` and `:::note`, each with a case. The
definition-list parser sits at priority 101 and this one at 100, so it gets
first refusal and everything it rejects falls through.
Two things the syntax change would have broken silently. check's alt-text regex
still matched the old form, so the one accessibility check the engine has would
have stopped finding anything — it moves with the syntax and keeps its case. And
a test asserted that hostile arguments fail at the syntax because quotes cannot
be expressed; unquoted values are legal now, so it asserts the property that
actually holds: the fragment escapes them.
Demo migrated. core 2790/2800, ext 1077/2000, 34 gates green, 0 warnings.
|
||
|
|
0465785e81
|
settle the Markdown dialect, and namespace an include's footnotes
Tables, footnotes, definition lists, strikethrough and automatic heading ids. Which dialect a site is written against is permanent, so ADR-0058 names the whole set at once — including the four refused, each for a reason rather than a taste: task lists publish nothing, linkify rewrites plain text into markup that ADR-0034 forbids the engine to touch, CJK is the wrong script family for a Bengali site, and the GFM bundle is a package deal for the first two. Footnotes collided with includes, as the queue predicted but worse. An include converts its file on its own bytes (ADR-0038), so goldmark numbered its notes from one again and the page carried two id="fn:1"s — the parent's reference jumped to the fragment's note. shortcodes.FootnotePrefix stamps the file name on the nested document and hands it to goldmark's id-prefix function, so the fragment gets _method-fn:1 and the page keeps fn:1. Two things nothing tested before. The extender list ships from cmd/khosra, which no package can import, so the dialect had never been rendered through the list the binary actually uses — cmd/khosra/wire_test.go now does exactly that, including that the typographer no longer eats a table's delimiter row. And the demo carries the dialect and the footnote namespacing as cases, which caught auto heading ids changing markup in three existing assertions. The reference theme gains five lines: a rule under each table row, an indent for definitions, smaller footnotes. core 2790/2800, ext 1058/2000, 34 gates green. |
||
|
|
b5ec3bfc9d
|
serve one theme snapshot to the whole site, and honour -poll
Per-render reparsing could not keep the site coherent, and did not. Only two of the four render methods called fresh() — Bundle and Tag never did — so under -dev on a listing served an edited template while a bundle served the old one. Verified on the pre-G1 binary: /posts/ answered V2 while /posts/hello/ answered V1, permanently, not for a window. Adding the two missing calls would have left four places that must each remember, and Partial runs during a page's Markdown conversion, so one page could still mix two themes. So the per-render path is deleted instead: Renderer.reload, Reload() and fresh() are gone, Refresh is the only thing that replaces a theme, and -dev on gets its promptness from polling every 250ms. Coherence is now structural rather than a discipline four methods share. -poll arrives as ADR-0022 specified it and never delivered: it sets the interval, and 0 stops watching for an immutable deployment. Watch takes the interval and settle window as arguments, so the Interval and Settle package variables are gone and no test mutates package state to control timing. The theme is reparsed in the watcher's callback rather than inside rebuilder, so startup parses it exactly once, in New — there is one call site and it is not on the startup path. The two swaps it leaves are not one transaction; state.md's latent list carries that gap and its trigger. Measured on the real binary: bundle, section listing and tag listing all moved V1 -> V9 together within 1s of editing two templates; -poll 0 served and then ignored an edit; -dev on -poll 3s kept 3s. core 2780/2800, ext 1027/2000. |
||
|
|
633debf743
|
apply a template edit without a restart
The watcher fingerprinted templates/ but a rebuild only re-scanned content, so editing a template fired a rebuild that changed nothing. ADR-0022 already promised the opposite — "a template edit in the site root invalidates through the same path as content" — which makes this a defect against a recorded decision rather than a missing feature. ADR-0055 records the fix and supersedes ADR-0048's narrower clause. The parsed sets and the stylesheet become one parsedTheme behind an atomic.Pointer, swapped by Refresh once per rebuild instead of per request. A parse failure keeps the theme that was working, so a typo cannot take the site down. The swap also retires the in-place field mutation -dev was doing, which was a data race with every in-flight render. site.yaml goes the other way and leaves the fingerprint: the settings are copied by value into the renderer, the handler, the feeds and the sitemap, so applying a change to some of them is worse than applying it to none. It is restart-only. Corrects the Effects counter row while proving it did not move: it still said startup was the only change signal "until queue 21", but queue 21 shipped as ADR-0048 and put the derivative pass inside rebuilder, so that has been wrong since. The row now also answers the question ADR-0055 invites — an in-memory swap is not an Effect, because it writes no artifact and calls nothing outbound. Measured on the real binary: a template edit went live in ~2s; a typo logged "keeping the previous theme" and kept answering 200 with the last good markup; a site.yaml edit now fires no rebuild at all. core 2766/2800, ext 1030/2000, 34 gates green, 0 warnings. |
||
|
|
de1ce73430
|
track the demo as a real site in examples/, gated like the docs
The human asked for a demo extensive enough to review by hand, tracked as files, and kept current the way docs are. Generated filler cannot be reviewed — you cannot read a Go function and see what a reader sees — so the generator is deleted and `examples/demo-site/` is an ordinary site: 33 Markdown files, six pictures, site.yaml, a template override and static files. My reason for generating it was ADR-0011, and I had that rule wrong: it puts the *author's site root* outside this repository, not fixtures. conventions.md already keeps golden files in testdata/, and this is the same category one size up. Keeping both a generator and files would have been two sources of truth, so one had to go. Kept true by two gates rather than by good intentions. A table-driven test in internal/web serves the directory through the real handler with one case per feature — thirty-five of them, each naming what it proves — and verify.sh runs `khosra check` over it and fails on anything fatal. Adding a feature now means adding it to the demo and adding its case, and the build says so if you don't. Extensive on purpose: eleven dated posts so a section actually paginates, a four-chapter series so first/last are not the neighbours, a gallery with three JPEGs and an SVG so both the resampled and the untouched paths show, a Bengali-only bundle, a titleless status, a draft, a future date, an include, a nested extras tree, and a typography page that demonstrates what the engine will not do. Two expectations of mine were wrong and the demo corrected them: the site's own list template overrides *tag* listings too, so there are no group headings there — which turns out to be the better demonstration of ADR-0046, since the theme picking the flat shape is exactly the point. And template literal text is not escaped, so an apostrophe stays an apostrophe. |
||
|
|
4396303771
|
add khosra demo, and give the site a front page
The demo writes a whole site root that exercises every feature: two languages with a fallback, a series with ordered chapters, a gallery, a figure, an include, extras, tags across sections, a slug with an alias, an undated page, a draft, a template override, static files and site.yaml. It generates its filler rather than copying stored files, because nothing in this repository is content (ADR-0011) — and that makes it a test of the engine rather than a fixture: anything khosra can do that the demo cannot express is a gap. Two things found by generating and then serving it, which is the whole point: `khosra check` reported the demo's own series as mixing ordered and unordered members. It was right — the chapter bodies *described* `order: 10` while the frontmatter never carried it. The checker caught its own author. And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but "engine-owned" was never given an answer, so a visitor to the site's own address got nothing. The root now lists every bundle, newest first, paginated like any other listing, and 404s only when nothing is published. A hand-written home page stays a separate decision, recorded as such. Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean, and every URL the demo promises answers — including the alias redirecting, the draft hidden, and the front page rendering through the site's *own* template override. |
||
|
|
96313e4eda
|
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. |
||
|
|
9100ce4876
|
notice content changes and rebuild without a restart
Polling lives in internal/ext/watch, per the human's call to keep core under its ceiling rather than raise it a second time — which is what ADR-0041 said a second raise would mean. It is a poller, deletable without trace, and core stayed at 2671/2800. A settled change calls the same `rebuilder` that startup calls, because a reload path that differs from the startup path is a reload path that drifts. The index is an atomic.Pointer swapped whole, so a request reads the site that was current when it arrived instead of one being rebuilt underneath it — the alternative, mutating in place, is a data race with every in-flight request. Names, sizes and modification times, not contents: reading every file to detect a change costs more than the rebuild it triggers. Editor droppings are excluded, because saving in vim writes a swap file, a backup and the number 4913, and each would otherwise look like a change. A change must hold still for a moment first, since one save is often several operations. Verified against the running binary: a page 404s, the file appears, and five seconds later it serves — one "site root changed" in the log. Then three droppings written at once produced no rebuild at all. Two warnings fired and were fixed rather than silenced: `runServe` gave up the rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to `record`, where three exclusions read as a list instead of as nesting. The Dockerfile ships the binary alone. The site root arrives as a volume and is never copied in — it is somebody's content repository with its own history (ADR-0011), so the image is the same for every site. |
||
|
|
669a94a26a
|
publish a bundle's extras as a browsable tree
Re-adopts the parked extras entry as ADR-0047: `extras/` inside a bundle is skipped
by the scanner entirely, so a `.md` in there is an asset with no identity and no URL
of its own. The engine enumerates the tree, sorts it by path, classifies by
extension, renders markdown and text, and offers everything else as bytes. One route
with two behaviours — `…/extras/{path}` selects an entry, `?raw` returns the file.
Almost everything it needed already existed, which is the sign the model was right:
the scanner had a directory exclusion, `Assets()` knew which bundles own a
directory, and `Lookup` already decided visibility — so a draft hides its extras
with no new check. A test proves that, including `?raw`.
Two deviations from the parked shape, both because the shape was written before the
code. The directory name is fixed rather than a cascade key, since nothing reads a
section-level setting yet. And an entry is resolved against the *enumeration* rather
than the filesystem: not being in the listing is a stronger answer than os.Root
refusing a path, and cheaper.
Selecting is a link and a full page. No JavaScript is involved, and a
sidebar-and-pane layout is the theme's business — which is the layer rule applied
before writing the feature rather than after.
Three size warnings fired as a result and were fixed by splitting at seams, not by
sharding: render.go gave up its type declarations to view.go, which is the theme
contract in Go and nothing else; serve() split into a dispatcher and serveBundle;
resolve() gave up its language-prefix step to cutLang.
|
||
|
|
f3e54b1849
|
add khosra new, which writes the first bytes into a site root
Scaffolds a **directory** bundle — the only shape that can own local files, so the other kind would hand an author a page their pictures cannot live beside. What it writes is a draft: title, today's date, `draft: true`. A tool that publishes the moment it runs publishes by accident, and drafts are honoured now. This is the first thing that writes into somebody's content directory, so it goes through os.Root like every read does (ADR-0031), and it never overwrites: an existing bundle is an error. Two bugs found by running it rather than by testing it: A key of `../escape` did not fail. It never left the site root — path.Join collapses `..` first — but it wrote a real directory *inside* the root and outside content/, which is not an escape and not a bundle either. Refused outright now, the same guard the include path needed for the same reason. The test asserts what should be true — content/ is the only thing this creates — because the weaker assertion I wrote first would have passed. `khosra new posts/x -site dir` silently ignored -site, because Go's flag package stops at the first non-flag argument, and then failed complaining there was no site root. Parsed in rounds now, so either order works. main() crossed the function-length warning as a result, so it became a dispatch table with runServe beside it — the warning was right about the code. |
||
|
|
bc8c26d8f4
|
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. |
||
|
|
ab882980ad
|
hide drafts and future-dated bundles; reveal them with -dev on
A draft is now not served at all, and neither is a bundle whose date has not arrived. The filter sits in `Site.Lookup` and `Site.Run`, which is every path to a bundle — so the files inside an unpublished bundle inherit its status for free, which is what ADR-0024 asks for and what the asset route was written to allow. A test asserts the 404 for the bundle *and* its picture, and that nothing leaks into a listing, a feed or a sitemap. The clock is read per request rather than at startup, so a scheduled post appears exactly when its date arrives with nothing to restart and nothing to invalidate. That first clock read created internal/content/clock.go, which is the only place `verify.sh` allows `time.Now` — a render that depends on the time is worth being able to find. `-dev on` reveals both and reparses the theme before each render. Deliberately not a bare boolean flag: turning unpublished work into public work should not be one fumbled argument away. A reload that fails to parse leaves the working template set in place, so a typo shows an error rather than replacing a good set with a broken one. |
||
|
|
b574800adb
|
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. |
||
|
|
479d8c6bd6
|
add khosra check, the place content errors are looked for
The server never fails on a bad bundle — it works around it and logs, which is easy to miss (ADR-0029). This is the command that looks on purpose, and it exits non-zero on anything that makes the site wrong rather than merely untidy. Fatal: content the engine had to drop (unparseable frontmatter, a key two files claim, an ignored slug or alias) and links that will 404 for a reader. Warnings: no title, a figure with no alt text, a series where some members declare order and others do not — that last one because unordered members sort last, so adding order to one chapter silently moves every chapter that lacks it. Two things this needed rather than invented. `Scan` and `Site` now *return* what they worked around instead of only logging it: `ScanReport` and `Site.Problems`, with `Scan` staying the logging wrapper so nothing else changed. And required-fields-per-type is deliberately absent — `title` is the only field the engine requires today, so checking more would mean inventing the type declaration that is still parked. Its trigger stays where it was. The link checker asks the same questions the resolver asks — bundle, alias, section listing, file inside a bundle — because a checker that guesses differently from the server is worse than no checker. Engine-owned paths are skipped: they are generated, not authored. It lives in internal/ext/check, so cmd/ stays wiring and the feature stays deletable. Verified against the evidence site: clean before, and five findings across five fault classes after I introduced them on purpose. |
||
|
|
b061f4590f
|
measure the render path, then remember pictures instead of caching pages
The entry said to measure first and put the number in the commit, so: a plain page renders in 14µs, a twelve-picture gallery in 1.23ms. Of that, ~102µs per picture was reading, hashing and decoding bytes the previous request had already read. Remembering that one fact — keyed by path, size and modification time — brings the same gallery to 63µs. 19.5× faster, 21× fewer bytes allocated, twenty-odd lines. After which nothing is slow enough to justify caching whole pages, so ADR-0044 declines the page cache and leaves the parked validity model parked, now with a measurement rather than an intuition behind its trigger. That parked model has five axes and was written before any code existed. The problem it would have been built for turned out to be one repeated file read. Benchmarks live in internal/web so they measure through the real handler, which is also what conventions.md wants before any cache goes in the render path. The invalidation risk has its own test: an edited picture is a different key, so the memo cannot serve yesterday's dimensions. Everything runs clean under -race, since the map is read by concurrent requests. |
||
|
|
57f5520de8
|
serve Atom feeds for the site, a section and a tag
Membership is a publication date, not a type declaration (ADR-0043). The parked
feed shape said "every type declared primary", which would have made feeds wait on
declared types a third time — but the thing that distinguishes a feed item is
already on disk. Pages and section landings drop out because they have no date,
which is the right reason. The parked idea stays parked with a sharper trigger:
someone wanting a *dated* bundle kept out.
Built with encoding/xml from typed structs, never a template: XML in html/template
is escaping for the wrong grammar, and that is a correctness trap rather than a
matter of taste.
A bug the evidence found, older than feeds: content.URL("", lang) built "//", so a
whole-site feed's id and alternate link were https://khosra.example// — every
entry identity wrong in every reader. The root is "/" now, with a test, and the
hand-built "/" the resolver carried for the same reason can follow later.
Two counters re-scoped rather than incremented, the same way transforms was:
Views now counts *per-bundle selection* — the thing architecture.md means by the
View layer, still at zero consumers. Output formats are not it: HTML, sitemap XML
and Atom are three functions with nothing to share, so an interface over them
would have one member and no leverage.
Effects stays at 1. A feed is generated per request like the sitemap, so it is not
a second Effect and the runner is not yet due — the next thing that writes files
off the request path is.
|
||
|
|
282093fb55
|
generate sized derivatives ahead of the request
A pass over the content at startup writes three widths per picture into a cache outside the site root, named by the source's content hash and the width (ADR-0042). Idempotent by construction: a rerun stats and skips, an edited picture takes a new name, and nothing stale can be served under an old one. Restarting the evidence site made 0 derivatives the second time, as it should. Ahead of the request rather than during it, because resampling is felt and there is no page cache yet to hide it. Outside the site root, because the engine reads that directory and must not leave generated files in somebody's content git — a lost cache costs one startup pass and no correctness. Markup now carries the original as src, the derivatives as srcset closed by the original at its own width, and width/height from the original — which retires most of the latent row about the output floor; only a gallery's alt is still empty, and a filename cannot supply that. Two things the work itself decided: `Fragment.Items` became `Fragment.Pictures`, ADR-0037's own revisit trigger. Items had one consumer, so widening it beat adding a second list beside it. "A browser can show it" and "we can resample it" are different questions, and conflating them nearly deleted content: an SVG has no decoder here, so a single predicate would have dropped SVGs from galleries silently. Undecodable and unsupported pictures are now rendered as they are, without a size or a srcset. |
||
|
|
c88ca93107
|
serve the files a bundle owns
Every figure and gallery shipped so far emitted links a browser could not fetch: a relative src resolves under the page's URL, and nothing answered there. Found by fetching the pages' own links rather than by reading their markup — the evidence runs had been checking that the right src appeared, never that it worked. A directory bundle's files are now served under its URL. The bundle is looked up first and the file is read only from the directory that bundle owns, never from a path assembled out of the request: ADR-0024 requires that no route serve bundle bytes by path alone, since every byte inside a bundle inherits its publish status. When drafts arrive at queue 19 the filter belongs beside that lookup and nowhere else, which is why the ordering is written down in the comment. A single-file bundle owns nothing: its neighbours belong to the section, and its slash-terminated URL has nothing beneath it. An author with assets writes a directory bundle, now stated in content-model.md. A .md inside a bundle directory is never an asset — it is a bundle with its own URL or a fragment that was never addressable, and serving either raw would publish source. http.ServeFileFS handles content type, conditional requests and ranges, none of which is worth reimplementing here. |
||
|
|
e7287a0370
|
serve a declared slug as an address, leaving the key alone
The human chose the second option: a route sits beside the key rather than replacing it. So `slug` renames what a bundle is served at, in every language, and identity stays derived from the path — which is exactly what keeps ADR-0033 intact, since series membership is the directory. A series landing page can now be renamed without orphaning its chapters, and there is a test that says so. `Site` resolves routes at index time, because only it can see whether every variant agrees. Disagreement is dropped rather than resolved, as is a slug landing where another bundle already answers — the same rule colliding keys and contested aliases already follow. The key a slug moved away from stops answering, so the old address does not quietly keep working. Two bugs surfaced doing this, both older than this change: An alias naming its own bundle's former key was rejected as "an alias that names a real bundle" — which made rename-plus-alias, the entire point of ADR-0008's alias mechanism, impossible. The check now asks what a request asks: is anything actually served there. Aliases were counted per declaring *file*, so a bundle whose two language variants both listed the same alias looked like two rival claimants and lost the alias. It is a set of keys now. This one only appears with translated content, which is why no fixture had caught it since entry 4 — the real binary did, on the first multilingual rename. |
||
|
|
09b94d74f2
|
serve robots.txt and sitemap.xml
Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases — no bundle can collide, since a key always sits under a section. robots.txt at the site root is served verbatim, because a site that ships one has said something deliberate; otherwise the engine emits the minimum that is true and points at the sitemap. The sitemap lists every bundle in every language it exists in, since each variant is separately reachable, with lastmod only where a bundle has a date. Every URL comes from content.URL like every other path the engine emits, so a sitemap cannot disagree with what is actually served. Both need a declared base. Without one the sitemap answers 404 rather than listing paths no crawler can resolve, and robots omits the Sitemap line rather than writing a relative one. write() was setting text/html for every caller, and headers only go out with the first byte — so a handler setting its own type would have had it silently replaced, which is how a sitemap gets served as a web page. It now splits into write and writeAs, and the tests assert the content types rather than only the bodies. |
||
|
|
04ae2ff475
|
prevent widows, as the second feature package
The last two words of a paragraph or heading are joined by a non-breaking space, so one word never falls alone onto its own line. Deferred from entry 11 for the right reason: over rendered HTML this cannot tell prose from an escaped code span, so it had to wait for a tree transform. The interesting failure is worth keeping: written against goldmark alone it passed six tests, and did nothing in the real engine. The typographer splits a text run wherever it looks for a substitution, so a paragraph ending "hand." arrives as two text nodes and the last of them holds no space at all. A version that inspects only the last child therefore finds nothing to join. It now takes the whole trailing run of text nodes, stopping at a line break or any markup, and there is a regression test that builds both extensions together — the only configuration that would have caught it. The joined text becomes a String node, which carries its own bytes: a segment is an offset into bytes every node shares, so editing the source in place is not possible. That path still escapes, and a test says so, since otherwise this transform would be an injection route. PhaseMarkup is now empty in extensions.md, and honestly so: everything expected there turned out to belong either earlier or later. |
||
|
|
238caf064b
|
a .md file starting with _ is a fragment, not a bundle
Found by serving the include evidence: `tools.md` beside a bundle's index was
itself scanned as a bundle, so a file meant only to be included took a URL of its
own, appeared in its section's listing, and turned the including bundle into a
one-member series. The real binary showed the phantom series nav; no test would
have, because every fixture happened to name its partials differently.
The rule mirrors the one directories already have, `_index` excepted since that
names its directory. `{{< include file="_tools.md" >}}` is now the shape to write.
|
||
|
|
2041c43b70
|
include another file from the bundle, one level deep
{{< include file="notes.md" >}} renders a file from the bundle as Markdown in
place. The included file is converted by the same goldmark instance that is
rendering the page — handed to the transformer in Extend — so its configuration
can never drift from the page's.
Three properties, each tested:
A name containing ".." is refused. os.Root would stop a path leaving the site
root, but path.Join collapses ".." long before the filesystem sees it, so without
this an include could read a template or a stray dotfile from the site root and
publish it. Verified the test fails without the guard: it took three levels of
".." from content/pages/d to reach the root, and the first version of the test
used two, so it passed either way and proved nothing.
An include inside an included file renders nothing and logs. The nested parse is
marked, so one level is all there is and a file including itself is a log line
rather than a stack overflow (ADR-0038, ADR-0029).
A gallery inside an included file still resolves, because the nested parse carries
the same Origin.
The node gained `content` for output a feature produced itself, plus `isContent`
so a failed include renders nothing instead of falling through to a fragment
lookup and complaining about a template that was never meant to exist.
|