Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/the-system/system-renderer.ts

Raw
import {
	copyFileSync,
	cpSync,
	existsSync,
	mkdirSync,
	mkdtempSync,
	readFileSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
	layout as dagreLayout,
	type EdgeLabel,
	Graph,
	type GraphLabel,
	type NodeLabel,
	type Point,
} from "@dagrejs/dagre";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeHighlight from "rehype-highlight";
import rehypeMathjax from "rehype-mathjax";
import rehypeRaw from "rehype-raw";
import rehypeSlug from "rehype-slug";
import rehypeStringify from "rehype-stringify";
import { remarkDefinitionList } from "remark-definition-list";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import remarkGithubAlert from "remark-github-blockquote-alert";
import remarkMath from "remark-math";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import { visit } from "unist-util-visit";
import {
	type BoardProposal,
	type BoardState,
	boardStateFor,
} from "./board-state.ts";
import {
	type BundleDocumentRecord,
	type Diagnostic,
	type DocumentMetadata,
	type DocumentType,
	dependencyReferences,
	parseSystemConfig,
	references,
} from "./documents.ts";
import type { FrontmatterMap, FrontmatterValue } from "./markdown.ts";

export type RenderDocumentInput = BundleDocumentRecord &
	Readonly<{ markdown: string }>;
export type RenderInput = Readonly<{
	root: string;
	currentPhase?: string;
	documents: readonly RenderDocumentInput[];
	diagnostics: readonly Diagnostic[];
}>;
export type RenderOutput = Readonly<{ file: string; directory: string }>;
type LiveContext = Readonly<{ board: BoardState; revision: string }>;

type CoreDocument = Readonly<{
	id: string;
	title: string;
	path: string;
	markdown: string;
}>;
type RenderedDocument = Readonly<{
	id: string;
	title: string;
	type: string;
	sourcePath: string;
	outputPath: string;
	article: string;
	text: string;
	refs: readonly string[];
	deps: readonly string[];
	label?: string;
}>;

type MarkdownText = Readonly<{ value: string }>;

const GROUPS = ["law", "phase", "spec", "plan", "issue", "research"] as const;
const DOCUMENT_ORDER: readonly DocumentType[] = [
	"phase",
	"spec",
	"plan",
	"issue",
	"research",
];

const processor = unified()
	.use(remarkParse)
	.use(remarkFrontmatter, ["yaml"])
	.use(remarkGfm)
	.use(remarkDefinitionList)
	.use(remarkMath)
	.use(remarkGithubAlert)
	.use(remarkRehype, { allowDangerousHtml: true })
	.use(rehypeRaw)
	.use(rehypeSlug)
	.use(rehypeAutolinkHeadings, { behavior: "wrap" })
	.use(rehypeMathjax)
	.use(rehypeHighlight)
	.use(rehypeStringify, { allowDangerousHtml: true });

// Board re-renders process unchanged documents again on every mutation; the
// pipeline output only depends on the markdown source, so memoize it. The text
// comes from the same parse the HTML pipeline consumes, because a separate
// text-only parse doubles the cold-path parse cost.
const PROCESSED_CACHE_LIMIT = 512;
const processedCache = new Map<
	string,
	Readonly<{ html: string; text: string }>
>();

const processedDocument = (
	markdown: string,
): Readonly<{ html: string; text: string }> => {
	const cached = processedCache.get(markdown);
	if (cached) return cached;
	const tree = processor.parse(markdown);
	let text = "";
	visit(tree, ["text", "inlineMath", "math"] as const, (node: MarkdownText) => {
		text += `${node.value} `;
	});
	const processed = {
		html: rewriteMermaidBlocks(
			String(processor.stringify(processor.runSync(tree))),
		),
		text: text.trim(),
	};
	if (processedCache.size >= PROCESSED_CACHE_LIMIT) processedCache.clear();
	processedCache.set(markdown, processed);
	return processed;
};

export function renderSystemViewSite(
	input: RenderInput,
	selected: RenderDocumentInput,
): RenderOutput {
	const directory = createOutput(input);
	const rendered = renderAll(input, directory);
	const diagnostics = [...input.diagnostics, ...referenceDiagnostics(input)];
	writePages(directory, rendered, diagnostics, "single");
	const page = rendered.find((item) => item.id === selected.metadata.id);
	if (!page)
		throw new Error(`No rendered System document: ${selected.metadata.id}`);
	return { file: path.join(directory, page.outputPath), directory };
}

export function renderSystemBookSite(input: RenderInput): RenderOutput {
	const directory = createOutput(input);
	const rendered = renderAll(input, directory);
	const diagnostics = [...input.diagnostics, ...referenceDiagnostics(input)];
	writePages(directory, rendered, diagnostics, "book");
	const title = `${projectName(input.root)} Dossier`;
	writeFileSync(
		path.join(directory, "index.html"),
		htmlPage({
			title,
			body: bookBody(
				title,
				rendered,
				diagnostics,
				directory,
				input.currentPhase,
			),
			mode: "book",
			mermaidPath: "assets/mermaid.min.js",
			miniSearchPath: "assets/minisearch.js",
		}),
		"utf8",
	);
	return { file: path.join(directory, "index.html"), directory };
}

export function renderSystemBoardSite(
	input: RenderInput,
	board: BoardState,
	proposals: readonly BoardProposal[],
	revision = "",
): RenderOutput {
	const live: LiveContext = { board, revision };
	const directory = createOutput(input);
	const rendered = renderAll(input, directory, live);
	const diagnostics = [...input.diagnostics, ...referenceDiagnostics(input)];
	writePages(directory, rendered, diagnostics, "book", live);
	const title = `${projectName(input.root)} Board`;
	const body = boardBody(title, input, rendered, board, proposals).replaceAll(
		pathToFileURL(`${directory}${path.sep}`).href,
		"/",
	);
	writeFileSync(
		path.join(directory, "index.html"),
		htmlPage({
			title,
			body,
			mode: "board",
			mermaidPath: "assets/mermaid.min.js",
			miniSearchPath: "assets/minisearch.js",
			live,
		}),
		"utf8",
	);
	return { file: path.join(directory, "index.html"), directory };
}

const projectName = (root: string) => {
	try {
		return (
			parseSystemConfig(
				readFileSync(path.join(root, ".system/config.json"), "utf8"),
			).title ?? path.basename(root)
		);
	} catch {
		return path.basename(root) || "Project";
	}
};

const createOutput = (input: RenderInput): string => {
	const directory = mkdtempSync(path.join(tmpdir(), "system-render-"));
	copyBundles(input.root, input.documents, directory);
	copyMermaid(directory);
	copyMiniSearch(directory);
	return directory;
};

const renderAll = (
	input: RenderInput,
	outputRoot: string,
	live?: LiveContext,
): readonly RenderedDocument[] => {
	const coreSources = coreDocuments(input.root);
	const renderedSources = new Set([
		...coreSources.map((document) => document.path),
		...input.documents.map((document) => document.path),
	]);
	const cores = coreSources.map((document) =>
		renderCore(document, outputRoot, renderedSources),
	);
	const authored = orderedSystemDocuments(input.documents).map((document) =>
		renderDocument(input, document, outputRoot, renderedSources, live),
	);
	const current = authored.find(
		(document) => document.id === input.currentPhase,
	);
	return [
		...cores.filter(
			(document) =>
				document.id === "CORE-SYSTEM" || document.id === "CORE-MISSION",
		),
		...(current ? [current] : []),
		...cores.filter((document) => document.id === "CORE-RULES"),
		...authored.filter((document) => document !== current),
	];
};

const orderedSystemDocuments = (
	documents: readonly RenderDocumentInput[],
): readonly RenderDocumentInput[] =>
	DOCUMENT_ORDER.flatMap((type) => {
		const group = documents.filter(
			(document) => document.metadata.type === type,
		);
		return type === "plan"
			? topoDocuments(group)
			: group.sort(compareDocuments);
	});

const compareDocuments = (
	left: RenderDocumentInput,
	right: RenderDocumentInput,
) =>
	left.metadata.title.localeCompare(right.metadata.title) ||
	left.metadata.id.localeCompare(right.metadata.id);

const topoDocuments = (
	documents: readonly RenderDocumentInput[],
): readonly RenderDocumentInput[] => {
	const sorted = [...documents].sort(compareDocuments);
	const byId = new Map(
		sorted.map((document) => [document.metadata.id, document]),
	);
	const visiting = new Set<string>();
	const visited = new Set<string>();
	const result: RenderDocumentInput[] = [];
	const visit = (document: RenderDocumentInput) => {
		const id = document.metadata.id;
		if (visited.has(id)) return;
		if (visiting.has(id)) return;
		visiting.add(id);
		for (const dependencyId of [
			...dependencyReferences(document.metadata),
		].sort()) {
			const dependency = byId.get(dependencyId);
			if (dependency) visit(dependency);
		}
		visiting.delete(id);
		visited.add(id);
		result.push(document);
	};
	for (const document of sorted) visit(document);
	return result;
};

