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:
Fusion
2026-05-10 10:05:07 -07:00
committed by gsxdsm
parent 85b96a7546
commit e9c4c6e4f1
23 changed files with 911 additions and 3 deletions

View File

@@ -0,0 +1,81 @@
## ReportRecord input shape
```ts
interface ReportRecord {
id: string;
title: string;
cadence: "daily" | "weekly" | "monthly" | "quarterly" | "manual";
period: { start: string; end: string };
generatedAt: string;
status: string;
settings: {
sections?: string[];
sectionOrder?: string[];
branding?: {
accentColor?: string;
logoDataUri?: string;
};
};
sections: {
summary?: string;
system?: {
wins?: string[];
highlights?: string[];
lowlights?: string[];
proposals?: string[];
deepDives?: string[];
};
perAgent?: Array<{
agentId: string;
agentName?: string;
wins?: string[];
highlights?: string[];
lowlights?: string[];
proposals?: string[];
deepDives?: string[];
}>;
dataCoverage?: string[];
};
reviewPanel?: {
overallVerdict: string;
consensusSummary: string;
individual: Array<{ memberName: string; verdict: string }>;
};
}
```
## Section identifiers
- `data-section="summary"`
- `data-section="system-wins"`
- `data-section="system-highlights"`
- `data-section="system-lowlights"`
- `data-section="system-proposals"`
- `data-section="system-deep-dives"`
- `data-section="agent-card"`
- `data-section="data-coverage"`
- `data-section="review-panel"`
## Theme tokens
- `--space-xs`, `--space-sm`, `--space-md`, `--space-lg`, `--space-xl`
- `--radius-sm`, `--radius-md`, `--radius-lg`
- `--bg`, `--surface`, `--card`, `--text`, `--text-muted`, `--border`
- `--triage`, `--todo`, `--in-progress`, `--in-review`, `--done`
- `--color-success`, `--color-error`, `--color-warning`, `--color-info`
- `--report-accent`
## HTTP endpoints
- `GET /api/plugins/reports/reports/:id/export.html`
- `200 text/html; charset=utf-8` + `Content-Disposition: attachment; filename="<slug>.html"`
- `404` when report ID does not exist
- `409` when report is not yet generated
- `GET /api/plugins/reports/reports/:id/preview.html`
- `200 text/html; charset=utf-8` body-only fragment (`<article>...</article>`)
- `404` when report ID does not exist
- `409` when report is not yet generated
## Stability
This rendering contract is shared with downstream dashboard/share integrations (FN-3786 / FN-3787). Any breaking change to markers, token names, or endpoint contract requires coordinated updates across those tasks.

View File

@@ -8,6 +8,10 @@
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./render": {
"types": "./src/render/index.ts",
"import": "./src/render/index.ts"
}
},
"scripts": {

View File

@@ -2,6 +2,7 @@ import type { PluginContext } from "@fusion/core";
import { definePlugin } from "@fusion/plugin-sdk";
import { runReviewPanel } from "./review-panel.js";
import { ensureReportSchema } from "./report-schema.js";
import { createReportExportRoutes } from "./routes/report-export-routes.js";
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
import type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
import { ReportStore } from "./store/report-store.js";
@@ -21,6 +22,7 @@ const plugin = definePlugin({
hooks: {
onSchemaInit: ensureReportSchema,
},
routes: createReportExportRoutes(),
});
export interface RunGeneratedReportReviewInput {
@@ -85,3 +87,4 @@ export * from "./review-panel.js";
export { ensureReportSchema } from "./report-schema.js";
export { ReportStore, ReportStoreError, type ReportStoreEvents } from "./store/report-store.js";
export * from "./store/report-types.js";
export * from "./render/index.js";

View File

@@ -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("&amp;&lt;&gt;&#39;&quot;");
});
it("escapes attribute-sensitive characters", () => {
expect(escapeAttr("a`b&c")).toBe("a&#96;b&amp;c");
});
it("passes unicode through", () => {
expect(escapeHtml("こんにちは 🌍")).toBe("こんにちは 🌍");
});
it("passes unicode through in attributes", () => {
expect(escapeAttr("こんにちは 🌍")).toBe("こんにちは 🌍");
});
});

View File

@@ -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);
});
});

View File

@@ -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");
});
});

View File

@@ -0,0 +1,12 @@
export function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export function escapeAttr(input: string): string {
return escapeHtml(input).replaceAll("`", "&#96;");
}

View 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} }`;
}

View 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>`;
}

View File

@@ -0,0 +1,4 @@
export * from "./escape.js";
export * from "./html-styles.js";
export * from "./html-template.js";
export * from "./standalone-html.js";

View 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);
}

View File

