// 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;
}
}
}
}