#!/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"], ], }, { 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/numtower.md", "code/numtower", "numtower, the numeric tower"], ["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/numtower.md", "test-evidence/numtower", "numtower"], ["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, "&") .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 `${mark}${rendered}`; } function blocks(ls) { const outHtml = []; let i = 0; let para = []; const flush = () => { if (para.length) outHtml.push(`

${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("
"); 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(`
${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 `${body}`; }) .join("\n"); return [`<${tag}${attr}>\n${rendered}\n`, 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 ( `${head.map((c) => ``).join("")}` + body.map((r) => `${r.map((c) => ``).join("")}`).join("") + "
${c}
${c}
" ); } } // --------------------------------------------------------------------------- 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) => `

  • ${label}
  • `; // Do NOT "simplify" this to
    : the sidebar must force it OPEN, which // `display` cannot do, and the desktop nav collapses. doc-site.md §7. const parts = [ `"); return parts.join(""); } function page({ slug, title, body, depth }) { const up = "../".repeat(depth); return ` ${esc(title)} — improvise docs
    ${nav(slug, depth)}
    ${body}
    `; } 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 ? `
    On this page
    ` : ""; // 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 ? `

    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 `${id}not exportedCited in the prose; the committed tracker export does not carry it.`; return `${id}${esc(t.status)}${esc(t.title)}`; }) .join(""); const body = `

    Ticket index

    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.

    ${rows}
    IdStatusTitle
    `; 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) => `

    #${s.title}

    ${s.blurb}

    ${s.docs .map(([, slug, title]) => ``) .join("")}
    `, ).join(""); const body = `

    improvise — documentation

    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

    ${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); }