re-voice/tools/mermaid/mermaid-validate-worker.js

137 lines
4 KiB
JavaScript

#!/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("<!doctype html><html><head></head><body></body></html>", { 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);
});