import { readFile, writeFile, mkdir, rm, copyFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { resolve, extname } from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { load } from "cheerio"; import { convert } from "./convert.mjs"; import { configureBrowser } from "./browser.mjs"; import { serve } from "./server.mjs"; const root = resolve(import.meta.dirname, ".."); process.chdir(root); const exec = promisify(execFile); const publication = JSON.parse(await readFile("publication.json", "utf8")); if (typeof publication.preview !== "boolean") throw new Error("publication.preview must explicitly select preview or publication mode."); const source = await readFile("spec.md", "utf8"); const { html, diagrams, report } = convert(source, publication); await rm("dist", { recursive: true, force: true }); await mkdir("dist/assets", { recursive: true }); await mkdir(".build/cache", { recursive: true }); await copyFile("styles/spec.css", "dist/assets/spec.css"); await copyFile("static/favicon.svg", "dist/assets/favicon.svg"); await copyFile("spec.md", "dist/spec.md"); await writeFile(".build/source.html", html); const browserPath = await configureBrowser(); await writeFile(".build/puppeteer.json", JSON.stringify({ headless: true, args: ["--no-sandbox"], ...(browserPath ? { executablePath: browserPath } : {}) })); await writeFile(".build/mermaid.json", JSON.stringify({ theme: "neutral", flowchart: { htmlLabels: false }, fontFamily: "Arial, sans-serif" })); for (const diagram of diagrams) { await writeFile(`.build/${diagram.name}.mmd`, diagram.source); await exec(process.execPath, ["node_modules/@mermaid-js/mermaid-cli/src/cli.js", "-i", `.build/${diagram.name}.mmd`, "-o", `dist/assets/${diagram.name}.svg`, "-p", ".build/puppeteer.json", "-c", ".build/mermaid.json", "-b", "transparent"], { maxBuffer: 2 ** 20 }); } const server = await serve(".build", { mounts: { "/node_modules/respec/": resolve("node_modules/respec"), "/assets/": resolve("dist/assets"), } }); let rendered; try { const { toHTML } = await import("respec"); rendered = await toHTML(new URL("source.html", server.url).href, { timeout: 120_000, // Renders this repo's own source; the sandbox cannot start on hosts that // restrict unprivileged user namespaces. disableSandbox: true, onError: error => console.error("ReSpec error:", error.message), onWarning: warning => console.warn("ReSpec warning:", warning.message), }); } finally { await server.close(); } if (rendered.errors.length) throw new Error(`ReSpec reported ${rendered.errors.length} error(s).`); report.respecWarnings = rendered.warnings.map(w => w.message); await writeFile(".build/respec.html", rendered.html); // Cache build-time upstream resources, and ship all actual rendering dependencies locally. const downloads = new Map(); async function download(url) { const key = createHash("sha256").update(url).digest("hex"); const file = `.build/cache/${key}`; try { return await readFile(file); } catch {} const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) throw new Error(`Download failed: ${response.status} ${url}`); const bytes = Buffer.from(await response.arrayBuffer()); await writeFile(file, bytes); return bytes; } async function localize(url, css = false) { if (downloads.has(url)) return downloads.get(url); const task = (async () => { const key = createHash("sha256").update(url).digest("hex").slice(0, 16); const extension = css ? ".css" : extname(new URL(url).pathname) || ".bin"; const name = `upstream-${key}${extension}`; let bytes = await download(url); if (css) { let text = bytes.toString(); const pattern = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]*))\s*\)/g; const matches = [...text.matchAll(pattern)]; for (const match of matches.reverse()) { const target = match[1] ?? match[2] ?? match[3]; if (!target || target.startsWith("data:") || target.startsWith("#")) continue; const absolute = new URL(target, url).href; const path = await localize(absolute, /\.css(?:[?#]|$)/.test(absolute)); text = text.slice(0, match.index) + `url("${path}")` + text.slice(match.index + match[0].length); } const imports = [...text.matchAll(/@import\s+["']([^"']+)["']/g)]; for (const match of imports.reverse()) { const path = await localize(new URL(match[1], url).href, true); text = text.slice(0, match.index) + `@import url("${path}")` + text.slice(match.index + match[0].length); } bytes = Buffer.from(text); } await writeFile(`dist/assets/${name}`, bytes); return name; })(); downloads.set(url, task); return task; } const $ = load(rendered.html); $("head").append(''); // No authoring engine, analytics, remote executable, or JS needed to read the document. $("script, link[rel=preconnect], link[rel=dns-prefetch], link[rel=preload], link[rel=modulepreload]").remove(); // ReSpec's conformance plugin inserts an h1 even in an unnumbered front-matter section. $("#conformance > h1").replaceWith("