import { basename } from "path"; import type { ScanResult, ScannedCookie, DarkPatternIssue, ConsentButton } from "../types.js"; import { lookupCookie } from "../classifiers/cookie-lookup.js"; import { IAB_PURPOSES, IAB_SPECIAL_FEATURES } from "../analyzers/tcf-decoder.js"; const GRADE_COLOR: Record = { A: "#16a34a", B: "#65a30d", C: "#ca8a04", D: "#ea580c", F: "#dc2626", }; const GRADE_BG: Record = { A: "#f0fdf4", B: "#f7fee7", C: "#fefce8", D: "#fff7ed", F: "#fef2f2", }; export function generateHtmlReport(result: ScanResult): string { const hostname = new URL(result.url).hostname.replace(/^www\./, ""); const scanDate = new Date(result.scanDate).toLocaleString("en-GB", { dateStyle: "long", timeStyle: "short", }); const durationSec = (result.duration / 1000).toFixed(1); const { grade, total, breakdown, issues } = result.compliance; const color = GRADE_COLOR[grade] ?? "#64748b"; const bg = GRADE_BG[grade] ?? "#f8fafc"; const criticalIssues = issues.filter((i) => i.severity === "critical"); const warningIssues = issues.filter((i) => i.severity === "warning"); return ` GDPR Report — ${esc(hostname)}
${buildHero(hostname, scanDate, durationSec, grade, total, color)} ${buildScoreGrid(breakdown)} ${buildIssuesSection(criticalIssues, warningIssues)} ${buildModalSection(result)} ${buildCookiesSection(result)} ${buildNetworkSection(result)} ${buildTcfSection(result)} ${buildRecommendationsSection(result)} ${buildChecklistSection(result)}
`; } // ── Hero ────────────────────────────────────────────────────────────────────── function buildHero( hostname: string, scanDate: string, durationSec: string, grade: string, total: number, color: string, ): string { return `
${esc(grade)}

${esc(hostname)}

Scanned on ${esc(scanDate)} · ${durationSec}s

${total}/100
Compliance score
`; } // ── Score grid ──────────────────────────────────────────────────────────────── function buildScoreGrid(breakdown: { consentValidity: number; easyRefusal: number; transparency: number; cookieBehavior: number; }): string { const card = (label: string, value: number) => { const pct = Math.round((value / 25) * 100); const color = pct >= 80 ? GRADE_COLOR.A : pct >= 60 ? GRADE_COLOR.C : pct >= 40 ? GRADE_COLOR.D : GRADE_COLOR.F; return `
${label}
${value}/25
`; }; return `
${card("Consent validity", breakdown.consentValidity)} ${card("Easy refusal", breakdown.easyRefusal)} ${card("Transparency", breakdown.transparency)} ${card("Cookie behavior", breakdown.cookieBehavior)}
`; } // ── Issues ──────────────────────────────────────────────────────────────────── function buildIssuesSection( criticalIssues: DarkPatternIssue[], warningIssues: DarkPatternIssue[], ): string { if (criticalIssues.length === 0 && warningIssues.length === 0) { return `

Issues

✓ No compliance issue detected
`; } const cards = [ ...criticalIssues.map(issueCard("critical")), ...warningIssues.map(issueCard("warning")), ].join("\n"); const total = criticalIssues.length + warningIssues.length; return `

Issues

${total} ${criticalIssues.length > 0 ? `${criticalIssues.length} critical` : ""} ${warningIssues.length > 0 ? `${warningIssues.length} warning${warningIssues.length > 1 ? "s" : ""}` : ""}
${cards}
`; } function issueCard(severity: "critical" | "warning") { return (issue: DarkPatternIssue) => `
${esc(issue.description)}

${esc(issue.evidence)}

`; } // ── Consent modal ───────────────────────────────────────────────────────────── function buildModalSection(result: ScanResult): string { const { modal } = result; if (!modal.detected) { return `

Consent modal

Not detected

No consent banner was found on the page.

`; } const privacyLink = modal.privacyPolicyUrl ? `${esc(modal.privacyPolicyUrl)}` : `Not found`; const buttonsHtml = modal.buttons.length === 0 ? `

No buttons detected.

` : ` ${modal.buttons.map(buttonRow).join("\n")}
TypeLabelFont sizeContrastClicks
`; const preTicked = modal.checkboxes.filter((c) => c.isCheckedByDefault); const screenshotHtml = modal.screenshotPath ? `Consent modal screenshot` : ""; return `

Consent modal

