feat(docs): publish the context/ prose as a site folded into Pages

The landing page is the site root and the app is /app/; the prose now sits
at /docs/, assembled by the same web/assemble.sh the Pages job and the
browser harness both run, so what ships and what is tested are one tree.

Published: design-notes, design-principles, repo-map, the three ADRs, the
seven context/code/ arguments, and the 501 test-evidence entries as their
own clearly-labelled section. Not published: context/plan.md, which is an
agent prompt about a launch rather than a description of the program, and
context/cloud-agents.md, which is addressed to nobody who visits a website.
Neither is referenced by a published document outside a code fence, so
leaving them out dangles nothing. The reasoning is context/code/doc-site.md,
which is itself published.

Two things the corpus needed that a generator would not have given it. The
~190 improvise-xxxx citations resolve to a generated ticket index built from
the already-public .beads/issues.jsonl rather than to an invented URL that
would 404; an id the export does not yet carry stays plain text. And a code
span that is wholly a repo path links to GitHub at the assembled ref, but
only when the file exists on disk, so a stale citation degrades to text.

The generator link-checks its own output and exits non-zero on a break, and
assemble.sh runs under set -eu, so a dead cross-reference fails the assembly
rather than shipping. It earned that on its first two runs: it caught a
fence-close regex comparing a three-character marker to a one-character
string, which swallowed every document from its first code block onward, and
a list parser that ended the gotcha list at the first blank line, emitting 1
of the 48 anchors the jump strip links to. Proved by injection afterwards --
renaming the anchors reports 48 missing anchors, pointing the ticket links at
an ungenerated page reports 763 missing pages.

No framework, no node_modules for the site, no build step beyond one
zero-dependency node script, matching the landing page's posture.

