- Reports: mc: blocks (score, KPIs, reach, funnel, followers, formats, rhythm, top posts, actions, screenshots, callouts) drawn as SVG from stored account data, frozen per report; same page in PDF and preview - Audit/weekly-report runs refresh account numbers first - Screenshots tab: monthly shot list, uploads, examples; audit reads them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
20 KiB
JavaScript
269 lines
20 KiB
JavaScript
// Visual report blocks: the AI writes small fenced markers, this file draws them as inline SVG/HTML.
|
||
// Charts are drawn from the stored account numbers (never from figures typed by the AI) and frozen
|
||
// next to the report as <report>.data.json the first time it renders, so a re-export shows the same numbers.
|
||
//
|
||
// Data blocks: ```mc:kpis ```mc:reach ```mc:funnel ```mc:followers ```mc:formats ```mc:rhythm ```mc:top-posts
|
||
// Written blocks: ```mc:score (Area | 6/10 | why) ```mc:actions (Action | Owner | When)
|
||
// ```mc:screenshot (first line: shot id, then one note per line)
|
||
// Callouts: > [!WIN] title / [!FIX] / [!RISK] / [!NOTE]
|
||
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
||
import { dirname, extname, join, resolve, sep } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { Marked } from "marked";
|
||
|
||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||
const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || join(root, "data", "dashboard");
|
||
const DAY = 86400000;
|
||
const C = { blue: "#3a7bbf", sky: "#52ace0", ink: "#232327", gray: "#8a8f99", line: "#e3e7ee", good: "#2e9e6b", warn: "#d99a1e", bad: "#d0543f" };
|
||
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||
const num = (n) => (n == null ? "—" : n >= 10000 ? `${(n / 1000).toFixed(n >= 100000 ? 0 : 1)}k` : Math.round(n).toLocaleString("en-CA"));
|
||
const pct = (a, b) => (b ? Math.round((a / b) * 100) : null);
|
||
const day = (d) => new Date(d).toLocaleDateString("en-CA", { month: "short", day: "numeric", timeZone: "UTC" });
|
||
const plain = (s) => String(s || "").normalize("NFKC").replace(/\s+/g, " ").trim(); // 𝐁𝐨𝐥𝐝 unicode → Bold
|
||
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
|
||
let uid = 0;
|
||
|
||
// ---------- data ----------
|
||
function slugFor(mdPath) {
|
||
const parts = resolve(mdPath).split(sep);
|
||
const i = parts.lastIndexOf("clients");
|
||
return i >= 0 ? parts[i + 1] : null;
|
||
}
|
||
|
||
function freshData(slug) {
|
||
const snap = readJson(join(DATA_DIR, "snapshots", `${slug}.json`));
|
||
if (!snap) return null;
|
||
const typeOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : "Image");
|
||
const ig = snap.ig && {
|
||
username: snap.ig.profile?.username,
|
||
followers: snap.ig.profile?.followers_count,
|
||
totals: snap.ig.totals || {},
|
||
dailyReach: snap.ig.dailyReach || [],
|
||
dailyFollowers: snap.ig.dailyFollowers || [],
|
||
derived: snap.ig.derived || {},
|
||
posts: (snap.ig.posts || []).map((p) => ({
|
||
at: p.timestamp, type: typeOf(p), caption: plain(p.caption).slice(0, 140), link: p.permalink,
|
||
reach: p.insights?.reach ?? null, views: p.insights?.views ?? null, likes: p.like_count ?? 0, comments: p.comments_count ?? 0,
|
||
saves: p.insights?.saved ?? 0, shares: p.insights?.shares ?? 0,
|
||
})),
|
||
};
|
||
const fb = snap.fb && { name: snap.fb.page?.name, followers: snap.fb.page?.followers_count, totals: snap.fb.totals || {}, posts: (snap.fb.posts || []).map((p) => ({ at: p.created_time })) };
|
||
return { slug, asOf: snap.fetchedAt, ig, fb };
|
||
}
|
||
|
||
export function reportData(mdPath) {
|
||
const frozen = resolve(mdPath).replace(/\.md$/i, ".data.json");
|
||
if (existsSync(frozen)) return readJson(frozen);
|
||
const slug = slugFor(mdPath);
|
||
const data = slug && freshData(slug);
|
||
if (data) try { writeFileSync(frozen, JSON.stringify(data)); } catch { /* read-only folder: render live */ }
|
||
return data;
|
||
}
|
||
|
||
// ---------- small SVG pieces ----------
|
||
function spark(values, color = C.blue, w = 96, h = 26) {
|
||
if (values.length < 2) return "";
|
||
const max = Math.max(...values, 1), step = w / (values.length - 1);
|
||
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(h - 2 - (v / max) * (h - 4)).toFixed(1)}`).join(" ");
|
||
return `<svg class="mc-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>`;
|
||
}
|
||
|
||
function ring(score, size = 92) {
|
||
const r = size / 2 - 7, c = 2 * Math.PI * r, f = Math.max(0, Math.min(1, score / 10));
|
||
const col = score < 4 ? C.bad : score < 7 ? C.warn : C.good;
|
||
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">
|
||
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${C.line}" stroke-width="8"/>
|
||
<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round" stroke-dasharray="${(c * f).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${size / 2} ${size / 2})"/>
|
||
<text x="50%" y="52%" text-anchor="middle" dominant-baseline="middle" class="mc-ring-num">${score.toFixed(1)}</text></svg>`;
|
||
}
|
||
|
||
const card = (cls, title, sub, body, foot = "") =>
|
||
`<figure class="mc-card ${cls}">${title ? `<figcaption><span class="mc-card-title">${esc(title)}</span>${sub ? `<span class="mc-card-sub">${sub}</span>` : ""}</figcaption>` : ""}${body}${foot ? `<p class="mc-card-foot">${foot}</p>` : ""}</figure>`;
|
||
const noData = (what) => `<p class="mc-nodata">${esc(what)}: no account data stored yet.</p>`;
|
||
|
||
// ---------- data blocks ----------
|
||
const BLOCKS = {
|
||
kpis(d) {
|
||
const ig = d?.ig;
|
||
if (!ig) return noData("Key numbers");
|
||
const t = ig.totals, net = (t.new_follows ?? 0) - (t.unfollows ?? 0);
|
||
const reachRatio = pct(t.reach, ig.followers);
|
||
const tiles = [
|
||
{ v: num(ig.followers), l: "Instagram followers", n: t.new_follows != null ? `${net >= 0 ? "▲" : "▼"} ${num(Math.abs(net))} net this month` : "", tone: net < 0 ? "bad" : "good" },
|
||
{ v: num(t.reach), l: "People reached", n: reachRatio != null ? `${reachRatio}% of followers` : "", tone: reachRatio != null && reachRatio < 20 ? "bad" : "", s: ig.dailyReach.map((x) => x.value) },
|
||
{ v: num(t.total_interactions), l: "Likes, comments, saves, shares", n: t.reach ? `${pct(t.total_interactions, t.reach)} per 100 reached` : "" },
|
||
{ v: num(ig.derived.postsLast28), l: "Posts in 28 days", n: ig.derived.daysSinceLastPost != null ? `last post ${ig.derived.daysSinceLastPost} days ago` : "" },
|
||
{ v: num(t.profile_links_taps), l: "Taps on the website link", n: t.profile_links_taps ? "" : "nobody reached the website", tone: t.profile_links_taps ? "" : "bad" },
|
||
...(d.fb ? [{ v: num(d.fb.followers), l: "Facebook followers", n: d.fb.totals.page_total_media_view_unique != null ? `${num(d.fb.totals.page_total_media_view_unique)} people saw posts` : "" }] : []),
|
||
];
|
||
return `<section class="mc-kpigrid">${tiles.map((k) => `<div class="mc-kpi2 ${k.tone ? `is-${k.tone}` : ""}"><div class="mc-kpi2-top"><span class="mc-kpi2-v">${esc(k.v)}</span>${k.s ? spark(k.s) : ""}</div><div class="mc-kpi2-l">${esc(k.l)}</div>${k.n ? `<div class="mc-kpi2-n">${esc(k.n)}</div>` : ""}</div>`).join("")}</section>`;
|
||
},
|
||
|
||
reach(d) {
|
||
const s = d?.ig?.dailyReach || [];
|
||
if (s.length < 2) return noData("Daily reach");
|
||
const W = 640, H = 180, L = 34, R = 10, T = 12, B = 34, id = `g${++uid}`;
|
||
const max = Math.max(...s.map((x) => x.value), 1), yMax = Math.ceil(max / 5) * 5 || 5;
|
||
const x = (i) => L + (i * (W - L - R)) / (s.length - 1), y = (v) => T + (H - T - B) * (1 - v / yMax);
|
||
const line = s.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(" ");
|
||
const grid = [0, 0.5, 1].map((f) => `<line x1="${L}" x2="${W - R}" y1="${y(yMax * f)}" y2="${y(yMax * f)}" stroke="${C.line}"/><text x="${L - 6}" y="${y(yMax * f) + 3}" text-anchor="end" class="mc-ax">${num(yMax * f)}</text>`).join("");
|
||
const dates = s.map((p) => p.date);
|
||
const postDays = new Set((d.ig.posts || []).map((p) => p.at.slice(0, 10)));
|
||
const marks = dates.map((dt, i) => (postDays.has(dt) ? `<path d="M${x(i)} ${H - B + 8} l4 6 h-8 z" fill="${C.blue}"/>` : "")).join("");
|
||
const labels = [0, Math.floor((s.length - 1) / 2), s.length - 1].map((i) => `<text x="${x(i)}" y="${H - 4}" text-anchor="${i === 0 ? "start" : i === s.length - 1 ? "end" : "middle"}" class="mc-ax">${day(dates[i])}</text>`).join("");
|
||
const best = s.reduce((a, b) => (b.value > a.value ? b : a));
|
||
const svg = `<svg viewBox="0 0 ${W} ${H}" class="mc-chart" role="img" aria-label="Daily people reached over the last ${s.length} days">
|
||
<defs><linearGradient id="${id}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${C.sky}" stop-opacity=".45"/><stop offset="1" stop-color="${C.sky}" stop-opacity="0"/></linearGradient></defs>
|
||
${grid}<polygon points="${x(0)},${y(0)} ${line} ${x(s.length - 1)},${y(0)}" fill="url(#${id})"/><polyline points="${line}" fill="none" stroke="${C.blue}" stroke-width="2" stroke-linejoin="round"/>${marks}${labels}</svg>`;
|
||
return card("", "People reached each day", `▲ marks a day with a post · busiest day ${day(best.date)} (${num(best.value)})`, svg);
|
||
},
|
||
|
||
funnel(d) {
|
||
const t = d?.ig?.totals;
|
||
if (!t) return noData("Funnel");
|
||
const steps = [["Saw a post or reel", t.views], ["Different people reached", t.reach], ["Liked, commented, saved or shared", t.accounts_engaged], ["Tapped the website link", t.profile_links_taps]].filter((s) => s[1] != null);
|
||
const max = Math.max(...steps.map((s) => s[1]), 1);
|
||
let leak = -1, worst = 101;
|
||
steps.forEach((s, i) => { if (i && steps[i - 1][1]) { const r = pct(s[1], steps[i - 1][1]); if (r < worst) { worst = r; leak = i; } } });
|
||
const rows = steps.map(([label, v], i) => {
|
||
const w = Math.max(2, Math.sqrt(v / max) * 100);
|
||
const conv = i && steps[i - 1][1] ? `<span class="mc-fun-conv ${i === leak ? "is-bad" : ""}">${pct(v, steps[i - 1][1])}% kept${i === leak ? " · biggest drop" : ""}</span>` : "";
|
||
return `<div class="mc-fun-row">${conv}<div class="mc-fun-bar" style="width:${w.toFixed(1)}%;background:${i === leak ? C.bad : i % 2 ? C.sky : C.blue}"><span>${num(v)}</span></div><div class="mc-fun-l">${esc(label)}</div></div>`;
|
||
}).join("");
|
||
return card("", "From seeing a post to visiting the website", "last 28 days, Instagram", `<div class="mc-fun">${rows}</div>`);
|
||
},
|
||
|
||
followers(d) {
|
||
const t = d?.ig?.totals;
|
||
if (t?.new_follows == null) return noData("Followers gained and lost");
|
||
const gained = t.new_follows, lost = t.unfollows ?? 0, net = gained - lost, max = Math.max(gained, lost, 1);
|
||
const bar = (v, col, label) => `<div class="mc-flow-row"><span class="mc-flow-l">${label}</span><div class="mc-flow-track"><div style="width:${((v / max) * 100).toFixed(1)}%;background:${col}"></div></div><span class="mc-flow-v">${num(v)}</span></div>`;
|
||
const daily = (d.ig.dailyFollowers || []).map((x) => x.value);
|
||
return card("", "Followers gained vs lost", "last 28 days", `<div class="mc-flow"><div class="mc-flow-net ${net < 0 ? "is-bad" : "is-good"}"><b>${net > 0 ? "+" : ""}${num(net)}</b><span>net change</span></div><div class="mc-flow-bars">${bar(gained, C.good, "New")}${bar(lost, C.bad, "Unfollowed")}${daily.length > 1 ? `<div class="mc-flow-spark">${spark(daily, C.good, 220, 30)}<span>new followers per day</span></div>` : ""}</div></div>`,
|
||
net < 0 ? "More people left than joined: the content isn't matching the audience the account has." : "");
|
||
},
|
||
|
||
formats(d) {
|
||
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
|
||
if (!posts.length) return noData("Formats");
|
||
const types = ["Reel", "Carousel", "Image"].map((type) => {
|
||
const ps = posts.filter((p) => p.type === type);
|
||
const avg = ps.length ? ps.reduce((a, p) => a + p.reach, 0) / ps.length : 0;
|
||
const act = ps.length ? ps.reduce((a, p) => a + p.saves + p.shares, 0) / ps.length : 0;
|
||
return { type, n: ps.length, share: pct(ps.length, posts.length), avg, act };
|
||
}).filter((t) => t.n);
|
||
const maxAvg = Math.max(...types.map((t) => t.avg), 1);
|
||
const rows = types.map((t) => `<tr><td>${t.type}s</td>
|
||
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${t.share}%;background:#c9d6e6"></div><span>${t.share}% of posts</span></div></td>
|
||
<td><div class="mc-pair"><div class="mc-pair-bar" style="width:${((t.avg / maxAvg) * 100).toFixed(1)}%;background:${C.sky}"></div><span>${num(t.avg)} reached per post</span></div></td></tr>`).join("");
|
||
const best = types.reduce((a, b) => (b.avg > a.avg ? b : a)), most = types.reduce((a, b) => (b.n > a.n ? b : a));
|
||
const foot = best.type !== most.type && most.avg ? `${best.type}s reach ${(best.avg / most.avg).toFixed(1)}× more people per post than ${most.type.toLowerCase()}s, but ${most.type.toLowerCase()}s are ${most.share}% of what gets posted.` : "";
|
||
return card("", "What gets posted vs what reaches people", `last ${posts.length} posts`, `<table class="mc-pairs"><tbody>${rows}</tbody></table>`, foot);
|
||
},
|
||
|
||
rhythm(d) {
|
||
const posts = d?.ig?.posts?.length ? d.ig.posts : d?.fb?.posts || [];
|
||
if (!posts.length) return noData("Posting rhythm");
|
||
const end = new Date(d.asOf); end.setUTCHours(0, 0, 0, 0);
|
||
const monday = new Date(+end - (((end.getUTCDay() + 6) % 7) + 7 * 7) * DAY); // Monday, 8 weeks ago
|
||
const byDay = new Map();
|
||
posts.forEach((p) => { const k = p.at.slice(0, 10); byDay.set(k, (byDay.get(k) || 0) + 1); });
|
||
const weeks = [];
|
||
for (let w = monday; w <= end; w = new Date(+w + 7 * DAY)) weeks.push(w);
|
||
const cells = weeks.map((w) => `<div class="mc-rh-week"><span class="mc-rh-wl">${day(w)}</span>${[...Array(7)].map((_, i) => {
|
||
const dt = new Date(+w + i * DAY), k = dt.toISOString().slice(0, 10), n = byDay.get(k) || 0;
|
||
return `<i class="${dt > end ? "is-future" : n ? "is-on" : ""}" title="${k}${n ? `: ${n} post${n > 1 ? "s" : ""}` : ""}"></i>`;
|
||
}).join("")}</div>`).join("");
|
||
const inRange = posts.filter((p) => Date.parse(p.at) >= +monday).length;
|
||
const dv = d.ig?.derived || {};
|
||
return card("", "Posting rhythm, last 8 weeks", "one square per day · Mon → Sun",
|
||
`<div class="mc-rh"><div class="mc-rh-grid"><div class="mc-rh-week mc-rh-head"><span class="mc-rh-wl"></span>${"MTWTFSS".split("").map((c) => `<b>${c}</b>`).join("")}</div>${cells}</div>
|
||
<div class="mc-rh-stats"><div><b>${(inRange / weeks.length).toFixed(1)}</b><span>posts a week</span></div>${dv.longestGapDays != null ? `<div><b>${dv.longestGapDays}</b><span>days, longest gap</span></div>` : ""}${dv.daysSinceLastPost != null ? `<div><b>${dv.daysSinceLastPost}</b><span>days since last post</span></div>` : ""}</div></div>`);
|
||
},
|
||
|
||
"top-posts"(d) {
|
||
const posts = (d?.ig?.posts || []).filter((p) => p.reach != null && Date.parse(d.asOf) - Date.parse(p.at) > 2 * DAY);
|
||
if (posts.length < 2) return noData("Top posts");
|
||
const sorted = [...posts].sort((a, b) => b.reach - a.reach), max = sorted[0].reach || 1, n = Math.min(3, Math.floor(posts.length / 2));
|
||
const item = (p) => `<li><div class="mc-post-meta"><span class="mc-chip">${p.type}</span>${day(p.at)}${p.link ? ` · <a href="${esc(p.link)}">open</a>` : ""}</div>
|
||
<div class="mc-post-cap">${esc(p.caption.slice(0, 90) || "(no caption)")}${p.caption.length > 90 ? "…" : ""}</div>
|
||
<div class="mc-post-bar"><div style="width:${((p.reach / max) * 100).toFixed(1)}%"></div></div>
|
||
<div class="mc-post-nums"><b>${num(p.reach)}</b> reached · ${num(p.saves + p.shares)} saved or shared${p.reach ? ` (${((p.saves + p.shares) / p.reach * 100).toFixed(1)} per 100)` : ""}</div></li>`;
|
||
return card("mc-wide", "Best and weakest posts", `last ${posts.length} posts, by people reached`, `<div class="mc-posts"><div><h4 class="is-good">Best ${n}</h4><ol>${sorted.slice(0, n).map(item).join("")}</ol></div><div><h4 class="is-bad">Weakest ${n}</h4><ol>${sorted.slice(-n).reverse().map(item).join("")}</ol></div></div>`);
|
||
},
|
||
};
|
||
|
||
// ---------- written blocks ----------
|
||
const rowsOf = (body) => body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.replace(/^[-*]\s+/, "").split("|").map((s) => s.trim()));
|
||
|
||
function scoreBlock(body) {
|
||
const rows = rowsOf(body).map(([area, score, why]) => ({ area, why, s: Math.max(0, Math.min(10, parseFloat(score) || 0)) }));
|
||
if (!rows.length) return "";
|
||
const avg = rows.reduce((a, r) => a + r.s, 0) / rows.length;
|
||
const tone = (s) => (s < 4 ? C.bad : s < 7 ? C.warn : C.good);
|
||
return `<section class="mc-score"><div class="mc-score-ring">${ring(avg)}<span>overall, out of 10</span></div><div class="mc-score-rows">${rows.map((r) => `
|
||
<div class="mc-score-row"><div class="mc-score-head"><b>${esc(r.area)}</b><span style="color:${tone(r.s)}">${r.s}/10</span></div>
|
||
<div class="mc-score-track"><div style="width:${r.s * 10}%;background:${tone(r.s)}"></div></div>${r.why ? `<p>${esc(r.why)}</p>` : ""}</div>`).join("")}</div></section>`;
|
||
}
|
||
|
||
function actionsBlock(body) {
|
||
const rows = rowsOf(body);
|
||
if (!rows.length) return "";
|
||
return `<ol class="mc-actions">${rows.map(([what, owner = "", when = ""]) => {
|
||
const who = /client|you/i.test(owner) ? "client" : "us";
|
||
return `<li><div class="mc-act-what">${esc(what).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")}</div><div class="mc-act-meta">${owner ? `<span class="mc-owner is-${who}">${esc(owner)}</span>` : ""}${when ? `<span class="mc-when">${esc(when)}</span>` : ""}</div></li>`;
|
||
}).join("")}</ol>`;
|
||
}
|
||
|
||
const LEGACY_SHOTS = { "ig-profile": "instagram-profile", "fb-page": "facebook-page" };
|
||
function screenshotBlock(body, ctx) {
|
||
const [id = "", ...notes] = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||
const dir = join(dirname(ctx.mdPath), "screenshots");
|
||
if (!existsSync(dir)) return "";
|
||
const months = readdirSync(dir).filter((m) => /^\d{4}-\d{2}$/.test(m)).sort().reverse();
|
||
const want = [id, LEGACY_SHOTS[id]].filter(Boolean);
|
||
const find = (folder) => existsSync(folder) && readdirSync(folder).find((f) => want.some((w) => f.startsWith(`${w}.`)) && /\.(png|jpe?g|webp)$/i.test(f));
|
||
let file = null;
|
||
for (const m of months) { const f = find(join(dir, m)); if (f) { file = join(dir, m, f); break; } }
|
||
if (!file) { const f = find(dir); file = f && join(dir, f); }
|
||
if (!file || statSync(file).size > 6 * 1024 * 1024) return ctx.internal ? `<p class="mc-nodata">Screenshot "${esc(id)}" not uploaded yet.</p>` : "";
|
||
const mime = { ".png": "image/png", ".webp": "image/webp" }[extname(file).toLowerCase()] || "image/jpeg";
|
||
const img = `<img src="data:${mime};base64,${readFileSync(file).toString("base64")}" alt="${esc(id)} screenshot">`;
|
||
return `<figure class="mc-shot ${notes.length ? "" : "is-solo"}">${img}${notes.length ? `<ol class="mc-shot-notes">${notes.map((n) => `<li>${esc(n.replace(/^\d+[.)]\s*|^[-*]\s+/, ""))}</li>`).join("")}</ol>` : ""}</figure>`;
|
||
}
|
||
|
||
// ---------- markdown ----------
|
||
const ALERTS = { WIN: ["good", "What's working"], FIX: ["warn", "To fix"], RISK: ["bad", "Risk"], NOTE: ["info", "Note"] };
|
||
|
||
export function renderMarkdown(md, ctx) {
|
||
const data = () => (ctx._data === undefined ? (ctx._data = reportData(ctx.mdPath)) : ctx._data);
|
||
const marked = new Marked({ gfm: true, breaks: true });
|
||
marked.use({
|
||
renderer: {
|
||
code(code, info) {
|
||
const kind = String(info || "").trim().match(/^mc:([\w-]+)/)?.[1];
|
||
if (!kind) return false;
|
||
if (BLOCKS[kind]) return BLOCKS[kind](data());
|
||
if (kind === "score") return scoreBlock(code);
|
||
if (kind === "actions") return actionsBlock(code);
|
||
if (kind === "screenshot") return screenshotBlock(code, ctx);
|
||
return ctx.internal ? `<p class="mc-nodata">Unknown block mc:${esc(kind)}</p>` : "";
|
||
},
|
||
blockquote(quote) {
|
||
const m = quote.match(/^\s*<p>\[!(WIN|FIX|RISK|NOTE)\]\s*([^<]*)(?:<br>)?/);
|
||
if (!m) return false;
|
||
const [tone, fallback] = ALERTS[m[1]];
|
||
const rest = quote.slice(m[0].length).replace(/^\s*<\/p>/, "");
|
||
return `<aside class="mc-alert is-${tone}"><div class="mc-alert-title">${m[2].trim() || fallback}</div>${rest.startsWith("<") ? rest : `<p>${rest}`}</aside>`;
|
||
},
|
||
},
|
||
});
|
||
return marked.parse(md);
|
||
}
|
||
|
||
export const asOfLabel = (mdPath) => {
|
||
const d = reportData(mdPath);
|
||
return d?.asOf ? `Account data as of ${new Date(d.asOf).toLocaleDateString("en-CA", { month: "long", day: "numeric", year: "numeric" })}` : "";
|
||
};
|