@@ -20,6 +20,8 @@ export function ensureReportSchema(db: Database): void {
failureReason TEXT,
draftMarkdown TEXT,
renderedHtmlPath TEXT,
rendered_html TEXT,
rendered_html_generated_at TEXT,
metadataJson TEXT NOT NULL DEFAULT '{}',
combinedReviewJson TEXT,
createdAt TEXT NOT NULL,
@@ -35,4 +37,13 @@ export function ensureReportSchema(db: Database): void {
CREATE INDEX IF NOT EXISTS idxReportsPeriod
ON reports(periodStart, periodEnd, id);
`);
const columns = db.prepare("PRAGMA table_info(reports)").all() as Array<{ name: string }>;
const names = new Set(columns.map((column) => column.name));
if (!names.has("rendered_html")) {
db.exec("ALTER TABLE reports ADD COLUMN rendered_html TEXT");
}
if (!names.has("rendered_html_generated_at")) {
db.exec("ALTER TABLE reports ADD COLUMN rendered_html_generated_at TEXT");
}
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginContext } from "@fusion/core";
import type { Report } from "../../store/report-types.js";
import { createReportExportRoutes } from "../report-export-routes.js";
function report(overrides: Partial<Report> = {}): 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",
...overrides,
};
}
function ctxWithStore(store: { getReport: (id: string) => Report | null; setRenderedHtml: (id: string, html: string) => void }): PluginContext {
return {
pluginId: "fusion-plugin-reports",
taskStore: { getDatabase: () => ({}), getReportStore: () => store } as any,
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: undefined,
resolveProjectTaskStore: undefined,
...({} as any),
} as PluginContext;
}
describe("report export routes", () => {
it("returns export html with attachment header", async () => {
const routes = createReportExportRoutes();
const route = routes.find((r) => r.path.endsWith("export.html"))!;
const record = report();
const getReport = vi.fn().mockReturnValue(record);
const setRenderedHtml = vi.fn();
const ctx = ctxWithStore({ getReport, setRenderedHtml });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(200);
expect(res.contentType).toContain("text/html");
expect(res.headers["Content-Disposition"]).toContain("attachment;");
});
it("returns 404 for missing id", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(null), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "missing" } }, ctx as any) as any;
expect(res.status).toBe(404);
});
it("returns 409 for generating report", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report({ status: "generating" })), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(409);
});
it("returns body-only preview html", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("preview.html"))!;
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report()), setRenderedHtml: vi.fn() });
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(res.status).toBe(200);
expect(res.contentType).toContain("text/html");
expect(res.body).toContain("<article");
expect(res.body).not.toContain("<!doctype html>");
});
it("caches rendered html after first export", async () => {
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
const mutable = report();
const getReport = vi.fn().mockImplementation(() => mutable);
const setRenderedHtml = vi.fn().mockImplementation((_id: string, html: string) => {
mutable.renderedHtml = html;
});
const ctx = ctxWithStore({ getReport, setRenderedHtml });
const first = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
const second = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(setRenderedHtml).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,77 @@
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
import { ReportStore } from "../store/report-store.js";
import { renderReportHtml } from "../render/html-template.js";
import { renderStandaloneReportHtml, slugifyReportFilename } from "../render/standalone-html.js";
interface RouteRequest {
params: Record<string, string>;
}
const reportStoreCache = new WeakMap<object, ReportStore>();
function getStore(ctx: PluginContext): ReportStore {
const taskStoreWithReports = ctx.taskStore as PluginContext["taskStore"] & { getReportStore?: () => ReportStore };
if (typeof taskStoreWithReports.getReportStore === "function") {
return taskStoreWithReports.getReportStore();
}
const key = ctx.taskStore as object;
const cached = reportStoreCache.get(key);
if (cached) return cached;
const store = new ReportStore(ctx.taskStore.getDatabase());
reportStoreCache.set(key, store);
return store;
}
function notFound(message: string): PluginRouteResponse {
return { status: 404, body: { error: message } };
}
function conflict(message: string): PluginRouteResponse {
return { status: 409, body: { error: message } };
}
export function createReportExportRoutes(): PluginRouteDefinition[] {
return [
{
method: "GET",
path: "/reports/:id/export.html",
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
const request = req as RouteRequest;
const id = request.params.id;
const store = getStore(ctx);
const record = store.getReport(id);
if (!record) return notFound(`Report ${id} not found`);
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
const html = record.renderedHtml ?? renderStandaloneReportHtml(record);
if (!record.renderedHtml) {
store.setRenderedHtml(id, html);
}
return {
status: 200,
body: html,
contentType: "text/html; charset=utf-8",
headers: {
"Content-Disposition": `attachment; filename="${slugifyReportFilename(record)}"`,
},
};
},
},
{
method: "GET",
path: "/reports/:id/preview.html",
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
const request = req as RouteRequest;
const id = request.params.id;
const store = getStore(ctx);
const record = store.getReport(id);
if (!record) return notFound(`Report ${id} not found`);
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
return {
status: 200,
body: renderReportHtml(record, { includeChrome: false }),
contentType: "text/html; charset=utf-8",
};
},
},
];
}

View File

@@ -29,6 +29,8 @@ interface ReportRow {
failureReason: string | null;
draftMarkdown: string | null;
renderedHtmlPath: string | null;
rendered_html: string | null;
rendered_html_generated_at: string | null;
metadataJson: string;
combinedReviewJson: string | null;
createdAt: string;
@@ -76,6 +78,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
failureReason: null,
draftMarkdown: input.draftMarkdown ?? null,
renderedHtmlPath: null,
renderedHtml: null,
renderedHtmlGeneratedAt: null,
metadata: input.metadata ?? {},
combinedReview: null,
createdAt: now,
@@ -88,12 +92,12 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
id, cadence, periodStart, periodEnd, title, status,
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
draftMarkdown, renderedHtmlPath, metadataJson, combinedReviewJson, createdAt, updatedAt
draftMarkdown, renderedHtmlPath, rendered_html, rendered_html_generated_at, metadataJson, combinedReviewJson, createdAt, updatedAt
) VALUES (
@id, @cadence, @periodStart, @periodEnd, @title, @status,
@generationStartedAt, @generationCompletedAt, @reviewStartedAt, @reviewCompletedAt,
@approvedAt, @approvedBy, @publishedAt, @archivedAt, @failureReason,
@draftMarkdown, @renderedHtmlPath, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
@draftMarkdown, @renderedHtmlPath, @renderedHtml, @renderedHtmlGeneratedAt, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
)
`).run(this.toDbParams(report, true));
});
@@ -157,6 +161,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
draftMarkdown: patch.draftMarkdown ?? current.draftMarkdown,
renderedHtmlPath: patch.renderedHtmlPath ?? current.renderedHtmlPath,
metadata: patch.metadata ?? current.metadata,
renderedHtml: patch.renderedHtml ?? current.renderedHtml,
renderedHtmlGeneratedAt: patch.renderedHtmlGeneratedAt ?? current.renderedHtmlGeneratedAt,
failureReason: patch.failureReason ?? current.failureReason,
updatedAt: new Date().toISOString(),
};
@@ -224,6 +230,13 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
return this.updateReport(id, { renderedHtmlPath: htmlPath });
}
setRenderedHtml(id: string, html: string): Report {
return this.updateReport(id, {
renderedHtml: html,
renderedHtmlGeneratedAt: new Date().toISOString(),
});
}
deleteReport(id: string): void {
this.requireReport(id);
this.db.transaction(() => {
@@ -258,6 +271,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
failureReason: row.failureReason,
draftMarkdown: row.draftMarkdown,
renderedHtmlPath: row.renderedHtmlPath,
renderedHtml: row.rendered_html,
renderedHtmlGeneratedAt: row.rendered_html_generated_at,
metadata: this.parseMetadata(row.metadataJson),
combinedReview: this.parseCombinedReview(row.combinedReviewJson),
createdAt: row.createdAt,
@@ -284,6 +299,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
failureReason = @failureReason,
draftMarkdown = @draftMarkdown,
renderedHtmlPath = @renderedHtmlPath,
rendered_html = @renderedHtml,
rendered_html_generated_at = @renderedHtmlGeneratedAt,
metadataJson = @metadataJson,
combinedReviewJson = @combinedReviewJson,
updatedAt = @updatedAt
@@ -314,6 +331,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
failureReason: report.failureReason,
draftMarkdown: report.draftMarkdown,
renderedHtmlPath: report.renderedHtmlPath,
renderedHtml: report.renderedHtml,
renderedHtmlGeneratedAt: report.renderedHtmlGeneratedAt,
metadataJson: JSON.stringify(report.metadata ?? {}),
combinedReviewJson: report.combinedReview ? JSON.stringify(report.combinedReview) : null,
...(includeCreatedAt ? { createdAt: report.createdAt } : {}),

View File

@@ -30,6 +30,8 @@ export interface Report {
failureReason: string | null;
draftMarkdown: string | null;
renderedHtmlPath: string | null;
renderedHtml: string | null;
renderedHtmlGeneratedAt: string | null;
metadata: Record<string, unknown>;
combinedReview: CombinedReview | null;
createdAt: string;
@@ -45,7 +47,7 @@ export interface ReportCreateInput {
draftMarkdown?: string;
}
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "metadata" | "failureReason">>;
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "renderedHtml" | "renderedHtmlGeneratedAt" | "metadata" | "failureReason">>;
export interface ReportListFilter {
cadence?: ReportCadence;