scripts/render-diagrams.mjs (15841B)
1 /**
2 * render-diagrams.mjs — bakes every mermaid fence in the corpus to a committed SVG.
3 *
4 * Mermaid cannot be rendered without a browser. Under jsdom it does not merely look wrong,
5 * it lays out wrong: with CSSOM, getBBox, getComputedTextLength and getScreenCTM all
6 * shimmed it *succeeds* and hands back a flowchart 30x too wide (viewBox 42256 against the
7 * real 1424) and sequence diagrams ~10% short, because text layout is diagram layout.
8 *
9 * So the browser is the one on the machine, driven over CDP with no dependency at all:
10 * Node 22+ ships a global WebSocket, which is the entire protocol client. ubuntu-latest
11 * preinstalls Google Chrome, and withastro/action is a composite action running on the
12 * host, so CI has one in scope without a Puppeteer download.
13 *
14 * And it almost never runs. Each fence is keyed by a hash over its source and everything
15 * that could move a pixel (src/lib/diagrams.mjs), the SVG is committed under that name, and
16 * a build with every key present exits here before a browser is ever looked for. A cache
17 * miss means an author changed a diagram: it renders locally, or on a machine with a
18 * browser, and the result is committed. A miss with no browser anywhere is a hard error —
19 * never a silently stale picture, which is the one failure mode a committed cache invites.
20 *
21 * node scripts/render-diagrams.mjs render what is missing, prune what is not used
22 * node scripts/render-diagrams.mjs --check report only; exit 1 if anything is missing
23 * node scripts/render-diagrams.mjs --force re-render everything (palette work)
24 */
25 import { readdir, readFile, writeFile, mkdir, unlink, rm } from 'node:fs/promises';
26 import { existsSync } from 'node:fs';
27 import { spawn } from 'node:child_process';
28 import { createRequire } from 'node:module';
29 import os from 'node:os';
30 import path from 'node:path';
31 import {
32 DIAGRAM_DIR,
33 DIAGRAM_FONTS,
34 MERMAID_CONFIG,
35 MERMAID_VERSION,
36 diagramFile,
37 diagramKey,
38 findMermaidFences,
39 finishDiagram,
40 } from '../src/lib/diagrams.mjs';
41
42 const SRC = process.env.CONTENT_DIR ?? '.';
43 const CHECK = process.argv.includes('--check');
44 const FORCE = process.argv.includes('--force');
45 const RENDER_TIMEOUT_MS = 120_000;
46
47 /* ── 1. every fence in the corpus ─────────────────────────────────────────── */
48
49 /** content/ is prose and is never written here — only read. */
50 async function markdownFiles(dir) {
51 const out = [];
52 for (const entry of await readdir(dir, { withFileTypes: true })) {
53 const abs = path.join(dir, entry.name);
54 if (entry.isDirectory()) out.push(...await markdownFiles(abs));
55 else if (entry.name.endsWith('.md') && !entry.name.startsWith('_')) out.push(abs);
56 }
57 return out;
58 }
59
60 const contentDir = path.join(SRC, 'content');
61 if (!existsSync(contentDir)) {
62 console.error(`render-diagrams: no content directory at ${contentDir} — the checkout may have failed`);
63 process.exit(1);
64 }
65
66 /** key -> { source, where }. Identical fences in two posts share one file. */
67 const wanted = new Map();
68 for (const abs of (await markdownFiles(contentDir)).sort()) {
69 const rel = path.relative(SRC, abs).split(path.sep).join('/');
70 for (const fence of findMermaidFences(await readFile(abs, 'utf8'))) {
71 const key = diagramKey(fence.source);
72 if (!wanted.has(key)) wanted.set(key, { source: fence.source, where: `${rel}:${fence.line}` });
73 }
74 }
75
76 await mkdir(DIAGRAM_DIR, { recursive: true });
77 const onDisk = new Set(
78 (await readdir(DIAGRAM_DIR).catch(() => [])).filter((f) => f.endsWith('.svg')).map((f) => f.slice(0, -4)),
79 );
80
81 const missing = [...wanted].filter(([key]) => FORCE || !onDisk.has(key));
82 const orphans = [...onDisk].filter((key) => !wanted.has(key));
83
84 /* ── 2. prune, then decide whether a browser is needed at all ─────────────── */
85
86 if (!CHECK) {
87 for (const key of orphans) {
88 await unlink(diagramFile(key));
89 console.log(` pruned ${key}.svg — no fence hashes to it any more`);
90 }
91 if (orphans.length) await invalidateRenderedMarkdown();
92 }
93
94 const summary = `render-diagrams: ${wanted.size} fence(s), ${wanted.size - missing.length} cached`;
95
96 if (missing.length === 0) {
97 console.log(`${summary}, 0 to render`);
98 process.exit(0);
99 }
100
101 if (CHECK) {
102 console.error(`${summary}, ${missing.length} MISSING:`);
103 for (const [key, { where }] of missing) console.error(` ${where} -> ${diagramFile(key)}`);
104 console.error('\nRun: npm run diagrams');
105 process.exit(1);
106 }
107
108 /* ── 3. a browser, or a loud failure ──────────────────────────────────────── */
109
110 /** CHROME_PATH is the variable Lighthouse, karma and chrome-launcher all read. */
111 function findBrowser() {
112 const listed = [process.env.CHROME_PATH, process.env.CHROME_BIN].filter(Boolean);
113 const byPlatform = {
114 win32: [
115 'C:/Program Files/Google/Chrome/Application/chrome.exe',
116 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
117 path.join(process.env.LOCALAPPDATA ?? '', 'Google/Chrome/Application/chrome.exe'),
118 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
119 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
120 ],
121 darwin: [
122 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
123 '/Applications/Chromium.app/Contents/MacOS/Chromium',
124 '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
125 ],
126 linux: [
127 '/usr/bin/google-chrome',
128 '/usr/bin/google-chrome-stable',
129 '/usr/bin/chromium',
130 '/usr/bin/chromium-browser',
131 '/snap/bin/chromium',
132 '/usr/bin/microsoft-edge',
133 ],
134 };
135 for (const candidate of [...listed, ...(byPlatform[process.platform] ?? [])]) {
136 if (candidate && existsSync(candidate)) return candidate;
137 }
138 return null;
139 }
140
141 const browser = findBrowser();
142 if (!browser) {
143 console.error(`${summary}, ${missing.length} to render — but no Chrome or Chromium was found.`);
144 for (const [key, { where }] of missing) console.error(` ${where} -> ${diagramFile(key)}`);
145 console.error(
146 '\nA mermaid diagram is laid out by measuring text, so rendering one needs a real browser.\n' +
147 'Install Chrome, or point CHROME_PATH at one, then run `npm run diagrams` and commit\n' +
148 `the files it writes to ${DIAGRAM_DIR}/.`,
149 );
150 process.exit(1);
151 }
152
153 const require = createRequire(import.meta.url);
154 let mermaidBundle;
155 try {
156 const installed = require('mermaid/package.json').version;
157 if (installed !== MERMAID_VERSION) {
158 console.error(
159 `render-diagrams: mermaid ${installed} is installed but src/lib/diagrams.mjs keys diagrams to ` +
160 `${MERMAID_VERSION}. Update MERMAID_VERSION and re-render, or reinstall ${MERMAID_VERSION}.`,
161 );
162 process.exit(1);
163 }
164 // The UMD build, deliberately: it is one self-contained file with no dynamic imports,
165 // which is what makes it injectable into a page over CDP with nothing to serve.
166 mermaidBundle = await readFile(require.resolve('mermaid/dist/mermaid.min.js'), 'utf8');
167 } catch {
168 console.error('render-diagrams: mermaid is not installed. It is a devDependency, used only here — run `npm install`.');
169 process.exit(1);
170 }
171
172 /* ── 4. CDP, with a global WebSocket and nothing else ─────────────────────── */
173
174 async function connect(wsUrl) {
175 const socket = new WebSocket(wsUrl);
176 await new Promise((resolve, reject) => {
177 socket.onopen = resolve;
178 socket.onerror = () => reject(new Error(`cannot attach to ${wsUrl}`));
179 });
180 let seq = 0;
181 const pending = new Map();
182 socket.onmessage = (event) => {
183 const message = JSON.parse(event.data);
184 const waiter = message.id && pending.get(message.id);
185 if (!waiter) return;
186 pending.delete(message.id);
187 if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
188 else waiter.resolve(message.result);
189 };
190 return {
191 send: (method, params = {}) => new Promise((resolve, reject) => {
192 const id = ++seq;
193 pending.set(id, { resolve, reject });
194 socket.send(JSON.stringify({ id, method, params }));
195 }),
196 close: () => socket.close(),
197 };
198 }
199
200 /**
201 * `promise`, or a rejection after `ms`. The timer is cleared either way: a bare
202 * Promise.race leaves it pending, and Node then stays alive until it fires, which would
203 * hold every successful render open for the full two-minute render budget.
204 */
205 function within(promise, ms, message) {
206 let timer;
207 const guard = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); });
208 return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
209 }
210
211 const started = Date.now();
212 // A throwaway profile per run, so a Chrome the author already has open cannot be joined
213 // (which would hand back a browser whose fonts and flags are not the ones set here).
214 const profile = path.join(os.tmpdir(), `render-diagrams-${process.pid}`);
215 await mkdir(profile, { recursive: true });
216 const child = spawn(browser, [
217 '--headless=new',
218 '--disable-gpu',
219 '--no-sandbox', // the CI runner is already a throwaway container-shaped VM
220 '--no-first-run',
221 '--no-default-browser-check',
222 '--disable-extensions',
223 '--remote-debugging-port=0', // the port is read back off stderr, so parallel runs cannot clash
224 `--user-data-dir=${profile}`,
225 'about:blank',
226 ], { stdio: ['ignore', 'ignore', 'pipe'] });
227
228 const fail = (message) => {
229 try { child.kill(); } catch { /* already gone */ }
230 console.error(`render-diagrams: ${message}`);
231 process.exit(1);
232 };
233 // Nothing below may block a deploy forever: a dead socket would otherwise leave every
234 // `page.send` waiting on a reply that never comes, with no output and no exit.
235 const watchdog = setTimeout(() => fail('the browser stopped responding'), RENDER_TIMEOUT_MS * 2);
236 child.on('error', (error) => fail(`could not start ${browser} — ${error.message}`));
237
238 const wsUrl = await within(
239 new Promise((resolve) => {
240 let buffered = '';
241 child.stderr.on('data', (chunk) => {
242 buffered += chunk;
243 const match = buffered.match(/ws:\/\/\S+/);
244 if (match) resolve(match[0]);
245 });
246 }),
247 30_000,
248 'timeout',
249 ).catch(() => fail(`${browser} started but never announced a debugging endpoint`));
250
251 const origin = wsUrl.replace('ws://', 'http://').replace(/\/devtools.*/, '');
252 const targets = await (await fetch(`${origin}/json/list`)).json();
253 const page = await connect(targets.find((t) => t.type === 'page').webSocketDebuggerUrl);
254
255 // A data: URL rather than a file: one, so nothing on disk is implied and no server is needed.
256 await page.send('Page.navigate', {
257 url: 'data:text/html;charset=utf-8,' + encodeURIComponent('<!doctype html><meta charset="utf-8"><body></body>'),
258 });
259 await new Promise((resolve) => setTimeout(resolve, 200));
260
261 // Injected as an expression, not as a <script> in the page's markup: the minified bundle
262 // contains character sequences the HTML parser ends a script element on, and the page then
263 // reports `mermaid is not defined`. mermaid.min.js assigns globalThis.mermaid on its last line.
264 const loaded = await page.send('Runtime.evaluate', { expression: mermaidBundle, returnByValue: false });
265 if (loaded.exceptionDetails) fail(`mermaid failed to load: ${loaded.exceptionDetails.text}`);
266
267 const faces = [];
268 for (const font of DIAGRAM_FONTS) {
269 const file = path.join(SRC, font.file);
270 if (!existsSync(file)) fail(`${font.file} is missing — diagram text would be measured in a system font`);
271 faces.push({ ...font, data: (await readFile(file)).toString('base64') });
272 }
273
274 const job = {
275 config: MERMAID_CONFIG,
276 faces: faces.map((f) => ({ family: f.family, data: f.data, descriptors: f.descriptors })),
277 fences: missing.map(([key, { source, where }]) => ({ id: `mmd-${key}`, source, where })),
278 };
279
280 const expression = `(async () => {
281 const job = ${JSON.stringify(job)};
282 for (const face of job.faces) {
283 const f = new FontFace(face.family, 'url(data:font/woff2;base64,' + face.data + ') format("woff2")', face.descriptors);
284 await f.load();
285 document.fonts.add(f);
286 }
287 await document.fonts.ready;
288 mermaid.initialize(job.config);
289 const out = [];
290 for (const fence of job.fences) {
291 document.body.innerHTML = '';
292 try {
293 const { svg } = await mermaid.render(fence.id, fence.source);
294 out.push({ id: fence.id, svg });
295 } catch (error) {
296 out.push({ id: fence.id, error: String(error && error.message || error) });
297 }
298 }
299 return JSON.stringify(out);
300 })()`;
301
302 const result = await within(
303 page.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }),
304 RENDER_TIMEOUT_MS,
305 'render timed out',
306 ).catch((error) => fail(error.message));
307
308 if (result.exceptionDetails) fail(`render failed: ${JSON.stringify(result.exceptionDetails).slice(0, 800)}`);
309
310 clearTimeout(watchdog);
311 page.close();
312 child.kill();
313 await rm(profile, { recursive: true, force: true }).catch(() => { /* Chrome may still hold it */ });
314
315 /* ── 5. write, and account for it ─────────────────────────────────────────── */
316
317 const rendered = JSON.parse(result.result.value);
318 const failures = [];
319 let bytes = 0;
320
321 for (let i = 0; i < missing.length; i++) {
322 const [key, { where }] = missing[i];
323 const svg = rendered[i];
324 if (svg.error || !svg.svg?.startsWith('<svg')) {
325 failures.push(` ${where}: ${svg.error ?? 'mermaid returned something that is not an SVG'}`);
326 continue;
327 }
328 // LF and a final newline: .gitattributes normalises the working tree to LF, and a file
329 // that ends without one shows up as a whole-file diff on the next platform that touches it.
330 const file = diagramFile(key);
331 const text = finishDiagram(svg.svg, `mmd-${key}`).replace(/\r\n?/g, '\n').trimEnd() + '\n';
332 await writeFile(file, text, 'utf8');
333 bytes += Buffer.byteLength(text);
334 console.log(` rendered ${where} -> ${file} (${Buffer.byteLength(text).toLocaleString()} B)`);
335 }
336
337 if (failures.length) {
338 console.error(`\nrender-diagrams: ${failures.length} fence(s) did not render:`);
339 for (const line of failures) console.error(line);
340 process.exit(1);
341 }
342
343 await invalidateRenderedMarkdown();
344
345 console.log(
346 `${summary}, ${missing.length} rendered in ${Date.now() - started} ms ` +
347 `(${bytes.toLocaleString()} B of SVG) using ${browser}`,
348 );
349 if (process.env.CI) {
350 console.log(' note: these were rendered on CI, so the committed cache is behind — run `npm run diagrams` and commit.');
351 }
352 // Explicit, like every other exit path in this file. On Windows a Chrome that is slow to
353 // die can hold the stderr pipe or the CDP socket open for minutes after the work is done.
354 process.exit(0);
355
356 /**
357 * Astro's content layer caches rendered Markdown keyed on the *Markdown* file's digest, and
358 * the SVG is inlined into that HTML. `npm run build` passes --force and re-renders every
359 * entry regardless, but `astro dev` and a build without --force read the store: re-bake a
360 * diagram without touching the prose and they serve the previous picture — the one failure
361 * this whole design exists to prevent. So a bake drops the store: it is a cache, the
362 * rebuild costs a second, and it only happens on the rare run that changed a diagram in
363 * the first place.
364 */
365 async function invalidateRenderedMarkdown() {
366 for (const store of ['node_modules/.astro/data-store.json', '.astro/data-store.json']) {
367 const file = path.join(SRC, store);
368 if (!existsSync(file)) continue;
369 await unlink(file);
370 console.log(` dropped ${store} so the pages holding these diagrams re-render`);
371 if (!process.env.CI) console.log(' (a dev server that is already running keeps its own copy — restart it)');
372 }
373 }