/** * 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:
` : ""; 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}${rows}
    RuleTitleFileDetail
    `; } 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}}
    {{logo}}

    {{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 ?? []) + `

    No issues found.

    ` + 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] ?? ""); }