metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
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>
2026-10-19 13:02:29 -04:00

158 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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);
}