137 lines
4 KiB
JavaScript
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);
|
|
});
|
|
|