eaa-kit 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +51 -4
  2. package/dist/astro/index.d.ts +7 -34
  3. package/dist/astro/index.js +6 -26
  4. package/dist/audit/runners/worker.js +1 -1
  5. package/dist/audit-CPoZMXGM.js +779 -0
  6. package/dist/audit-CpXH2Mk8.js +2 -0
  7. package/dist/{baseline-CV_3lbER.js → baseline-22Y1NWxM.js} +1 -1
  8. package/dist/{baseline-CgBmzFTr.js → baseline-DB9CZGnV.js} +17 -27
  9. package/dist/cli/index.js +336 -142
  10. package/dist/collect-CFM8gEVv.js +134 -0
  11. package/dist/command-D8l_oYbV.js +77 -0
  12. package/dist/component-C3GL1Mnu.js +146 -0
  13. package/dist/component-DKd3EHOg.js +2 -0
  14. package/dist/coverage-B4IBKMO8.js +521 -0
  15. package/dist/{crawl-CtJbMNNb.js → crawl-BiI1Lau_.js} +13 -23
  16. package/dist/eleventy/index.d.ts +28 -0
  17. package/dist/eleventy/index.js +19 -0
  18. package/dist/frameworks-B4ClIJgE.js +314 -0
  19. package/dist/frameworks-etFg_O8K.js +2 -0
  20. package/dist/fs-BmPtmFke.js +40 -0
  21. package/dist/html-BGTO3ypW.js +543 -0
  22. package/dist/{impact-DvgBjupx.js → impact-DZt2oBCP.js} +15 -1
  23. package/dist/index.js +2 -2
  24. package/dist/{init-DRKIdpK1.js → init-DIWDE35F.js} +13 -10
  25. package/dist/jsdom-4IMzv0eE.js +3 -0
  26. package/dist/jsdom-DCpGSLfW.js +81 -0
  27. package/dist/{json-C9xS1PNC.js → json-DjEvy1nX.js} +19 -22
  28. package/dist/json-QQuFIw1W.js +2 -0
  29. package/dist/{load-sCkKsvGQ.js → load-5wRGLvub.js} +3 -9
  30. package/dist/nuxt/index.d.ts +35 -0
  31. package/dist/nuxt/index.js +23 -0
  32. package/dist/{playwright-DSRnXmcd.js → playwright-BWniOain.js} +70 -23
  33. package/dist/{pool-DixLeu8L.js → pool-BMevaLWD.js} +4 -12
  34. package/dist/{project-CufCqIE2.js → project-CzOnkLH6.js} +14 -21
  35. package/dist/project-MFrXcw1M.js +2 -0
  36. package/dist/remediation-Dtowi2EC.js +321 -0
  37. package/dist/{render-BO0nVrrZ.js → render-DrvXRCEn.js} +8 -16
  38. package/dist/{result-2aZPfM8w.js → result-DoamKFsp.js} +115 -1
  39. package/dist/routes-CmdRUuOs.js +265 -0
  40. package/dist/run-BMASMmwO.d.ts +45 -0
  41. package/dist/run-DB34BSOZ.js +53 -0
  42. package/dist/{sarif-eSCuI0eX.js → sarif-SR3_lLYd.js} +22 -36
  43. package/dist/{schema-CMZ8ItGk.js → schema-is6CGX2D.js} +4 -1
  44. package/dist/text-CKKpzkYM.js +43 -0
  45. package/dist/vite/index.d.ts +39 -0
  46. package/dist/vite/index.js +40 -0
  47. package/dist/webpack/index.d.ts +33 -0
  48. package/dist/webpack/index.js +20 -0
  49. package/package.json +36 -9
  50. package/dist/audit-B2dIKpJ5.js +0 -2
  51. package/dist/audit-DXpKkXsC.js +0 -950
  52. package/dist/escape-Dm1o_RAk.js +0 -21
  53. package/dist/html-BLEuzep6.js +0 -337
  54. package/dist/jsdom-BEu6Ra_2.js +0 -163
  55. package/dist/jsdom-C6dIyaxN.js +0 -3
  56. package/dist/routes-BxbSZKXC.js +0 -123
@@ -0,0 +1,2 @@
1
+ import { n as runAuditCommand } from "./audit-CPoZMXGM.js";
2
+ export { runAuditCommand };
@@ -1,2 +1,2 @@
1
- import { a as readBaseline, r as applyBaseline, t as BaselineError } from "./baseline-CgBmzFTr.js";
1
+ import { a as readBaseline, r as applyBaseline, t as BaselineError } from "./baseline-DB9CZGnV.js";
2
2
  export { BaselineError, applyBaseline, readBaseline };
@@ -1,5 +1,6 @@
1
- import { _ as withDefault, c as object, f as safeParse, i as isoDate$1, l as optional, o as nullable, p as string, s as number, t as array } from "./schema-CMZ8ItGk.js";
1
+ import { a as isoDate$1, c as number, l as object, m as string, p as safeParse, s as nullable, t as array, u as optional, v as withDefault } from "./schema-is6CGX2D.js";
2
2
  import { t as elementFingerprint } from "./fingerprint-DRoneAjj.js";
3
+ import { a as findingElements } from "./result-DoamKFsp.js";
3
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
5
  import path from "node:path";
5
6
  /** Default filename, used by the CLI when no path is given. */
