// Rendered-DOM "captures": real HTML serialized from a component render (a test or a // build) so the static engine can audit the TRUE markup a component library / SFC // emits — the output that lives in node_modules at runtime and is invisible to // source analysis. A capture carries a leading provenance comment // // linking the serialized DOM back to the source component. This module parses that // provenance; coverage (which components have a capture) lives alongside it. // // NOTE: the harvester that WRITES captures is generated by captureSetup() in // src/render.ts and written to `.ultra11y/capture-setup.mjs` in the target repo (by // `render --setup`) — a separate, dependency-free module. It carries its own copy of // the escaping below (it cannot import from src at runtime), so keep the two schemes // byte-identical — tests/capture-escape-sync.test.ts locks this. import type { CaptureProvenance } from "./parse/html.js"; import type { DepGraph } from "./graph/graph.js"; import type { ComponentDef } from "./graph/imports.js"; import type { Finding } from "./types.js"; import { toPosix, expandInputs } from "./glob.js"; import { readText } from "./util.js"; /** Where the rendered-capture set lives by default — the sibling of PAGES_DIR (src/snapshot.ts). * Spelled once so the CLI default, the MCP surface and the audit loop cannot drift apart. */ export const CAPTURES_DIR = ".ultra11y/captures"; /** Is `file` inside `dir`? Path-only, separator-agnostic, no filesystem access — used to tell an * artefact the CLI ingested on the user's behalf from an input the user actually named. */ export function isUnderDir(file: string, dir: string): boolean { const d = toPosix(dir).replace(/\/+$/, ""); if (!d) return false; const f = toPosix(file); return f === d || f.startsWith(`${d}/`) || f.includes(`/${d}/`); } // A capture's provenance comment must contain no literal `"` (it would end an // attribute value early) and no `--` digraph (it would end the HTML comment early). // We entity-escape both — plus `& < >` — so the value round-trips and the surrounding // file stays valid HTML. Single hyphens are preserved so kebab paths stay readable. export function escapeCommentValue(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">").replace(/--/g, "--"); } export function unescapeCommentValue(s: string): string { return s .replace(/-/g, "-") .replace(/>/g, ">") .replace(/</g, "<") .replace(/"/g, '"') .replace(/&/g, "&"); } /** Build a `` provenance comment (escaped) — the inverse of * parseCaptureProvenance. Used by producers that attribute HTML after the fact (e.g. the * Storybook post-processor); the zero-touch harvester inlines the same scheme. */ export function formatCaptureComment(prov: CaptureProvenance): string { const attrs = [`v="${prov.v || 1}"`]; if (prov.sourceFile) attrs.push(`source="${escapeCommentValue(prov.sourceFile)}"`); if (prov.component) attrs.push(`component="${escapeCommentValue(prov.component)}"`); if (prov.test) attrs.push(`test="${escapeCommentValue(prov.test)}"`); if (prov.name) attrs.push(`name="${escapeCommentValue(prov.name)}"`); if (prov.page) attrs.push(`page="${escapeCommentValue(prov.page)}"`); if (prov.url) attrs.push(`url="${escapeCommentValue(prov.url)}"`); return ``; } // Only the head of the file can hold the marker (the harvester writes it first), so // scan a bounded prefix — never the whole (potentially large) capture. const SCAN_LIMIT = 4096; const MARKER = //; const PAIR = /(\w+)="([^"]*)"/g; /** Parse a capture's provenance comment, or return undefined when absent — so * Storybook / manual dumps with no marker still audit as ordinary HTML. */ export function parseCaptureProvenance(source: string): CaptureProvenance | undefined { const head = source.length > SCAN_LIMIT ? source.slice(0, SCAN_LIMIT) : source; const m = MARKER.exec(head); if (!m) return undefined; const body = m[1] ?? ""; const kv = new Map(); PAIR.lastIndex = 0; for (let p = PAIR.exec(body); p; p = PAIR.exec(body)) { kv.set(p[1] as string, unescapeCommentValue(p[2] as string)); } const vRaw = kv.get("v"); const v = vRaw ? Number.parseInt(vRaw, 10) : 1; return { v: Number.isFinite(v) ? v : 1, ...(kv.has("source") ? { sourceFile: kv.get("source") } : {}), ...(kv.has("component") ? { component: kv.get("component") } : {}), ...(kv.has("test") ? { test: kv.get("test") } : {}), ...(kv.has("name") ? { name: kv.get("name") } : {}), ...(kv.has("page") ? { page: kv.get("page") } : {}), ...(kv.has("url") ? { url: kv.get("url") } : {}), }; } // ---- coverage: which components have a rendered capture vs which remain blind spots export interface CaptureEntry { file: string; // the capture file's path provenance?: CaptureProvenance; // absent for un-tagged dumps (Storybook/manual) } export interface CaptureCoverage { total: number; // components in scope that warrant a capture covered: string[]; // "posix/path#Component" keys with a matching capture blindSpots: string[]; // component keys still audited from opaque source only unattributed: number; // capture files with no resolvable source component } // Capture provenance is repo-relative ("src/Button.tsx"); graph node paths may be // cwd-relative or absolute. Match on a path suffix so either side resolves the other. function pathMatch(a: string, b: string): boolean { return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`); } /** Cross-reference the component graph against the captures present. A component needs a * capture when its runtime a11y can't be judged from source: it renders an intrinsic * control (whose accessible name may be injected) OR lives in a file that renders * component-LIBRARY components (the real markup lives in node_modules). */ export function computeCaptureCoverage(graph: DepGraph, captures: CaptureEntry[]): CaptureCoverage { const sigs = captures .map((c) => c.provenance) .filter((p): p is CaptureProvenance => !!p?.sourceFile) .map((p) => ({ sourceFile: toPosix(p.sourceFile as string), component: p.component })); const covered: string[] = []; const blindSpots: string[] = []; const seen = new Set(); for (const node of graph.nodes.values()) { const opaque = node.opaqueComponents.length > 0; for (const def of new Set(node.components.values())) { if (!def.hasControl && !opaque) continue; // nothing rendered worth capturing const key = `${node.file}#${def.name}`; if (seen.has(key)) continue; seen.add(key); const isCovered = sigs.some((s) => pathMatch(s.sourceFile, node.file) && (!s.component || s.component === def.name)); (isCovered ? covered : blindSpots).push(key); } } covered.sort(); blindSpots.sort(); return { total: covered.length + blindSpots.length, covered, blindSpots, unattributed: captures.filter((c) => !c.provenance?.sourceFile).length }; } /** Read every capture (`.html`/`.htm`) under a directory into entries with parsed * provenance — the full repo-wide capture set for coverage, independent of what any * particular audit run scoped to. Returns [] when the directory is absent/empty. */ export function readCaptureDir(dir: string): CaptureEntry[] { let files: string[]; try { files = expandInputs([dir]); } catch { return []; } const out: CaptureEntry[] = []; for (const f of files) { if (!/\.html?$/i.test(f)) continue; try { out.push({ file: toPosix(f), provenance: parseCaptureProvenance(readText(f)) }); } catch { /* unreadable — skip */ } } return out; } /** Capture files (under `captureDir`) whose provenance `sourceFile` matches one of * `sourceFiles` (path suffix, see `pathMatch`) — the captures relevant to a set of * changed/staged source files. Used by `runAudit`'s diff-scoped modes (--changed/ * --staged/--since): a capture is rarely itself part of the diff (the SOURCE * changed; its already-committed capture usually didn't), so ingesting the whole * captures dir there would be wrong scope, but skipping captures entirely leaves * the diff-scoped audit blind to the real rendered DOM for the touched components. */ export function capturesForSources(captureDir: string, sourceFiles: string[]): string[] { const wanted = sourceFiles.map(toPosix); return readCaptureDir(captureDir) .filter((e) => { const src = e.provenance?.sourceFile; return src ? wanted.some((w) => pathMatch(toPosix(src), w)) : false; }) .map((e) => e.file); } /** Resolve `origin.sourceLine` for capture findings from the component graph, so reports * anchor at the source component's definition line (not just the file). Mutates in place; * no-op for findings already resolved or without a source. */ export function enrichCaptureOrigins(findings: Finding[], graph: DepGraph): void { for (const f of findings) { const o = f.origin; if (!o?.sourceFile || o.sourceLine !== undefined) continue; for (const node of graph.nodes.values()) { if (!pathMatch(toPosix(o.sourceFile), node.file)) continue; const def = (o.component ? node.components.get(o.component) : undefined) ?? node.components.values().next().value; if (def) { o.sourceLine = def.line; break; } } } }