Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
7.4 kB · 180 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181// Renders the Mermaid sources in web/src/diagrams/*.mmd to committed SVG,// which the Astro pages inline with `?raw`.//// Why not run Mermaid in the browser: substandard.blog is served behind a// strict CSP (`local.content_security_policy` in infra/main.tf) with no// `unsafe-inline` and no `unsafe-eval`, so a client-side Mermaid would need// either a script hash for a 3.5 MB library or a CSP loosening. Neither is// worth it for a picture that never changes between deploys.//// The same CSP is why the emitted SVG carries no `<style>` element and no// `style` attribute: `style-src 'self'` covers inline SVG in the document,// so Mermaid's own stylesheet would be dropped on the floor in production// and the diagram would render as black boxes. Everything Mermaid would// have put there is stripped here and restated as `.diagram` rules in// web/src/styles/global.css, which ships as a same-origin stylesheet and// gets the site's light/dark tokens for free.//// Mermaid needs a real layout engine (it measures text with getBBox), so it// runs in the same headless Chrome the brand render scripts use. Set CHROME// to point at a specific binary.//// Usage: node scripts/render-diagram.mjs [--check]
import { execFileSync } from "node:child_process";import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";import { tmpdir } from "node:os";import { basename, join, resolve } from "node:path";import { fileURLToPath, pathToFileURL } from "node:url";
import { findChrome } from "../../scripts/render-icons.mjs";
const webRoot = resolve(fileURLToPath(import.meta.url), "../..");const diagrams = join(webRoot, "src/diagrams");const mermaidBundle = join(webRoot, "node_modules/mermaid/dist/mermaid.min.js");
// Mermaid derives every generated id in the file — the <svg> id, the arrowhead// marker ids, the aria label ids — from the id handed to render(). A random// one would rewrite the committed SVG on every run, so each diagram renders// under its own stable name.const renderId = (name) => `diagram-${name}`;
/** * Drives one Mermaid render in headless Chrome and returns the SVG source. * * The page reports base64 rather than the SVG itself: --dump-dom serializes * the live DOM, so an SVG left in the document would come back re-serialized * by Chrome and an SVG left in a text node would come back HTML-escaped. */function renderSvg(chrome, work, name, source) { const wrapper = join(work, `render-${name}.html`); writeFileSync( wrapper, `<!doctype html><meta charset="utf-8"><body><div id="out"></div> <script src="${pathToFileURL(mermaidBundle).href}"></script> <script> (async () => { try { mermaid.initialize({ startOnLoad: false, // 'strict' would HTML-escape the labels; the source is ours and // sits in this repo, and 'antiscript' still strips script URLs. securityLevel: 'antiscript', // Real <text> nodes instead of <foreignObject> HTML: the // foreignObject flavour needs the stylesheet this build strips, // and renders as unwrapped full-width text without it. htmlLabels: false, // wrappingWidth over the 200px default so a bare hostname stays // one word; every other break in the source is an explicit <br/>. flowchart: { htmlLabels: false, curve: 'basis', padding: 12, wrappingWidth: 280 }, fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif', }); const { svg } = await mermaid.render( ${JSON.stringify(renderId(name))}, ${JSON.stringify(source)}, ); document.body.textContent = 'RESULT ' + btoa(String.fromCharCode(...new TextEncoder().encode(svg))); } catch (err) { document.body.textContent = 'RESULT ERROR ' + (err && err.message); } })(); </script></body>`, ); const dom = execFileSync( chrome, [ "--headless=new", "--disable-gpu", "--no-sandbox", `--user-data-dir=${join(work, `profile-${name}`)}`, "--virtual-time-budget=20000", "--dump-dom", pathToFileURL(wrapper).href, ], // stderr ignored: headless Chrome logs harmless GPU-init noise there. { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }, ); const failed = dom.match(/RESULT ERROR ([^<]*)/); if (failed) throw new Error(`${name}: mermaid failed: ${failed[1]}`); const ok = dom.match(/RESULT ([A-Za-z0-9+/=]+)/); if (!ok) throw new Error(`${name}: no render result in page output`); return Buffer.from(ok[1], "base64").toString("utf8");}
/** * Strips everything the CSP would drop, and everything that would pin the * diagram to one theme or one width. What survives is shape, text and class * names; global.css supplies the colors. */export function sanitize(svg, name) { let out = svg // Mermaid's own stylesheet, and the per-element overrides it pairs with. .replace(/<style[\s\S]*?<\/style>/g, "") .replace(/\s+style="[^"]*"/g, "") // A fixed pixel width would stop the figure scaling down on a phone; the // viewBox stays and CSS drives the box. .replace(/(<svg\b[^>]*?)\s+width="[^"]*"/, "$1") .replace(/(<svg\b[^>]*?)\s+height="[^"]*"/, "$1");
const viewBox = out.match(/<svg\b[^>]*\sviewBox="([^"]*)"/)?.[1]; if (!viewBox) throw new Error(`${name}: rendered SVG has no viewBox to scale by`);
// Belt and braces against a future Mermaid that emits either: both would be // inert under this CSP, so failing here beats shipping a broken picture. if (/<script/i.test(out)) throw new Error(`${name}: rendered SVG contains a script`); if (/\sstyle=/.test(out) || /<style/i.test(out)) { throw new Error(`${name}: rendered SVG still carries CSS the site CSP would drop`); } return `${out.trim()}\n`;}
function main() { const check = process.argv.includes("--check"); const sources = readdirSync(diagrams) .filter((file) => file.endsWith(".mmd")) .sort(); if (sources.length === 0) throw new Error(`no .mmd sources in ${diagrams}`);
const chrome = findChrome(); const work = mkdtempSync(join(tmpdir(), "substandard-diagram-")); const stale = []; try { for (const file of sources) { const name = basename(file, ".mmd"); const source = readFileSync(join(diagrams, file), "utf8"); const svg = sanitize(renderSvg(chrome, work, name, source), name); const dest = join(diagrams, `${name}.svg`); if (safeRead(dest) === svg) { console.log(`render-diagram: ${name}.svg unchanged`); continue; } if (check) { stale.push(`${name}.svg`); continue; } writeFileSync(dest, svg); console.log(`render-diagram: wrote ${name}.svg (${svg.length} bytes)`); } } finally { rmSync(work, { recursive: true, force: true }); } if (stale.length > 0) { console.error( `render-diagram: ${stale.join(", ")} out of date — run \`npm --prefix web run diagram\``, ); process.exit(1); }}
function safeRead(path) { try { return readFileSync(path, "utf8"); } catch { return null; }}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { main();}