#!/usr/bin/env node
// Generate the documentation site from context/ into OUT_DIR (default site/docs).
// The argument and the rationale live in context/code/doc-site.md.
//
// NOT standalone: the link check resolves ../index.html and ../app/ on disk, so
// OUT_DIR's parent must already be an assembled site. Run web/assemble.sh, which
// invokes this last for that reason. Bare `node scripts/build-doc-site.mjs out`
// exits 1 on 48 outward links, and that strictness is the point — the hole it
// replaced was counting those links as checked while skipping them.
//
// The reorder also moved WHERE a failure shows: a dead link now leaves a site/
// that looks complete with a partial docs/, where before it left one with no
// app/. Contained only because assemble.sh is `set -eu` and CI gates on it.
import { readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync, existsSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const out = process.argv[2] || join(root, "site", "docs");
const token = process.argv[3] || "";
const ref = /^[0-9a-f]{40}$/.test(token) ? token : "main";
const REPO = "https://github.com/fiddlerwoaroof/improvise";
// ---------------------------------------------------------------------------
// The manifest. A doc is published only by being named here — see
// context/code/doc-site.md for what was left out and why.
const SECTIONS = [
{
id: "start",
title: "Start here",
blurb:
"What the project is for, and the principles the code is held to.",
docs: [
["docs/design-notes.md", "design-notes", "Design notes"],
["context/design-principles.md", "design-principles", "Design principles"],
["context/repo-map.md", "repo-map", "Repository map"],
],
},
{
id: "adr",
title: "Decision records",
blurb: "Decisions with consequences, including the options that were rejected.",
docs: [
["context/adr/001-concurrent-edits.md", "adr/001-concurrent-edits", "ADR 001 — Concurrent edits"],
["context/adr/002-naming-a-cell.md", "adr/002-naming-a-cell", "ADR 002 — Naming a cell"],
["context/adr/003-live-backed-models.md", "adr/003-live-backed-models", "ADR 003 — Live-backed models"],
["context/adr/004-faces-and-themes.md", "adr/004-faces-and-themes", "ADR 004 — Faces"],
["context/adr/007-stable-identities.md", "adr/007-stable-identities", "ADR 007 — Stable identities"],
],
},
{
id: "code",
title: "The arguments, by area",
blurb:
"The reasoning that used to live in code comments. Written for a reader with the source open.",
docs: [
["context/code/core.md", "code/core", "improvise-core"],
["context/code/formula-and-io.md", "code/formula-and-io", "improvise-formula and improvise-io"],
["context/code/protocol.md", "code/protocol", "improvise-protocol"],
["context/code/command.md", "code/command", "improvise-command"],
["context/code/clients.md", "code/clients", "The clients"],
["context/code/tui.md", "code/tui", "The TUI"],
["context/code/cli.md", "code/cli", "The headless CLI"],
["context/code/gp.md", "code/gp", "gp-tree and improvise-gp"],
["context/code/kb-tool.md", "code/kb-tool", "The kb tool"],
["context/code/tooling.md", "code/tooling", "The repository's own checkers"],
],
},
{
id: "evidence",
title: "Test evidence",
blurb:
"For each test whose narrative is longer than its code: what broke, what fault was injected, and why the test can fail. Contributor reference, not a tour.",
docs: [
["context/test-evidence/core.md", "test-evidence/core", "improvise-core"],
["context/test-evidence/formula-and-io.md", "test-evidence/formula-and-io", "improvise-formula and improvise-io"],
["context/test-evidence/protocol.md", "test-evidence/protocol", "improvise-protocol"],
["context/test-evidence/command.md", "test-evidence/command", "improvise-command"],
["context/test-evidence/clients.md", "test-evidence/clients", "The clients"],
["context/test-evidence/tui.md", "test-evidence/tui", "The TUI"],
["context/test-evidence/cli.md", "test-evidence/cli", "The headless CLI"],
["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"],
],
},
{
id: "meta",
title: "About this site",
blurb: "What is published here, what is not, and why.",
docs: [["context/code/doc-site.md", "doc-site", "About this site"]],
},
];
const PAGE_BY_SOURCE = new Map();
for (const s of SECTIONS) for (const [src, slug] of s.docs) PAGE_BY_SOURCE.set(src, `${slug}.html`);
// An omission is a DECISION, so it is restated here beside the manifest rather
// than only in doc-site.md section 2. An allowlist and not a count: a new
// omission has to be argued in code, and a vanished one has to be removed --
// a stale entry asserting a reason for a file nobody can find is the failure a
// dead ticket id is (improvise-res9).
const OMITTED = new Map([
["context/plan.md", "a prompt, not a plan; cites a context/SPEC.md that no longer exists"],
["context/cloud-agents.md", "operational notes for agents, not about the program"],
["context/code/pr-review.md", "how an agent reads a PR's reviews; about this repo's process, not about the program"],
]);
// Derived, because the manifest only ever grows and a doc added without a line
// here publishes nothing and says nothing: build green, link check green, doc
// simply absent. This runs at module scope, before any page is generated, so
// it answers the manifest question before the outward-link check answers a
// different one -- a missing doc is actionable wherever you ran the generator.
{
const onDisk = [];
(function walk(d) {
for (const e of readdirSync(join(root, d), { withFileTypes: true })) {
const rel = `${d}/${e.name}`;
if (e.isDirectory()) walk(rel);
else if (e.name.endsWith(".md")) onDisk.push(rel);
}
})("context");
onDisk.push("docs/design-notes.md");
const unaccounted = onDisk.filter((f) => !PAGE_BY_SOURCE.has(f) && !OMITTED.has(f));
const vanished = [...OMITTED.keys()].filter((f) => !existsSync(join(root, f)));
if (unaccounted.length || vanished.length) {
for (const f of unaccounted) console.error(`doc-site: ${f} is neither published nor a named omission`);
for (const f of vanished) console.error(`doc-site: ${f} is listed as omitted but does not exist`);
process.exit(1);
}
}
// The generator's link check resolves ../index.html and ../app/ against the
// filesystem, so it must run once the rest of the tree is there. PR #63's
// appended loop happened to land above it; one landing below would make the
// outward-link check pass against a half-built tree, silently.
{
const cmds = readFileSync(join(root, "web", "assemble.sh"), "utf8")
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
// An INVOCATION, not a mention: `echo "see scripts/build-doc-site.mjs"` after
// a real command satisfied `.includes` while running nothing (review, PR #83).
if (!/^(node|sh)\s.*build-doc-site\.mjs/.test(cmds[cmds.length - 1])) {
console.error("doc-site: web/assemble.sh must invoke this generator LAST;");
console.error(`doc-site: its last command is instead: ${cmds[cmds.length - 1]}`);
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// The ticket index. `.beads/issues.jsonl` is a passive export and lags the Dolt
// database, so an id it does not carry is rendered as plain text rather than a
// link that 404s.
function readTickets() {
const path = join(root, ".beads", "issues.jsonl");
const map = new Map();
if (!existsSync(path)) return map;
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line.trim()) continue;
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (o && o._type === "issue" && typeof o.id === "string" && typeof o.title === "string") {
map.set(o.id, { title: o.title, status: o.status || "unknown", kind: o.issue_type || o.type || "" });
}
}
return map;
}
const TICKETS = readTickets();
// Tokens that look like an id but name a crate or a package. Anchored, because
// an unanchored alternative is a prefix match that silently excludes a real id.
const NON_TICKET = new Set(
"core formula io protocol command gp ws worker wasm harness registry side".split(" "),
);
// ---------------------------------------------------------------------------
// Markdown. A deliberately small subset, covering exactly what the corpus uses;
// anything unrecognised becomes paragraph text, so no input line is ever lost.
const esc = (s) =>
s
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
// A TOC entry must not nest an anchor inside the one wrapping it: a heading
// whose text is a path or a ticket id already carries an , and the parser
// closes the outer one early, leaving an empty unclickable stub.
const stripTags = (html) => html.replace(/<[^>]+>/g, "");
function slugify(s) {
return (
s
.toLowerCase()
.replace(/<[^>]+>/g, "")
.replace(/&[a-z]+;/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "section"
);
}
// A code span whose whole content is a repo path becomes a link: to the
// generated page when the path is a published doc, to GitHub otherwise. The
// path must exist on disk, so a stale citation stays plain text.
function pathHref(p, depth) {
const clean = p.replace(/^\.\//, "");
if (!/^[A-Za-z0-9_.\/-]+$/.test(clean) || !/[\/.]/.test(clean)) return null;
// A span of punctuation alone (`.`, `-`, `/`) is prose, not a path.
if (!/[A-Za-z0-9]/.test(clean)) return null;
if (clean.includes("..") || clean.startsWith("/")) return null;
if (PAGE_BY_SOURCE.has(clean)) return "../".repeat(depth) + PAGE_BY_SOURCE.get(clean);
const abs = join(root, clean);
if (!existsSync(abs)) return null;
// GitHub serves a directory under /tree/, not /blob/; relying on its
// redirect is a 404 waiting on a change of behaviour we do not control.
const kind = statSync(abs).isDirectory() ? "tree" : "blob";
return `${REPO}/${kind}/${ref}/${clean.replace(/\/$/, "")}`;
}
function inline(text, ctx) {
// Code spans first: a backtick run opens a span only if a run of the same
// length closes it, so an unmatched ``` in prose stays literal.
const parts = [];
let i = 0;
while (i < text.length) {
if (text[i] === "`") {
let n = 0;
while (text[i + n] === "`") n++;
const fence = "`".repeat(n);
const end = text.indexOf(fence, i + n);
const after = end >= 0 ? end + n : -1;
if (end >= 0 && (after >= text.length || text[after] !== "`")) {
parts.push({ code: text.slice(i + n, end) });
i = after;
continue;
}
}
const next = text.indexOf("`", i + 1);
parts.push({ text: text.slice(i, next < 0 ? text.length : next) });
i = next < 0 ? text.length : next;
}
// Code spans stand aside as placeholders while emphasis is matched, because
// the corpus routinely opens `**` on one side of a code span and closes it on
// the other; matching per segment silently drops that bold.
const held = [];
let s = parts
.map((p) => {
if (p.code === undefined) return esc(p.text);
const body = `${esc(p.code)}`;
const href = pathHref(p.code.trim(), ctx.depth);
held.push(href ? `${body}` : body);
return `\u0000${held.length - 1}\u0000`;
})
.join("");
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, href) => `${label}`);
// Strong first, and its body may contain a single `*` pair: `[^*]+` cannot
// match `**a *b* c**`, and the leftover `*` then pairs across the bold.
s = s.replace(/\*\*((?:[^*]|\*(?!\*))+)\*\*/g, "$1");
s = s.replace(/\*([^*]+)\*/g, "$1");
s = s.replace(/\bimprovise-([a-z0-9]{3,8})\b/g, (m, tail) => {
if (NON_TICKET.has(tail)) return m;
ctx.cited.add(m);
if (!TICKETS.has(m)) {
ctx.unresolved.add(m);
return `${m}`;
}
const t = TICKETS.get(m);
const href = `${"../".repeat(ctx.depth)}tickets.html#${m}`;
return `${m}`;
});
return s.replace(/\u0000(\d+)\u0000/g, (_m, n) => held[Number(n)]);
}
// Block parse. Returns { html, toc }.
function renderMarkdown(src, ctx) {
const lines = src.split("\n");
const toc = [];
const seen = new Map();
const html = blocks(lines);
return { html: html.join("\n"), toc };
function heading(level, text) {
const rendered = inline(text, ctx);
let id = slugify(text);
const n = (seen.get(id) || 0) + 1;
seen.set(id, n);
if (n > 1) id = `${id}-${n}`;
ctx.anchors.add(id);
if (level <= 3) toc.push({ level, id, text: stripTags(rendered) });
const mark = level > 1 ? `#` : "";
return `
${inline(para.join(" "), ctx)}
`); para = []; }; while (i < ls.length) { const line = ls[i]; const fence = line.match(/^(\s*)(`{3,}|~{3,})\s*([A-Za-z0-9_-]*)\s*$/); if (fence) { flush(); const close = new RegExp(`^\\s*${fence[2][0] === "~" ? "~" : "\`"}{3,}\\s*$`); const body = []; i++; while (i < ls.length && !close.test(ls[i])) { body.push(ls[i]); i++; } i++; outHtml.push(`${esc(body.join("\n"))}`);
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
flush();
outHtml.push(heading(h[1].length, h[2].trim()));
i++;
continue;
}
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line) && !para.length) {
flush();
outHtml.push("${blocks(body).join("\n")}`); continue; } const li = line.match(/^(\s*)([-*+]|\d{1,3}\.)\s+(.*)$/); if (li) { flush(); const [listHtml, next] = list(ls, i, li[1].length); outHtml.push(listHtml); i = next; continue; } if (!line.trim()) { flush(); i++; continue; } para.push(line.trim()); i++; } flush(); return outHtml; } // An item's continuation lines are dedented by that item's own marker width, // so a nested list keeps its relative indentation and reparses as a list. function list(ls, start, indent) { const ordered = /^\s*\d{1,3}\./.test(ls[start]); const items = []; let i = start; let first = null; while (i < ls.length) { const m = ls[i].match(/^(\s*)([-*+]|(\d{1,3})\.)\s+(.*)$/); if (m && m[1].length === indent) { if (ordered && first === null) first = Number(m[3]); items.push({ num: m[3] ? Number(m[3]) : null, cw: m[0].length - m[4].length, body: [m[4]] }); i++; continue; } if (!items.length) break; const cur = items[items.length - 1]; if (!ls[i].trim()) { let j = i; while (j < ls.length && !ls[j].trim()) j++; if (j >= ls.length) break; // A blank line ends the list only if what follows is neither the next // item nor a continuation of the open one. const isSibling = /^(\s*)([-*+]|\d{1,3}\.)\s+/.test(ls[j]) && ls[j].search(/\S/) === indent; if (isSibling) { i = j; continue; } if (ls[j].search(/\S/) > indent) { cur.body.push(""); i = j; continue; } break; } const col = ls[i].search(/\S/); if (col <= indent) break; cur.body.push(ls[i].slice(Math.min(col, cur.cw))); i++; } const tag = ordered ? "ol" : "ul"; const attr = ordered && first !== null && first !== 1 ? ` start="${first}"` : ""; const rendered = items .map((it) => { const inner = blocks(it.body); const body = inner.length === 1 && inner[0].startsWith("
") ? inner[0].slice(3, -4) : inner.join("\n"); const id = ctx.listItemId && it.num !== null ? ctx.listItemId(it.num) : null; if (id) ctx.anchors.add(id); return `
| ${c} | `).join("")}
|---|
| ${c} | `).join("")}
Gotchas: ${gotchas.map((n) => `${n}`).join("")}
` : ""; const srcLine = `Source: ${src}
`; written.push({ rel: `${slug}.html`, html: page({ slug, title, depth, body: `${srcLine}${tocHtml}${jump}${html}`, }), anchors: ctx.anchors, }); } } // Ticket index. { const ctx = { depth: 0, cited, unresolved, anchors: new Set() }; const ids = [...cited].sort(); const rows = ids .map((id) => { const t = TICKETS.get(id); ctx.anchors.add(id); if (!t) return `The prose on this site cites issue ids like improvise-9c8c. They are this project's own tracker ids, kept in-repo with beads; there is no public issue URL to link to, so this page is what they resolve to. Titles come from .beads/issues.jsonl, a passive export that lags the database — an id it does not carry is listed here without a title and is not linked from the prose.
${ids.length} ids are cited, of which ${ids.length - unresolved.size} resolve.
| Id | Status | Title |
|---|
${s.blurb}
These are the working documents of the improvise repository, published as written. They are addressed to someone reading or changing the code: the arguments behind the architecture, the decisions that were made and the ones that were rejected, and the record of what each load-bearing test is guarding against.
Nothing here is a tutorial. For what the program does, see the project home or run the browser demo.
${cards}${conventions}
`; written.push({ rel: "index.html", html: page({ slug: "", title: "Documentation", depth: 0, body }), anchors: new Set(["conventions", ...SECTIONS.map((s) => s.id)]) }); } for (const w of written) write(w.rel, w.html); // --------------------------------------------------------------------------- // An nested in an is closed early by the parser, so the outer link // becomes an empty unclickable stub and the inner one wins. The link check // cannot see it: both hrefs resolve. It asks a different question. const nested = []; for (const w of written) { for (const m of w.html.matchAll(/]*>((?:(?!<\/a>)[\s\S])*?) inside near ${JSON.stringify(m[1].slice(0, 60))}`); } // Link check over the generated output. Every internal href must resolve to a // generated file, and every fragment to an id that page really carries. const anchorsByPage = new Map(written.map((w) => [w.rel, w.anchors])); const files = new Set(written.map((w) => w.rel).concat(["style.css"])); const broken = []; let checked = 0; for (const w of written) { const base = dirname(w.rel); for (const m of w.html.matchAll(/(?:href|src)="([^"]+)"/g)) { const raw = m[1]; if (/^(https?:|mailto:|data:)/.test(raw)) continue; checked++; const [pathPart, frag] = raw.split("#"); if (!pathPart) { if (frag && !w.anchors.has(frag)) broken.push(`${w.rel} -> ${raw} (no such anchor on this page)`); continue; } const resolved = relative(".", join(base === "." ? "" : base, pathPart)); // A link out of the docs tree (../index.html, ../app/) names a file this // script does not generate, so it is checked on disk instead — which is why // assemble.sh builds the docs LAST, once its siblings exist. if (resolved.startsWith("..")) { const onDisk = join(out, base === "." ? "" : base, pathPart); const withIndex = raw.endsWith("/") ? join(onDisk, "index.html") : onDisk; if (!existsSync(withIndex) && !existsSync(onDisk)) broken.push(`${w.rel} -> ${raw} (no such file outside the docs tree: ${withIndex})`); continue; } const target = resolved.endsWith("/") ? `${resolved}index.html` : resolved; if (!files.has(target)) broken.push(`${w.rel} -> ${raw} (no such page: ${target})`); else if (frag && !anchorsByPage.get(target)?.has(frag)) broken.push(`${w.rel} -> ${raw} (no such anchor: ${frag})`); } } console.log(`doc-site: ${written.length} pages -> ${out}`); console.log(`doc-site: ${cited.size} ticket ids cited, ${cited.size - unresolved.size} resolve, ${unresolved.size} not in the export`); console.log(`doc-site: ${checked} internal links checked, ${broken.length} broken`); console.log(`doc-site: ${nested.length} nested anchors`); for (const n of nested) console.error(`doc-site: nested anchor: ${n}`); if (broken.length || nested.length) { for (const b of broken) console.error(`doc-site: broken link: ${b}`); process.exit(1); }