Detected
${screenshotHtml}
Selector
${esc(modal.selector ?? "—")}
Granular controls
${modal.hasGranularControls ? '✓ Yes' : '✗ No'}
Privacy policy link
${privacyLink}
Pre-ticked checkboxes
${preTicked.length === 0 ? '✓ None' : `✗ ${preTicked.length} (${preTicked.map((c) => esc(c.label || c.name)).join(", ")})`}
Buttons
${buttonsHtml}
`; } function buttonRow(b: ConsentButton): string { const chip = `${esc(b.type)}`; const fontSize = b.fontSize ? `${b.fontSize}px` : "—"; const contrast = b.contrastRatio !== null ? `${b.contrastRatio}:1` : "—"; return ` ${chip} ${esc(b.text.substring(0, 40))} ${fontSize} ${contrast} ${b.clickDepth} `; } // ── Cookies ─────────────────────────────────────────────────────────────────── function buildCookiesSection(result: ScanResult): string { const phases: Array<{ label: string; cookies: ScannedCookie[]; phase: ScannedCookie["capturedAt"]; }> = [ { label: "Before interaction", cookies: result.cookiesBeforeInteraction, phase: "before-interaction", }, { label: "After reject", cookies: result.cookiesAfterReject, phase: "after-reject" }, { label: "After accept", cookies: result.cookiesAfterAccept, phase: "after-accept" }, ]; const illegalPre = result.cookiesBeforeInteraction.filter((c) => c.requiresConsent); const illegalPost = result.cookiesAfterReject.filter( (c) => c.requiresConsent && c.capturedAt === "after-reject", ); const phaseTables = phases .map(({ label, cookies, phase }) => { const filtered = cookies.filter((c) => c.capturedAt === phase); return `
${esc(label)} ${filtered.length} ${phase === "before-interaction" && illegalPre.length > 0 ? `${illegalPre.length} non-essential` : ""} ${phase === "after-reject" && illegalPost.length > 0 ? `${illegalPost.length} non-essential` : ""}
${cookieTable(filtered)}
`; }) .join("\n"); return `

Cookies

${phaseTables}
`; } function cookieTable(cookies: ScannedCookie[]): string { if (cookies.length === 0) { return `

No cookies detected.

`; } const rows = cookies .map((c) => { const consent = c.requiresConsent ? `Required` : `No`; const ocd = lookupCookie(c.name); const descCell = ocd ? `${esc(ocd.description)}` : ``; return ` ${esc(c.name)} ${esc(c.domain)} ${esc(c.category)} ${descCell} ${formatExpiry(c)} ${consent} `; }) .join("\n"); return `${rows}
NameDomainCategoryDescriptionExpiryConsent
`; } function formatExpiry(c: ScannedCookie): string { if (c.expires === null) return "Session"; const days = Math.round((c.expires * 1000 - Date.now()) / 86_400_000); if (days < 0) return "Expired"; if (days === 0) return "< 1 day"; if (days < 30) return `${days}d`; return `${Math.round(days / 30)}mo`; } // ── Network ─────────────────────────────────────────────────────────────────── function buildNetworkSection(result: ScanResult): string { const trackers = [ ...result.networkBeforeInteraction, ...result.networkAfterReject, ...result.networkAfterAccept, ].filter((r) => r.trackerCategory !== null); if (trackers.length === 0) { return `

Network trackers

✓ No network tracker detected
`; } const preTrackers = result.networkBeforeInteraction.filter( (r) => r.trackerCategory !== null && r.requiresConsent, ); const rows = trackers .slice(0, 50) .map((req) => { const isBefore = req.capturedAt === "before-interaction"; const url = req.url.length > 70 ? req.url.substring(0, 67) + "…" : req.url; let phaseCell: string; if (isBefore) { phaseCell = req.requiresConsent ? `before consent` : `exempt`; } else { phaseCell = `${esc(req.capturedAt)}`; } return ` ${esc(req.trackerName ?? "Unknown")} ${esc(req.trackerCategory ?? "")} ${phaseCell} ${esc(url)} `; }) .join("\n"); return `

Network trackers

${trackers.length} ${preTrackers.length > 0 ? `${preTrackers.length} before consent` : ""}
${rows}
TrackerCategoryPhaseURL
${trackers.length > 50 ? `

… and ${trackers.length - 50} more.

` : ""}
`; } // ── IAB TCF ─────────────────────────────────────────────────────────────────── function buildTcfSection(result: ScanResult): string { const { tcf } = result; const infoNote = `

Informational only — does not affect the compliance score.

`; if (!tcf.detected) { return `

IAB TCF

Not detected
${infoNote}

No IAB Transparency & Consent Framework implementation found on this page.

