// THE IMPURE HALF of the HTML tier: read the image bytes, decide what fits, write the files. // // src/html.ts renders; src/html-report.ts decides what a criterion IS. This module owns the // only two things neither of them may touch — the filesystem, and the SIZE POLICY. // // The size policy exists because the composite is a single self-contained file: every crop it // shows travels inside it as a data: URI, and base64 costs a third more than the bytes it // encodes. A 38-page RGAA audit with 200 crops can reach tens of megabytes, which is a file // no reviewer opens twice. So there is a budget and a LADDER — and the rule the ladder obeys // is that IMAGES DEGRADE AND NON-CONFORMITIES NEVER DO. Every rung is announced in the // document itself and on stderr; nothing is dropped quietly. import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { findingId } from "./baseline.js"; import { evidenceRefusals, type EvidenceManifest } from "./evidence.js"; import { renderHtmlDocument, type Doc, type Run } from "./html.js"; import { compositeDoc, indexDoc, pageDoc, pagesIndexDoc, type CropLookup, type RefusalLookup } from "./html-report.js"; import { derivePages, pagesOf } from "./pages.js"; import { CORE, type StandardId, isCore, loadPack } from "./standards/index.js"; import type { AuditResult, Finding, Lang } from "./types.js"; /** 12 MB of inlined image data. Chosen so the composite stays inside what a browser opens * from a file:// URL without visible strain, and well under the artifact limits. */ export const DEFAULT_INLINE_BUDGET = 12 * 1024 * 1024; /** The rungs, in the order they are climbed. Each drops the LEAST specific evidence first: * a full-page screenshot says less about a non-conformity than a crop of the element does. */ export type DegradeStep = "screenshots" | "crops" | "none"; export interface EmitOpts { outDir: string; standard?: StandardId; lang?: Lang; /** The evidence tier's manifest — absolute paths and per-language alt text. */ evidence?: EvidenceManifest; /** pageId → absolute path of the page screenshot. */ screenshots?: Map; /** Bytes of inlined image data the composite may carry. */ inlineBudget?: number; /** Also write the navigable page sheets. */ pages?: boolean; /** WHERE the sheets go, and what `index.html` is. * * `report` (the default, for `report --html`): `index.html` is the dashboard and the sheets * sit in `pages/`. `pages` (for `pages --html`): `index.html` IS the page index and the * sheets sit beside it — mirroring exactly where that command already writes its Markdown, * so `--out audits/pages` does not produce `audits/pages/pages/`. */ layout?: "report" | "pages"; } export interface EmitResult { /** The entry point — always written. */ index: string; /** The detachable, printable single file. Absent in the `pages` layout: that command's * deliverable is the index and its sheets, and writing a second composite beside them * would inline every crop a second time into the same artifact. */ composite?: string; /** The page site's sheets, when `pages` was asked for. */ sheets: string[]; inlinedBytes: number; degraded: DegradeStep[]; /** True at the last rung: not one image fit. The caller decides what that costs. */ imagesDropped: boolean; /** Localized, for stderr. The same sentences are written into the document. */ notices: string[]; } const T = { fr: { overComposite: (mb: string, budget: string) => `Les images dépassent le budget d'inclusion (${mb} Mo pour ${budget} Mo) : les captures de page ne sont pas incluses dans le fichier composite. Elles restent dans le rapport page par page.`, overCrops: (mb: string, budget: string) => `Les images dépassent encore le budget (${mb} Mo pour ${budget} Mo) : une seule vignette par critère est incluse. Le rapport page par page les porte toutes.`, overNone: (budget: string) => `Aucune image ne tient dans le budget de ${budget} Mo : le fichier composite est sans illustration. Les non-conformités, elles, y sont toutes. Augmentez \`--inline-budget\`, ou ouvrez le rapport page par page, qui référence les images au lieu de les inclure.`, docTitle: "Rapport complet, en un seul fichier (imprimable en PDF)", pagesTitle: "Rapport page par page", indexTitle: "Tableau de bord", }, en: { overComposite: (mb: string, budget: string) => `Images exceed the inline budget (${mb} MB for ${budget} MB): page screenshots are not embedded in the composite file. They remain in the page-by-page report.`, overCrops: (mb: string, budget: string) => `Images still exceed the budget (${mb} MB for ${budget} MB): one crop per criterion is embedded. The page-by-page report carries them all.`, overNone: (budget: string) => `No image fits within the ${budget} MB budget: the composite file carries no illustration. Every non-conformity is still in it. Raise \`--inline-budget\`, or open the page-by-page report, which references images instead of embedding them.`, docTitle: "Full report, in a single file (printable to PDF)", pagesTitle: "Page-by-page report", indexTitle: "Dashboard", }, } as const; const mb = (n: number): string => (n / (1024 * 1024)).toFixed(1); /** The base64 cost of a file, or 0 when it cannot be read. A missing image degrades the * document; it never fails the run. */ function inlineSize(path: string): number { try { return Math.ceil(statSync(path).size / 3) * 4; } catch { return 0; } } function dataUri(path: string): string | undefined { try { return `data:image/png;base64,${readFileSync(path).toString("base64")}`; } catch { return undefined; } } /** Crops keyed by finding, as the report tier wants them: a lookup, not a manifest. * `hrefOf` decides whether the image travels as a file reference or as a data: URI. */ function cropLookup(m: EvidenceManifest | undefined, lang: Lang, hrefOf: (path: string, href: string) => string | undefined): CropLookup { if (!m) return () => undefined; return (f: Finding) => { const c = m.crops.get(findingId(f)); if (!c) return undefined; const href = hrefOf(c.path, c.href); return href ? { href, alt: c.alt[lang] } : undefined; }; } /** What the tier refused to draw, keyed the way the report tier wants it. Independent of the * budget ladder on purpose: the ladder's own notices say which pictures were dropped FOR * SIZE, this says which occurrences have no picture AT ALL. Both belong in the document, and * neither substitutes for the other. */ function refusalLookup(m: EvidenceManifest | undefined, lang: Lang): RefusalLookup | undefined { if (!m) return undefined; return (pageId) => evidenceRefusals(m, pageId, lang); } /** Which rung the evidence lands on for a given budget. Pure — the sizes are measured once * and the decision is taken before a single byte is encoded. */ export function pickRung(cropBytes: number[], shotBytes: number[], budget: number): { steps: DegradeStep[]; cropsPerCriterion: number; shots: boolean } { const sum = (xs: number[]): number => xs.reduce((a, b) => a + b, 0); if (sum(cropBytes) + sum(shotBytes) <= budget) return { steps: [], cropsPerCriterion: Number.POSITIVE_INFINITY, shots: true }; if (sum(cropBytes) <= budget) return { steps: ["screenshots"], cropsPerCriterion: Number.POSITIVE_INFINITY, shots: false }; // One crop per criterion is the smallest illustration that still says something: it shows // WHAT the defect looks like, and the occurrence table still lists every place it occurs. const smallest = [...cropBytes].sort((a, b) => a - b); let kept = 0; let total = 0; for (const b of smallest) { if (total + b > budget) break; total += b; kept++; } if (!kept) return { steps: ["screenshots", "crops", "none"], cropsPerCriterion: 0, shots: false }; return { steps: ["screenshots", "crops"], cropsPerCriterion: 1, shots: false }; } /** Write the artifact's HTML: the entry point, the composite, and — on request — the site. */ export function writeHtml(result: AuditResult, opts: EmitOpts): EmitResult { const standard = opts.standard ?? CORE; const lang = opts.lang ?? "en"; const t = T[lang]; const budget = opts.inlineBudget ?? DEFAULT_INLINE_BUDGET; const stdKey = isCore(standard) ? "wcag" : loadPack(standard).key; mkdirSync(opts.outDir, { recursive: true }); const crops = [...(opts.evidence?.crops.values() ?? [])]; const shots = [...(opts.screenshots?.values() ?? [])]; const rung = pickRung( crops.map((c) => inlineSize(c.path)), shots.map(inlineSize), budget, ); const notices: string[] = []; if (rung.steps.includes("none")) notices.push(t.overNone(mb(budget))); else if (rung.steps.includes("crops")) notices.push(t.overCrops(mb(crops.reduce((a, c) => a + inlineSize(c.path), 0)), mb(budget))); else if (rung.steps.includes("screenshots")) notices.push(t.overComposite(mb(crops.reduce((a, c) => a + inlineSize(c.path), 0) + shots.reduce((a, s) => a + inlineSize(s), 0)), mb(budget))); // ---- the composite: every image it shows travels inside it ---- let inlinedBytes = 0; const perCriterion = new Map(); const inlineCrops = cropLookup(opts.evidence, lang, (path) => { if (rung.cropsPerCriterion === 0) return undefined; const uri = dataUri(path); if (!uri) return undefined; inlinedBytes += uri.length; return uri; }); const budgetedCrops: CropLookup = (f) => { const c = opts.evidence?.crops.get(findingId(f)); if (!c) return undefined; const n = perCriterion.get(c.criteriaId) ?? 0; if (n >= rung.cropsPerCriterion) return undefined; const drawn = inlineCrops(f); if (drawn) perCriterion.set(c.criteriaId, n + 1); return drawn; }; // The two layouts differ in exactly two places: where a sheet is written, and what // `index.html` is. Everything below reads `up` and `sheetDir`, so neither can drift. const flat = opts.layout === "pages"; const sheetDir = flat ? opts.outDir : join(opts.outDir, "pages"); const up = flat ? "./" : "../"; const compositeName = `ultra11y-${stdKey}-${result.date}.html`; const nav = flat ? [{ href: "./index.html", text: t.pagesTitle }] : [ { href: "./index.html", text: t.indexTitle }, { href: `./${compositeName}`, text: t.docTitle }, ...(opts.pages ? [{ href: "./pages/index.html", text: t.pagesTitle }] : []), ]; // ONE composite per artifact. `report --html` owns it; `pages --html` writes the index and // its sheets beside the Markdown they mirror. Emitting one from both would put two copies of // every inlined crop into the same upload. const refusals = refusalLookup(opts.evidence, lang); let compositePath: string | undefined; if (!flat) { const composite = compositeDoc(result, { standard, lang, crops: budgetedCrops, ...(refusals ? { refusals } : {}), nav: nav.map((n) => ({ ...n, current: n.href === `./${compositeName}` })), }); if (notices.length) composite.blocks.unshift({ kind: "note", tone: "warn", runs: noticeRuns(notices) }); compositePath = join(opts.outDir, compositeName); writeFileSync(compositePath, renderHtmlDocument(composite)); } // Images stay FILES on the sheets, so the same crop is not duplicated into every document // that shows it. Only the composite pays the base64 tax, because only it has to travel alone. const fileCrops = cropLookup(opts.evidence, lang, (_p, href) => href.replace(/^\.\//, up)); // ---- the entry point: links and numbers, never an image ---- const indexNav = nav.map((n) => ({ ...n, current: n.href === "./index.html" })); const index = flat ? pagesIndexDoc(result, { standard, lang, nav: indexNav, sheetHref: (id) => `./page-${id}.html` }) : indexDoc(result, { standard, lang, nav: indexNav, links: nav.filter((n) => n.href !== "./index.html") }); const indexPath = join(opts.outDir, "index.html"); writeFileSync(indexPath, renderHtmlDocument(index)); const sheets: string[] = []; if (opts.pages) { const derived = derivePages(result, pagesOf(result)); mkdirSync(sheetDir, { recursive: true }); const sheetNav = flat ? [{ href: "./index.html", text: t.pagesTitle }] : [ { href: `${up}index.html`, text: t.indexTitle }, { href: `${up}${compositeName}`, text: t.docTitle }, { href: "./index.html", text: t.pagesTitle }, ]; // In the nested layout the page index is its own document; flat, `index.html` already is it. if (!flat) { writeFileSync( join(sheetDir, "index.html"), renderHtmlDocument( pagesIndexDoc(result, { standard, lang, nav: sheetNav.map((n) => ({ ...n, current: n.href === "./index.html" })), sheetHref: (id) => `./page-${id}.html`, }), ), ); sheets.push(join(sheetDir, "index.html")); } for (const p of derived) { const doc = pageDoc(result, p, { standard, lang, crops: fileCrops, ...(refusals ? { refusals } : {}), nav: sheetNav, // Where `cmdPages`' screenshot copier already put the capture: `assets/.png`, // beside the entry point, seen from wherever this sheet sits. ...(opts.screenshots?.has(p.id) ? { screenshot: `${up}assets/${p.id}.png` } : {}), }); const path = join(sheetDir, `page-${p.id}.html`); writeFileSync(path, renderHtmlDocument(doc)); sheets.push(path); } } return { index: indexPath, ...(compositePath ? { composite: compositePath } : {}), sheets, inlinedBytes, degraded: rung.steps, imagesDropped: rung.steps.includes("none"), notices, }; } function noticeRuns(notices: string[]): Run[] { return notices.flatMap((n, i) => (i ? [{ text: " " }, { text: n }] : [{ text: n }])); } /** The composite's own filename, so the CLI can report it without rebuilding the rule. */ export function compositeFileName(standard: StandardId, date: string): string { return `ultra11y-${isCore(standard) ? "wcag" : loadPack(standard).key}-${date}.html`; } /** Every `src`/`href` the emitted pages carry, for the self-containment gate. A report that * points outside its own directory is a report that breaks the moment it is unzipped * somewhere else — which is the only way anyone ever reads an artifact. */ export function externalReferences(html: string, depth: number): string[] { const out: string[] = []; for (const m of html.matchAll(/(?:src|href)="([^"]+)"/g)) { const ref = m[1]!; if (ref.startsWith("#") || ref.startsWith("data:")) continue; if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) { out.push(ref); // an absolute URL — http:, file:, anything continue; } // Count how far the relative path climbs; more than the document's own depth escapes. let climbs = 0; for (const seg of ref.split("/")) if (seg === "..") climbs++; if (climbs > depth) out.push(ref); } return out; } /** Convenience for a caller that only wants the single file. */ export function renderComposite(result: AuditResult, opts: { standard?: StandardId; lang?: Lang } = {}): string { return renderHtmlDocument(compositeDoc(result, opts) as Doc); }