blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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 }