`; } const versionBadge = tcf.version ? `TCF v${tcf.version}` : `Detected`; const metaRows = [ [ "CMP API (__tcfapi)", tcf.apiPresent ? `✓ Present` : `✗ Not present`, ], [ "Locator frame (__tcfapiLocator)", tcf.locatorFramePresent ? `✓ Present` : `✗ Not present`, ], [ "Consent string cookie", tcf.cookieName ? `${esc(tcf.cookieName)}` : ``, ], [ "CMP ID", tcf.cmpId !== null ? String(tcf.cmpId) : ``, ], ] .map( ([label, value]) => `${label}${value}`, ) .join("\n"); const cs = tcf.consentString; let consentStringHtml = `

No consent string could be decoded.

`; if (cs) { const decodedRows = [ ["Version", `TCF v${cs.version}`], ["Created", cs.created.toISOString().split("T")[0]], ["Last updated", cs.lastUpdated.toISOString().split("T")[0]], ["CMP ID", String(cs.cmpId)], ["CMP version", String(cs.cmpVersion)], ["Consent language", cs.consentLanguage], ["Vendor list version", String(cs.vendorListVersion)], ...(cs.tcfPolicyVersion !== undefined ? [["TCF policy version", String(cs.tcfPolicyVersion)]] : []), ...(cs.isServiceSpecific !== undefined ? [["Service specific", cs.isServiceSpecific ? "Yes" : "No"]] : []), ...(cs.publisherCC ? [["Publisher country", cs.publisherCC]] : []), ] .map( ([label, value]) => `${esc(label)}${esc(value)}`, ) .join("\n"); const purposesConsentHtml = cs.purposesConsent.length > 0 ? `
Purposes with consent
${cs.purposesConsent.map((id) => ``).join("")}
IDPurpose
${id}${esc(IAB_PURPOSES[id] ?? "Unknown")}
` : `

No purposes with explicit consent.

`; const purposesLiHtml = cs.purposesLegitimateInterest.length > 0 ? `
Purposes with legitimate interest
${cs.purposesLegitimateInterest.map((id) => ``).join("")}
IDPurpose
${id}${esc(IAB_PURPOSES[id] ?? "Unknown")}
` : ""; const specialFeaturesHtml = cs.specialFeatureOptins.length > 0 ? `
Special features opted in
${cs.specialFeatureOptins.map((id) => ``).join("")}
IDFeature
${id}${esc(IAB_SPECIAL_FEATURES[id] ?? "Unknown")}
` : ""; consentStringHtml = `
Decoded consent string
${decodedRows}
FieldValue
${purposesConsentHtml} ${purposesLiHtml} ${specialFeaturesHtml}`; } return `

IAB TCF

${versionBadge} ${tcf.cmpId !== null ? `CMP ${tcf.cmpId}` : ""}
${infoNote} ${metaRows}
PropertyValue
${consentStringHtml}
`; } // ── Recommendations ─────────────────────────────────────────────────────────── function buildRecommendationsSection(result: ScanResult): string { const recs: string[] = []; const { modal, compliance } = result; const { issues } = compliance; const has = (type: string) => issues.some((i) => i.type === type); if (!modal.detected) recs.push( "Deploy a CMP solution (Axeptio, Didomi, OneTrust, Cookiebot) that displays a consent modal before any non-essential cookie.", ); if (has("pre-ticked")) recs.push( "Remove pre-ticked checkboxes. Consent must result from an explicit positive action (GDPR Recital 32).", ); if (has("no-reject-button") || has("buried-reject")) recs.push( 'Add a "Reject all" button at the first layer of the modal, requiring no more clicks than "Accept all" (CNIL 2022).', ); if (has("click-asymmetry")) recs.push( "Balance the number of clicks to accept and reject. Rejection must not require more steps than acceptance.", ); if (has("asymmetric-prominence") || has("nudging")) recs.push( "Equalise the styling of Accept / Reject buttons: same size, same colour, same level of visibility.", ); if (has("auto-consent")) recs.push( "Do not set any non-essential cookie before consent. Gate the initialisation of third-party scripts on the acceptance callback.", ); if (has("missing-info")) recs.push( "Complete the modal information: processing purposes, identity of sub-processors, retention period, right to withdraw.", ); if (result.cookiesAfterReject.filter((c) => c.requiresConsent).length > 0) recs.push( "Remove or block non-essential cookies after rejection, and verify consent handling server-side.", ); if (recs.length === 0) { return `

Recommendations