const writePages = (
	outputRoot: string,
	documents: readonly RenderedDocument[],
	diagnostics: readonly Diagnostic[],
	mode: "single" | "book",
	live?: LiveContext,
) => {
	for (const [index, document] of documents.entries()) {
		const previous = documents[index - 1];
		const next = documents[index + 1];
		const controls =
			mode === "book"
				? [
						previous
							? `<a rel="prev" href="${rel(document.outputPath, previous.outputPath)}">← ${esc(previous.title)}</a>`
							: "",
						next
							? `<a rel="next" href="${rel(document.outputPath, next.outputPath)}">${esc(next.title)} →</a>`
							: "",
					]
						.filter(Boolean)
						.join(" ")
				: "";
		writeFileSync(
			path.join(outputRoot, document.outputPath),
			htmlPage({
				title: document.title,
				body: `${controls ? `<nav class="doc-move">${controls}</nav>` : ""}${document.article}${diagnosticsHtml(diagnostics)}`,
				mode,
				mermaidPath: rel(document.outputPath, "assets/mermaid.min.js"),
				miniSearchPath: rel(document.outputPath, "assets/minisearch.js"),
				...(live ? { live } : {}),
			}),
			"utf8",
		);
	}
};

const coreDocuments = (root: string): readonly CoreDocument[] =>
	["SYSTEM.md", "MISSION.md", "RULES.md"].flatMap((name) => {
		const sourcePath = `.system/${name}`;
		const absolute = path.join(root, sourcePath);
		if (!existsSync(absolute)) return [];
		return [
			{
				id: `CORE-${name.replace(/\.md$/, "")}`,
				title: name.replace(/\.md$/, ""),
				path: sourcePath,
				markdown: readFileSync(absolute, "utf8"),
			},
		];
	});

const renderCore = (
	document: CoreDocument,
	outputRoot: string,
	renderedSources: ReadonlySet<string>,
): RenderedDocument => {
	const outputPath = document.path.replace(/\.md$/, ".html");
	const article = articleHtml({
		id: document.id,
		title: document.title,
		type: "law",
		sourcePath: document.path,
		frontmatter: undefined,
		markdown: document.markdown,
		outputRoot,
		renderedSources,
	});
	const processed = processedDocument(document.markdown);
	return {
		id: document.id,
		title: document.title,
		type: "law",
		sourcePath: document.path,
		outputPath,
		article,
		text: processed.text,
		refs: [],
		deps: [],
	};
};

const renderDocument = (
	input: RenderInput,
	document: RenderDocumentInput,
	outputRoot: string,
	renderedSources: ReadonlySet<string>,
	live?: LiveContext,
): RenderedDocument => {
	const outputPath = htmlPath(document.path);
	const article = articleHtml({
		...(live ? { live } : {}),
		id: document.metadata.id,
		title: document.metadata.title,
		type: document.metadata.type,
		sourcePath: document.path,
		metadata: document.metadata,
		frontmatter: document.frontmatter,
		markdown: document.markdown,
		outputRoot,
		documents: input.documents,
		current: document.metadata.id === input.currentPhase,
		renderedSources,
	});
	const processed = processedDocument(document.markdown);
	return {
		id: document.metadata.id,
		title: document.metadata.title,
		type: document.metadata.type,
		sourcePath: document.path,
		outputPath,
		article,
		text: `${frontmatterText(document.frontmatter)} ${processed.text}`,
		refs: refsFor(document.metadata),
		deps: dependencyReferences(document.metadata),
		...(document.metadata.type === "phase"
			? { label: document.metadata.label }
			: {}),
	};
};

const articleHtml = (input: {
	id: string;
	title: string;
	type: string;
	sourcePath: string;
	metadata?: DocumentMetadata;
	frontmatter?: FrontmatterMap;
	markdown: string;
	outputRoot: string;
	documents?: readonly RenderDocumentInput[];
	current?: boolean;
	renderedSources: ReadonlySet<string>;
	live?: LiveContext;
}): string => {
	const metadata = input.frontmatter
		? metadataHtml(
				input.frontmatter,
				input.documents ?? [],
				input.outputRoot,
				input.metadata,
				input.live,
			)
		: "";
	const backlinks = input.documents
		? backlinksHtml(input.id, input.documents, input.outputRoot)
		: "";
	const processed = processedDocument(input.markdown);
	const body = prefixHeadingAnchors(
		rewriteLocalRefs(
			rewriteVisualAssetLinks(
				processed.html,
				input.sourcePath,
				input.outputRoot,
			),
			input.sourcePath,
			input.outputRoot,
			input.renderedSources,
		),
		input.id,
	);
	return `<article class="system-article${input.current ? " current-phase" : ""}" data-id="${esc(input.id)}"><header class="mast"><p class="eyebrow">${esc(input.type)} · <code>${esc(input.id)}</code>${input.current ? '<strong class="phase-current">Current phase</strong>' : ""}</p><h1>${esc(input.title)}</h1><p class="source">${esc(input.sourcePath)}</p>${summaryHtml(input.metadata)}</header>${metadata}${body}${backlinks}</article>`;
};

const summaryHtml = (metadata?: DocumentMetadata): string => {
	if (!metadata) return "";
	const dependencies = dependencyReferences(metadata);
	const refs = refsFor(metadata).filter((id) => !dependencies.includes(id));
	return `<p class="summary">${esc(metadata.type)}${metadata.type === "phase" ? ` · ${esc(metadata.label)}` : ""}${"status" in metadata ? ` · ${esc(metadata.status)}` : ""}${refs.length ? ` · refs ${refs.map(esc).join(", ")}` : ""}${dependencies.length ? ` · after ${dependencies.map(esc).join(", ")}` : ""}</p>`;
};

const backlinksHtml = (
	id: string,
	documents: readonly RenderDocumentInput[],
	outputRoot: string,
): string => {
	const backlinks = documents
		.filter(
			(document) =>
				document.metadata.id !== id && refsFor(document.metadata).includes(id),
		)
		.sort(compareDocuments);
	if (!backlinks.length) return "";
	return `<details class="doc-related"><summary>Referenced by</summary>${backlinks
		.map(
			(document) =>
				`<a href="${documentHref(outputRoot, document.path)}"><span>${esc(document.metadata.type)}</span>${esc(document.metadata.title)}<code>${esc(document.metadata.id)}</code></a>`,
		)
		.join("")}</details>`;
};

const metadataHtml = (
	frontmatter: FrontmatterMap,
	documents: readonly RenderDocumentInput[],
	outputRoot: string,
	metadata?: DocumentMetadata,
	live?: LiveContext,
): string => {
	const rows = [...frontmatter.entries()]
		.map(([key, value]) => {
			const toggle =
				live && metadata?.type === "plan" && key === "status"
					? statusToggle(metadata.id, metadata.status)
					: undefined;
			return `<tr><th>${esc(key)}</th><td>${toggle ?? valueHtml(value, documents, outputRoot)}</td></tr>`;
		})
		.join("");
	const boardRow =
		live && metadata?.type === "spec"
			? `<tr><th>board</th><td>${boardStateToggle(metadata.id, boardStateFor(live.board, metadata))}</td></tr>`
			: "";
	return `<details class="ledger"><summary>Metadata</summary><table>${rows}${boardRow}</table></details>`;
};

const statusToggle = (id: string, status: string) =>
	`<button class="ledger-toggle" type="button" data-status-toggle data-id="${esc(id)}" data-status="${esc(status)}" title="Toggle plan status">${esc(status)}</button>`;

const boardStateToggle = (id: string, state: string) =>
	`<button class="ledger-toggle" type="button" data-board-toggle data-id="${esc(id)}" data-state="${esc(state)}" data-default-state="todo" title="Toggle board state">${esc(state)}</button>`;

const valueHtml = (
	value: FrontmatterValue,
	documents: readonly RenderDocumentInput[],
	outputRoot: string,
): string => {
	if (value === null) return "<code>null</code>";
	if (typeof value === "boolean" || typeof value === "number")
		return `<code>${String(value)}</code>`;
	if (typeof value === "string") return linkId(value, documents, outputRoot);
	if (Array.isArray(value))
		return `<ol>${value.map((item) => `<li>${valueHtml(item, documents, outputRoot)}</li>`).join("")}</ol>`;
	return `<table>${[...value.entries()]
		.map(
			([key, item]) =>
				`<tr><th>${esc(String(key))}</th><td>${valueHtml(item, documents, outputRoot)}</td></tr>`,
		)
		.join("")}</table>`;
};

const linkId = (
	value: string,
	documents: readonly RenderDocumentInput[],
	outputRoot: string,
): string => {
	const target = documents.find((document) => document.metadata.id === value);
	if (!target) return esc(value);
	return `<a href="${documentHref(outputRoot, target.path)}">${esc(value)}</a>`;
};

const refsFor = (metadata: DocumentMetadata): readonly string[] =>
	references(metadata).map(([id]) => id);

const htmlPath = (sourcePath: string) =>
	sourcePath.replace(/index\.md$/, "index.html");

