c0e6bd8f33
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
762 lines
34 KiB
JavaScript
762 lines
34 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"],
|
|
["context/gotchas.md", "gotchas", "Gotchas (long form)"],
|
|
["context/file-inventory.md", "file-inventory", "File inventory (long form)"],
|
|
],
|
|
},
|
|
{
|
|
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"],
|
|
["context/adr/009-incremental-evaluation.md", "adr/009-incremental-evaluation", "ADR 009 — Incremental evaluation (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/numtower.md", "code/numtower", "numtower, the numeric tower"],
|
|
["context/code/zset.md", "code/zset", "zset, the Z-set group"],
|
|
["context/code/improvise-incremental.md", "code/improvise-incremental", "improvise-incremental, the plug"],
|
|
["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/zset.md", "test-evidence/zset", "zset"],
|
|
["context/test-evidence/improvise-incremental.md", "test-evidence/improvise-incremental", "improvise-incremental"],
|
|
["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, """)
|
|
.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 <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" || slug === "gotchas") 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);
|
|
}
|