Files
improvise/scripts/build-doc-site.mjs
T
Claude (adr007-steward) 38058ec73f docs(adr): move ADR 007's amendment to its own file, stating only what it changes (improvise-cq1q.1)
On the user's instruction an ADR states only what its decision changes,
and mentions an existing convention only where it conflicts. The accepted
ADR 007 is restored to main's text plus a one-line pointer, so merging
this PR keeps it; the amendment is context/adr/007-stable-identities-
amendment-1.md (280 lines, from 784), with a table of the accepted rules
it replaces. The doc site lists the new file, and core.md section 20
points at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-10-09 09:43:16 -07:00

753 lines
33 KiB
JavaScript

#!/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/005-exact-decimal-arithmetic.md", "adr/005-exact-decimal-arithmetic", "ADR 005 — Exact decimal arithmetic (proposed)"],
["context/adr/007-stable-identities.md", "adr/007-stable-identities", "ADR 007 — Stable identities"],
["context/adr/007-stable-identities-amendment-1.md", "adr/007-stable-identities-amendment-1", "ADR 007, amendment 1 (proposed)"],
],
},
{
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"],
["context/test-evidence/layout-cost.md", "test-evidence/layout-cost", "Layout and keypress cost"],
["context/test-evidence/scale-bisect.md", "test-evidence/scale-bisect", "Scale bisect"],
],
},
{
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
// 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 <a>, 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 = `<code>${esc(p.code)}</code>`;
const href = pathHref(p.code.trim(), ctx.depth);
held.push(href ? `<a href="${href}">${body}</a>` : body);
return `\u0000${held.length - 1}\u0000`;
})
.join("");
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, label, href) => `<a href="${href}">${label}</a>`);
// 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, "<strong>$1</strong>");
s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
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 `<span class="tid tid-unknown" title="not in the committed tracker export">${m}</span>`;
}
const t = TICKETS.get(m);
const href = `${"../".repeat(ctx.depth)}tickets.html#${m}`;
return `<a class="tid" href="${href}" title="${esc(t.title)}">${m}</a>`;
});
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 ? `<a class="anchor" href="#${id}">#</a>` : "";
return `<h${level} id="${id}">${mark}${rendered}</h${level}>`;
}
function blocks(ls) {
const outHtml = [];
let i = 0;
let para = [];
const flush = () => {
if (para.length) outHtml.push(`<p>${inline(para.join(" "), ctx)}</p>`);
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(`<pre><code>${esc(body.join("\n"))}</code></pre>`);
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("<hr />");
i++;
continue;
}
if (/^\s*\|/.test(line) && i + 1 < ls.length && /^\s*\|[\s:|-]+\|?\s*$/.test(ls[i + 1])) {
flush();
const rows = [];
while (i < ls.length && /^\s*\|/.test(ls[i])) {
rows.push(ls[i]);
i++;
}
outHtml.push(table(rows));
continue;
}
if (/^\s*>/.test(line)) {
flush();
const body = [];
while (i < ls.length && /^\s*>/.test(ls[i])) {
body.push(ls[i].replace(/^\s*>\s?/, ""));
i++;
}
outHtml.push(`<blockquote>${blocks(body).join("\n")}</blockquote>`);
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("<p>")
? 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 `<li${id ? ` id="${id}"` : ""}>${body}</li>`;
})
.join("\n");
return [`<${tag}${attr}>\n${rendered}\n</${tag}>`, i];
}
// A cell boundary is an UNESCAPED pipe outside a code span. Splitting the raw
// row on every `|` shreds `\|` and this repo's own `|Time Period|` syntax into
// extra columns, which no column count can detect because the row still parses.
function splitRow(r) {
const out = [];
let cur = "";
let tick = 0;
for (let i = 0; i < r.length; i++) {
const c = r[i];
if (c === "\\" && r[i + 1] === "|" && !tick) {
cur += "|";
i++;
continue;
}
if (c === "`") {
let n = 0;
while (r[i + n] === "`") n++;
if (!tick) tick = n;
else if (tick === n) tick = 0;
cur += "`".repeat(n);
i += n - 1;
continue;
}
if (c === "|" && !tick) {
out.push(cur);
cur = "";
continue;
}
cur += c;
}
out.push(cur);
if (out.length && !out[0].trim()) out.shift();
if (out.length && !out[out.length - 1].trim()) out.pop();
return out;
}
function table(rows) {
const cells = (r) => splitRow(r.trim()).map((c) => inline(c.trim(), ctx));
const head = cells(rows[0]);
const body = rows.slice(2).map(cells);
return (
`<table><thead><tr>${head.map((c) => `<th>${c}</th>`).join("")}</tr></thead><tbody>` +
body.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("") +
"</tbody></table>"
);
}
}
// ---------------------------------------------------------------------------
const CSS = `:root{--bg:#14161a;--panel:#1b1e24;--fg:#d7dae0;--dim:#8b93a1;--accent:#8ec07c;--rule:#2b2f37;--mono:ui-monospace,"SFMono-Regular","Menlo","Consolas",monospace}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.65 system-ui,-apple-system,"Segoe UI",sans-serif}
a{color:var(--accent)}
.shell{display:flex;align-items:flex-start;max-width:1400px;margin:0 auto}
nav.side{position:sticky;top:0;flex:0 0 17rem;max-height:100vh;overflow-y:auto;padding:1.5rem 1rem 3rem;border-right:1px solid var(--rule);font-size:.9rem}
nav.side .brand{font-family:var(--mono);font-size:1.15rem;color:var(--accent);text-decoration:none;font-weight:600}
nav.side .navtoggle{display:none}
nav.side .navsummary{display:none;cursor:pointer;margin:.9rem 0 .2rem;padding:.4rem .55rem;border:1px solid var(--rule);border-radius:4px;font-family:var(--mono);font-size:.85rem;color:var(--dim)}
nav.side .navsummary::before{content:"\\2630 " / ""}
nav.side .navtoggle:checked~.navsummary{border-color:var(--accent);color:var(--fg)}
nav.side h3{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin:1.6rem 0 .4rem}
nav.side ul{list-style:none;margin:0;padding:0}
nav.side li{margin:.15rem 0}
nav.side a{display:block;padding:.2rem .45rem;border-radius:4px;color:var(--fg);text-decoration:none}
nav.side a:hover{background:var(--panel)}
nav.side a.here{background:var(--panel);color:var(--accent);font-weight:600}
main{flex:1 1 auto;min-width:0;max-width:60rem;padding:2.5rem 2rem 6rem}
h1,h2,h3,h4,h5{font-family:var(--mono);font-weight:600;letter-spacing:-.01em;line-height:1.3;overflow-wrap:anywhere}
h1{font-size:2rem;margin:0 0 1.2rem;color:var(--accent)}
h2{font-size:1.25rem;margin:2.6rem 0 .6rem;padding-top:1.2rem;border-top:1px solid var(--rule)}
h3{font-size:1.02rem;margin:1.8rem 0 .4rem;color:#c8cdd6}
h4,h5{font-size:.95rem;margin:1.3rem 0 .3rem;color:var(--dim)}
.anchor{float:left;margin-left:-1.1em;padding-right:.35em;color:var(--rule);text-decoration:none;opacity:0}
h2:hover .anchor,h3:hover .anchor,h4:hover .anchor,h5:hover .anchor{opacity:1}
p{max-width:74ch}
li{max-width:74ch}
code{font-family:var(--mono);font-size:.88em;background:var(--panel);padding:.1em .3em;border-radius:3px;overflow-wrap:anywhere}
a code{background:#22262e}
pre{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:1rem;overflow-x:auto}
pre code{background:none;padding:0;font-size:.84rem;line-height:1.5;overflow-wrap:normal}
blockquote{margin:1rem 0;padding:.5rem .9rem;border-left:3px solid var(--accent);background:var(--panel)}
blockquote p{margin:.4rem 0}
table{border-collapse:collapse;margin:1rem 0;font-size:.92rem;display:block;overflow-x:auto}
th,td{border:1px solid var(--rule);padding:.4rem .6rem;text-align:left;vertical-align:top}
th{background:var(--panel)}
hr{border:0;border-top:1px solid var(--rule);margin:2rem 0}
.tid{font-family:var(--mono);font-size:.86em;white-space:nowrap}
.tid-unknown{color:var(--dim);border-bottom:1px dotted var(--rule)}
.toc{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:.8rem 1.1rem;margin:0 0 2rem;font-size:.92rem}
.toc summary{cursor:pointer;color:var(--dim);font-family:var(--mono)}
.toc ul{list-style:none;margin:.6rem 0 0;padding:0;columns:2;column-gap:2rem}
.toc li{break-inside:avoid;margin:.12rem 0;overflow-wrap:anywhere}
.toc li.l3{padding-left:1rem;font-size:.92em}
.toc a{text-decoration:none;overflow-wrap:anywhere}
.toc a:hover{text-decoration:underline}
.jump{margin:0 0 2rem;font-size:.85rem;font-family:var(--mono)}
.jump a{display:inline-block;min-width:2.1rem;text-align:center;padding:.12rem .3rem;margin:.1rem;border:1px solid var(--rule);border-radius:4px;text-decoration:none}
.jump a:hover{border-color:var(--accent)}
.lede{color:var(--dim);max-width:70ch}
.src{font-size:.85rem;color:var(--dim);font-family:var(--mono);margin:-.6rem 0 1.8rem}
.cards{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(19rem,1fr));margin:1rem 0 2rem}
.card{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:.9rem 1.1rem}
.card a{font-family:var(--mono);text-decoration:none}
.card p{color:var(--dim);font-size:.88rem;margin:.3rem 0 0}
.tickets{width:100%}
.tickets td:first-child{white-space:nowrap;font-family:var(--mono);font-size:.86rem}
.tickets td:last-child{overflow-wrap:anywhere}
.st{font-size:.78rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em}
@media(max-width:900px){.toc ul{max-height:45vh;overflow-y:auto}nav.side .navtoggle{display:block;position:absolute;width:1px;height:1px;margin:-1px;clip-path:inset(50%);overflow:hidden}nav.side .navtoggle:focus-visible~.navsummary{outline:2px solid var(--accent);outline-offset:2px}nav.side .navsummary{display:block}nav.side .navlinks{display:none}nav.side .navtoggle:checked~.navlinks{display:block}.shell{display:block}nav.side{position:static;flex:none;max-height:none;border-right:0;border-bottom:1px solid var(--rule)}main{padding:1.5rem 1rem 4rem}.toc ul{columns:1}}`;
function nav(currentSlug, depth) {
const up = "../".repeat(depth);
const link = (href, label, here) =>
`<li><a href="${href}"${here ? ' class="here"' : ""}>${label}</a></li>`;
// Do NOT "simplify" this to <details>: the sidebar must force it OPEN, which
// `display` cannot do, and the desktop nav collapses. doc-site.md §7.
const parts = [
`<nav class="side"><a class="brand" href="${up}index.html">improvise docs</a>`,
`<input type="checkbox" id="navtoggle" class="navtoggle" />`,
`<label class="navsummary" for="navtoggle">Pages</label>`,
`<div class="navlinks">`,
`<ul>${link(up + "../index.html", "← Project home")}${link(up + "../app/", "Browser demo")}${link(REPO, "Source on GitHub")}</ul>`,
];
for (const s of SECTIONS) {
parts.push(`<h3>${s.title}</h3><ul>`);
for (const [, slug, title] of s.docs)
parts.push(link(`${up}${slug}.html`, title, slug === currentSlug));
parts.push("</ul>");
}
parts.push(`<h3>Reference</h3><ul>${link(up + "tickets.html", "Ticket index", currentSlug === "tickets")}</ul>`);
parts.push("</div></nav>");
return parts.join("");
}
function page({ slug, title, body, depth }) {
const up = "../".repeat(depth);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${esc(title)} — improvise docs</title>
<link rel="stylesheet" href="${up}style.css" />
</head>
<body>
<div class="shell">
${nav(slug, depth)}
<main>
${body}
</main>
</div>
</body>
</html>
`;
}
function write(rel, content) {
const p = join(out, rel);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, content);
return p;
}
// ---------------------------------------------------------------------------
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true });
write("style.css", CSS);
const cited = new Set();
const unresolved = new Set();
const written = [];
for (const section of SECTIONS) {
for (const [src, slug, title] of section.docs) {
const depth = slug.split("/").length - 1;
const ctx = { depth, cited, unresolved, anchors: new Set() };
if (slug === "repo-map") ctx.listItemId = (n) => (n >= 1 && n <= 200 ? `gotcha-${n}` : null);
const md = readFileSync(join(root, src), "utf8");
const { html, toc } = renderMarkdown(md, ctx);
const tocHtml = toc.length
? `<details class="toc" open><summary>On this page</summary><ul>${toc
.filter((t) => t.level <= 3)
.map((t) => `<li class="l${t.level}"><a href="#${t.id}">${t.text}</a></li>`)
.join("")}</ul></details>`
: "";
// Derived from the anchors actually emitted. A hardcoded count fails closed
// on a deleted gotcha (the link check catches it) and SILENTLY on an added
// one, which is the direction the list moves in.
const gotchas = [...ctx.anchors]
.map((a) => /^gotcha-(\d+)$/.exec(a))
.filter(Boolean)
.map((m) => Number(m[1]))
.sort((a, b) => a - b);
const jump = gotchas.length
? `<p class="jump">Gotchas: ${gotchas.map((n) => `<a href="#gotcha-${n}">${n}</a>`).join("")}</p>`
: "";
const srcLine = `<p class="src">Source: <a href="${REPO}/blob/${ref}/${src}">${src}</a></p>`;
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 `<tr id="${id}"><td>${id}</td><td class="st">not exported</td><td>Cited in the prose; the committed tracker export does not carry it.</td></tr>`;
return `<tr id="${id}"><td>${id}</td><td class="st">${esc(t.status)}</td><td>${esc(t.title)}</td></tr>`;
})
.join("");
const body = `<h1>Ticket index</h1>
<p class="lede">The prose on this site cites issue ids like <code>improvise-9c8c</code>. They are this project's own tracker ids, kept in-repo with <a href="https://github.com/gastownhall/beads">beads</a>; there is no public issue URL to link to, so this page is what they resolve to. Titles come from <a href="${REPO}/blob/${ref}/.beads/issues.jsonl">.beads/issues.jsonl</a>, 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.</p>
<p class="lede">${ids.length} ids are cited, of which ${ids.length - unresolved.size} resolve.</p>
<table class="tickets"><thead><tr><th>Id</th><th>Status</th><th>Title</th></tr></thead><tbody>${rows}</tbody></table>`;
written.push({ rel: "tickets.html", html: page({ slug: "tickets", title: "Ticket index", depth: 0, body }), anchors: ctx.anchors });
}
// Landing page for the docs section.
{
// The two conventions go through `inline` rather than being hand-written, so
// the sentence describing the linking demonstrates it instead of asserting it.
const conventions = inline(
"Ids like `improvise-9c8c` are tracker issues and link to the ticket index below. " +
"Paths like `crates/improvise-core/src/model/types.rs` link to the source on GitHub at `" +
ref +
"`.",
{ depth: 0, cited, unresolved, anchors: new Set() },
);
const cards = SECTIONS.map(
(s) =>
`<h2 id="${s.id}"><a class="anchor" href="#${s.id}">#</a>${s.title}</h2><p class="lede">${s.blurb}</p><div class="cards">${s.docs
.map(([, slug, title]) => `<div class="card"><a href="${slug}.html">${title}</a></div>`)
.join("")}</div>`,
).join("");
const body = `<h1>improvise — documentation</h1>
<p class="lede">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.</p>
<p class="lede">Nothing here is a tutorial. For what the program does, see the <a href="../index.html">project home</a> or run the <a href="../app/">browser demo</a>.</p>
${cards}
<h2 id="conventions"><a class="anchor" href="#conventions">#</a>Conventions</h2>
<p>${conventions}</p>`;
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 <a> nested in an <a> 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\b[^>]*>((?:(?!<\/a>)[\s\S])*?)<a\b/g))
nested.push(`${w.rel}: <a> inside <a> 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);
}