const documentHref = (outputRoot: string, sourcePath: string) =>
	pathToFileURL(path.join(outputRoot, htmlPath(sourcePath))).href;

const frontmatterText = (frontmatter: FrontmatterMap): string =>
	[...frontmatter.entries()]
		.map(([key, value]) => `${key} ${plainValue(value)}`)
		.join(" ");

const plainValue = (value: FrontmatterValue): string => {
	if (value === null) return "null";
	if (typeof value !== "object") return String(value);
	if (Array.isArray(value)) return value.map(plainValue).join(" ");
	return [...value.entries()]
		.map(([key, item]) => `${String(key)} ${plainValue(item)}`)
		.join(" ");
};

const prefixHeadingAnchors = (html: string, prefix: string) =>
	html.replaceAll(
		/<h([1-6]) id="([^"]+)"><a href="#\2">/g,
		(_match, level: string, id: string) =>
			`<h${level} id="${esc(`${prefix}-${id}`)}"><a href="#${esc(`${prefix}-${id}`)}">`,
	);

const rewriteLocalRefs = (
	html: string,
	sourcePath: string,
	outputRoot: string,
	renderedSources: ReadonlySet<string>,
) =>
	html.replaceAll(
		/\b(href|src)="([^"]+)"/g,
		(match, attribute: string, target: string) => {
			if (/^(?:https?:|mailto:|data:|file:|#)/i.test(target)) return match;
			const [, bare = "", suffix = ""] =
				target.match(/^([^?#]*)([?#].*)?$/) ?? [];
			const sourceTarget = path.posix.normalize(
				path.posix.join(path.posix.dirname(sourcePath), bare),
			);
			const rewritten =
				/\.md$/i.test(bare) && renderedSources.has(sourceTarget)
					? bare.replace(/\.md$/i, ".html")
					: bare;
			const absolute = path.join(
				outputRoot,
				path.dirname(sourcePath),
				rewritten,
			);
			return `${attribute}="${pathToFileURL(absolute).href}${suffix}"`;
		},
	);

const rewriteMermaidBlocks = (html: string) =>
	html.replaceAll(
		/<pre><code class="hljs language-mermaid">([\s\S]*?)<\/code><\/pre>/g,
		(_match, source: string) => `<pre class="mermaid">${source}</pre>`,
	);

const rewriteVisualAssetLinks = (
	html: string,
	sourcePath: string,
	outputRoot: string,
) =>
	html.replaceAll(
		/<p><a href="([^"]+)">([\s\S]*?)<\/a><\/p>/g,
		(match, href: string, label: string) => {
			const kind = visualAssetKind(href, sourcePath, outputRoot);
			if (!kind) return match;
			const title = label.replaceAll(/<[^>]+>/g, "");
			const preview =
				kind === "html"
					? `<iframe src="${href}" loading="lazy" title="${title}"></iframe>`
					: kind === "image"
						? `<img src="${href}" alt="${title}" loading="lazy">`
						: `<video controls preload="metadata"><source src="${href}"><a href="${href}">Open video</a></video>`;
			return `<figure class="visual-asset-preview visual-asset-${kind}"><figcaption>${label}</figcaption>${preview}<p><a href="${href}">Open full-size</a></p></figure>`;
		},
	);

const visualAssetKind = (
	href: string,
	sourcePath: string,
	outputRoot: string,
): "html" | "image" | "video" | undefined => {
	if (/^(?:[a-z]+:|\/|\\|#)/i.test(href)) return undefined;
	const [bare = ""] = href.split(/[?#]/, 1);
	if (!bare || bare.includes("\\")) return undefined;
	const bundle = path.posix.dirname(sourcePath);
	const target = path.posix.normalize(path.posix.join(bundle, bare));
	if (target === bundle || !target.startsWith(`${bundle}/`)) return undefined;
	if (!existsSync(path.join(outputRoot, target))) return undefined;
	const extension = path.posix.extname(bare).toLowerCase();
	if (extension === ".html" || extension === ".htm") return "html";
	if (
		[".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"].includes(
			extension,
		)
	)
		return "image";
	if (extension === ".mp4" || extension === ".webm") return "video";
	return undefined;
};

const GROUP_LABELS: Readonly<Record<(typeof GROUPS)[number], string>> = {
	law: "Law",
	spec: "Specs",
	phase: "Phases",
	plan: "Plans",
	issue: "Issues",
	research: "Research",
};

const boardBody = (
	title: string,
	input: RenderInput,
	rendered: readonly RenderedDocument[],
	board: BoardState,
	proposals: readonly BoardProposal[],
): string => {
	const authored = new Map(
		input.documents.map((document) => [document.metadata.id, document]),
	);
	const pages = new Map(rendered.map((document) => [document.id, document]));
	const specs = input.documents
		.filter((document) => document.metadata.type === "spec")
		.sort((left, right) => compareBoardItems(left, right, board));
	const issues = input.documents
		.filter((document) => document.metadata.type === "issue")
		.sort((left, right) => compareBoardItems(left, right, board));
	const plansBySpec = new Map<string, RenderDocumentInput[]>();
	for (const document of input.documents) {
		if (document.metadata.type !== "plan") continue;
		const plans = plansBySpec.get(document.metadata.spec) ?? [];
		plans.push(document);
		plansBySpec.set(document.metadata.spec, plans);
	}
	for (const plans of plansBySpec.values())
		plans.sort((left, right) => compareBoardItems(left, right, board));
	const proposalById = new Map(
		proposals.map((proposal) => [proposal.id, proposal]),
	);
	const todo = specs.filter(
		(document) => boardStateFor(board, document.metadata) !== "done",
	).length;
	const open = issues.filter(
		(document) => boardStateFor(board, document.metadata) !== "done",
	).length;
	const specCards = specs
		.map((spec) => {
			const plans = plansBySpec.get(spec.metadata.id) ?? [];
			const donePlans = plans.filter(
				(plan) => boardStateFor(board, plan.metadata) === "done",
			).length;
			const planSummary = plans.length
				? ` · ${donePlans}/${plans.length} ${plans.length === 1 ? "plan" : "plans"} done`
				: "";
			return `<article id="${esc(spec.metadata.id)}" class="board-spec${boardStateFor(board, spec.metadata) === "done" ? " is-done" : ""}" data-board-item data-kind="spec" data-id="${esc(spec.metadata.id)}" data-default-state="todo"><div class="board-row">${boardDisclosure(spec, pages.get(spec.metadata.id), planSummary)}${boardStateButton(spec.metadata.id, boardStateFor(board, spec.metadata))}</div>${plans.length ? `<div class="board-plans" data-plan-list>${plans.map((plan) => boardPlan(plan, pages.get(plan.metadata.id), board, proposalById.get(plan.metadata.id))).join("")}</div>` : ""}${boardAssessment(board.items[spec.metadata.id])}${boardProposal(proposalById.get(spec.metadata.id))}</article>`;
		})
		.join("");
	const issueCards = issues
		.map((issue) => {
			const refs = issue.metadata.type === "issue" ? issue.metadata.specs : [];
			const links = refs
				.map((id) => {
					const target = authored.get(id);
					return `<a href="#${esc(id)}">→ ${esc(target?.metadata.title ?? id)}</a>`;
				})
				.join("");
			return `<article class="board-issue${boardStateFor(board, issue.metadata) === "done" ? " is-done" : ""}" data-board-item data-kind="issue" data-id="${esc(issue.metadata.id)}" data-default-state="open"><div class="board-row">${boardDisclosure(issue, pages.get(issue.metadata.id), "")}${boardStateButton(issue.metadata.id, boardStateFor(board, issue.metadata))}</div>${links ? `<div class="board-links">${links}</div>` : ""}${boardAssessment(board.items[issue.metadata.id])}${boardProposal(proposalById.get(issue.metadata.id))}</article>`;
		})
		.join("");
	return `<style>${boardCss()}</style><header class="board-top"><div><a class="board-brand" href="#">${esc(title)}</a><p>Spec toggles never change plan states. Reopening a plan demotes its spec.</p></div><span><b data-spec-count>${todo}</b> todo · <b data-issue-count>${open}</b> ${open === 1 ? "issue" : "issues"} open</span></header><main class="board-grid"><section class="board-region"><h1>Specs <span>${specs.length}</span></h1><div class="board-spec-list" data-spec-list>${specCards || '<p class="board-empty">No specs.</p>'}</div></section><aside class="board-region"><h1>Issues <span>${issues.length}</span></h1><div class="board-issue-list" data-issue-list>${issueCards || '<p class="board-empty">No issues.</p>'}</div></aside></main><output class="board-notice" data-board-notice aria-live="polite"></output><script>${boardScript()}</script>`;
};

const compareBoardItems = (
	left: RenderDocumentInput,
	right: RenderDocumentInput,
	board: BoardState,
) =>
	Number(boardStateFor(board, left.metadata) === "done") -
		Number(boardStateFor(board, right.metadata) === "done") ||
	compareDocuments(left, right);

const boardDisclosure = (
	document: RenderDocumentInput,
	rendered: RenderedDocument | undefined,
	suffix: string,
) =>
	`<details class="board-bundle"><summary><span><span class="board-kind">${esc(document.metadata.id)}${esc(suffix)}</span><strong>${esc(document.metadata.title)}</strong></span></summary><div class="board-details">${rendered?.article ?? ""}</div></details>`;

const boardStateButton = (id: string, state: string) =>
	`<button class="board-state" type="button" data-board-state="${esc(state)}" aria-label="Toggle ${esc(id)} state">${esc(state.toUpperCase())}</button>`;

const boardPlan = (
	plan: RenderDocumentInput,
	rendered: RenderedDocument | undefined,
	board: BoardState,
	proposal?: BoardProposal,
) => {
	const state = boardStateFor(board, plan.metadata);
	return `<article class="board-plan${state === "done" ? " is-done" : ""}" data-board-item data-kind="plan" data-id="${esc(plan.metadata.id)}" data-default-state="todo"><div class="board-row">${boardDisclosure(plan, rendered, plan.metadata.type === "plan" ? ` · ${plan.metadata.status}` : "")}${boardStateButton(plan.metadata.id, state)}</div>${boardAssessment(board.items[plan.metadata.id])}${boardProposal(proposal)}</article>`;
};

const boardAssessment = (entry: BoardState["items"][string] | undefined) =>
	entry
		? `<span class="board-assessment" data-assessment data-source="${esc(entry.source)}" data-updated-at="${esc(entry.updatedAt)}"${entry.note ? ` data-note="${esc(entry.note)}"` : ""}>${esc(entry.source)} · ${esc(entry.updatedAt)}${entry.note ? ` · ${esc(entry.note)}` : ""}</span>`
		: '<span class="board-assessment" data-assessment>default</span>';

const boardProposal = (proposal?: BoardProposal) =>
	proposal
		? `<aside class="board-proposal" data-proposal><span><b>ASSESS → ${esc(proposal.state.toUpperCase())}</b>${esc(proposal.rationale)}</span><button type="button" data-apply-proposal="${esc(proposal.state)}">Apply</button></aside>`
		: "";

const boardCss = () => String.raw`
body[data-mode=board]{padding:10px;background:var(--bg);font:12px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}.board-top{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end;border-bottom:2px solid var(--text);padding-bottom:7px;margin-bottom:7px}.board-brand{color:var(--text);font-size:17px;font-weight:900;text-decoration:none;text-transform:uppercase}.board-top p{margin:2px 0 0;color:var(--muted);font-size:10px}.board-top>span{color:var(--muted)}.board-grid{display:grid;grid-template-columns:minmax(0,3fr) minmax(240px,1fr);gap:7px;align-items:start}.board-region{border:1px solid var(--line);background:var(--panel);min-width:0}.board-region>h1{font-size:11px;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:6px 7px;border-bottom:1px solid var(--line)}.board-region>h1 span{float:right;color:var(--muted);font-weight:400}.board-spec-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:6px;padding:6px;align-items:start}.board-issue-list{display:grid;gap:5px;padding:6px}.board-spec{border:2px solid var(--accent);background:var(--page);min-width:0}.board-spec.is-done{border-color:var(--line);background:var(--bg)}.board-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;align-items:start;padding:6px 7px}.board-bundle{min-width:0}.board-bundle>summary{cursor:pointer;list-style:none;display:grid;grid-template-columns:minmax(0,1fr);padding-left:14px;position:relative}.board-bundle>summary::-webkit-details-marker{display:none}.board-bundle>summary::before{content:'▸';position:absolute;left:0;color:var(--accent)}.board-bundle[open]>summary::before{content:'▾'}.board-kind{display:block;font-size:9px;color:var(--muted);text-transform:uppercase}.board-bundle>summary strong{display:block;font:700 13px/1.15 system-ui,sans-serif;margin-top:1px;overflow-wrap:anywhere}.is-done>.board-row .board-bundle>summary strong{color:var(--muted);text-decoration:line-through}.board-details{grid-column:1/-1;margin:6px 0 0 14px;border-top:1px solid var(--line);padding-top:6px;font-family:system-ui,sans-serif}.board-details>.system-article{border:0;padding:0;background:transparent}.board-details .mast{display:none}.board-details .ledger{margin:.35rem 0}.board-details h1{font-size:19px}.board-details h2{font-size:16px}.board-details h3{font-size:14px}.board-details pre{padding:.6rem}.board-plans{display:grid;gap:4px;border-top:1px solid var(--line);background:var(--bg);padding:4px}.board-plan{border-left:3px solid var(--accent);background:var(--page)}.board-plan.is-done{border-left-color:var(--line);background:var(--bg)}.board-plan .board-row{padding:4px 5px}.board-issue{border-left:3px solid var(--warn);background:var(--page)}.board-issue.is-done{border-left-color:var(--line);background:var(--bg)}.board-issue .board-bundle>summary::before{color:var(--warn)}.board-links{display:grid;gap:2px;padding:0 7px 6px 21px;font-size:10px}.board-state{border:1px solid var(--accent);background:var(--bg);color:var(--accent);font:9px ui-monospace,monospace;padding:3px 5px;cursor:pointer;min-width:43px}.board-issue .board-state{border-color:var(--warn);color:var(--warn)}.is-done>.board-row>.board-state,.board-plan.is-done .board-state,.board-issue.is-done .board-state{border-color:var(--line);color:var(--text)}.board-state:disabled{cursor:not-allowed;border-color:var(--line);color:var(--muted)}.board-assessment{display:block;color:var(--muted);font-size:9px;padding:0 7px 5px 21px}.board-proposal{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;border-top:1px solid var(--info);padding:5px 7px;color:var(--muted)}.board-proposal b{display:block;color:var(--info);font-size:9px}.board-proposal button{border:1px solid var(--info);background:var(--bg);color:var(--info);font:9px ui-monospace,monospace;padding:3px 6px}.board-empty{color:var(--muted);margin:6px}@media(max-width:760px){.board-top,.board-grid{grid-template-columns:1fr}.board-spec-list{grid-template-columns:1fr}.board-top>span{text-align:left}}
`;

const boardScript = () => String.raw`
const root=document.body;const direct=(node,selector)=>[...node.children].filter(child=>child.matches(selector));const sort=(node,selector)=>{const items=direct(node,selector);const ordered=[...items].sort((a,b)=>Number(a.classList.contains('is-done'))-Number(b.classList.contains('is-done'))||(a.dataset.id||'').localeCompare(b.dataset.id||''));if(ordered.some((item,at)=>item!==items[at]))for(const item of ordered)node.append(item)};const notice=document.querySelector('[data-board-notice]');const message=(text,error=false)=>{notice.textContent=text;notice.className='board-notice is-visible'+(error?' is-error':'');setTimeout(()=>{notice.className='board-notice'},3000)};const stateOf=card=>card.dataset.state||card.dataset.defaultState;const age=value=>{if(!value)return'default';const days=Math.max(0,Math.floor((Date.now()-Date.parse(value))/86400000));return days===0?'today':days===1?'1 day ago':days+' days ago'};function refresh(snapshot){for(const card of document.querySelectorAll('[data-board-item]')){const entry=snapshot.items[card.dataset.id];const state=entry?.state||card.dataset.defaultState;card.dataset.state=state;card.classList.toggle('is-done',state==='done');const button=card.querySelector(':scope > .board-row > .board-state');button.textContent=state.toUpperCase();const assessment=card.querySelector(':scope > [data-assessment]');assessment.textContent=entry?entry.source+' · '+age(entry.updatedAt)+(entry.note?' · '+entry.note:''):'default';assessment.title=entry?.updatedAt||''}for(const spec of document.querySelectorAll('[data-kind="spec"]')){const button=spec.querySelector(':scope > .board-row > .board-state');const unfinished=spec.querySelector('[data-kind="plan"]:not(.is-done)');button.disabled=!spec.classList.contains('is-done')&&Boolean(unfinished);button.title=button.disabled?'Finish every plan first':'No plan states will change'}document.querySelectorAll('[data-plan-list]').forEach(list=>sort(list,'[data-kind="plan"]'));sort(document.querySelector('[data-spec-list]'),'[data-kind="spec"]');sort(document.querySelector('[data-issue-list]'),'[data-kind="issue"]');document.querySelector('[data-spec-count]').textContent=String(document.querySelectorAll('[data-kind="spec"]:not(.is-done)').length);document.querySelector('[data-issue-count]').textContent=String(document.querySelectorAll('[data-kind="issue"]:not(.is-done)').length)}globalThis.systemBoardRefresh=refresh;async function post(path,payload){const response=await fetch(path,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});const result=await response.json();if(!response.ok)throw new Error(result.error||'Board update failed');globalThis.systemBoardSeen?.(result.items);return result}root.addEventListener('click',async event=>{const proposal=event.target.closest('[data-apply-proposal]');const button=event.target.closest('.board-state');if(!proposal&&!button)return;event.preventDefault();const card=(proposal||button).closest('[data-board-item]');try{if(proposal){const result=await post('/api/proposal',{id:card.dataset.id,state:proposal.dataset.applyProposal});proposal.closest('[data-proposal]').remove();refresh(result);message('Assessment applied.')}else{const state=stateOf(card);const target=state==='done'?card.dataset.defaultState:'done';const result=await post('/api/state',{id:card.dataset.id,state:target});refresh(result);message('Board updated.')}}catch(error){message(error.message,true)}});for(const assessment of document.querySelectorAll('[data-assessment]'))if(assessment.dataset.updatedAt)assessment.textContent=assessment.dataset.source+' · '+age(assessment.dataset.updatedAt)+(assessment.dataset.note?' · '+assessment.dataset.note:'');
`;

const ledgerCss = () => String.raw`
.ledger-toggle{border:1px solid var(--accent);background:var(--bg);color:var(--accent);font:700 11px ui-monospace,monospace;padding:2px 8px;text-transform:uppercase}.ledger-toggle:disabled{cursor:progress;opacity:.55}.board-notice{position:fixed;right:10px;bottom:10px;z-index:9;max-width:min(34rem,calc(100vw - 20px));background:var(--panel);border:1px solid var(--line);padding:7px;font:12px ui-monospace,monospace;display:none}.board-notice.is-visible{display:block}.board-notice.is-error{border-color:var(--error);color:var(--error)}
`;

const ledgerScript = () => String.raw`
const element=()=>{let node=document.querySelector('[data-board-notice]');if(!node){node=document.createElement('output');node.className='board-notice';node.setAttribute('data-board-notice','');node.setAttribute('aria-live','polite');document.body.append(node)}return node};const notify=(text,isError=false)=>{const node=element();node.textContent=text;node.className='board-notice is-visible'+(isError?' is-error':'');setTimeout(()=>{node.className='board-notice'},3000)};let known=null;let reloading=false;const request=async(path,payload)=>{const response=await fetch(path,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});const result=await response.json();if(!response.ok)throw new Error(result.error||'Board request failed');if(result&&result.items)known=JSON.stringify(result.items);return result};globalThis.systemBoardSeen=items=>{known=JSON.stringify(items)};const syncLedger=items=>{for(const button of document.querySelectorAll('[data-board-toggle]')){const state=items[button.dataset.id]?.state||button.dataset.defaultState;button.dataset.state=state;button.textContent=state}};const OPEN='system-board-open';const remember=()=>{try{sessionStorage.setItem(OPEN,JSON.stringify([...document.querySelectorAll('.board-bundle[open]')].map(node=>node.closest('[data-board-item]')?.dataset.id).filter(Boolean)))}catch{}};const restore=()=>{try{for(const id of JSON.parse(sessionStorage.getItem(OPEN)||'[]'))document.querySelector('[data-board-item][data-id="'+id+'"] > .board-row > .board-bundle')?.setAttribute('open','')}catch{}};document.body.addEventListener('click',async event=>{const button=event.target.closest?.('.ledger-toggle');if(!button)return;event.preventDefault();button.disabled=true;try{if(button.hasAttribute('data-status-toggle')){const next=button.dataset.status==='approved'?'draft':'approved';await request('/api/status',{id:button.dataset.id,status:next});button.dataset.status=next;button.textContent=next;notify('Plan status is now '+next+'.')}else{const next=button.dataset.state==='done'?button.dataset.defaultState:'done';const result=await request('/api/state',{id:button.dataset.id,state:next});syncLedger(result.items);globalThis.systemBoardRefresh?.(result);notify('Board updated.')}}catch(error){notify(error.message,true)}finally{button.disabled=false}});const poll=async()=>{if(reloading)return;try{const response=await fetch('/api/state');const snapshot=await response.json();if(!response.ok||typeof snapshot.revision!=='string')return;if(snapshot.revision!==document.body.dataset.revision){reloading=true;remember();location.reload();return}const encoded=JSON.stringify(snapshot.items);if(encoded===known)return;known=encoded;syncLedger(snapshot.items);globalThis.systemBoardRefresh?.(snapshot);if(snapshot.problem)notify(snapshot.problem,true)}catch{}};restore();void poll();setInterval(poll,1000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)void poll()});
`;

const bookBody = (
	title: string,
	documents: readonly RenderedDocument[],
	diagnostics: readonly Diagnostic[],
	outputRoot: string,
	currentPhase?: string,
): string => {
	const diagnosticNav = diagnostics.length
		? '<a href="#diagnostics">Diagnostics</a>'
		: "";
	const utilities = `<div class="nav-utils"><a href="#book-overview">Top</a><a href="#reference-graph">Reference graph</a>${diagnosticNav}</div>`;
	const nav = bookNav(documents, utilities, currentPhase);
	const diagnosticPanel = diagnostics.length
		? `<section>${diagnosticsHtml(diagnostics, documents)}</section>`
		: "";
	const current = documents.find((document) => document.id === currentPhase);
	const stats = [
		...(current
			? [
					`<span class="active-phase">Current phase <a href="#${esc(current.id)}">${esc(current.label ?? current.title)}</a></span>`,
				]
			: []),
		...(diagnostics.length
			? [`<span class="bad">${diagnostics.length} diagnostics</span>`]
			: []),
	];
	const status = stats.length ? `<p class="stats">${stats.join("")}</p>` : "";
	return `<header class="book-top" id="book-overview"><a class="brand" href="#book-overview">${esc(title)}</a><label class="book-search"><span>Search</span><span class="search-row"><input id="search" type="search" autocomplete="off"><button type="button" id="search-button">Search</button></span><ol id="results" class="search-results" aria-live="polite"></ol></label>${status}</header><div class="book-shell"><nav class="mobile-nav"><details><summary>Book navigation</summary>${nav}</details></nav><aside class="book-nav">${nav}</aside><main class="book-main"><section class="book-head"><h1>${esc(title)}</h1><div class="overview-grid"><section>${graphHtml(documents)}</section>${diagnosticPanel}</div></section>${documents
		.map(
			(document) =>
				`<section id="${esc(document.id)}" class="book-section${document.id === currentPhase ? " current-phase-section" : ""}">${bookArticle(document.article, documents, outputRoot)}</section>`,
		)
		.join(
			"\n",
		)}</main></div><script type="application/json" id="search-data">${jsonScript(documents.map(({ id, title, type, text }) => ({ id, title, type, text, href: `#${id}` })))}</script><script>${searchScript()}${graphScript()}</script>`;
};

const bookArticle = (
	article: string,
	documents: readonly RenderedDocument[],
	outputRoot: string,
): string => {
	let result = article;
	for (const document of documents) {
		const href = pathToFileURL(path.join(outputRoot, document.outputPath)).href;
		result = result.replaceAll(`href="${href}"`, `href="#${esc(document.id)}"`);
	}
	return result;
};

const bookNav = (
	documents: readonly RenderedDocument[],
	utilities: string,
	currentPhase?: string,
): string => {
	const byType = new Map(
		GROUPS.map((group) => [group, [] as RenderedDocument[]]),
	);
	for (const document of documents)
		byType.get(document.type as (typeof GROUPS)[number])?.push(document);
	return `${utilities}${GROUPS.map((group) => {
		const items = byType.get(group) ?? [];
		if (!items.length) return "";
		return `<details ${group === "law" || group === "phase" || group === "spec" ? "open" : ""}><summary>${GROUP_LABELS[group]} <span class="count">${items.length}</span></summary>${items
			.map((item) => navItem(item, item.id === currentPhase))
			.join("")}</details>`;
	}).join("")}`;
};

const navItem = (item: RenderedDocument, current = false): string => {
	const label = item.type === "law" ? item.id : `${item.id} ${item.title}`;
	return `<a href="#${esc(item.id)}"${current ? ' class="current-phase-link" aria-current="true"' : ""}>${current ? "● " : ""}${esc(label)}</a>`;
};

const graphHtml = (documents: readonly RenderedDocument[]) => {
	const byId = new Map(documents.map((document) => [document.id, document]));
	const linked = documents.filter((document) => {
		const backlinks = documents.some((candidate) =>
			graphRefsForNode(candidate).includes(document.id),
		);
		return graphRefsForNode(document).some((id) => byId.has(id)) || backlinks;
	});
	if (!linked.length)
		return `<section id="reference-graph" class="graph"><h3>Reference graph</h3><p>No references yet.</p></section>`;
	const layout = layoutGraph(linked);
	const paths = layout.edges
		.map((edge) => {
			if (edge.points.length === 0) return "";
			const [first, ...rest] = edge.points;
			return `<path d="M${first.x} ${first.y}${rest.map((point) => ` L${point.x} ${point.y}`).join("")}"></path>`;
		})
		.join("");
	const boxes = layout.nodes
		.map((document) => {
			const position = layout.positions.get(document.id);
			if (!position) return "";
			const size = graphNodeSize(document);
			return `<a href="#${esc(document.id)}"><title>${esc(document.id)} ${esc(document.title)}</title><rect x="${position.x}" y="${position.y}" width="${size.width}" height="${size.height}" rx="2"></rect><text x="${position.x + 8}" y="${position.y + 18}">${esc(shortLabel(document))}</text><text class="graph-node-type" x="${position.x + 8}" y="${position.y + 34}">${esc(document.type)}</text></a>`;
		})
		.join("");
	const rows = linked.map((document) => {
		const backlinks = documents
			.filter((candidate) => graphRefsForNode(candidate).includes(document.id))
			.map((candidate) => candidate.id);
		const graphRefs = graphRefsForNode(document);
		return `<li><a href="#${esc(document.id)}">${esc(document.id)}</a><br>References: ${graphRefs.map((id) => graphLink(id, byId)).join(", ") || "none"}<br>Backlinks: ${backlinks.map((id) => graphLink(id, byId)).join(", ") || "none"}</li>`;
	});
	return `<section id="reference-graph" class="graph"><div class="graph-head"><h3>Reference graph</h3><div class="graph-controls" aria-label="Graph controls"><button type="button" data-graph-zoom="in">+</button><button type="button" data-graph-zoom="out">−</button><button type="button" data-graph-zoom="reset">Reset</button></div></div><div class="graph-frame"><svg data-graph-svg data-world-width="${layout.width}" data-world-height="${layout.height}" viewBox="0 0 ${Math.min(layout.width, 1100)} ${Math.min(layout.height, 520)}" preserveAspectRatio="xMinYMin meet" role="img" aria-label="System reference graph">${paths}${boxes}</svg></div><details class="graph-list"><summary>Text graph</summary><ol>${rows.join("")}</ol></details></section>`;
};

const NODE_WIDTH = 168;
const NODE_HEIGHT = 44;
const GRAPH_PAD = 36;
const GRAPH_X_GAP = 112;
const GRAPH_Y_GAP = 30;

type GraphLayoutEdge = Readonly<{
	from: string;
	to: string;
	points: readonly Point[];
}>;
type GraphPoint = Readonly<{ x: number; y: number }>;
type GraphLayout = Readonly<{
	nodes: readonly RenderedDocument[];
	edges: readonly GraphLayoutEdge[];
	positions: ReadonlyMap<string, GraphPoint>;
	width: number;
	height: number;
}>;

const layoutGraph = (nodes: readonly RenderedDocument[]): GraphLayout => {
	const byId = new Map(nodes.map((node) => [node.id, node]));
	const graph = new Graph<GraphLabel, NodeLabel, EdgeLabel>();
	graph.setGraph({
		rankdir: "LR",
		acyclicer: "greedy",
		ranker: "network-simplex",
		align: "UL",
		marginx: GRAPH_PAD,
		marginy: GRAPH_PAD,
		nodesep: GRAPH_Y_GAP,
		ranksep: GRAPH_X_GAP,
		edgesep: 12,
	});
	graph.setDefaultEdgeLabel(() => ({}));
	for (const node of nodes) graph.setNode(node.id, graphNodeSize(node));
	for (const node of nodes)
		for (const ref of graphRefsForNode(node))
			if (byId.has(ref))
				graph.setEdge(ref, node.id, {
					weight: node.deps.includes(ref) ? 3 : 1,
				});
	dagreLayout(graph);
	const positions = new Map<string, GraphPoint>();
	for (const id of graph.nodes()) {
		const node = graph.node(id);
		positions.set(id, {
			x: (node.x ?? 0) - node.width / 2,
			y: (node.y ?? 0) - node.height / 2,
		});
	}
	const edges = graph.edges().map((edge) => ({
		from: edge.v,
		to: edge.w,
		points: graph.edge(edge).points ?? [],
	}));
	const label = graph.graph();
	return {
		nodes,
		edges,
		positions,
		width: Math.max(260, label.width ?? 0),
		height: Math.max(220, label.height ?? 0),
	};
};

const graphNodeSize = (node: RenderedDocument) => ({
	width: node.type === "law" ? 130 : NODE_WIDTH,
	height: NODE_HEIGHT,
});

const graphRefsForNode = (node: RenderedDocument): readonly string[] =>
	node.deps.length ? node.deps : node.refs;

const shortLabel = (document: RenderedDocument) => {
	if (document.type === "law") return document.title;
	const [prefix, type, token] = document.id.split("-");
	return `${type ?? prefix}-${token ?? ""}`.slice(0, 18);
};

const graphLink = (id: string, byId: ReadonlyMap<string, RenderedDocument>) => {
	const document = byId.get(id);
	return document ? `<a href="#${esc(id)}">${esc(id)}</a>` : esc(id);
};

const referenceDiagnostics = (input: RenderInput): readonly Diagnostic[] => {
	const diagnostics: Diagnostic[] = [];
	for (const document of input.documents) {
		const base = path.dirname(document.path);
		for (const target of markdownTargets(document.markdown)) {
			const bare = target.href.split("#", 1)[0] ?? "";
			if (!bare || /^(?:https?:|mailto:|#)/i.test(target.href)) continue;
			const absolute = path.resolve(input.root, base, bare);
			if (!absolute.startsWith(input.root) || !existsSync(absolute))
				diagnostics.push({
					code: "render.missing-reference",
					path: `${document.path}:${target.line}`,
					message: `Missing referenced asset or document: ${target.href}`,
				});
		}
	}
	return diagnostics;
};

const markdownTargets = (
	markdown: string,
): readonly { href: string; line: number }[] =>
	[...markdown.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)].map(
		(match) => ({
			href: match[1] ?? "",
			line: markdown.slice(0, match.index).split("\n").length,
		}),
	);

const diagnosticsHtml = (
	diagnostics: readonly Diagnostic[],
	documents: readonly RenderedDocument[] = [],
) => {
	if (!diagnostics.length && documents.length === 0) return "";
	if (!diagnostics.length)
		return `<aside class="diagnostics ok" id="diagnostics"><h2>Diagnostics</h2><p>No diagnostics.</p></aside>`;
	return `<aside class="diagnostics" id="diagnostics"><h2>Diagnostics</h2><ol>${diagnostics
		.map((item) => {
			const href = diagnosticHref(item, documents);
			const target = href
				? `<a href="${href}"><code>${esc(item.path)}</code></a>`
				: `<code>${esc(item.path)}</code>`;
			return `<li>${target} <strong>${esc(item.code)}</strong> ${esc(item.message)}</li>`;
		})
		.join("")}</ol></aside>`;
};

const diagnosticHref = (
	diagnostic: Diagnostic,
	documents: readonly RenderedDocument[],
) => {
	const source = diagnostic.path.split(":", 1)[0];
	const document = documents.find((item) => item.sourcePath === source);
	return document ? `#${esc(document.id)}` : undefined;
};

const copyBundles = (
	root: string,
	documents: readonly RenderDocumentInput[],
	outputRoot: string,
) => {
	for (const document of documents) {
		const parts = document.path.split("/");
		const bundle = parts.slice(0, 3).join("/");
		const target = path.join(outputRoot, bundle);
		if (existsSync(target)) continue;
		mkdirSync(path.dirname(target), { recursive: true });
		cpSync(path.join(root, bundle), target, { recursive: true });
	}
	mkdirSync(path.join(outputRoot, ".system"), { recursive: true });
};

// Browser assets ship inside the generated package under `assets/`; the source
// checkout falls back to the installed dependency.
const browserAsset = (
	name: string,
	specifier: string,
	file: string,
): string | undefined => {
	const packaged = fileURLToPath(new URL(`./assets/${name}`, import.meta.url));
	if (existsSync(packaged)) return packaged;
	try {
		const installed = path.join(
			path.dirname(fileURLToPath(import.meta.resolve(specifier))),
			file,
		);
		return existsSync(installed) ? installed : undefined;
	} catch {
		return undefined;
	}
};

const copyBrowserAsset = (
	outputRoot: string,
	name: string,
	source?: string,
) => {
	if (!source) return;
	mkdirSync(path.join(outputRoot, "assets"), { recursive: true });
	copyFileSync(source, path.join(outputRoot, "assets", name));
};

const copyMermaid = (outputRoot: string) =>
	copyBrowserAsset(
		outputRoot,
		"mermaid.min.js",
		browserAsset(
			"mermaid.min.js",
			"mermaid/package.json",
			"dist/mermaid.min.js",
		),
	);

const copyMiniSearch = (outputRoot: string) =>
	copyBrowserAsset(
		outputRoot,
		"minisearch.js",
		browserAsset("minisearch.js", "minisearch", "../umd/index.js"),
	);

const rel = (from: string, to: string) =>
	path.relative(path.dirname(from), to).split(path.sep).join("/") || ".";

const htmlPage = ({
	title,
	body,
	mode,
	mermaidPath,
	miniSearchPath,
	live,
}: Readonly<{
	title: string;
	body: string;
	mode: "single" | "book" | "board";
	mermaidPath: string;
	miniSearchPath: string;
	live?: LiveContext;
}>) => `<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="${esc(title)}">
<link rel="icon" href="${favicon(title)}">
<title>${esc(title)}</title>
<style>${css()}</style>${live ? `<style>${ledgerCss()}</style>` : ""}
<body data-mode="${mode}"${live ? ` data-revision="${esc(live.revision)}"` : ""}><script src="${esc(miniSearchPath)}"></script>${body}<script src="${esc(mermaidPath)}"></script><script>if(globalThis.mermaid){const theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';globalThis.mermaid.initialize({startOnLoad:true,theme})}</script>${live ? `<script>${ledgerScript()}</script>` : ""}</body>
</html>`;

const favicon = (title: string) => {
	const initial = esc((title.trim()[0] ?? "P").toUpperCase());
	const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='%23ededed'/><text x='32' y='42' text-anchor='middle' font-family='monospace' font-size='34' font-weight='900' fill='%23922792'>${initial}</text></svg>`;
	return `data:image/svg+xml,${svg}`;
};

const css = () => String.raw`
:root{color-scheme:light dark;--bg:#ededed;--panel:#dbdbdb;--page:#ededed;--text:#121212;--muted:#3b3b3b;--line:#5d5d5d;--accent:#922792;--link:#184e5b;--warn:#6e6c10;--error:#bd172e;--info:#116477;--code:#dbdbdb;--mark:#6e6c10}
@media(prefers-color-scheme:dark){:root{--bg:#121212;--panel:#242424;--page:#121212;--text:#ededed;--muted:#8b8b8b;--line:#6f6f6f;--accent:#d86dd8;--link:#7ba1aa;--warn:#f0ee69;--error:#ec6376;--info:#3ec5e3;--code:#242424;--mark:#f0ee69}}
body[data-mode=book]{overflow-x:hidden;--book-gutter:26px;--book-nav-width:15rem;--book-top-height:76px}
*{box-sizing:border-box}html{scroll-behavior:smooth;max-width:100%;overflow-x:hidden}body{font-family:system-ui,sans-serif;line-height:1.55;margin:0;color:var(--text);background:var(--bg);max-width:100%;overflow-x:hidden}body[data-mode=single]{max-width:78rem;margin:2rem auto;padding:0 1rem}a{color:var(--link)}a:focus-visible,button:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:2px}button,input{font:inherit}button{cursor:pointer}.brand{font:900 12px ui-monospace,monospace;color:var(--accent);letter-spacing:.1em;text-transform:uppercase;text-decoration:none;align-self:end;padding-bottom:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.book-top{position:fixed;top:0;left:0;right:0;z-index:5;display:grid;grid-template-columns:var(--book-nav-width) minmax(0,1fr) max-content;gap:16px;align-items:end;height:var(--book-top-height);background:var(--panel);border-bottom:2px solid var(--line);padding:10px var(--book-gutter);overflow:hidden}.book-search{position:relative;display:grid;gap:4px;font:800 11px ui-monospace,monospace;color:var(--muted);text-transform:uppercase}.search-row{display:flex;gap:8px}.search-row input{width:100%;min-width:0;background:var(--bg);color:var(--text);border:2px solid var(--line);padding:8px;text-transform:none}.search-row button{background:var(--accent);color:var(--bg);border:0;padding:8px 12px;font-weight:900}.search-results{position:absolute;left:0;right:0;top:100%;z-index:8;display:grid;gap:6px;margin:6px 0 0;padding:0;list-style:none;background:var(--bg);border:2px solid var(--line);box-shadow:0 10px 24px color-mix(in srgb,var(--text),transparent 86%)}.search-results:empty{display:none}.search-results li{border-left:4px solid var(--accent);padding:7px 9px;background:var(--panel);text-transform:none}.search-results small{color:var(--muted)}mark{background:color-mix(in srgb,var(--mark),transparent 35%);color:var(--text);padding:0 .1em}.stats{display:flex;gap:8px;margin:0 0 10px;justify-content:end;font-variant-numeric:tabular-nums}.stats span{border:1px solid var(--line);background:var(--bg);padding:4px 7px;white-space:nowrap}.stats .bad{border-color:var(--error);color:var(--error)}.stats .ok{border-color:var(--info);color:var(--info)}.stats .active-phase{border-color:var(--accent)}.stats .active-phase a{font-weight:900;text-decoration:none}.book-shell{display:block;width:100%;max-width:100vw;padding:var(--book-top-height) var(--book-gutter) 16px calc(var(--book-gutter) + var(--book-nav-width) + 16px)}.book-shell>*{min-width:0}.book-nav,.book-toc,.book-head,.system-article,.doc-move,.diagnostics,.graph{background:var(--panel);border:1px solid var(--line)}.book-nav{position:fixed;left:var(--book-gutter);top:var(--book-top-height);bottom:0;width:var(--book-nav-width);z-index:4;padding:10px;overflow:auto;border-top:0}.book-nav details{border-bottom:1px solid var(--line);padding:7px 0}.book-nav summary{font:900 11px ui-monospace,monospace;text-transform:uppercase;color:var(--muted);cursor:pointer}.book-nav a{display:block;text-decoration:none;padding:4px 6px 4px 12px;border-left:3px solid transparent;overflow-wrap:anywhere}.nav-utils{border-bottom:1px solid var(--line);margin-bottom:7px;padding-bottom:7px}.nav-utils a{font:800 11px ui-monospace,monospace;text-transform:uppercase;color:var(--muted)}.book-nav a:hover,.book-nav .current-phase-link{border-color:var(--accent);background:color-mix(in srgb,var(--accent),transparent 88%)}.book-nav .current-phase-link{color:var(--accent);font-weight:900}.count{float:right;color:var(--muted);font-weight:400}.book-main{display:grid;gap:16px;min-width:0;max-width:100%;overflow:hidden}.book-head{padding:18px;overflow:hidden}.book-head h1{margin:0;font:900 clamp(26px,4vw,32px)/1.05 Georgia,serif;overflow-wrap:anywhere}.book-head p{color:var(--muted)}.overview-grid{display:grid;grid-template-columns:1fr;gap:12px}.overview-grid>section,.overview-grid aside{min-width:0}.book-toc{padding:12px;max-height:calc(100vh - 104px);overflow:auto;position:sticky;top:88px;font-size:13px;z-index:1}.book-toc h2{margin-top:0;color:var(--accent);font:800 12px ui-monospace,monospace;text-transform:uppercase}.book-toc a{display:block;color:var(--muted);margin:6px 0;text-decoration:none}.mobile-nav{display:none}.book-section{scroll-margin-top:72px}.system-article{display:block;min-width:0;max-width:100%;padding:28px 40px;background:var(--page);margin-block:0;overflow:hidden}.system-article+.diagnostics{margin-top:1rem}.current-phase-section .system-article{border-left:5px solid var(--accent)}.phase-current{margin-left:.6rem;padding:.15rem .35rem;background:var(--accent);color:var(--bg);font-size:10px}.mast h1{font:800 clamp(24px,4vw,34px)/1.1 Georgia,serif;margin:.2rem 0 1rem;overflow-wrap:anywhere}.eyebrow,.source,.summary{color:var(--muted)}.eyebrow{font:800 11px ui-monospace,monospace;text-transform:uppercase}.source{overflow-wrap:anywhere}.doc-move{display:flex;justify-content:space-between;gap:1rem;margin:1rem 0;padding:1rem}.ledger,.toc{border:1px solid var(--line);background:var(--panel);padding:.7rem;margin:1rem 0}.doc-related{clear:both;margin:1rem 0;padding:.8rem;background:var(--panel);border-left:4px solid var(--accent)}.doc-related summary{color:var(--accent);font:900 12px ui-monospace,monospace;text-transform:uppercase;cursor:pointer}.doc-related a{display:grid;gap:.1rem;padding:.45rem 0;border-top:1px solid var(--line);text-decoration:none}.doc-related span{color:var(--muted);font:800 10px ui-monospace,monospace;text-transform:uppercase}.doc-related code{font-size:11px;color:var(--muted)}.toc ol{margin:.5rem 0}.depth-3{margin-left:1rem}.depth-4,.depth-5,.depth-6{margin-left:2rem}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}pre{padding:1rem;overflow:auto;background:var(--code);border:1px solid var(--line)}table{border-collapse:collapse;width:100%;margin:.5rem 0;display:block;overflow:auto}th,td{border:1px solid var(--line);padding:.35rem;text-align:left;vertical-align:top}.ledger ol{margin:0}.diagnostics{border-color:var(--warn);padding:1rem}.diagnostics h2{margin-top:0}.diagnostics strong{color:var(--warn)}.diagnostics.ok{border-color:var(--info)}.diagnostics li{margin:.4rem 0}.graph{padding:1rem;overflow:hidden}.graph-head{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:start}.graph h3{margin:0}.graph-head p{margin:.2rem 0 0;color:var(--muted)}.graph-controls{display:flex;gap:.35rem}.graph-controls button{background:var(--bg);color:var(--text);border:1px solid var(--line);padding:.35rem .55rem;font-weight:900}.graph-frame{height:min(62vh,520px);min-height:18rem;border:1px solid var(--line);background:var(--panel);overflow:hidden;touch-action:none;cursor:grab}.graph-frame:active{cursor:grabbing}.graph svg{display:block;width:100%;height:100%;touch-action:none}.graph path{fill:none;stroke:var(--line);stroke-width:1.6;stroke-opacity:.72}.graph a rect{fill:var(--bg);stroke:var(--accent);stroke-width:2}.graph a:hover rect{fill:color-mix(in srgb,var(--accent),transparent 86%)}.graph text{fill:var(--text);font:700 11px ui-monospace,monospace}.graph-node-type{fill:var(--muted);font:800 10px ui-monospace,monospace;text-transform:uppercase}.graph-list{margin-top:.7rem}.graph-list ol{max-height:16rem;overflow:auto}.visual-asset-preview iframe{width:100%;min-height:22rem;border:1px solid var(--line);background:white}.visual-asset-preview img,.visual-asset-preview video{display:block;max-width:100%;max-height:75vh;border:1px solid var(--line);background:white}.hljs{background:transparent}.math{overflow:auto}@media(max-width:1200px){.book-toc{display:none}}@media(max-width:980px){.book-top{grid-template-columns:1fr;position:static;height:auto}.search-results{position:static}.stats{justify-content:start;flex-wrap:wrap}.book-shell{display:block;padding:10px}.book-nav,.book-toc{display:none}.mobile-nav{display:grid;gap:8px;margin-bottom:10px}.mobile-nav details{background:var(--panel);border:1px solid var(--line);padding:8px}.mobile-nav a{display:block;margin:.35rem 0;overflow-wrap:anywhere}.mobile-nav .book-nav{display:block;position:static;height:auto;border:0;padding:0}.overview-grid{grid-template-columns:1fr}.system-article{padding:20px 18px}.mast h1{font-size:clamp(24px,8vw,28px)}.doc-move{flex-wrap:wrap}.doc-related{margin:1rem 0}}@media(max-width:620px){body{font-size:15px}.book-top{padding:10px}.brand{white-space:normal}.book-head h1{font-size:clamp(28px,10vw,38px)}.search-row{display:grid}.search-row button{width:100%}.book-shell{padding:8px}.book-head{padding:14px}.system-article{padding:18px 14px;border-left:0;border-right:0}.stats span{font-size:12px}.graph-head{grid-template-columns:1fr}.graph-controls button{flex:1}.graph-frame{height:12rem}.graph-list{display:block}}@media print{.book-top,.book-nav,.book-toc,.book-head,.doc-move,.mobile-nav{display:none}.book-shell{display:block;padding:0}.system-article{border:0;background:white;color:black;max-width:none;padding:0;font:12pt/1.55 Georgia,serif}.mast h1{break-before:page}.diagnostics{break-inside:avoid;border-color:#777;background:#eee}mark{background:#ddd}}
`;

const jsonScript = (value: unknown) =>
	JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("&", "\\u0026");

const graphScript = () =>
	`for(const svg of document.querySelectorAll('[data-graph-svg]')){const world={w:Number(svg.dataset.worldWidth),h:Number(svg.dataset.worldHeight)};let vb=box();if(svg.clientWidth<700)vb={x:0,y:0,w:Math.min(world.w,1100),h:Math.min(world.h,520)};const initial={...vb};set(vb);const pointers=new Map;let drag=null,pinch=null;function box(){const [x,y,w,h]=svg.getAttribute('viewBox').split(/\\s+/).map(Number);return{x,y,w,h}}function set(next){const w=Math.min(Math.max(next.w,180),world.w);const h=Math.min(Math.max(next.h,120),world.h);vb={w,h,x:Math.min(Math.max(next.x,0),Math.max(0,world.w-w)),y:Math.min(Math.max(next.y,0),Math.max(0,world.h-h))};svg.setAttribute('viewBox',[vb.x,vb.y,vb.w,vb.h].join(' '))}function point(e,b=vb){const r=svg.getBoundingClientRect();return{x:b.x+(e.clientX-r.left)/r.width*b.w,y:b.y+(e.clientY-r.top)/r.height*b.h}}function zoomAt(p,f,b=vb){const w=b.w*f,h=b.h*f;set({w,h,x:p.x-(p.x-b.x)*(w/b.w),y:p.y-(p.y-b.y)*(h/b.h)})}function pair(){const a=[...pointers.values()];return[a[0],a[1]]}function distance(){const [a,b]=pair();return Math.hypot(a.clientX-b.clientX,a.clientY-b.clientY)}function center(b=vb){const [a,c]=pair();return point({clientX:(a.clientX+c.clientX)/2,clientY:(a.clientY+c.clientY)/2},b)}svg.addEventListener('wheel',e=>{e.preventDefault();zoomAt(point(e),e.deltaY<0?.85:1.18)},{passive:false});svg.addEventListener('pointerdown',e=>{if(e.target.closest('a'))return;svg.setPointerCapture(e.pointerId);pointers.set(e.pointerId,e);if(pointers.size===1)drag={x:e.clientX,y:e.clientY,v:{...vb}};if(pointers.size===2)pinch={d:distance(),v:{...vb},p:center()}});svg.addEventListener('pointermove',e=>{if(!pointers.has(e.pointerId))return;pointers.set(e.pointerId,e);const r=svg.getBoundingClientRect();if(pointers.size===1&&drag)set({w:drag.v.w,h:drag.v.h,x:drag.v.x-(e.clientX-drag.x)/r.width*drag.v.w,y:drag.v.y-(e.clientY-drag.y)/r.height*drag.v.h});if(pointers.size===2&&pinch)zoomAt(pinch.p,pinch.d/distance(),pinch.v)});function end(e){pointers.delete(e.pointerId);drag=null;pinch=null}svg.addEventListener('pointerup',end);svg.addEventListener('pointercancel',end);svg.closest('.graph').querySelector('.graph-controls').addEventListener('click',e=>{const action=e.target.closest('button')?.dataset.graphZoom;if(action==='in')zoomAt({x:vb.x+vb.w/2,y:vb.y+vb.h/2},.8);if(action==='out')zoomAt({x:vb.x+vb.w/2,y:vb.y+vb.h/2},1.25);if(action==='reset')set(initial)})}`;

const searchScript = () =>
	`const data=JSON.parse(document.getElementById('search-data').textContent);const index=new MiniSearch({fields:['id','title','type','text'],storeFields:['id','title','type','text','href']});index.addAll(data);const input=document.getElementById('search');const button=document.getElementById('search-button');const out=document.getElementById('results');const terms=()=>input.value.trim().toLowerCase().split(/\\s+/).filter(Boolean);function appendHits(node,text,parts){let i=0;const lower=text.toLowerCase();while(i<text.length){let hit=-1,term='';for(const part of parts){const at=lower.indexOf(part,i);if(at>=0&&(hit<0||at<hit)){hit=at;term=part}}if(hit<0){node.append(document.createTextNode(text.slice(i)));break}node.append(document.createTextNode(text.slice(i,hit)));const mark=document.createElement('mark');mark.dataset.searchHit='';mark.textContent=text.slice(hit,hit+term.length);node.append(mark);i=hit+term.length}}function clearArticleHits(){for(const mark of document.querySelectorAll('mark[data-search-hit]'))mark.replaceWith(document.createTextNode(mark.textContent||''))}function highlightArticle(id,parts){clearArticleHits();const root=document.getElementById(id);if(!root||!parts.length)return;const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,{acceptNode(node){const parent=node.parentElement;if(!parent||['SCRIPT','STYLE','TEXTAREA','INPUT','MARK'].includes(parent.tagName))return NodeFilter.FILTER_REJECT;return parts.some(part=>(node.nodeValue||'').toLowerCase().includes(part))?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_REJECT}});const hits=[];while(hits.length<80){const node=walker.nextNode();if(!node)break;hits.push(node)}for(const node of hits){const span=document.createDocumentFragment();appendHits(span,node.nodeValue||'',parts);node.replaceWith(span)}}function render(){const q=input.value.trim();const parts=terms();out.textContent='';if(!q)return;for(const item of index.search(q,{prefix:true,fuzzy:.2}).slice(0,20)){const li=document.createElement('li');const a=document.createElement('a');const body=item.text||'';const lower=body.toLowerCase();const at=Math.min(...parts.map(part=>{const found=lower.indexOf(part);return found<0?Number.POSITIVE_INFINITY:found}));const start=Number.isFinite(at)?Math.max(0,at-40):0;const context=body.slice(start,Number.isFinite(at)?at+120:120);a.href=item.href;a.textContent=item.id+' '+item.title;const small=document.createElement('small');appendHits(small,context,parts);a.addEventListener('click',()=>{highlightArticle(item.id,parts);out.textContent=''});li.append(a,document.createElement('br'),small);out.append(li)}}input.addEventListener('input',render);button?.addEventListener('click',()=>{render();out.querySelector('a')?.click()});`;

const esc = (value: string) =>
	value
		.replaceAll("&", "&amp;")
		.replaceAll("<", "&lt;")
		.replaceAll(">", "&gt;")
		.replaceAll('"', "&quot;");