Commit Graph
11 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 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 &lt;b&gt; while the <span> beside it renders.

core 2793/2800, ext 1077/2000, 34 gates green, 0 warnings.
2026-08-01 21:08:06 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 10:54:30 +06:00
Claude Opus 5andbdeshi 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.
2026-08-01 10:54:30 +06:00
Claude Opus 5andbdeshi 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.
2026-07-31 19:48:32 +06:00
Claude Opus 5andbdeshi 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.
2026-07-31 12:51:31 +06:00
Claude Opus 5andbdeshi 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.
2026-07-31 02:23:26 +06:00
Claude Opus 5andbdeshi c16bf4bd9d add shortcodes as the first internal/ext feature
A call is `{{< name key="value" >}}` alone on a line, parsed by a goldmark block
parser into an AST node and rendered by executing a theme template of that name
(ADR-0036). `figure` ships; `include` and `gallery` need the including bundle's
directory, which the parser does not carry yet, so they wait.

The layering did the design work here. internal/render may not import
internal/ext, so render.New takes a callback that receives a Partial and returns
Markdown extensions, and cmd/khosra/wire.go holds the only list of enabled
features. Empty that list and the engine still builds and serves — which is the
property extensions.md says the contract should have.

Raw HTML stays disabled. An author's text reaches a page only as arguments that
html/template escapes in context, which the real binary shows: a hostile alt
becomes &lt;script&gt; and src="javascript:…" becomes #ZgotmplZ. Getting
contextual escaping from the standard library rather than writing it is the whole
reason a fragment renders this instead of the feature.

parseSet became variadic so the fragment set reuses it rather than growing a
second copy of the overlay logic; `Partial` takes map[string]string after the
advisory correctly flagged `any` as generality nothing had asked for.
2026-07-30 10:29:00 +06:00
Claude Opus 5andbdeshi fbaf077f74 resolve sequences from the directory tree
A bundle nested under another bundle is a member of that series (ADR-0033), so
`Site.Sequence` walks up to the nearest bundle ancestor and back down to its
members: ordered by `order` where set, then by name. Members resolve through the
language fallback, so a chapter with no Bengali variant still holds its place in
Bengali reading order instead of breaking prev/next.

One `.Sequence` field carries both shapes a theme needs. A landing page renders
`.Members` as an archive; a chapter renders `.Prev`/`.Next`, which are pointers
into `.Members` so `{{with}}` yields nothing at the ends. `Index == 0` is what
tells the two apart.

`Query` was deliberately not extended. A series ascends where `Run` descends, and
an order knob on `Query` is the config knob rule 6 bans; instead `Site.keys()`
came out so both iterate the index one way, deleting `Run`'s own dedupe map.

`draft` is not honoured: no bundle carries the field and nothing else excludes
drafts, so entry 19 adds it in both places at once. Recorded in content-model.md
rather than left implied.

state.md also corrects six inventory rows that had drifted before this change —
three LOC figures, the test total, `go.mod`, and two lines that were flatly wrong
("Dependencies: none", "goldmark is not yet imported"). The coupling gate proves
state.md changed with the code; it cannot prove the numbers are right.
2026-07-30 03:03:20 +06:00
Claude Opus 5andbdeshi 9bccb304ff let a site root override the theme, and serve static/
A site's templates/ is parsed after the embedded set, so the last definition of a
name wins and a theme redefines one block while inheriting the document
(ADR-0019). Per kind exactly two files are overlaid — base.html and that kind's
block — because overlaying every site template into every set lets a listing's
"main" leak into bundle pages, which is the collision per-kind sets exist to
prevent. Both directions are tested.

templates/theme.css in the site root replaces the reference stylesheet outright;
there is no merging to reason about. static/ is served verbatim under /static/,
through the same os.Root-backed fs.FS, and directory paths answer 404 so it never
indexes its own contents.

Queue entries 6 and 7 are deferred again with triggers: the cascade's consumers are
stage toggles, view selection and cache flags — none of which exist — and declared
types are read by ordering, feeds and check, none of which have landed. Building
either now is the speculation that justified withdrawing them.

Evidence: a real site override renders <section class="mine"> inside the embedded
document with canonical intact, the listing still uses the embedded block, and
/static/site.css and /static/img/logo.svg are 200 while /static/ and /static/img/
are 404.
2026-07-30 02:12:24 +06:00
Claude Opus 5andbdeshi 275f67dc52 serve language variants under a prefix
The default locale stays at the root; every other language is the same key under
/{lang}/ (ADR-0009). /en/… is never live and redirects to the root form so the URL
space cannot fork. Lookup now takes a language and reports which one it served,
following requested → default → any rather than 404ing when a translation is
missing.

That is the second routing case, so the resolver is extracted to resolve.go and
the mux keeps one entry: URL shape is the resolver's business. A leading segment
counts as a language only when some bundle is written in it, so an unknown prefix
is a 404 rather than a stripped path — and a section may not be named after a
language in use, now recorded in content-model.md.

Because the served variant can differ from the URL requested, Page gained
.Canonical (the variant actually served) and .Alternates for hreflang. A theme
must never build a path, so both come from the engine.

Evidence: /bn/pages/about/ serves the Bengali body with lang="bn" and canonical
/bn/pages/about/; /bn/posts/hello-world/ falls back to English with canonical
/posts/hello-world/; /en/pages/about/ 301s to /pages/about/; /fr/… is 404.
2026-07-30 01:43:12 +06:00
Claude Opus 5andbdeshi 519a41cf2f serve a bundle at its permalink
-site (or KHOSRA_SITE) opens the site root through content.OpenSite, so every
read keeps the os.Root guarantee. A path is a bundle key: /{section}/{slug}/
serves, the slashless form redirects permanently to it (ADR-0008), anything
unknown is 404. Render failure logs and returns a bare 500 rather than leaking a
template or filesystem detail.

internal/render holds goldmark plus the embedded reference theme (ADR-0026):
base.html with a redefinable "main" block, and one stylesheet inlined through
.Style. Serving it at an asset route would have been a second routing case for no
gain, and static serving belongs to a later entry.

Evidence beyond the tests: the binary against a real site root returns 200 with
<h1>About</h1> and the rendered body, 301 from /pages/about to /pages/about/, and
404 for /nope/. A Bengali variant is scanned but not yet reachable — that is the
next entry.

theme-contract.md gains a "Live today" section listing the six fields and two
named templates a theme may now rely on; the rest stays marked as shape.
2026-07-30 01:35:50 +06:00