@@ -37,12 +38,10 @@ var BaselineError = class extends Error {
37
38
  */
38
39
  function applyBaseline(audits, baseline, options = {}) {
39
40
  const today = isoDate(options.today ?? /* @__PURE__ */ new Date());
40
- const expired = baseline.entries.filter((entry) => entry.expiresOn !== void 0 && entry.expiresOn < today);
41
+ const expired = [];
41
42
  const live = /* @__PURE__ */ new Map();
42
- for (const entry of baseline.entries) {
43
- if (entry.expiresOn !== void 0 && entry.expiresOn < today) continue;
44
- live.set(key(entry.page, entry.ruleId, entry.fingerprint), entry);
45
- }
43
+ for (const entry of baseline.entries) if (entry.expiresOn !== void 0 && entry.expiresOn < today) expired.push(entry);
44
+ else live.set(key(entry.page, entry.ruleId, entry.fingerprint), entry);
46
45
  const matched = /* @__PURE__ */ new Set();
47
46
  let accepted = 0;
48
47
  const next = audits.map((audit) => {
@@ -90,7 +89,7 @@ function applyBaseline(audits, baseline, options = {}) {
90
89
  return {
91
90
  audits: next,
92
91
  stale: [...live.entries()].filter(([id, entry]) => !matched.has(id) && audited.has(entry.page)).map(([, entry]) => entry).sort(byEntry),
93
- expired: [...expired].sort(byEntry),
92
+ expired: expired.sort(byEntry),
94
93
  accepted
95
94
  };
96
95
  }
@@ -98,26 +97,17 @@ function applyBaseline(audits, baseline, options = {}) {
98
97
  function buildBaseline(audits, options = {}) {
99
98
  const acceptedOn = isoDate(options.today ?? /* @__PURE__ */ new Date());
100
99
  const entries = [];
101
- for (const audit of audits) for (const finding of audit.violations) {
102
- const nodes = finding.nodes.length > 0 ? finding.nodes.map((node) => ({
103
- selector: node.target.join(" "),
104
- html: node.html
105
- })) : [{
106
- selector: "",
107
- html: ""
108
- }];
109
- for (const node of nodes) entries.push({
110
- page: audit.relativePath,
111
- ruleId: finding.ruleId,
112
- fingerprint: elementFingerprint(finding.ruleId, node.selector, node.html),
113
- selector: node.selector,
114
- help: finding.help,
115
- impact: finding.impact ?? null,
116
- acceptedOn,
117
- ...options.expiresOn ? { expiresOn: options.expiresOn } : {},
118
- ...options.note ? { note: options.note } : {}
119
- });
120
- }
100
+ for (const audit of audits) for (const finding of audit.violations) for (const node of findingElements(finding)) entries.push({
101
+ page: audit.relativePath,
102
+ ruleId: finding.ruleId,
103
+ fingerprint: elementFingerprint(finding.ruleId, node.selector, node.html),
104
+ selector: node.selector,
105
+ help: finding.help,
106
+ impact: finding.impact ?? null,
107
+ acceptedOn,
108
+ ...options.expiresOn ? { expiresOn: options.expiresOn } : {},
109
+ ...options.note ? { note: options.note } : {}
110
+ });
121
111
  return {
122
112
  schemaVersion: 1,
123
113
  createdOn: acceptedOn,
package/dist/cli/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { l as STATEMENT_LOCALES, o as COUNTRIES, r as loadConfig, s as ConfigError } from "../load-sCkKsvGQ.js";
3
- import { i as readAuditReport, o as StatementError, t as renderStatement } from "../render-BO0nVrrZ.js";
4
- import { i as isImpactLevel, n as IMPACT_LEVELS, t as DEFAULT_FAIL_ON } from "../impact-DvgBjupx.js";
2
+ import { c as number, f as record, l as object, m as string, n as boolean, p as safeParse, s as nullable, t as array, u as optional, v as withDefault } from "../schema-is6CGX2D.js";
3
+ import { l as STATEMENT_LOCALES, o as COUNTRIES, r as loadConfig, s as ConfigError } from "../load-5wRGLvub.js";
4
+ import { i as readAuditReport, o as StatementError, t as renderStatement } from "../render-DrvXRCEn.js";
5
+ import { a as impactRank, n as IMPACT_LEVELS, o as isImpactLevel, s as meetsThreshold, t as DEFAULT_FAIL_ON } from "../impact-DZt2oBCP.js";
6
+ import { n as count, t as collapse } from "../text-CKKpzkYM.js";
5
7
  import { t as TOOL_VERSION } from "../version-B3v4rNoG.js";
6
- import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-CgBmzFTr.js";
7
- import { i as resolvePages, n as isOutputFormat, r as runAuditCommand, t as OUTPUT_FORMATS } from "../audit-DXpKkXsC.js";
8
- import { mkdir, writeFile } from "node:fs/promises";
8
+ import { t as elementFingerprint } from "../fingerprint-DRoneAjj.js";
9
+ import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-DB9CZGnV.js";
10
+ import { n as runAuditCommand, r as resolvePages, t as OUTPUT_FORMATS } from "../audit-CPoZMXGM.js";
11
+ import { a as runEngine, i as note, n as emitDocument, o as warn, r as fail, t as advise } from "../command-D8l_oYbV.js";
12
+ import { readFile } from "node:fs/promises";
9
13
  import path from "node:path";
10
14
  import { Command, InvalidArgumentError } from "commander";
11
15
  import pc from "picocolors";
@@ -33,38 +37,23 @@ async function runBaselineCommand(dir, options = {}) {
33
37
  exitCode: 2
34
38
  };
35
39
  const { pages, origin, label } = resolved;
36
- process.stderr.write(pc.dim(`Auditing ${pages.length} ${pages.length === 1 ? "page" : "pages"} in ${label}…\n`));
37
- const effectiveBaseUrl = options.baseUrl ?? origin;
38
- const runnerOptions = {
40
+ note(`Auditing ${count(pages.length, "page")} in ${label}…`);
41
+ const baseUrl = options.baseUrl ?? origin;
42
+ const audits = await runEngine(pages, {
39
43
  cwd,
40
- ...effectiveBaseUrl === void 0 ? {} : { baseUrl: effectiveBaseUrl },
41
- ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
44
+ ...baseUrl === void 0 ? {} : { baseUrl },
45
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
46
+ ...options.browser ? { browser: true } : {},
47
+ ...options.concurrency === void 0 ? {} : { concurrency: options.concurrency },
48
+ ...options.url === void 0 ? { directory: path.resolve(cwd, dir) } : {}
49
+ });
50
+ if (!audits) return {
51
+ entries: 0,
52
+ exitCode: 2
42
53
  };
43
- let audits;
44
- if (options.browser) {
45
- const { BrowserUnavailableError, runBrowserAudit } = await import("../playwright-DSRnXmcd.js");
46
- try {
47
- audits = await runBrowserAudit(options.url === void 0 ? path.resolve(cwd, dir) : void 0, pages, runnerOptions);
48
- } catch (cause) {
49
- if (cause instanceof BrowserUnavailableError) {
50
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
51
- return {
52
- entries: 0,
53
- exitCode: 2
54
- };
55
- }
56
- throw cause;
57
- }
58
- } else {
59
- const { runPooledAudit } = await import("../pool-DixLeu8L.js");
60
- audits = await runPooledAudit(pages, {
61
- ...runnerOptions,
62
- ...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
63
- });
64
- }
65
54
  const unaudited = audits.filter((audit) => audit.error);
66
55
  if (unaudited.length > 0) {
67
- process.stderr.write(`${pc.red("error")} ${unaudited.length} of ${audits.length} pages could not be audited, so no baseline was written\n`);
56
+ fail(`${unaudited.length} of ${audits.length} pages could not be audited, so no baseline was written`);
68
57
  return {
69
58
  entries: 0,
70
59
  exitCode: 2
@@ -79,7 +68,7 @@ async function runBaselineCommand(dir, options = {}) {
79
68
  await writeBaseline(target, baseline, cwd);
80
69
  } catch (cause) {
81
70
  if (cause instanceof BaselineError) {
82
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
71
+ fail(cause.message);
83
72
  return {
84
73
  entries: 0,
85
74
  exitCode: 2
@@ -87,21 +76,267 @@ async function runBaselineCommand(dir, options = {}) {
87
76
  }
88
77
  throw cause;
89
78
  }
90
- const count = baseline.entries.length;
91
- process.stderr.write(pc.dim(`Wrote ${count} ${count === 1 ? "entry" : "entries"} to ${target}\n`));
92
- if (count > 0) process.stderr.write(pc.yellow("These are barriers, not exceptions. Commit the file, then work the list down.\n"));
79
+ const entries = baseline.entries.length;
80
+ note(`Wrote ${entries} ${entries === 1 ? "entry" : "entries"} to ${target}`);
81
+ if (entries > 0) advise("These are barriers, not exceptions. Commit the file, then work the list down.");
93
82
  return {
94
- entries: count,
83
+ entries,
95
84
  exitCode: 0
96
85
  };
97
86
  }
87
+ var DiffError = class extends Error {
88
+ name = "DiffError";
89
+ };
90
+ /**
91
+ * Only the fields a comparison reads. `completeness` is optional: a report
92
+ * written before it existed says nothing about coverage, which is not the same
93
+ * as saying the run was complete, and is handled as the unknown it is.
94
+ */
95
+ const reportSchema = object({
96
+ schemaVersion: number(),
97
+ generatedAt: string(),
98
+ target: withDefault(object({ source: withDefault(string(), () => "") }), () => ({ source: "" })),
99
+ completeness: optional(object({
100
+ complete: boolean(),
101
+ audited: withDefault(number(), () => 0)
102
+ })),
103
+ rules: withDefault(record(object({ help: withDefault(string(), () => "") })), () => ({})),
104
+ pages: withDefault(array(object({
105
+ path: string(),
106
+ error: withDefault(nullable(string()), () => null),
107
+ violations: withDefault(array(object({
108
+ ruleId: string(),
109
+ impact: withDefault(nullable(string()), () => null),
110
+ nodes: withDefault(array(object({
111
+ html: withDefault(string(), () => ""),
112
+ target: withDefault(array(string()), () => [])
113
+ })), () => [])
114
+ })), () => [])
115
+ })), () => [])
116
+ });
117
+ /**
118
+ * The elements a violation points at.
119
+ *
120
+ * Mirrors `findingElements` for the JSON shape, including its one subtlety: a
121
+ * rule can fail with no element attached, and it still has an identity. Getting
122
+ * this wrong here would make document-level failures invisible to a diff while
123
+ * remaining visible to the baseline.
124
+ */
125
+ function elementsOf(violation) {
126
+ if (violation.nodes.length === 0) return [{
127
+ selector: "",
128
+ html: ""
129
+ }];
130
+ return violation.nodes.map((node) => ({
131
+ selector: node.target.join(" "),
132
+ html: node.html
133
+ }));
134
+ }
135
+ /** Every violating element in a report, keyed by page and element identity. */
136
+ function entriesOf(report) {
137
+ const entries = /* @__PURE__ */ new Map();
138
+ for (const page of report.pages) for (const violation of page.violations) {
139
+ const impact = violation.impact !== null && isImpactLevel(violation.impact) ? violation.impact : null;
140
+ for (const { selector, html } of elementsOf(violation)) {
141
+ const fingerprint = elementFingerprint(violation.ruleId, selector, html);
142
+ const key = `${page.path}\n${fingerprint}`;
143
+ if (entries.has(key)) continue;
144
+ entries.set(key, {
145
+ page: page.path,
146
+ ruleId: violation.ruleId,
147
+ fingerprint,
148
+ impact,
149
+ help: report.rules[violation.ruleId]?.help ?? violation.ruleId,
150
+ selector,
151
+ html
152
+ });
153
+ }
154
+ }
155
+ return entries;
156
+ }
157
+ /** Pages a run reached a verdict on. A page that errored is not one of them. */
158
+ function auditedPages(report) {
159
+ return new Set(report.pages.filter((page) => page.error === null).map((page) => page.path));
160
+ }
161
+ function sideOf(report) {
162
+ return {
163
+ source: report.target.source,
164
+ generatedAt: report.generatedAt,
165
+ audited: report.completeness?.audited ?? auditedPages(report).size,
166
+ complete: report.completeness?.complete
167
+ };
168
+ }
169
+ /** Worst first, then by page, so two runs of one comparison agree. */
170
+ function bySeverityThenPage(a, b) {
171
+ return impactRank(a.impact) - impactRank(b.impact) || a.page.localeCompare(b.page) || a.ruleId.localeCompare(b.ruleId) || a.selector.localeCompare(b.selector);
172
+ }
173
+ /** Compare two parsed reports, earlier first. */
174
+ function diffReports(base, head) {
175
+ const before = entriesOf(base);
176
+ const after = entriesOf(head);
177
+ const measuredByHead = auditedPages(head);
178
+ const added = [];
179
+ const fixed = [];
180
+ const unchanged = [];
181
+ const unmeasured = [];
182
+ for (const [key, entry] of after) if (before.has(key)) unchanged.push(entry);
183
+ else added.push(entry);
184
+ for (const [key, entry] of before) {
185
+ if (after.has(key)) continue;
186
+ if (measuredByHead.has(entry.page)) fixed.push(entry);
187
+ else unmeasured.push(entry);
188
+ }
189
+ return {
190
+ base: sideOf(base),
191
+ head: sideOf(head),
192
+ added: added.sort(bySeverityThenPage),
193
+ fixed: fixed.sort(bySeverityThenPage),
194
+ unchanged: unchanged.sort(bySeverityThenPage),
195
+ unmeasured: unmeasured.sort(bySeverityThenPage)
196
+ };
197
+ }
198
+ /**
199
+ * Read a JSON report from disk.
200
+ *
201
+ * Refuses a schema version it was not written against rather than reading it
202
+ * optimistically: a field that changed meaning is exactly what the version
203
+ * number exists to signal, and a diff built on a misread field would be wrong
204
+ * quietly.
205
+ */
206
+ async function readReport(file, cwd = process.cwd()) {
207
+ const target = path.resolve(cwd, file);
208
+ let raw;
209
+ try {
210
+ raw = await readFile(target, "utf8");
211
+ } catch {
212
+ throw new DiffError(`Report not found: ${file}`);
213
+ }
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(raw);
217
+ } catch (cause) {
218
+ throw new DiffError(`${file} is not valid JSON (${cause.message})`);
219
+ }
220
+ const result = safeParse(reportSchema, parsed);
221
+ if (!result.success) {
222
+ const first = result.error.issues[0];
223
+ throw new DiffError(`${file} is not an eaa-kit JSON report${first === void 0 || first.path.length === 0 ? "" : ` at ${first.path.join(".")}`}: ${first?.message ?? "unrecognised shape"}`);
224
+ }
225
+ if (result.data.schemaVersion !== 1) throw new DiffError(`${file} is schemaVersion ${result.data.schemaVersion}; this version of eaa-kit reads 1`);
226
+ return result.data;
227
+ }
228
+ //#endregion
229
+ //#region src/cli/diff.ts
230
+ /**
231
+ * `eaa-kit diff <before.json> <after.json>`.
232
+ *
233
+ * What a change did to a site's accessibility, which is the question a review
234
+ * actually asks and which neither report answers on its own. Running the
235
+ * auditor on a branch prints everything wrong with the site; almost all of it
236
+ * was already there, and the two or three findings somebody introduced are
237
+ * somewhere in the middle of it.
238
+ *
239
+ * Deliberately stateless. A baseline is a file somebody commits and maintains,
240
+ * and it answers "what have we agreed to live with". This answers "what did
241
+ * this change do", needs no file and no decision, and can be run over any two
242
+ * reports after the fact.
243
+ */
244
+ const DIFF_FORMATS = ["console", "json"];
245
+ /** Longest element markup shown before it is truncated. */
246
+ const MAX_SNIPPET = 100;
247
+ async function runDiffCommand(base, head, options = {}) {
248
+ const cwd = options.cwd ?? process.cwd();
249
+ let diff;
250
+ try {
251
+ diff = diffReports(await readReport(base, cwd), await readReport(head, cwd));
252
+ } catch (cause) {
253
+ if (cause instanceof DiffError) {
254
+ fail(cause.message);
255
+ return { exitCode: 2 };
256
+ }
257
+ throw cause;
258
+ }
259
+ const failOn = options.failOn ?? "serious";
260
+ const failing = diff.added.filter((entry) => meetsThreshold(entry.impact, failOn));
261
+ const body = (options.format ?? "console") === "json" ? `${JSON.stringify(toJson(diff, failOn, failing.length), null, 2)}\n` : `${formatDiff(diff, failOn, options.output === void 0)}\n`;
262
+ await emitDocument(body, options.output, cwd);
263
+ if (options.output !== void 0) note(`Diff written to ${options.output}`);
264
+ if (diff.unmeasured.length > 0) warn(`${count(diff.unmeasured.length, "violation")} could not be compared: the later run did not audit ${diff.unmeasured.length === 1 ? "its page" : "their pages"}`);
265
+ return {
266
+ diff,
267
+ exitCode: failing.length > 0 ? 1 : 0
268
+ };
269
+ }
270
+ /**
271
+ * The machine-readable form.
272
+ *
273
+ * `unchanged` is counted and not listed: it is the whole of the pre-existing
274
+ * report, it is already available in the report itself, and repeating it here
275
+ * would make the common case — a diff with three entries — a document nobody
276
+ * can read.
277
+ */
278
+ function toJson(diff, failOn, failing) {
279
+ return {
280
+ base: diff.base,
281
+ head: diff.head,
282
+ summary: {
283
+ added: diff.added.length,
284
+ fixed: diff.fixed.length,
285
+ unchanged: diff.unchanged.length,
286
+ unmeasured: diff.unmeasured.length,
287
+ failOn,
288
+ failing
289
+ },
290
+ added: diff.added,
291
+ fixed: diff.fixed,
292
+ unmeasured: diff.unmeasured
293
+ };
294
+ }
295
+ function formatDiff(diff, failOn, colour = true) {
296
+ const c = pc.createColors(colour);
297
+ const lines = [""];
298
+ lines.push(c.bold("eaa-kit diff"));
299
+ lines.push(c.dim(` before ${describe(diff.base)}`));
300
+ lines.push(c.dim(` after ${describe(diff.head)}`));
301
+ lines.push("");
302
+ if (diff.added.length === 0 && diff.fixed.length === 0) lines.push(c.green("No change in violations."));
303
+ if (diff.added.length > 0) {
304
+ lines.push(c.red(c.bold(`New (${diff.added.length})`)));
305
+ lines.push(...diff.added.map((entry) => entryLine(entry, c)));
306
+ lines.push("");
307
+ }
308
+ if (diff.fixed.length > 0) {
309
+ lines.push(c.green(c.bold(`Fixed (${diff.fixed.length})`)));
310
+ lines.push(...diff.fixed.map((entry) => entryLine(entry, c)));
311
+ lines.push("");
312
+ }
313
+ if (diff.unmeasured.length > 0) {
314
+ lines.push(c.yellow(c.bold(`Not compared (${diff.unmeasured.length})`)));
315
+ lines.push(c.dim(" These failed before, and the later run did not audit the page they were on."));
316
+ lines.push(...diff.unmeasured.map((entry) => entryLine(entry, c)));
317
+ lines.push("");
318
+ }
319
+ const failing = diff.added.filter((entry) => meetsThreshold(entry.impact, failOn)).length;
320
+ lines.push(c.bold("Summary"));
321
+ lines.push(` ${diff.added.length} new · ${diff.fixed.length} fixed · ${diff.unchanged.length} unchanged`);
322
+ lines.push(failing > 0 ? c.red(` ${failing} new at or above ${failOn} (--fail-on ${failOn})`) : c.green(` no new violations at or above ${failOn} (--fail-on ${failOn})`));
323
+ return lines.join("\n");
324
+ }
325
+ function describe(side) {
326
+ const source = side.source === "" ? "unknown source" : side.source;
327
+ const coverage = side.complete === void 0 ? "coverage not recorded" : side.complete ? "complete" : "incomplete";
328
+ return `${source} · ${count(side.audited, "page")} · ${coverage} · ${side.generatedAt}`;
329
+ }
330
+ function entryLine(entry, c) {
331
+ const where = entry.selector === "" ? entry.page : `${entry.page} ${c.dim(entry.selector)}`;
332
+ const impact = entry.impact ?? "unclassified";
333
+ const snippet = entry.html === "" ? "" : `\n ${c.dim(collapse(entry.html, MAX_SNIPPET))}`;
334
+ return ` ${c.bold(entry.ruleId)} ${c.dim(`(${impact})`)} — ${entry.help}\n ${where}${snippet}`;
335
+ }
98
336
  //#endregion
99
337
  //#region src/cli/statement.ts
100
338
  /** Markdown for a content directory, HTML for dropping straight onto a site. */
101
339
  const STATEMENT_FORMATS = ["markdown", "html"];
102
- function isStatementFormat(value) {
103
- return STATEMENT_FORMATS.includes(value);
104
- }
105
340
  /**
106
341
  * `eaa-kit statement`.
107
342
  *
@@ -122,19 +357,16 @@ async function runStatementCommand(options = {}) {
122
357
  ...options.country ? { country: options.country } : {},
123
358
  ...audit ? { audit } : {}
124
359
  });
125
- process.stderr.write(pc.dim(`Statement for ${config.site.url} from ${path.basename(configPath)} (${statement.template}, ${format})\n`));
360
+ note(`Statement for ${config.site.url} from ${path.basename(configPath)} (${statement.template}, ${format})`);
126
361
  if (audit) {
127
- process.stderr.write(pc.dim(`${audit.findings.length} ${audit.findings.length === 1 ? "barrier" : "barriers"} taken from ${path.basename(options.audit ?? "")}\n`));
128
- if (audit.findings.length > 0) process.stderr.write(pc.yellow("Rewrite those descriptions in your own words before publishing.\n"));
362
+ const from = path.basename(options.audit ?? "");
363
+ note(`${count(audit.findings.length, "barrier")} taken from ${from}`);
364
+ if (audit.findings.length > 0) advise("Rewrite those descriptions in your own words before publishing.");
129
365
  }
130
366
  const document = format === "html" ? statement.html : statement.markdown;
131
- if (options.output) {
132
- const target = path.resolve(options.cwd ?? process.cwd(), options.output);
133
- await mkdir(path.dirname(target), { recursive: true });
134
- await writeFile(target, document, "utf8");
135
- process.stderr.write(pc.dim(`Written to ${options.output}\n`));
136
- } else process.stdout.write(document);
137
- process.stderr.write(pc.yellow("Review before publishing. This is a draft, not legal advice.\n"));
367
+ await emitDocument(document, options.output, options.cwd ?? process.cwd());
368
+ if (options.output !== void 0) note(`Written to ${options.output}`);
369
+ advise("Review before publishing. This is a draft, not legal advice.");
138
370
  return {
139
371
  document,
140
372
  format,
@@ -142,8 +374,8 @@ async function runStatementCommand(options = {}) {
142
374
  };
143
375
  } catch (cause) {
144
376
  if (cause instanceof ConfigError || cause instanceof StatementError) {
145
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
146
- if (cause instanceof ConfigError) for (const issue of cause.issues) process.stderr.write(pc.dim(` ${issue}\n`));
377
+ fail(cause.message);
378
+ if (cause instanceof ConfigError) for (const issue of cause.issues) note(` ${issue}`);
147
379
  return {
148
380
  document: "",
149
381
  format,
@@ -163,109 +395,71 @@ function formatFor(output) {
163
395
  }
164
396
  //#endregion
165
397
  //#region src/cli/index.ts
398
+ /**
399
+ * A parser for a fixed set of words, listing them when the answer is not one.
400
+ *
401
+ * `normalise` is for `--country de`, where the accepted spelling differs from
402
+ * what somebody types.
403
+ */
404
+ function oneOf(values, normalise = (value) => value) {
405
+ return (value) => {
406
+ const candidate = normalise(value);
407
+ if (!values.includes(candidate)) throw new InvalidArgumentError(`expected one of ${values.join(", ")}`);
408
+ return candidate;
409
+ };
410
+ }
411
+ /** A whole number no smaller than `min`. Depth allows 0: audit the entry page alone. */
412
+ function wholeNumber(min) {
413
+ return (value) => {
414
+ const parsed = Number(value);
415
+ if (!Number.isInteger(parsed) || parsed < min) throw new InvalidArgumentError(`expected a whole number of ${min} or more`);
416
+ return parsed;
417
+ };
418
+ }
419
+ function parseDate(value) {
420
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) throw new InvalidArgumentError("expected an ISO date, e.g. 2026-12-31");
421
+ return value;
422
+ }
423
+ const parseLocale = oneOf(STATEMENT_LOCALES);
424
+ const parseCountry = oneOf(COUNTRIES, (value) => value.toUpperCase());
425
+ const parseStatementFormat = oneOf(STATEMENT_FORMATS);
426
+ const parseImpact = oneOf(IMPACT_LEVELS);
427
+ const parseFormat = oneOf(OUTPUT_FORMATS);
428
+ const parsePositive = wholeNumber(1);
429
+ const parseDepth = wholeNumber(0);
430
+ const parseConcurrency = wholeNumber(1);
166
431
  const program = new Command();
167
432
  program.exitOverride();
168
433
  program.name("eaa-kit").description("WCAG 2.2 AA auditor and EU accessibility statement generator for static sites.\nNot legal advice.").version(TOOL_VERSION, "-v, --version");
169
- program.command("audit").description("Audit built HTML against WCAG 2.2 AA").argument("[dir]", "directory holding the built site (default: found automatically)").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "audit a running site instead of a build directory").option("--no-build", "never run the project build or start its server").option("--per-page", "also list every page and its result").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--fail-on <impact>", `exit 1 on violations at or above this impact (${IMPACT_LEVELS.join("|")})`, parseImpact, DEFAULT_FAIL_ON).option("--format <format>", `output format (${OUTPUT_FORMATS.join("|")})`, parseFormat, "console").option("--output <path>", "write the report to a file instead of stdout").option("--browser", "audit in real Chromium, covering the rules jsdom cannot evaluate").option("--concurrency <n>", "worker threads to audit with, or 1 for none (default: from the page and core count)", parseConcurrency).option("--baseline <path>", "accept the violations recorded in this file; fail only on new ones").action(async (dir, options) => {
434
+ program.command("audit").description("Audit built HTML against WCAG 2.2 AA").argument("[dir]", "directory holding the built site (default: found automatically)").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "audit a running site instead of a build directory").option("--no-build", "never run the project build or start its server").option("--per-page", "also list every page and its result").option("--manual", "what to check by hand for the rules this engine cannot evaluate").option("--coverage", "list every WCAG 2.2 A/AA criterion and what this run reached on it").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--sitemap <path>", "where the site lists its pages, if not /sitemap.xml").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--fail-on <impact>", `exit 1 on violations at or above this impact (${IMPACT_LEVELS.join("|")})`, parseImpact, DEFAULT_FAIL_ON).option("--format <format>", `output format (${OUTPUT_FORMATS.join("|")})`, parseFormat, "console").option("--output <path>", "write the report to a file instead of stdout").option("--browser", "audit in real Chromium, covering the rules jsdom cannot evaluate").option("--concurrency <n>", "worker threads to audit with, or 1 for none (default: from the page and core count)", parseConcurrency).option("--baseline <path>", "accept the violations recorded in this file; fail only on new ones").action(async (dir, flags) => {
435
+ const { build, ...options } = flags;
170
436
  const { exitCode } = await runAuditCommand(dir, {
171
- ...options["build"] === false ? { noBuild: true } : {},
172
- ...options["perPage"] === true ? { perPage: true } : {},
173
- ...Array.isArray(options["include"]) ? { include: options["include"] } : {},
174
- ...Array.isArray(options["exclude"]) ? { exclude: options["exclude"] } : {},
175
- ...typeof options["baseUrl"] === "string" ? { baseUrl: options["baseUrl"] } : {},
176
- failOn: options["failOn"],
177
- format: options["format"],
178
- ...typeof options["output"] === "string" ? { output: options["output"] } : {},
179
- ...options["browser"] === true ? { browser: true } : {},
180
- ...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {},
181
- ...typeof options["baseline"] === "string" ? { baseline: options["baseline"] } : {},
182
- ...typeof options["url"] === "string" ? { url: options["url"] } : {},
183
- ...options["allowRemote"] === true ? { allowRemote: true } : {},
184
- ...options["ignoreRobots"] === true ? { ignoreRobots: true } : {},
185
- ...typeof options["maxPages"] === "number" ? { maxPages: options["maxPages"] } : {},
186
- ...typeof options["maxDepth"] === "number" ? { maxDepth: options["maxDepth"] } : {}
437
+ ...options,
438
+ ...build === false ? { noBuild: true } : {}
187
439
  });
188
440
  process.exitCode = exitCode;
189
441
  });
190
- program.command("baseline").description("Record the violations a build already has, so later runs fail only on new ones").argument("[dir]", "directory holding the built site", "./dist").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "record a baseline from a running site instead of a directory").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--output <path>", `where to write it (default: ${DEFAULT_BASELINE_FILE})`).option("--note <text>", "recorded on every entry, for whoever reads the file").option("--expires-on <date>", "ISO date after which the entries stop suppressing", parseDate).option("--browser", "audit in real Chromium instead of jsdom").option("--concurrency <n>", "worker threads to audit with, or 1 for none", parseConcurrency).action(async (dir, options) => {
191
- const { exitCode } = await runBaselineCommand(dir, {
192
- ...Array.isArray(options["include"]) ? { include: options["include"] } : {},
193
- ...Array.isArray(options["exclude"]) ? { exclude: options["exclude"] } : {},
194
- ...typeof options["baseUrl"] === "string" ? { baseUrl: options["baseUrl"] } : {},
195
- ...typeof options["output"] === "string" ? { output: options["output"] } : {},
196
- ...typeof options["note"] === "string" ? { note: options["note"] } : {},
197
- ...typeof options["expiresOn"] === "string" ? { expiresOn: options["expiresOn"] } : {},
198
- ...options["browser"] === true ? { browser: true } : {},
199
- ...typeof options["concurrency"] === "number" ? { concurrency: options["concurrency"] } : {},
200
- ...typeof options["url"] === "string" ? { url: options["url"] } : {},
201
- ...options["allowRemote"] === true ? { allowRemote: true } : {},
202
- ...options["ignoreRobots"] === true ? { ignoreRobots: true } : {},
203
- ...typeof options["maxPages"] === "number" ? { maxPages: options["maxPages"] } : {},
204
- ...typeof options["maxDepth"] === "number" ? { maxDepth: options["maxDepth"] } : {}
205
- });
442
+ program.command("baseline").description("Record the violations a build already has, so later runs fail only on new ones").argument("[dir]", "directory holding the built site", "./dist").option("--include <globs...>", "glob patterns to audit, relative to dir").option("--exclude <globs...>", "glob patterns to skip").option("--base-url <url>", "audit pages under their real site URL").option("--url <url>", "record a baseline from a running site instead of a directory").option("--allow-remote", "allow --url to crawl a host that is not localhost").option("--ignore-robots", "crawl paths robots.txt disallows").option("--sitemap <path>", "where the site lists its pages, if not /sitemap.xml").option("--max-pages <n>", "stop the crawl after this many pages", parsePositive).option("--max-depth <n>", "how far from the entry URL to follow links", parseDepth).option("--output <path>", `where to write it (default: ${DEFAULT_BASELINE_FILE})`).option("--note <text>", "recorded on every entry, for whoever reads the file").option("--expires-on <date>", "ISO date after which the entries stop suppressing", parseDate).option("--browser", "audit in real Chromium instead of jsdom").option("--concurrency <n>", "worker threads to audit with, or 1 for none", parseConcurrency).action(async (dir, flags) => {
443
+ const { exitCode } = await runBaselineCommand(dir, flags);
206
444
  process.exitCode = exitCode;
207
445
  });
208
- program.command("init").description("Write an eaa.config.json to generate statements from").option("--output <path>", "write here instead of eaa.config.json").option("--force", "overwrite a config that is already there").option("-y, --yes", "take every default without asking").action(async (options) => {
209
- const { runInitCommand } = await import("../init-DRKIdpK1.js");
210
- const { exitCode } = await runInitCommand({
211
- ...typeof options["output"] === "string" ? { output: options["output"] } : {},
212
- ...options["force"] === true ? { force: true } : {},
213
- ...options["yes"] === true ? { yes: true } : {}
214
- });
446
+ program.command("diff").description("Compare two JSON reports: what a change made worse, and what it fixed").argument("<before>", "JSON report from before the change").argument("<after>", "JSON report from after it").option("--format <format>", `output format (${DIFF_FORMATS.join("|")})`, oneOf(DIFF_FORMATS)).option("--output <path>", "write the diff here instead of stdout").option("--fail-on <impact>", `exit 1 on NEW violations at or above this impact (${IMPACT_LEVELS.join("|")})`, parseImpact, DEFAULT_FAIL_ON).action(async (before, after, flags) => {
447
+ const { exitCode } = await runDiffCommand(before, after, flags);
448
+ process.exitCode = exitCode;
449
+ });
450
+ program.command("init").description("Write an eaa.config.json to generate statements from").option("--output <path>", "write here instead of eaa.config.json").option("--force", "overwrite a config that is already there").option("-y, --yes", "take every default without asking").action(async (flags) => {
451
+ const { runInitCommand } = await import("../init-DIWDE35F.js");
452
+ const { exitCode } = await runInitCommand(flags);
215
453
  process.exitCode = exitCode;
216
454
  });
217
- program.command("statement").description("Generate an EU accessibility statement from eaa.config").option("--config <path>", "path to the config file, otherwise it is searched for").option("--lang <locale>", `statement language (${STATEMENT_LOCALES.join("|")})`, parseLocale).option("--country <code>", `override the country template (${COUNTRIES.join("|")})`, parseCountry).option("--audit <path>", "list the barriers from an eaa-kit audit --format json report").option("--format <format>", `output format (${STATEMENT_FORMATS.join("|")}), otherwise from the --output extension`, parseStatementFormat).option("--output <path>", "write the statement to a file instead of stdout").action(async (options) => {
455
+ program.command("statement").description("Generate an EU accessibility statement from eaa.config").option("--config <path>", "path to the config file, otherwise it is searched for").option("--lang <locale>", `statement language (${STATEMENT_LOCALES.join("|")})`, parseLocale).option("--country <code>", `override the country template (${COUNTRIES.join("|")})`, parseCountry).option("--audit <path>", "list the barriers from an eaa-kit audit --format json report").option("--format <format>", `output format (${STATEMENT_FORMATS.join("|")}), otherwise from the --output extension`, parseStatementFormat).option("--output <path>", "write the statement to a file instead of stdout").action(async (flags) => {
456
+ const { lang, ...options } = flags;
218
457
  const { exitCode } = await runStatementCommand({
219
- ...typeof options["config"] === "string" ? { config: options["config"] } : {},
220
- ...options["lang"] ? { locale: options["lang"] } : {},
221
- ...options["country"] ? { country: options["country"] } : {},
222
- ...typeof options["audit"] === "string" ? { audit: options["audit"] } : {},
223
- ...options["format"] ? { format: options["format"] } : {},
224
- ...typeof options["output"] === "string" ? { output: options["output"] } : {}
458
+ ...options,
459
+ ...lang === void 0 ? {} : { locale: lang }
225
460
  });
226
461
  process.exitCode = exitCode;
227
462
  });
228
- function parseLocale(value) {
229
- if (!STATEMENT_LOCALES.includes(value)) throw new InvalidArgumentError(`expected one of ${STATEMENT_LOCALES.join(", ")}`);
230
- return value;
231
- }
232
- function parseCountry(value) {
233
- const upper = value.toUpperCase();
234
- if (!COUNTRIES.includes(upper)) throw new InvalidArgumentError(`expected one of ${COUNTRIES.join(", ")}`);
235
- return upper;
236
- }
237
- function parseStatementFormat(value) {
238
- if (!isStatementFormat(value)) throw new InvalidArgumentError(`expected one of ${STATEMENT_FORMATS.join(", ")}`);
239
- return value;
240
- }
241
- function parseDate(value) {
242
- if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`))) throw new InvalidArgumentError("expected an ISO date, e.g. 2026-12-31");
243
- return value;
244
- }
245
- function parsePositive(value) {
246
- const parsed = Number(value);
247
- if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a whole number of 1 or more");
248
- return parsed;
249
- }
250
- /** Depth 0 is meaningful here: audit only the entry page. */
251
- function parseDepth(value) {
252
- const parsed = Number(value);
253
- if (!Number.isInteger(parsed) || parsed < 0) throw new InvalidArgumentError("expected a whole number of 0 or more");
254
- return parsed;
255
- }
256
- function parseConcurrency(value) {
257
- const parsed = Number(value);
258
- if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a whole number of 1 or more");
259
- return parsed;
260
- }
261
- function parseImpact(value) {
262
- if (!isImpactLevel(value)) throw new InvalidArgumentError(`expected one of ${IMPACT_LEVELS.join(", ")}`);
263
- return value;
264
- }
265
- function parseFormat(value) {
266
- if (!isOutputFormat(value)) throw new InvalidArgumentError(`expected one of ${OUTPUT_FORMATS.join(", ")}`);
267
- return value;
268
- }
269
463
  try {
270
464
  await program.parseAsync(process.argv);
271
465
  } catch (cause) {