feat(FN-3785): add standalone HTML rendering and export routes for reports
Adds a complete HTML rendering and export pipeline to the fusion-plugin-reports plugin, including a standalone HTML renderer with template and stylesheet support, new export routes (`/api/reports/:id/export/html`) that persist rendered HTML to the store, and plugin-type support for non-JSON route re Fusion-Task-Id: FN-3785
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeAttr, escapeHtml } from "../escape.js";
|
||||
|
||||
describe("escape", () => {
|
||||
it("escapes html special characters", () => {
|
||||
expect(escapeHtml("&<>'\"")).toBe("&<>'"");
|
||||
});
|
||||
|
||||
it("escapes attribute-sensitive characters", () => {
|
||||
expect(escapeAttr("a`b&c")).toBe("a`b&c");
|
||||
});
|
||||
|
||||
it("passes unicode through", () => {
|
||||
expect(escapeHtml("こんにちは 🌍")).toBe("こんにちは 🌍");
|
||||
});
|
||||
|
||||
it("passes unicode through in attributes", () => {
|
||||
expect(escapeAttr("こんにちは 🌍")).toBe("こんにちは 🌍");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Report } from "../../store/report-types.js";
|
||||
import { renderReportHtml } from "../html-template.js";
|
||||
|
||||
function createRecord(overrides: Partial<Report> = {}, metadata: Record<string, unknown> = {}): Report {
|
||||
const base: Report = {
|
||||
id: "rep_1",
|
||||
cadence: "daily",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-02",
|
||||
title: "Weekly report",
|
||||
status: "review_complete",
|
||||
generationStartedAt: "2026-05-02T00:00:00.000Z",
|
||||
generationCompletedAt: "2026-05-02T00:01:00.000Z",
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: null,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
combinedReview: null,
|
||||
createdAt: "2026-05-02T00:00:00.000Z",
|
||||
updatedAt: "2026-05-02T00:01:00.000Z",
|
||||
metadata,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
renderedHtml: overrides.renderedHtml ?? base.renderedHtml,
|
||||
renderedHtmlGeneratedAt: overrides.renderedHtmlGeneratedAt ?? base.renderedHtmlGeneratedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderReportHtml", () => {
|
||||
it("renders shell for empty record", () => {
|
||||
const html = renderReportHtml(createRecord());
|
||||
expect(html).toContain("<!doctype html>");
|
||||
expect(html).toContain("data-section=\"data-coverage\"");
|
||||
});
|
||||
|
||||
it("renders mixed sections with markers", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
sections: {
|
||||
summary: "hello",
|
||||
system: { wins: ["w1"], highlights: ["h1"], lowlights: ["l1"], proposals: ["p1"], deepDives: ["d1"] },
|
||||
perAgent: [{ agentId: "a1", wins: ["x"] }],
|
||||
},
|
||||
}));
|
||||
expect(html).toContain('data-section="summary"');
|
||||
expect(html).toContain('data-section="system-wins"');
|
||||
expect(html).toContain('data-section="system-highlights"');
|
||||
expect(html).toContain('data-section="system-lowlights"');
|
||||
expect(html).toContain('data-section="system-proposals"');
|
||||
expect(html).toContain('data-section="system-deep-dives"');
|
||||
expect(html).toContain('data-section="agent-card"');
|
||||
});
|
||||
|
||||
it("omits toggled off sections", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
settings: { enabledSections: ["wins"] },
|
||||
sections: { system: { wins: ["w1"], highlights: ["h1"] } },
|
||||
}));
|
||||
expect(html).toContain('data-section="system-wins"');
|
||||
expect(html).not.toContain('data-section="system-highlights"');
|
||||
});
|
||||
|
||||
it("respects section order", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
settings: { sectionOrder: ["proposals", "wins"], enabledSections: ["wins", "proposals"] },
|
||||
sections: { system: { wins: ["w1"], proposals: ["p1"] } },
|
||||
}));
|
||||
expect(html.indexOf('data-section="system-proposals"')).toBeLessThan(html.indexOf('data-section="system-wins"'));
|
||||
});
|
||||
|
||||
it("renders per-agent cards with stable ids", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
sections: { perAgent: [{ agentId: "agent-1" }, { agentId: "agent-2" }] },
|
||||
}));
|
||||
expect(html).toContain('data-agent-id="agent-1"');
|
||||
expect(html).toContain('data-agent-id="agent-2"');
|
||||
});
|
||||
|
||||
it("escapes hostile inputs", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
sections: { summary: '<script>alert(1)</script> " onmouseover=' },
|
||||
}));
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
expect(html).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("applies explicit theme", () => {
|
||||
const dark = renderReportHtml(createRecord(), { theme: "dark" });
|
||||
const light = renderReportHtml(createRecord(), { theme: "light" });
|
||||
expect(dark).toContain('data-theme="dark"');
|
||||
expect(light).toContain('data-theme="light"');
|
||||
});
|
||||
|
||||
it("returns body-only when includeChrome false", () => {
|
||||
const body = renderReportHtml(createRecord(), { includeChrome: false });
|
||||
expect(body.startsWith("<!doctype html>")).toBe(false);
|
||||
expect(body.startsWith("<article")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Report } from "../../store/report-types.js";
|
||||
import { renderStandaloneReportHtml, slugifyReportFilename } from "../standalone-html.js";
|
||||
|
||||
function createRecord(metadata: Record<string, unknown> = {}): Report {
|
||||
return {
|
||||
id: "rep_1",
|
||||
cadence: "daily",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-02",
|
||||
title: "Demo",
|
||||
status: "review_complete",
|
||||
generationStartedAt: "2026-05-02T00:00:00.000Z",
|
||||
generationCompletedAt: "2026-05-02T00:01:00.000Z",
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: null,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
metadata,
|
||||
combinedReview: null,
|
||||
createdAt: "2026-05-02T00:00:00.000Z",
|
||||
updatedAt: "2026-05-02T00:01:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderStandaloneReportHtml", () => {
|
||||
it("renders one full html document with one style block", () => {
|
||||
const html = renderStandaloneReportHtml(createRecord());
|
||||
expect(html.startsWith("<!doctype html>")).toBe(true);
|
||||
expect((html.match(/<style>/g) ?? []).length).toBe(1);
|
||||
expect(html).toContain("--space-xs");
|
||||
});
|
||||
|
||||
it("contains no external links except allowlisted ones", () => {
|
||||
const html = renderStandaloneReportHtml(createRecord());
|
||||
const matches = [...html.matchAll(/(href|src)=\"https?:[^\"]+/gi)];
|
||||
expect(matches.length).toBe(0);
|
||||
});
|
||||
|
||||
it("strips external image sources", () => {
|
||||
const html = renderStandaloneReportHtml(createRecord({
|
||||
settings: { branding: { logoDataUri: "http://example.com/logo.png" } },
|
||||
}));
|
||||
expect(html).not.toContain("http://example.com/logo.png");
|
||||
});
|
||||
|
||||
it("is deterministic for same input", () => {
|
||||
const a = renderStandaloneReportHtml(createRecord());
|
||||
const b = renderStandaloneReportHtml(createRecord());
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("slugifies report filenames", () => {
|
||||
const slug = slugifyReportFilename({ title: "My Weekly Report", periodStart: "2026-05-01", periodEnd: "2026-05-02" });
|
||||
expect(slug).toBe("my-weekly-report-2026-05-01-2026-05-02.html");
|
||||
});
|
||||
});
|
||||
12
plugins/fusion-plugin-reports/src/render/escape.ts
Normal file
12
plugins/fusion-plugin-reports/src/render/escape.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function escapeAttr(input: string): string {
|
||||
return escapeHtml(input).replaceAll("`", "`");
|
||||
}
|
||||
40
plugins/fusion-plugin-reports/src/render/html-styles.ts
Normal file
40
plugins/fusion-plugin-reports/src/render/html-styles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { escapeAttr } from "./escape.js";
|
||||
|
||||
export interface ReportBranding {
|
||||
accentColor?: string;
|
||||
logoDataUri?: string;
|
||||
logoTextColor?: string;
|
||||
}
|
||||
|
||||
export const REPORT_STYLESHEET = `
|
||||
:root {
|
||||
--space-xs: 4px; --space-sm: 8px; --space-md: 12px; --space-lg: 16px; --space-xl: 24px;
|
||||
--radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px;
|
||||
--bg: #0d1117; --surface: #161b22; --card: #1f2733; --text: #e6edf3; --text-muted: #8b949e; --border: #30363d;
|
||||
--triage: #8b949e; --todo: #58a6ff; --in-progress: #d29922; --in-review: #a371f7; --done: #3fb950;
|
||||
--color-success: #3fb950; --color-error: #f85149; --color-warning: #d29922; --color-info: #58a6ff;
|
||||
--report-accent: #5b8def;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff; --surface: #f6f8fa; --card: #ffffff; --text: #1f2328; --text-muted: #59636e; --border: #d0d7de;
|
||||
}
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
|
||||
.report { max-width: 980px; margin: 0 auto; padding: var(--space-xl); }
|
||||
.report-header, .report-section, .agent-card, .report-footer { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); }
|
||||
.report-title { margin: 0 0 var(--space-sm); font-size: 28px; }
|
||||
.report-meta { display: flex; flex-wrap: wrap; gap: var(--space-sm); color: var(--text-muted); }
|
||||
.pill { border-radius: 999px; padding: 2px 10px; border: 1px solid var(--border); }
|
||||
.status { background: color-mix(in srgb, var(--report-accent) 22%, transparent); color: var(--report-accent); }
|
||||
.section-title { margin: 0 0 var(--space-sm); font-size: 18px; }
|
||||
.section-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-md); }
|
||||
.panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-md); }
|
||||
ul { margin: var(--space-sm) 0 0; padding-left: 18px; }
|
||||
`;
|
||||
|
||||
export function buildBrandingCss(branding: ReportBranding | undefined): string {
|
||||
if (!branding) return "";
|
||||
const accent = branding.accentColor ? `--report-accent: ${escapeAttr(branding.accentColor)};` : "";
|
||||
const logo = branding.logoTextColor ? `--report-logo-text: ${escapeAttr(branding.logoTextColor)};` : "";
|
||||
if (!accent && !logo) return "";
|
||||
return `:root { ${accent} ${logo} }`;
|
||||
}
|
||||
137
plugins/fusion-plugin-reports/src/render/html-template.ts
Normal file
137
plugins/fusion-plugin-reports/src/render/html-template.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Rendering approach: all agent/user text is escaped as plain text.
|
||||
* No markdown/HTML pass-through is allowed in this renderer.
|
||||
*/
|
||||
import type { CombinedReview } from "../review-types.js";
|
||||
import type { Report } from "../store/report-types.js";
|
||||
import { escapeAttr, escapeHtml } from "./escape.js";
|
||||
import { buildBrandingCss, REPORT_STYLESHEET, type ReportBranding } from "./html-styles.js";
|
||||
|
||||
export interface ReportRecord extends Report {
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReportRenderOptions {
|
||||
theme?: "dark" | "light" | "auto";
|
||||
includeChrome?: boolean;
|
||||
}
|
||||
|
||||
interface ReportSectionsPayload {
|
||||
summary?: string;
|
||||
system?: SectionBuckets;
|
||||
perAgent?: Array<AgentSection>;
|
||||
dataCoverage?: string[];
|
||||
}
|
||||
|
||||
interface SectionBuckets {
|
||||
wins?: string[];
|
||||
highlights?: string[];
|
||||
lowlights?: string[];
|
||||
proposals?: string[];
|
||||
deepDives?: string[];
|
||||
}
|
||||
|
||||
interface AgentSection extends SectionBuckets {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
function asObj(v: unknown): Record<string, unknown> {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.trim() ? v : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").map((x) => x.trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function extract(record: ReportRecord): { sections: ReportSectionsPayload; order: string[]; enabled: Set<string>; branding: ReportBranding } {
|
||||
const metadata = asObj(record.metadata);
|
||||
const sections = asObj(metadata.sections);
|
||||
const settings = asObj(metadata.settings);
|
||||
const branding = asObj(settings.branding);
|
||||
const order = asStringArray(settings.sectionOrder);
|
||||
const enabled = new Set(asStringArray(settings.enabledSections));
|
||||
return {
|
||||
sections: {
|
||||
summary: asString(sections.summary),
|
||||
system: asObj(sections.system) as SectionBuckets,
|
||||
perAgent: Array.isArray(sections.perAgent)
|
||||
? sections.perAgent.map((item) => {
|
||||
const agent = asObj(item);
|
||||
return {
|
||||
agentId: asString(agent.agentId) ?? "unknown",
|
||||
agentName: asString(agent.agentName),
|
||||
wins: asStringArray(agent.wins),
|
||||
highlights: asStringArray(agent.highlights),
|
||||
lowlights: asStringArray(agent.lowlights),
|
||||
proposals: asStringArray(agent.proposals),
|
||||
deepDives: asStringArray(agent.deepDives),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
dataCoverage: asStringArray(sections.dataCoverage),
|
||||
},
|
||||
order,
|
||||
enabled,
|
||||
branding: {
|
||||
accentColor: asString(branding.accentColor),
|
||||
logoDataUri: asString(branding.logoDataUri),
|
||||
logoTextColor: asString(branding.logoTextColor),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listSection(title: string, items: string[] | undefined, marker: string): string {
|
||||
if (!items || items.length === 0) return "";
|
||||
return `<section class="panel" data-section="${marker}"><h3>${escapeHtml(title)}</h3><ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
function cadenceLabel(cadence: string): string {
|
||||
return cadence.charAt(0).toUpperCase() + cadence.slice(1);
|
||||
}
|
||||
|
||||
export function renderReportHtml(record: ReportRecord, options: ReportRenderOptions = {}): string {
|
||||
const { sections, order, enabled, branding } = extract(record);
|
||||
const dataTheme = options.theme && options.theme !== "auto" ? options.theme : "dark";
|
||||
const includeChrome = options.includeChrome !== false;
|
||||
const title = asString(record.title) ?? "Fusion Activity Report";
|
||||
const summary = sections.summary ? `<section class="report-section" data-section="summary"><h2 class="section-title">Executive Summary</h2><p>${escapeHtml(sections.summary)}</p></section>` : "";
|
||||
const system = sections.system ?? {};
|
||||
const systemMap: Record<string, string> = {
|
||||
wins: listSection("Wins", system.wins, "system-wins"),
|
||||
highlights: listSection("Highlights", system.highlights, "system-highlights"),
|
||||
lowlights: listSection("Lowlights", system.lowlights, "system-lowlights"),
|
||||
proposals: listSection("Proposals", system.proposals, "system-proposals"),
|
||||
"deep-dives": listSection("Deep dives", system.deepDives, "system-deep-dives"),
|
||||
};
|
||||
const orderedKeys = order.length > 0 ? [...order, ...Object.keys(systemMap).filter((k) => !order.includes(k))] : Object.keys(systemMap);
|
||||
const systemSections = orderedKeys
|
||||
.filter((key) => key in systemMap)
|
||||
.filter((key) => enabled.size === 0 || enabled.has(key))
|
||||
.map((key) => systemMap[key])
|
||||
.join("");
|
||||
|
||||
const perAgent = sections.perAgent ?? [];
|
||||
const perAgentHtml = (enabled.size === 0 || enabled.has("per-agent"))
|
||||
? `<section class="report-section" data-section="agent-card"><h2 class="section-title">Per-agent sections</h2>${perAgent.map((agent) => `<article class="agent-card" data-agent-id="${escapeAttr(agent.agentId)}"><h3>${escapeHtml(agent.agentName ?? agent.agentId)}</h3><div class="section-grid">${listSection("Wins", agent.wins, "agent-wins")}${listSection("Highlights", agent.highlights, "agent-highlights")}${listSection("Lowlights", agent.lowlights, "agent-lowlights")}${listSection("Proposals", agent.proposals, "agent-proposals")}${listSection("Deep dives", agent.deepDives, "agent-deep-dives")}</div></article>`).join("")}</section>`
|
||||
: "";
|
||||
|
||||
const coverage = sections.dataCoverage ?? ["Task board", "Agent activity", "Missions", "Run audit", "Workflow/test/build", "Manual notes"];
|
||||
const coverageSection = `<section class="report-footer" data-section="data-coverage"><h2 class="section-title">Data sources & coverage</h2><ul>${coverage.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></section>`;
|
||||
|
||||
const review = (record.combinedReview as CombinedReview | null) ?? null;
|
||||
const reviewSection = review
|
||||
? `<section class="report-footer" data-section="review-panel"><h2 class="section-title">Review panel summary</h2><p>${escapeHtml(review.overallVerdict)} — ${escapeHtml(review.consensusSummary)}</p><ul>${review.individual.map((member) => `<li>${escapeHtml(member.memberName)}: ${escapeHtml(member.verdict)}</li>`).join("")}</ul></section>`
|
||||
: "";
|
||||
|
||||
const header = `<header class="report-header"><h1 class="report-title">${escapeHtml(title)}</h1><div class="report-meta"><span class="pill">${escapeHtml(cadenceLabel(record.cadence))}</span><span class="pill">${escapeHtml(record.periodStart)} → ${escapeHtml(record.periodEnd)}</span><span class="pill">Generated ${escapeHtml(record.generationCompletedAt ?? record.updatedAt)}</span><span class="pill status">${escapeHtml(record.status)}</span></div>${branding.logoDataUri ? `<p><img src="${escapeAttr(branding.logoDataUri)}" alt="Logo" style="max-height:36px"/></p>` : ""}</header>`;
|
||||
|
||||
const article = `<article class="report">${header}${summary}${systemSections ? `<section class="report-section"><h2 class="section-title">System-wide rollup</h2><div class="section-grid">${systemSections}</div></section>` : ""}${perAgentHtml}${coverageSection}${reviewSection}</article>`;
|
||||
|
||||
if (!includeChrome) return article;
|
||||
return `<!doctype html><html lang="en" data-theme="${escapeAttr(dataTheme)}"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(title)}</title><style>${REPORT_STYLESHEET}\n${buildBrandingCss(branding)}</style></head><body>${article}</body></html>`;
|
||||
}
|
||||
4
plugins/fusion-plugin-reports/src/render/index.ts
Normal file
4
plugins/fusion-plugin-reports/src/render/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./escape.js";
|
||||
export * from "./html-styles.js";
|
||||
export * from "./html-template.js";
|
||||
export * from "./standalone-html.js";
|
||||
75
plugins/fusion-plugin-reports/src/render/standalone-html.ts
Normal file
75
plugins/fusion-plugin-reports/src/render/standalone-html.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import type { Report } from "../store/report-types.js";
|
||||
import { escapeAttr } from "./escape.js";
|
||||
import { buildBrandingCss, REPORT_STYLESHEET } from "./html-styles.js";
|
||||
import { renderReportHtml, type ReportRecord, type ReportRenderOptions } from "./html-template.js";
|
||||
|
||||
const ALLOWLISTED_LINK_PREFIXES = ["https://runfusion.ai"];
|
||||
|
||||
function inferMime(path: string): string {
|
||||
const ext = extname(path).toLowerCase();
|
||||
if (ext === ".png") return "image/png";
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".svg") return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function toDataUri(path: string): string {
|
||||
const mime = inferMime(path);
|
||||
const bytes = readFileSync(path);
|
||||
return `data:${mime};base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
function removeScripts(html: string): string {
|
||||
return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
||||
}
|
||||
|
||||
function sanitizeExternalImages(html: string): string {
|
||||
return html.replace(/<img\b([^>]*?)\ssrc=["'](https?:[^"']+)["']([^>]*)>/gi, "<!-- stripped external image -->");
|
||||
}
|
||||
|
||||
function assertNoExternalRefs(html: string): string {
|
||||
const matches = [...html.matchAll(/(href|src)\s*=\s*["'](https?:[^"']+)["']/gi)];
|
||||
const disallowed = matches.filter((m) => !ALLOWLISTED_LINK_PREFIXES.some((prefix) => m[2]?.startsWith(prefix)));
|
||||
if (disallowed.length === 0) return html;
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
throw new Error(`Standalone HTML contains external refs: ${disallowed.map((m) => m[2]).join(", ")}`);
|
||||
}
|
||||
return `${html}\n<!-- WARNING: stripped/retained external refs detected -->`;
|
||||
}
|
||||
|
||||
function resolveBrandLogo(record: ReportRecord): string | undefined {
|
||||
const metadata = record.metadata && typeof record.metadata === "object" ? record.metadata as Record<string, unknown> : {};
|
||||
const settings = metadata.settings && typeof metadata.settings === "object" ? metadata.settings as Record<string, unknown> : {};
|
||||
const branding = settings.branding && typeof settings.branding === "object" ? settings.branding as Record<string, unknown> : {};
|
||||
const logoDataUri = typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined;
|
||||
const logoPath = typeof branding.logoPath === "string" ? branding.logoPath : undefined;
|
||||
if (logoDataUri?.startsWith("data:")) return logoDataUri;
|
||||
if (logoPath && !/^https?:/i.test(logoPath)) return toDataUri(logoPath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function slugifyReportFilename(record: Pick<Report, "title" | "periodStart" | "periodEnd">): string {
|
||||
const base = `${record.title}-${record.periodStart}-${record.periodEnd}`.toLowerCase();
|
||||
const slug = base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
|
||||
return `${slug || "fusion-report"}.html`;
|
||||
}
|
||||
|
||||
export function renderStandaloneReportHtml(record: ReportRecord, options: ReportRenderOptions = {}): string {
|
||||
const logoDataUri = resolveBrandLogo(record);
|
||||
const metadata = record.metadata && typeof record.metadata === "object" ? { ...(record.metadata as Record<string, unknown>) } : {};
|
||||
const settings = metadata.settings && typeof metadata.settings === "object" ? { ...(metadata.settings as Record<string, unknown>) } : {};
|
||||
const branding = settings.branding && typeof settings.branding === "object" ? { ...(settings.branding as Record<string, unknown>) } : {};
|
||||
if (logoDataUri) branding.logoDataUri = logoDataUri;
|
||||
settings.branding = branding;
|
||||
metadata.settings = settings;
|
||||
|
||||
const html = renderReportHtml({ ...record, metadata }, { ...options, includeChrome: true });
|
||||
const styleBlock = `<style>${REPORT_STYLESHEET}\n${buildBrandingCss({ accentColor: typeof branding.accentColor === "string" ? branding.accentColor : undefined, logoDataUri: typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined, logoTextColor: typeof branding.logoTextColor === "string" ? branding.logoTextColor : undefined })}</style>`;
|
||||
const withSingleStyle = html.replace(/<style>[\s\S]*?<\/style>/i, styleBlock);
|
||||
const sanitized = sanitizeExternalImages(removeScripts(withSingleStyle));
|
||||
return assertNoExternalRefs(sanitized);
|
||||
}
|
||||
Reference in New Issue
Block a user