metatroncubeswdev 82814f3757 White-label workspace and getting-started checklist
- Settings → Workspace: agency name, logo, brand colour, support email, website
- Logo replaces Quorla's in the app (with 'Powered by Quorla'), sign-in page, client approval page and PDF reports;
  brand colour drives buttons, links and highlights; emails and reports use the agency name
- Getting started checklist on the Clients page for admins, checked against the real state
  (workspace, Meta, first client, team, platforms, email, library, first post, approval link); dismissible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:28:23 -04:00

163 lines
6.9 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}`;
}
// opts.logoPath / opts.logoAlt: an agency's own logo (white-label) instead of branding/metatroncube-logo.png.
function buildHtml(mdPath, opts = {}) {
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 custom = opts.logoPath && existsSync(opts.logoPath);
const logo = readFileSync(custom ? opts.logoPath : join(brandDir, "metatroncube-logo.png")).toString("base64");
const logoMime = custom && /\.jpe?g$/i.test(opts.logoPath) ? "image/jpeg" : "image/png";
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">
${custom
? `<div><img src="data:${logoMime};base64,${logo}" alt="${escapeHtml(opts.logoAlt || "")}" style="display:block;height:44px;width:auto;max-width:260px;object-fit:contain"></div>`
: `<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, opts = {}) {
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, opts));
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);
}