✓ No critical recommendation. Conduct regular audits to maintain compliance.
`; } const items = recs .map( (rec, i) => `
  • ${i + 1} ${esc(rec)}
  • `, ) .join("\n"); return `

    Recommendations

    ${recs.length}
      ${items}
    `; } // ── Checklist ───────────────────────────────────────────────────────────────── function buildChecklistSection(result: ScanResult): string { const { modal, compliance } = result; const { issues } = compliance; const hasIssue = (type: string) => issues.some((i) => i.type === type); type Row = { category: string; rule: string; status: "ok" | "ko" | "warn"; detail: string }; const rows: Row[] = []; const push = (category: string, rule: string, status: "ok" | "ko" | "warn", detail: string) => rows.push({ category, rule, status, detail }); push( "Consent", "Consent modal detected", modal.detected ? "ok" : "ko", modal.detected ? `Detected (${modal.selector})` : "No consent banner found", ); const preTicked = modal.checkboxes.filter((c) => c.isCheckedByDefault); push( "Consent", "No pre-ticked checkboxes", preTicked.length === 0 ? "ok" : "ko", preTicked.length === 0 ? "None detected" : `${preTicked.length} pre-ticked`, ); push( "Consent", "Unambiguous accept label", !modal.detected || !hasIssue("misleading-wording") ? "ok" : "warn", modal.buttons.find((b) => b.type === "accept")?.text ?? "No accept button", ); push( "Easy refusal", "Reject button at first layer", !modal.detected ? "ko" : hasIssue("no-reject-button") || hasIssue("buried-reject") ? "ko" : "ok", modal.buttons.find((b) => b.type === "reject")?.text ?? "Not found", ); push( "Easy refusal", "Reject ≤ clicks than accept", !modal.detected ? "ko" : hasIssue("click-asymmetry") ? "ko" : "ok", (() => { const a = modal.buttons.find((b) => b.type === "accept"); const r = modal.buttons.find((b) => b.type === "reject"); return a && r ? `Accept: ${a.clickDepth} · Reject: ${r.clickDepth}` : "Cannot verify"; })(), ); push( "Easy refusal", "Button size symmetry", !modal.detected ? "ko" : hasIssue("asymmetric-prominence") ? "warn" : "ok", hasIssue("asymmetric-prominence") ? "Accept button is significantly larger" : "Comparable sizes", ); push( "Transparency", "Granular controls available", !modal.detected ? "ko" : modal.hasGranularControls ? "ok" : "warn", modal.hasGranularControls ? `${modal.checkboxes.length} control(s) detected` : "No granular controls", ); push( "Transparency", "Privacy policy in modal", !modal.detected ? "ko" : modal.privacyPolicyUrl ? "ok" : "warn", modal.privacyPolicyUrl ?? "Not found", ); push( "Transparency", "Privacy policy on page", result.privacyPolicyUrl ? "ok" : "warn", result.privacyPolicyUrl ?? "Not found", ); const illegalPre = result.cookiesBeforeInteraction.filter((c) => c.requiresConsent); push( "Cookie behavior", "No non-essential cookie before consent", illegalPre.length === 0 ? "ok" : "ko", illegalPre.length === 0 ? "None" : `${illegalPre.length}: ${illegalPre.map((c) => c.name).join(", ")}`, ); const persistAfterReject = result.cookiesAfterReject.filter( (c) => c.requiresConsent && c.capturedAt === "after-reject", ); push( "Cookie behavior", "Non-essential cookies removed after reject", persistAfterReject.length === 0 ? "ok" : "ko", persistAfterReject.length === 0 ? "Correctly removed" : `${persistAfterReject.length} persisting`, ); const preTrackers = result.networkBeforeInteraction.filter( (r) => r.trackerCategory !== null && r.trackerCategory !== "cdn" && r.requiresConsent, ); push( "Cookie behavior", "No tracker before consent", preTrackers.length === 0 ? "ok" : "ko", preTrackers.length === 0 ? "None" : `${preTrackers.length} tracker(s)`, ); const categories = [...new Set(rows.map((r) => r.category))]; const okCount = rows.filter((r) => r.status === "ok").length; const koCount = rows.filter((r) => r.status === "ko").length; const warnCount = rows.filter((r) => r.status === "warn").length; const statusCell = (status: "ok" | "ko" | "warn") => status === "ok" ? `✓ Compliant` : status === "ko" ? `✗ Non-compliant` : `⚠ Warning`; const tableRows = categories .map((cat) => { const catRows = rows.filter((r) => r.category === cat); return catRows .map( (row, i) => ` ${i === 0 ? `${esc(cat)}` : ""} ${esc(row.rule)} ${statusCell(row.status)} ${esc(row.detail)} `, ) .join("\n"); }) .join("\n"); const totalRules = rows.length; return `

    Compliance checklist

    ${totalRules} rules ${okCount} ✓ ${koCount} ✗ ${warnCount > 0 ? `${warnCount} ⚠` : ""}
    ${tableRows}
    CategoryRuleStatusDetail
    `; } // ── Helpers ─────────────────────────────────────────────────────────────────── function esc(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }