wiki-knowledge

Static HTML export reads the clean working tree, ships in two shapes, and selects get-started pages LLM-in-skill over a deterministic fallback

Context. The vault is markdown for machines and for humans who read it in an editor, but it has no browsable, linkable, offline form. enchiridion export adds one: it renders wiki/ (and, opt-in, raw/) to a self-contained static HTML site under web/ — every page an .html file, frontmatter as a table, tags as their own pages, and a generated front page. This is a new capability with the same model-assignment question every capability faces (start at the deterministic floor, escalate only on a measured need — see the standing convention), plus one it does not: what a static export reads, given that everything else in the read path reads committed HEAD, not the working tree.

Decision 1 — export reads the working tree, but refuses to run when the exported subtree is dirty. The search index is a materialised view of HEAD and an uncommitted page is structurally invisible to it (ADR-0015). Export deliberately does not inherit that rule: it is a deterministic file transform whose least-surprising contract is “export what is on disk,” and coupling it to the index would make a just-written page silently absent from its own site. But “read the working tree” and “the vault is its committed history” are only reconcilable when the two agree, so export closes the gap the other way: it runs git status and refuses when the exported subtree carries staged/modified tracked files or untracked non-ignored files, naming the offending paths. A clean tree means working tree ≡ HEAD, so export reads disk yet never publishes bytes that were never committed to the vault. The check is scoped to what is being exported: wiki/ always, raw/ only under --raw — a clean wiki/ with an uncommitted file in raw/ exports fine when raw is not included. --allow-dirty is the explicit escape hatch for the rare intentional preview of uncommitted work. This is the inverse of ADR-0015’s stance (that one ignores the working tree by construction; this one demands the working tree equal HEAD), and the reconciliation is that both refuse to let an uncommitted byte be treated as vault content — one by not seeing it, the other by not running.

Decision 2 — the front page’s get-started set is LLM judgment in the skill, over a deterministic candidate ranking the script emits, with a script-only fallback. The front page links 10–12 pages “to get started with.” Choosing the best entry points into an arbitrary knowledge vault is a judgment call — the kind of comprehension the deterministic script cannot make well. So the split is: the script owns the mechanical half (enchiridion export --candidates emits a ranked JSON list — page ref, title, summary, inbound-link count, kind, tags — and writes nothing), and the /wiki-export skill’s LLM owns the judgment (read the candidate ranking plus the vault’s own README.md/CLAUDE.md/AGENTS.md when present, pick the set, invoke export --starters <refs…> with an optional per-ref annotation). Crucially, the script never depends on the skill: bare enchiridion export with no --starters falls back to a deterministic rule — inbound-link count, top 12, tie-broken by title — so the script alone always produces a complete, valid site (for tests, dogfooding, and non-skill callers). The LLM improves the front page; it is never load-bearing for producing one. This keeps the capability at the deterministic floor by default and layers judgment on top, rather than making a model a hard dependency of a file transform. When a start page is supplied the block is not rendered at all — the nominated page supplies the entrance itself — so the seam below is idle for that export (see Mechanism).

Decision 3 — --single-file writes the same parts as one document, and navigates by hash because file:// is the point. A directory tree is the right shape for browsing in place and the wrong one for sending: a recipient who wants to read it on a phone must unzip it first, which on a phone is a small ordeal. So the export gains a second shape, --single-file, in which every page and aggregate of the exported set becomes a <section> of one self-contained document, --out names that file instead of a directory (default wiki.html at the vault root, replaced outright on a re-run — the file is the export, so refreshing it is the normal case and needs no --force), the stylesheet is inlined because there is no second file to link, and every internal link is rewritten from a relative .html path to a #slug fragment. The clean-tree refusal, the title resolution, the get-started seam and the parts themselves are all shared with multi-page mode: this is a different assembly of one render, not a second renderer.

The navigation is deliberately the hash, not the History API. pushState is unreliable on file:// — some browsers throw SecurityError on an opaque origin, and a file:// document has exactly that — and being openable straight from an email attachment with no server is not an incidental property of this mode, it is what the mode is for. A hash change gives the same three things the History API would: tapping a link shows the target section without leaving the file, each navigation is its own history entry so the back button steps back through visited pages, and a #slug deep link lands on its section. The mechanism is one small inline script (ES5 in an IIFE, ~40 lines, no framework and nothing to fetch) that shows the section named by location.hash on load and on every hashchange, falling back to the reserved __front section when a cold open has no hash to honour. This retires the “zero JS” claim the multi-page mode below still keeps: the single-file document ships a script, by construction, and a recipient opening it with scripting off sees no page at all. Multi-page output remains scriptless.

Decision 4 — a section’s id is derived from the page’s output path by one function, used for both writing and rewriting. sectionIdFor drops the .html extension and collapses every run of remaining non-alphanumerics to one -, so wiki/concepts/alpha-concept.html becomes wiki-concepts-alpha-concept; the front page alone takes the reserved __front, which no derived id can collide with because the derivation never lets an underscore through — and since a nominated start page is written at index.html, it takes __front with no special case. Links are rewritten through the HrefFor strategy the render layer threads through its parts generators (relativeHref for multi-page, hashHref for single-file), so every link in the document — nav bar, body, frontmatter edges, tags, kind indexes, the front page’s own lists — goes through one seam rather than each of a dozen call sites deciding again. Writing a section and rewriting a link to it are then the same function applied to the same path, which is why a rewritten link cannot miss.

Mechanism

Consequences

What would reopen this

A need to export continuously from a working session where pages sit uncommitted for a long time (the same pressure named in ADR-0015). The answer would be the same: shorten the write-then-commit gap (commit on write) rather than loosen the clean-tree refusal, because the refusal is what keeps “what the site publishes” and “what the vault contains” the same question with one answer.