")
+ ? 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 `
: 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
${toc
+ .filter((t) => t.level <= 3)
+ .map((t) => `- ${t.text}
`)
+ .join("")}
`
+ : "";
+
+ // 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 exported | Cited 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.
+`;
+ 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);
+}
diff --git a/web/assemble.sh b/web/assemble.sh
index f6ba1e73..1fe1cd20 100755
--- a/web/assemble.sh
+++ b/web/assemble.sh
@@ -98,3 +98,10 @@ for entry in app.js worker.js; do
}
done
done
+
+# The prose site at `docs/`, generated from context/ by the same script the
+# harness serves. It runs LAST because its link check verifies the links that
+# leave its own tree (`../index.html`, `../app/`) against the filesystem, which
+# means their targets must already be here. It exits non-zero on a break, so a
+# dead cross-reference fails the assembly rather than shipping.
+node "$root/scripts/build-doc-site.mjs" "$out/docs" "$token"