improvise-djoo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 13:37:20 -07:00
parent 1af51c99c6
commit f1011d7f6f
5 changed files with 753 additions and 1 deletions
+154
View File
@@ -0,0 +1,154 @@
# The documentation site — the arguments
`scripts/build-doc-site.mjs` turns the prose in `context/` into the static site
served at `/docs/` on GitHub Pages. This is the reasoning behind what it
publishes, what it deliberately does not, and why it is a hundred-odd lines of
node rather than a static site generator (improvise-djoo).
---
## 1. What is published, and what is not
The manifest is the `SECTIONS` array at the top of the script, and **a document
is published only by being named there**. That is the point: a glob over
`context/**` would publish the next working file somebody drops in, and the
decision about what a stranger should read is not one to make by accident.
Published, in four sections:
| Section | Contents | Why |
|---|---|---|
| Start here | `docs/design-notes.md`, `context/design-principles.md`, `context/repo-map.md` | What the project is for, the doctrine, and the single best explanation of how it is built. |
| Decision records | `context/adr/*.md` | An ADR is written for an outside reader by construction — it states the options that were rejected, which is the part that is worth reading and the part that is nowhere else. |
| The arguments, by area | `context/code/*.md` | This is the reasoning that used to live in code comments and was moved out under the two-line cap (design-principles §6). It is the most valuable prose in the repository and it is addressed to exactly the reader a documentation site has. |
| Test evidence | `context/test-evidence/*.md` | 501 entries, one per test whose narrative is longer than its code. |
Not published:
- **`context/plan.md`** is a *prompt*: it opens "You are working in the
`improvise` repository… Your job is to prepare this repository for a public
Show HN launch", and it instructs a reader to audit a `context/SPEC.md` that
no longer exists. It is an instruction to an agent about a launch, not a
description of the program, and publishing a launch plan on the site the
launch points at inverts the two.
- **`context/cloud-agents.md`** is operational notes for agents running in a
cloud session — tracker hydration, `bd` pitfalls, what the sandbox blocks.
It is true and it is useful and it is addressed to nobody who visits a
website.
Neither is referenced by any published document except as a row in
`repo-map.md`'s file inventory, which is inside a code fence, so leaving them
out creates no dangling link. That was checked rather than assumed.
**Test evidence is the one judgement call worth stating.** The argument for
leaving it out is that 501 entries about individual tests are noise to a reader
who wanted to know what a pivot table is. The argument for keeping it is
stronger: every one of the seven `context/code/*.md` documents points at its
evidence file by name, so omitting it would break the published prose in
exactly the way this site is otherwise careful not to; and the evidence file is
the most distinctive artefact the repository has — a written record of the
injected fault behind each load-bearing test is not a thing most projects can
show. It is therefore published as its **own section**, last, described as a
contributor reference rather than a tour, so nobody wanders into it expecting
a narrative.
## 2. Ticket ids resolve to a page, never to a URL that does not exist
The prose cites `improvise-xxxx` roughly 190 times. On a public site those are
meaningless strings, and the obvious repair — link them to an issue tracker —
is unavailable: beads keeps its issues in a Dolt database on a git ref, and
there is no public issue URL to point at. Inventing one would produce 190
links that 404, which is worse than plain text because a link is a promise.
So the ids resolve **inward**. `tickets.html` is generated from
`.beads/issues.jsonl` — already committed, already public — and carries id,
status and title, nothing else; descriptions are internal working notes and are
not the site's business. Each citation in the prose becomes a link to that
page's anchor, with the title in a `title=` attribute so a hover answers the
question without a navigation.
**`issues.jsonl` is a passive export and lags the database**, which is not a
defect to work around but a fact to render honestly: an id the export does not
carry is emitted as plain text with a dotted underline and no link, and the
ticket page lists it as "not exported". The site therefore never links to an
anchor it did not generate, whatever state the export is in. At the time of
writing 8 of 178 linkified ids are in that state. A generator that instead
linked every id it saw would produce a page that is right today and broken the
first time somebody cites a fresh ticket.
## 3. Source paths link only where the file exists
A code span whose entire content is a repo path becomes a link — to the
generated page when the path is one of the published documents, to GitHub
otherwise. Two constraints make that safe. The whole span must be the path, so
a sentence that merely mentions a directory is untouched; and **the path must
exist on disk at generation time**, so a citation that has gone stale degrades
to plain text rather than to a 404. The GitHub ref is the cache-bust token
`web/assemble.sh` already receives, used only when it is a 40-hex sha and
falling back to `main` otherwise — a local `dev` build must not emit
`blob/dev/…`.
## 4. The link check is the test, and it is part of the build
The generator collects every anchor it emits and every internal href it writes,
resolves them against each other, and **exits non-zero** if any href names a
page it did not generate or a fragment that page does not carry.
`web/assemble.sh` runs under `set -eu`, so a broken cross-reference fails the
assembly rather than shipping.
This is not ceremony. It caught two real defects on its first two runs: a
fence-close regex that compared a three-character marker against a
one-character string, which swallowed every document from its first code block
onward; and a list parser that ended the gotcha list at the first blank line,
so 47 of the 48 gotcha anchors were never emitted while the "jump to gotcha N"
strip linked to all of them. Both were invisible in a rendered page that
otherwise looked fine.
It is proved by injection rather than asserted: renaming the emitted anchor ids
makes it report 48 missing anchors, and pointing the ticket links at a page
name that is never generated makes it report 763 missing pages — two different
diagnoses, because "the page is absent" and "the page is there but the anchor
is not" call for different repairs.
What it does **not** cover is links that leave the `docs/` subtree — the ones
to `../index.html` and `../app/`. Those are checked against the assembled
`site/` tree by hand at the moment, and the honest statement is that a rename
of the app directory would not be caught here.
## 5. Why a node script and not a generator
The landing page is hand-written HTML with no build step and one pinned CDN
script; the same posture applies here. A static site generator would add a
dependency tree, a lockfile and a toolchain to a repository whose site is
currently four files, in exchange for features this corpus does not use — the
docs contain no front matter, no shortcodes and, as it happens, **not a single
markdown link**. Every cross-reference in them is prose naming a path or an
id, which is precisely why §2 and §3 are the interesting parts and Markdown
rendering is the boring part.
So the renderer is a deliberately small subset covering exactly what the corpus
contains: ATX headings, fenced code, pipe tables, blockquotes, ordered and
unordered lists with indented continuations, thematic breaks, and inline code,
strong, emphasis and links. Anything it does not recognise becomes paragraph
text, so **no input line is ever dropped** — the failure mode is an ugly
paragraph, never a missing one.
Two details in it are load-bearing and easy to undo. **Code spans are replaced
by placeholders before emphasis is matched**, because this corpus routinely
opens `**` on one side of a code span and closes it on the other; matching
emphasis per segment silently drops that bold, which is a fidelity loss with
no visible symptom. And **strong is matched before emphasis, with a body that
may contain a single `*` pair**, because `[^*]+` cannot match
`**a *b* c**` and the leftover `*` then pairs across the bold — which is how a
single sentence in gotcha 33 came out as three italic paragraphs.
## 6. Navigation
`repo-map.md` is one long document whose gotcha list is numbered 1 to 48, and
splitting it per gotcha was rejected: the gotchas cross-reference each other
constantly ("see gotcha 35", "for the reason gotcha 39 watches…"), and a reader
following those wants a scroll, not 48 page loads. It stays one page, with an
id on every list item and a compact strip of 48 numbered links under the
heading. The ids come from the list item's **own number**, not from a counter,
so an inserted gotcha renumbers the anchors exactly as it renumbers the prose
that cites them.