/**
* HTML report generator (the "expanded" mode) — a self-contained, presentable
* report rendered from the shared report model. Inline CSS, no external assets;
* opens in a browser and is shareable as a single file. Also the natural output
* for the future hosted service.
*
* Customization: a default template with `{{placeholder}}` slots, overridable
* via `template`, plus theme knobs (title, logo, accent, footer).
*/
import type { AuditFinding } from "./core";
import { ruleDocUrl, type RuleMeta } from "./catalog";
import { buildReportModel, buildReportJson, type AuditSnapshot, type BuildModelOptions, type EnrichedFinding, type GuidanceCluster, type QuickWinFile, type ReportCounts } from "./report-model";
export type { AuditSnapshot } from "./report-model";
/** Customizable theme knobs filled into the template. */
export interface ReportTheme {
title?: string;
/** Logo: a URL (rendered as
) or inline text/SVG. */
logo?: string;
/** CSS color for accents. */
accent?: string;
/** Footer HTML (defaults to attribution). */
footer?: string;
}
export interface RenderHtmlOptions extends BuildModelOptions {
snapshot?: AuditSnapshot;
theme?: ReportTheme;
/** Full template override; `{{title}} {{accent}} {{logo}} {{meta}} {{body}} {{footer}}` slots. */
template?: string;
notes?: string[];
}
const DEFAULT_ACCENT = "#7c3aed";
const DEFAULT_TITLE = "chant audit";
const DEFAULT_FOOTER = 'Generated by chant audit.';
function esc(s: string): string {
return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
}
/** A rule id as a link to its reference entry. */
function ruleLink(id: string): string {
return `${esc(id)}`;
}
/** External backing for a merge-worthy rule (OSSF Scorecard, CIS, vendor docs). */
function authorityLinks(meta: RuleMeta): string {
if (!meta.authority?.length) return "";
const links = meta.authority.map((a) => `${esc(a.name)}`).join(", ");
return ` per ${links}`;
}
function renderDiff(diff: string): string {
const rows = diff
.split("\n")
.map((l) => {
const cls = l.startsWith("@@") ? "hunk" : l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : "ctx";
return `${esc(l) || " "}`;
})
.join("\n");
return `
${rows}`;
}
function renderQuickWins(files: QuickWinFile[]): string {
const cards = files
.map((qw) => {
const chips = qw.addressed.map((m) => `${ruleLink(m.id)} ${esc(m.title)}${authorityLinks(m)}`).join(" ");
const diff = qw.diff ? renderDiff(qw.diff) : "";
const needs = qw.needsInput.length
? `Needs a value to auto-patch:${qw.needsInput
.map((f) => `- ${ruleLink(f.checkId)}${f.entity ? ` (
${esc(f.entity)})` : ""} — ${esc(f.meta.remediation)}${authorityLinks(f.meta)} `)
.join("")}
`
: "";
return `${esc(qw.file)} ${chips}
${diff}${needs}
`;
})
.join("\n");
return `Quick wins deterministic
Safe mechanical fixes — the diff changes only the flagged lines.
${cards}`;
}
function renderNeedsReview(clusters: GuidanceCluster[], n: number): string {
const body = clusters
.map((c) => {
const head = c.url ? `${esc(c.name)}` : esc(c.name);
const rules = c.rules
.map(({ meta, findings }) => {
const locs = findings
.map((f) => `${esc(f.file)}${f.entity ? ` (${esc(f.entity)})` : ""} — ${esc(f.message)}`)
.join("");
return `${ruleLink(meta.id)} — ${esc(meta.title)}. ${esc(meta.remediation)}${authorityLinks(meta)}
`;
})
.join("");
return `${head}
${rules}`;
})
.join("\n");
return `Needs review guidance — ${n}
These need a judgement call — remediation guidance, not an auto-fix.
${body} `;
}
function renderReportOnly(findings: EnrichedFinding[], n: number): string {
const rows = findings
.map((f) => `| ${ruleLink(f.checkId)} | ${esc(f.meta.title)} | ${esc(f.file)} | ${esc(f.message)} |
`)
.join("");
return `Report-only hygiene — ${n}
| Rule | Title | File | Detail |
${rows}
`;
}
function renderHeader(counts: ReportCounts, snapshot: AuditSnapshot | undefined, notes: string[]): string {
const sev = `${counts.errors} error ${counts.warnings} warning ${counts.infos} info`;
const tiers = `${counts.quickWin} quick-win ${counts.needsReview} needs-review ${counts.reportOnly} hygiene`;
const cats = `${counts.security} security ${counts.correctness} correctness ${counts.bestPractice} best-practice`;
const meta: string[] = [];
if (snapshot) {
if (snapshot.host) meta.push(esc(snapshot.host));
if (snapshot.repo) meta.push(esc(snapshot.repo));
else meta.push(esc(snapshot.target));
if (snapshot.ref) meta.push(`ref ${esc(snapshot.ref)}`);
if (snapshot.commit) meta.push(`commit ${esc(snapshot.commit.slice(0, 10))}`);
meta.push(esc(snapshot.generatedAt.slice(0, 10)));
meta.push(`${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"}`);
meta.push(`chant ${esc(snapshot.toolVersion)}`);
}
const noteHtml = notes.map((n) => `${esc(n)}
`).join("");
return `${meta.join(" · ")}
${sev}
${tiers}
${cats}
${noteHtml}
`;
}
const STYLES = `
:root { --accent: {{accent}}; }
* { box-sizing: border-box; }
body { font: 15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color: #1f2328; margin: 0; background: #f6f8fa; }
.wrap { max-width: 920px; margin: 0 auto; padding: 32px 20px 64px; }
header { display: flex; align-items: center; gap: 12px; border-bottom: 3px solid var(--accent); padding-bottom: 16px; margin-bottom: 20px; }
header h1 { font-size: 22px; margin: 0; }
.logo { height: 32px; }
.summary { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: 16px 18px; margin-bottom: 24px; }
.summary .meta { color: #57606a; font-size: 13px; margin-bottom: 10px; }
.counts { font-weight: 600; margin-bottom: 8px; }
.sev { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin: 0 4px 0 10px; vertical-align: middle; }
.sev.error { background: #cf222e; } .sev.warning { background: #d4a72c; } .sev.info { background: #8c959f; }
.sev:first-child { margin-left: 0; }
.chip { display: inline-block; background: #eef1f4; border: 1px solid #d0d7de; border-radius: 999px; padding: 1px 10px; font-size: 12px; color: #424a53; }
.tiers .chip { margin-right: 4px; }
.note { margin-top: 10px; background: #fff8c5; border: 1px solid #d4a72c66; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
h2 { font-size: 18px; margin: 28px 0 4px; } h3 { font-size: 15px; margin: 16px 0 6px; }
.muted { color: #57606a; font-weight: 400; font-size: 13px; }
.card { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: 14px 16px; margin: 12px 0; }
.card-head { margin-bottom: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.file { background: #eef1f4; padding: 2px 7px; border-radius: 6px; font-size: 13px; }
.card-head .chip { background: var(--accent); border-color: var(--accent); color: #fff; }
pre.diff { background: #0d1117; color: #c9d1d9; border-radius: 8px; padding: 12px 14px; overflow-x: auto; font: 12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; margin: 0; }
pre.diff span { display: block; white-space: pre; }
pre.diff .add { color: #3fb950; } pre.diff .del { color: #f85149; } pre.diff .hunk { color: #a371f7; } pre.diff .ctx { color: #8b949e; }
.needs { margin-top: 10px; font-size: 13px; }
details { background: #fff; border: 1px solid #d0d7de; border-radius: 10px; padding: 8px 16px; margin: 16px 0; }
summary { cursor: pointer; font-size: 16px; font-weight: 600; }
.cluster { margin: 10px 0; } .rule { margin: 8px 0; } .rule ul, .needs ul { margin: 4px 0 4px 0; }
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 8px; }
th, td { text-align: left; border-bottom: 1px solid #d0d7de; padding: 6px 8px; vertical-align: top; }
th { color: #57606a; }
code { font: 12.5px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid #d0d7de; color: #57606a; font-size: 13px; }
a { color: var(--accent); }
.rule-id { color: var(--accent); text-decoration: none; font-weight: 600; }
.rule-id:hover { text-decoration: underline; }
.card-head .chip .rule-id { color: #fff; }
`;
const DEFAULT_TEMPLATE = `
{{title}}
{{body}}
`;
/** Render an audit report as a self-contained HTML document. */
export function renderHtml(findings: AuditFinding[], opts: RenderHtmlOptions = {}): string {
const model = buildReportModel(findings, opts);
const theme = opts.theme ?? {};
const accent = theme.accent ?? DEFAULT_ACCENT;
const title = theme.title ?? DEFAULT_TITLE;
const logo = theme.logo ? (/^https?:\/\//.test(theme.logo) ? `
` : theme.logo) : "";
const footer = theme.footer ?? DEFAULT_FOOTER;
// Machine-readable data embedded so the HTML report is also parseable.
// `<` escaped so the JSON can't break out of the `;
let body: string;
if (model.counts.total === 0) {
body = renderHeader(model.counts, opts.snapshot, opts.notes ?? []) + `` + dataScript;
} else {
const parts = [renderHeader(model.counts, opts.snapshot, opts.notes ?? [])];
if (model.quickWins.length > 0) parts.push(renderQuickWins(model.quickWins));
if (model.needsReview.length > 0) parts.push(renderNeedsReview(model.needsReview, model.counts.needsReview));
if (model.reportOnly.length > 0) parts.push(renderReportOnly(model.reportOnly, model.counts.reportOnly));
parts.push(dataScript);
body = parts.join("\n");
}
const slots: Record = {
title: esc(title),
accent,
styles: STYLES.replace("{{accent}}", accent),
logo,
body,
footer,
};
const template = opts.template ?? DEFAULT_TEMPLATE;
// Single pass over the template only, so injected body content isn't re-scanned.
return template.replace(/\{\{(\w+)\}\}/g, (_, k: string) => slots[k] ?? "");
}