Dashboard (content library, publishing queue with approvals, inbox, team management, AI runs), Meta connector, branded PDF reports, agent skills, and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS, backup and path-relocation scripts). Client data, live data and secrets are excluded via .gitignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.3 KiB
JavaScript
158 lines
6.3 KiB
JavaScript
// Turn a report .md into a Metatroncube-branded PDF next to it.
|
||
// Usage: node tools/report-pdf/build.mjs <report.md> [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) => `<span>${escapeHtml(s)}</span>`).join("");
|
||
const kpis = meta.kpis.length
|
||
? `<section class="mc-kpis" style="--mc-kpi-cols:${Math.min(meta.kpis.length, 4)}">${meta.kpis.map((k) => `
|
||
<div class="mc-kpi"><div class="mc-kpi-value">${escapeHtml(k.value)}</div><div class="mc-kpi-label">${escapeHtml(k.label)}</div>${k.note ? `<div class="mc-kpi-note">${escapeHtml(k.note)}</div>` : ""}</div>`).join("")}
|
||
</section>`
|
||
: "";
|
||
return `<section class="mc-hero">
|
||
${meta.report ? `<div class="mc-eyebrow">${escapeHtml(meta.report)}</div>` : ""}
|
||
<h1>${escapeHtml(meta.client)}</h1>
|
||
${sub ? `<div class="mc-hero-sub">${sub}</div>` : ""}
|
||
</section>${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 `<!doctype html>
|
||
<html lang="en"><head><meta charset="utf-8">
|
||
<title>${escapeHtml(title)}</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet">
|
||
<style>${css}</style>
|
||
</head><body>
|
||
<header class="mc-topbar">
|
||
<div class="mc-logo-crop"><img src="data:image/png;base64,${logo}" alt="Metatroncube — Digital Solutions Simplified"></div>
|
||
<div class="mc-topmeta">
|
||
Prepared ${escapeHtml(date)}
|
||
${internal ? '<br><span class="mc-internal">INTERNAL — NOT FOR CLIENT</span>' : ""}
|
||
</div>
|
||
</header>
|
||
${meta.client ? heroHtml(meta) : ""}
|
||
<main>${body}</main>
|
||
</body></html>`;
|
||
}
|
||
|
||
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 <report.md> [more.md ...]");
|
||
process.exit(1);
|
||
}
|
||
for (const f of files) toPdf(f);
|
||
}
|