#!/usr/bin/env node /** * Validate Mermaid blocks in a Markdown file by actually calling `mermaid.render()` in headless Chromium. * * Designed to run inside the Forgejo PDF worker image. * * Usage (inside worker): * NODE_PATH=/opt/forgejo-pdf/node_modules node /script/mermaid-validate-worker.js /work/file.md */ const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); const crypto = require("node:crypto"); const puppeteer = require("puppeteer"); function sha256Hex(text) { return crypto.createHash("sha256").update(String(text)).digest("hex"); } function parseMermaidBlocks(markdown) { const blocks = []; const re = /```mermaid\\s*([\\s\\S]*?)```/g; let m; while ((m = re.exec(markdown)) !== null) { blocks.push({ start: m.index, end: m.index + m[0].length, rawBlock: m[1], }); } return blocks; } async function withBrowser(fn) { const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "chrome-profile-")); const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--disable-dev-shm-usage", "--allow-file-access-from-files", `--user-data-dir=${userDataDir}`], }); try { return await fn(browser); } finally { try { await browser.close(); } catch {} try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch {} } } async function createMermaidPage(browser) { const page = await browser.newPage(); await page.setRequestInterception(true); page.on("request", (req) => { const u = req.url(); if (u.startsWith("file:") || u.startsWith("about:") || u.startsWith("data:")) return req.continue(); return req.abort(); }); await page.setContent("", { waitUntil: "load" }); await page.addScriptTag({ path: "/opt/forgejo-pdf/assets/js/mermaid.min.js" }); await page.evaluate(() => { if (!globalThis.mermaid) throw new Error("mermaid_missing"); globalThis.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", htmlLabels: false, flowchart: { htmlLabels: false, useMaxWidth: false }, sequence: { htmlLabels: false }, state: { htmlLabels: false }, class: { htmlLabels: false }, fontFamily: "IBM Plex Sans", theme: "base", }); }); return page; } async function tryRender(page, id, code) { return await page.evaluate( async ({ id, code }) => { try { const r = await globalThis.mermaid.render(id, code); return { ok: true, svgLen: r && r.svg ? r.svg.length : 0 }; } catch (e) { const msg = e && typeof e === "object" && (e.str || e.message) ? String(e.str || e.message) : String(e); return { ok: false, error: msg }; } }, { id, code } ); } function firstNonEmptyLine(block) { const lines = String(block || "").replace(/\\r\\n?/g, "\\n").split("\\n"); for (const l of lines) { const t = l.trim(); if (t) return t; } return ""; } async function main() { const filePath = process.argv[2]; if (!filePath) { console.error("Usage: node mermaid-validate-worker.js /path/to/file.md"); process.exit(2); } const markdown = fs.readFileSync(filePath, "utf8"); const blocks = parseMermaidBlocks(markdown); const failures = []; await withBrowser(async (browser) => { const page = await createMermaidPage(browser); for (let i = 0; i < blocks.length; i++) { const b = blocks[i]; const id = "m-" + sha256Hex(`${path.basename(filePath)}|${i}|${b.rawBlock}`).slice(0, 12); const r = await tryRender(page, id, b.rawBlock); if (!r.ok) { failures.push({ index: i, header: firstNonEmptyLine(b.rawBlock), error: r.error }); if (failures.length >= 25) break; } } await page.close(); }); const out = { file: filePath, total: blocks.length, failures }; console.log(JSON.stringify(out)); process.exit(failures.length ? 1 : 0); } main().catch((e) => { console.error(JSON.stringify({ error: String(e && e.message ? e.message : e) })); process.exit(1); });