Files
improvise/scripts/build-doc-site.mjs
T
Edward Langley 05589676f8 fix(docs): the nav toggle is named "Pages", not "trigram for heaven, Pages"
`::before` content participates in accessible-name computation, so the hamburger
glyph was part of the toggle's name and a screen reader would read it out. The
CSS alt-text form `content: "\2630  " / ""` suppresses the glyph from the name
while still painting it.

Taken from the reviewer's optional polish, and verified here rather than
accepted: at 390 the checkbox's accessible name is exactly "Pages" and its
`checked` state flips false -> true on Space, so the collapsed/expanded state is
announced rather than merely the control's existence. The glyph still renders --
computed `::before` content is present and the label lays out at 358px. At 1280
there is still no checkbox in the tree at all.

This is the same principle the previous commit wrote down, one step further:
focusability is necessary and not sufficient, because a control a screen reader
can reach but cannot name, or whose state it cannot read, is still broken. The
instrument that observes that is the accessible name and the checked flag, not
the tab order that was enough for the last fix.

Firefox ignores the alt-text form and falls back to reading the content, which
is exactly today's behaviour, so the change is an improvement in Chromium and
WebKit and a no-op elsewhere.

Gates: 1378 rust, 26 node --test, 16 playwright (3 skipped, webkit OPFS), all
three check-comment-runs modes, 3462 site links / 0 broken, 0 nested anchors,
0 of 23 pages overflow at 320/390/1280, nav operability unchanged at 390/900/
901/1280.

improvise-djoo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:12:20 -07:00

689 lines
30 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, 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"],
],
},
{
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/gp.md", "code/gp", "gp-tree and improvise-gp"],
],
},
{
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/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"],
],
},
{
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`);
// ---------------------------------------------------------------------------
// 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);
}