// Turn a report .md into a Metatroncube-branded PDF next to it. // Usage: node tools/report-pdf/build.mjs [more.md ...] // Files with "-internal" in the name get an "INTERNAL — NOT FOR CLIENT" badge. import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, copyFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { execFileSync } from "node:child_process"; import { marked } from "marked"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const brandDir = join(root, "branding"); const BROWSERS = [ "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "C:/Program Files/Microsoft/Edge/Application/msedge.exe", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ]; function findBrowser() { const found = [process.env.REPORT_PDF_BROWSER, ...BROWSERS].find((p) => p && existsSync(p)); if (!found) throw new Error("No Chrome/Edge found. Set REPORT_PDF_BROWSER to a Chromium browser path."); return found; } function escapeHtml(s) { return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); } // Optional front matter at the top of the .md: // --- // client: Wallaceton Eye Care // report: Social media review · October 2026 // period: 12 Sep – 10 Oct 2026 // accounts: Instagram @handle · Facebook Page // prepared: October 10, 2026 (defaults to today) // kpis: // - 121 | People reached on Instagram | most don't follow you yet // - 0 | Clicks to your website | no link on the profile yet // --- // With front matter, the hero block and KPI tiles replace the markdown's own H1. function parseFrontMatter(md) { const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); if (!m) return { meta: {}, content: md }; const meta = { kpis: [] }; let inKpis = false; for (const line of m[1].split(/\r?\n/)) { const item = line.match(/^\s+-\s+(.*)$/); if (inKpis && item) { const [value, label = "", note = ""] = item[1].split("|").map((s) => s.trim()); meta.kpis.push({ value, label, note }); continue; } const kv = line.match(/^(\w+):\s*(.*)$/); if (!kv) continue; inKpis = kv[1] === "kpis"; if (!inKpis) meta[kv[1]] = kv[2].trim(); } return { meta, content: md.slice(m[0].length) }; } function heroHtml(meta) { const sub = [meta.period, meta.accounts].filter(Boolean).map((s) => `${escapeHtml(s)}`).join(""); const kpis = meta.kpis.length ? `
${meta.kpis.map((k) => `
${escapeHtml(k.value)}
${escapeHtml(k.label)}
${k.note ? `
${escapeHtml(k.note)}
` : ""}
`).join("")}
` : ""; return `
${meta.report ? `
${escapeHtml(meta.report)}
` : ""}

${escapeHtml(meta.client)}

${sub ? `
${sub}
` : ""}
${kpis}`; } function buildHtml(mdPath) { const { meta, content } = parseFrontMatter(readFileSync(mdPath, "utf8")); const body = marked.parse(content, { gfm: true, breaks: true }); const css = readFileSync(join(brandDir, "report.css"), "utf8"); const logo = readFileSync(join(brandDir, "metatroncube-logo.png")).toString("base64"); const h1 = content.match(/^#\s+(.+)$/m)?.[1]; const title = (meta.client ? `${meta.client} — ${meta.report ?? "Report"}` : h1 ?? basename(mdPath, ".md")).replace(/[*_`]/g, ""); const internal = /-internal/i.test(basename(mdPath)); const date = meta.prepared || new Date().toLocaleDateString("en-CA", { year: "numeric", month: "long", day: "numeric" }); return ` ${escapeHtml(title)}
Metatroncube — Digital Solutions Simplified
Prepared ${escapeHtml(date)} ${internal ? '
INTERNAL — NOT FOR CLIENT' : ""}
${meta.client ? heroHtml(meta) : ""}
${body}
`; } function toPdf(mdPath) { const abs = resolve(mdPath); const out = abs.replace(/\.md$/i, "") + ".pdf"; const work = mkdtempSync(join(tmpdir(), "mc-report-")); let written = out; try { const htmlPath = join(work, "report.html"); const tmpPdf = join(work, "report.pdf"); writeFileSync(htmlPath, buildHtml(abs)); execFileSync(findBrowser(), [ "--headless=new", "--disable-gpu", "--no-first-run", ...(process.platform === "linux" ? ["--no-sandbox"] : []), `--user-data-dir=${join(work, "profile")}`, "--no-pdf-header-footer", "--virtual-time-budget=8000", `--print-to-pdf=${tmpPdf}`, pathToFileURL(htmlPath).href, ], { stdio: "pipe" }); if (!existsSync(tmpPdf)) throw new Error(`PDF was not created for ${mdPath}`); try { copyFileSync(tmpPdf, out); } catch (err) { // Usually the old PDF is open in a viewer, which locks it on Windows. written = out.replace(/\.pdf$/, "-new.pdf"); copyFileSync(tmpPdf, written); console.warn(`WARNING: ${basename(out)} is locked (open in a viewer?). Saved as ${basename(written)} instead.`); } } finally { rmSync(work, { recursive: true, force: true }); } console.log(`PDF: ${written}`); return written; } export { toPdf, buildHtml }; // CLI: only when run directly (the dashboard imports toPdf instead). if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const files = process.argv.slice(2); if (!files.length) { console.error("Usage: node tools/report-pdf/build.mjs [more.md ...]"); process.exit(1); } for (const f of files) toPdf(f); }