eaa-kit 0.3.0 → 0.5.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 (66) hide show
  1. package/README.md +82 -14
  2. package/dist/astro/index.d.ts +1 -1
  3. package/dist/astro/index.js +1 -1
  4. package/dist/audit/runners/worker.js +5 -1
  5. package/dist/{audit-DcL73mOC.js → audit-B282A3EA.js} +207 -41
  6. package/dist/audit-DyHPmGeD.js +2 -0
  7. package/dist/{baseline-CuKFq4IF.js → baseline-0u4df12v.js} +1 -1
  8. package/dist/{baseline-s9F3fXTN.js → baseline-CuAS2lMk.js} +6 -5
  9. package/dist/cli/index.js +306 -18
  10. package/dist/{collect-BkAQ0viT.js → collect-Cu1q9NTB.js} +53 -11
  11. package/dist/command-C3D7JWn6.js +160 -0
  12. package/dist/{component-7kEBjv_y.js → component-C3GL1Mnu.js} +41 -6
  13. package/dist/component-DKd3EHOg.js +2 -0
  14. package/dist/coverage-B_Y6l-Ra.js +522 -0
  15. package/dist/{crawl-Oxt2Gaqo.js → crawl-BUWPgaGW.js} +83 -9
  16. package/dist/eleventy/index.d.ts +28 -0
  17. package/dist/eleventy/index.js +19 -0
  18. package/dist/fingerprint-BjYV_0F7.js +67 -0
  19. package/dist/{frameworks-BYa3tULg.js → frameworks-B4ClIJgE.js} +71 -0
  20. package/dist/{frameworks-DaDqrJOw.js → frameworks-etFg_O8K.js} +1 -1
  21. package/dist/{html-DKiI_3gs.js → html-C4vktg0A.js} +130 -5
  22. package/dist/{impact-YdoOtFqm.js → impact-DZt2oBCP.js} +1 -1
  23. package/dist/index.d.ts +119 -4
  24. package/dist/index.js +2 -2
  25. package/dist/{init-DiLiYvN1.js → init-CW7LfGT5.js} +10 -4
  26. package/dist/jsdom-22Bkt65v.js +3 -0
  27. package/dist/jsdom-B--cEH-G.js +89 -0
  28. package/dist/{json-Cnv9nd6U.js → json-DROX33kh.js} +9 -4
  29. package/dist/{json-B0Y7rNjt.js → json-D_Mnnft5.js} +1 -1
  30. package/dist/load-CFq2VQtT.js +2 -0
  31. package/dist/{load-UYXLqGV9.js → load-yAR4wzez.js} +133 -8
  32. package/dist/nuxt/index.d.ts +35 -0
  33. package/dist/nuxt/index.js +23 -0
  34. package/dist/{playwright-DYFsGUNd.js → playwright-BojtYVUa.js} +30 -6
  35. package/dist/{pool-BWkWZiJW.js → pool-BO25OIez.js} +52 -2
  36. package/dist/{project-DW08TseF.js → project-CzOnkLH6.js} +8 -2
  37. package/dist/project-MFrXcw1M.js +2 -0
  38. package/dist/remediation-CMBIrnpN.js +321 -0
  39. package/dist/{render-DI_aCnAZ.js → render-DbGOVmhx.js} +48 -10
  40. package/dist/{result-DLxd2Eip.js → result-BWcYXeRs.js} +114 -3
  41. package/dist/routes-CmdRUuOs.js +265 -0
  42. package/dist/{run-BW6CVuND.js → run-C2nKFcb-.js} +19 -2
  43. package/dist/{run-BMASMmwO.d.ts → run-CtcEUhbe.d.ts} +7 -0
  44. package/dist/{sarif-DB3WG7T9.js → sarif-B-UBcVu8.js} +16 -8
  45. package/dist/{schema-CMZ8ItGk.js → schema-DJSF4K05.js} +18 -1
  46. package/dist/statement/templates/es.en.md +125 -0
  47. package/dist/statement/templates/es.es.md +127 -0
  48. package/dist/statement/templates/fr.en.md +128 -0
  49. package/dist/statement/templates/fr.fr.md +131 -0
  50. package/dist/statement/templates/it.en.md +127 -0
  51. package/dist/statement/templates/it.it.md +130 -0
  52. package/dist/statement/templates/nl.en.md +125 -0
  53. package/dist/statement/templates/nl.nl.md +127 -0
  54. package/dist/{text-BFmNtMsV.js → text-CKKpzkYM.js} +1 -1
  55. package/dist/vite/index.d.ts +1 -1
  56. package/dist/vite/index.js +1 -1
  57. package/dist/webpack/index.d.ts +33 -0
  58. package/dist/webpack/index.js +20 -0
  59. package/package.json +35 -9
  60. package/dist/audit-CuG2hYyo.js +0 -2
  61. package/dist/command-Dxpa00Ha.js +0 -77
  62. package/dist/fingerprint-DRoneAjj.js +0 -20
  63. package/dist/jsdom-BjpF-2V-.js +0 -158
  64. package/dist/jsdom-X4KYfTp8.js +0 -3
  65. package/dist/manual-Vz-oX1I_.js +0 -239
  66. package/dist/routes-C2Cgf6Ko.js +0 -119
package/dist/cli/index.js CHANGED
@@ -1,14 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { l as STATEMENT_LOCALES, o as COUNTRIES, r as loadConfig, s as ConfigError } from "../load-UYXLqGV9.js";
3
- import { i as readAuditReport, o as StatementError, t as renderStatement } from "../render-DI_aCnAZ.js";
4
- import { n as IMPACT_LEVELS, t as DEFAULT_FAIL_ON } from "../impact-YdoOtFqm.js";
5
- import { n as count } from "../text-BFmNtMsV.js";
2
+ import { a as impactRank, n as IMPACT_LEVELS, o as isImpactLevel, s as meetsThreshold, t as DEFAULT_FAIL_ON } from "../impact-DZt2oBCP.js";
3
+ import { c as nullable, d as optional, h as string, l as number, m as safeParse, n as boolean, p as record, t as array, u as object, y as withDefault } from "../schema-DJSF4K05.js";
4
+ import { c as ConfigError, i as loadConfig, s as COUNTRIES, u as STATEMENT_LOCALES } from "../load-yAR4wzez.js";
5
+ import { i as readAuditReport, o as StatementError, t as renderStatement } from "../render-DbGOVmhx.js";
6
+ import { n as count, t as collapse } from "../text-CKKpzkYM.js";
6
7
  import { t as TOOL_VERSION } from "../version-B3v4rNoG.js";
7
- import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-s9F3fXTN.js";
8
- import { n as runAuditCommand, r as resolvePages, t as OUTPUT_FORMATS } from "../audit-DcL73mOC.js";
9
- import { a as runEngine, i as note, n as emitDocument, r as fail, t as advise } from "../command-Dxpa00Ha.js";
8
+ import { t as elementFingerprint } from "../fingerprint-BjYV_0F7.js";
9
+ import { i as buildBaseline, n as DEFAULT_BASELINE_FILE, s as writeBaseline, t as BaselineError } from "../baseline-CuAS2lMk.js";
10
+ import { n as runAuditCommand, r as resolvePages, t as OUTPUT_FORMATS } from "../audit-B282A3EA.js";
11
+ import { a as emitDocument, c as runEngine, i as baselineInvocation, l as warn, n as auditDefaults, o as fail, r as auditInvocation, s as note, t as advise } from "../command-C3D7JWn6.js";
12
+ import { enableCompileCache } from "node:module";
13
+ import { readFile } from "node:fs/promises";
10
14
  import path from "node:path";
11
15
  import { Command, InvalidArgumentError } from "commander";
16
+ import pc from "picocolors";
12
17
  //#region src/cli/baseline.ts
13
18
  /**
14
19
  * `eaa-kit baseline [dir]`.
@@ -32,7 +37,7 @@ async function runBaselineCommand(dir, options = {}) {
32
37
  entries: 0,
33
38
  exitCode: 2
34
39
  };
35
- const { pages, origin, label } = resolved;
40
+ const { pages, origin, label, directory } = resolved;
36
41
  note(`Auditing ${count(pages.length, "page")} in ${label}…`);
37
42
  const baseUrl = options.baseUrl ?? origin;
38
43
  const audits = await runEngine(pages, {
@@ -41,7 +46,7 @@ async function runBaselineCommand(dir, options = {}) {
41
46
  ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
42
47
  ...options.browser ? { browser: true } : {},
43
48
  ...options.concurrency === void 0 ? {} : { concurrency: options.concurrency },
44
- ...options.url === void 0 ? { directory: path.resolve(cwd, dir) } : {}
49
+ ...directory === void 0 ? {} : { directory }
45
50
  });
46
51
  if (!audits) return {
47
52
  entries: 0,
@@ -80,6 +85,255 @@ async function runBaselineCommand(dir, options = {}) {
80
85
  exitCode: 0
81
86
  };
82
87
  }
88
+ var DiffError = class extends Error {
89
+ name = "DiffError";
90
+ };
91
+ /**
92
+ * Only the fields a comparison reads. `completeness` is optional: a report
93
+ * written before it existed says nothing about coverage, which is not the same
94
+ * as saying the run was complete, and is handled as the unknown it is.
95
+ */
96
+ const reportSchema = object({
97
+ schemaVersion: number(),
98
+ generatedAt: string(),
99
+ target: withDefault(object({ source: withDefault(string(), () => "") }), () => ({ source: "" })),
100
+ completeness: optional(object({
101
+ complete: boolean(),
102
+ audited: withDefault(number(), () => 0)
103
+ })),
104
+ rules: withDefault(record(object({ help: withDefault(string(), () => "") })), () => ({})),
105
+ pages: withDefault(array(object({
106
+ path: string(),
107
+ error: withDefault(nullable(string()), () => null),
108
+ violations: withDefault(array(object({
109
+ ruleId: string(),
110
+ impact: withDefault(nullable(string()), () => null),
111
+ nodes: withDefault(array(object({
112
+ html: withDefault(string(), () => ""),
113
+ target: withDefault(array(string()), () => [])
114
+ })), () => [])
115
+ })), () => [])
116
+ })), () => [])
117
+ });
118
+ /**
119
+ * The elements a violation points at.
120
+ *
121
+ * Mirrors `findingElements` for the JSON shape, including its one subtlety: a
122
+ * rule can fail with no element attached, and it still has an identity. Getting
123
+ * this wrong here would make document-level failures invisible to a diff while
124
+ * remaining visible to the baseline.
125
+ */
126
+ function elementsOf(violation) {
127
+ if (violation.nodes.length === 0) return [{
128
+ selector: "",
129
+ html: ""
130
+ }];
131
+ return violation.nodes.map((node) => ({
132
+ selector: node.target.join(" "),
133
+ html: node.html
134
+ }));
135
+ }
136
+ /** Every violating element in a report, keyed by page and element identity. */
137
+ function entriesOf(report) {
138
+ const entries = /* @__PURE__ */ new Map();
139
+ for (const page of report.pages) for (const violation of page.violations) {
140
+ const impact = violation.impact !== null && isImpactLevel(violation.impact) ? violation.impact : null;
141
+ for (const { selector, html } of elementsOf(violation)) {
142
+ const fingerprint = elementFingerprint(violation.ruleId, selector, html);
143
+ const key = `${page.path}\n${fingerprint}`;
144
+ if (entries.has(key)) continue;
145
+ entries.set(key, {
146
+ page: page.path,
147
+ ruleId: violation.ruleId,
148
+ fingerprint,
149
+ impact,
150
+ help: report.rules[violation.ruleId]?.help ?? violation.ruleId,
151
+ selector,
152
+ html
153
+ });
154
+ }
155
+ }
156
+ return entries;
157
+ }
158
+ /** Pages a run reached a verdict on. A page that errored is not one of them. */
159
+ function auditedPages(report) {
160
+ return new Set(report.pages.filter((page) => page.error === null).map((page) => page.path));
161
+ }
162
+ function sideOf(report) {
163
+ return {
164
+ source: report.target.source,
165
+ generatedAt: report.generatedAt,
166
+ audited: report.completeness?.audited ?? auditedPages(report).size,
167
+ complete: report.completeness?.complete
168
+ };
169
+ }
170
+ /** Worst first, then by page, so two runs of one comparison agree. */
171
+ function bySeverityThenPage(a, b) {
172
+ return impactRank(a.impact) - impactRank(b.impact) || a.page.localeCompare(b.page) || a.ruleId.localeCompare(b.ruleId) || a.selector.localeCompare(b.selector);
173
+ }
174
+ /** Compare two parsed reports, earlier first. */
175
+ function diffReports(base, head) {
176
+ const before = entriesOf(base);
177
+ const after = entriesOf(head);
178
+ const measuredByHead = auditedPages(head);
179
+ const added = [];
180
+ const fixed = [];
181
+ const unchanged = [];
182
+ const unmeasured = [];
183
+ for (const [key, entry] of after) if (before.has(key)) unchanged.push(entry);
184
+ else added.push(entry);
185
+ for (const [key, entry] of before) {
186
+ if (after.has(key)) continue;
187
+ if (measuredByHead.has(entry.page)) fixed.push(entry);
188
+ else unmeasured.push(entry);
189
+ }
190
+ return {
191
+ base: sideOf(base),
192
+ head: sideOf(head),
193
+ added: added.sort(bySeverityThenPage),
194
+ fixed: fixed.sort(bySeverityThenPage),
195
+ unchanged: unchanged.sort(bySeverityThenPage),
196
+ unmeasured: unmeasured.sort(bySeverityThenPage)
197
+ };
198
+ }
199
+ /**
200
+ * Read a JSON report from disk.
201
+ *
202
+ * Refuses a schema version it was not written against rather than reading it
203
+ * optimistically: a field that changed meaning is exactly what the version
204
+ * number exists to signal, and a diff built on a misread field would be wrong
205
+ * quietly.
206
+ */
207
+ async function readReport(file, cwd = process.cwd()) {
208
+ const target = path.resolve(cwd, file);
209
+ let raw;
210
+ try {
211
+ raw = await readFile(target, "utf8");
212
+ } catch {
213
+ throw new DiffError(`Report not found: ${file}`);
214
+ }
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(raw);
218
+ } catch (cause) {
219
+ throw new DiffError(`${file} is not valid JSON (${cause.message})`);
220
+ }
221
+ const result = safeParse(reportSchema, parsed);
222
+ if (!result.success) {
223
+ const first = result.error.issues[0];
224
+ 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"}`);
225
+ }
226
+ if (result.data.schemaVersion !== 2) throw new DiffError(`${file} is schemaVersion ${result.data.schemaVersion}; this version of eaa-kit reads 2.\n Write both reports with the same version of eaa-kit before comparing them.`);
227
+ return result.data;
228
+ }
229
+ //#endregion
230
+ //#region src/cli/diff.ts
231
+ /**
232
+ * `eaa-kit diff <before.json> <after.json>`.
233
+ *
234
+ * What a change did to a site's accessibility, which is the question a review
235
+ * actually asks and which neither report answers on its own. Running the
236
+ * auditor on a branch prints everything wrong with the site; almost all of it
237
+ * was already there, and the two or three findings somebody introduced are
238
+ * somewhere in the middle of it.
239
+ *
240
+ * Deliberately stateless. A baseline is a file somebody commits and maintains,
241
+ * and it answers "what have we agreed to live with". This answers "what did
242
+ * this change do", needs no file and no decision, and can be run over any two
243
+ * reports after the fact.
244
+ */
245
+ const DIFF_FORMATS = ["console", "json"];
246
+ /** Longest element markup shown before it is truncated. */
247
+ const MAX_SNIPPET = 100;
248
+ async function runDiffCommand(base, head, options = {}) {
249
+ const cwd = options.cwd ?? process.cwd();
250
+ let diff;
251
+ try {
252
+ diff = diffReports(await readReport(base, cwd), await readReport(head, cwd));
253
+ } catch (cause) {
254
+ if (cause instanceof DiffError) {
255
+ fail(cause.message);
256
+ return { exitCode: 2 };
257
+ }
258
+ throw cause;
259
+ }
260
+ const failOn = options.failOn ?? "serious";
261
+ const failing = diff.added.filter((entry) => meetsThreshold(entry.impact, failOn));
262
+ const body = (options.format ?? "console") === "json" ? `${JSON.stringify(toJson(diff, failOn, failing.length), null, 2)}\n` : `${formatDiff(diff, failOn, options.output === void 0)}\n`;
263
+ await emitDocument(body, options.output, cwd);
264
+ if (options.output !== void 0) note(`Diff written to ${options.output}`);
265
+ 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"}`);
266
+ return {
267
+ diff,
268
+ exitCode: failing.length > 0 ? 1 : 0
269
+ };
270
+ }
271
+ /**
272
+ * The machine-readable form.
273
+ *
274
+ * `unchanged` is counted and not listed: it is the whole of the pre-existing
275
+ * report, it is already available in the report itself, and repeating it here
276
+ * would make the common case — a diff with three entries — a document nobody
277
+ * can read.
278
+ */
279
+ function toJson(diff, failOn, failing) {
280
+ return {
281
+ base: diff.base,
282
+ head: diff.head,
283
+ summary: {
284
+ added: diff.added.length,
285
+ fixed: diff.fixed.length,
286
+ unchanged: diff.unchanged.length,
287
+ unmeasured: diff.unmeasured.length,
288
+ failOn,
289
+ failing
290
+ },
291
+ added: diff.added,
292
+ fixed: diff.fixed,
293
+ unmeasured: diff.unmeasured
294
+ };
295
+ }
296
+ function formatDiff(diff, failOn, colour = true) {
297
+ const c = pc.createColors(colour);
298
+ const lines = [""];
299
+ lines.push(c.bold("eaa-kit diff"));
300
+ lines.push(c.dim(` before ${describe(diff.base)}`));
301
+ lines.push(c.dim(` after ${describe(diff.head)}`));
302
+ lines.push("");
303
+ if (diff.added.length === 0 && diff.fixed.length === 0) lines.push(c.green("No change in violations."));
304
+ if (diff.added.length > 0) {
305
+ lines.push(c.red(c.bold(`New (${diff.added.length})`)));
306
+ lines.push(...diff.added.map((entry) => entryLine(entry, c)));
307
+ lines.push("");
308
+ }
309
+ if (diff.fixed.length > 0) {
310
+ lines.push(c.green(c.bold(`Fixed (${diff.fixed.length})`)));
311
+ lines.push(...diff.fixed.map((entry) => entryLine(entry, c)));
312
+ lines.push("");
313
+ }
314
+ if (diff.unmeasured.length > 0) {
315
+ lines.push(c.yellow(c.bold(`Not compared (${diff.unmeasured.length})`)));
316
+ lines.push(c.dim(" These failed before, and the later run did not audit the page they were on."));
317
+ lines.push(...diff.unmeasured.map((entry) => entryLine(entry, c)));
318
+ lines.push("");
319
+ }
320
+ const failing = diff.added.filter((entry) => meetsThreshold(entry.impact, failOn)).length;
321
+ lines.push(c.bold("Summary"));
322
+ lines.push(` ${diff.added.length} new · ${diff.fixed.length} fixed · ${diff.unchanged.length} unchanged`);
323
+ 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})`));
324
+ return lines.join("\n");
325
+ }
326
+ function describe(side) {
327
+ const source = side.source === "" ? "unknown source" : side.source;
328
+ const coverage = side.complete === void 0 ? "coverage not recorded" : side.complete ? "complete" : "incomplete";
329
+ return `${source} · ${count(side.audited, "page")} · ${coverage} · ${side.generatedAt}`;
330
+ }
331
+ function entryLine(entry, c) {
332
+ const where = entry.selector === "" ? entry.page : `${entry.page} ${c.dim(entry.selector)}`;
333
+ const impact = entry.impact ?? "unclassified";
334
+ const snippet = entry.html === "" ? "" : `\n ${c.dim(collapse(entry.html, MAX_SNIPPET))}`;
335
+ return ` ${c.bold(entry.ruleId)} ${c.dim(`(${impact})`)} — ${entry.help}\n ${where}${snippet}`;
336
+ }
83
337
  //#endregion
84
338
  //#region src/cli/statement.ts
85
339
  /** Markdown for a content directory, HTML for dropping straight onto a site. */
@@ -143,6 +397,30 @@ function formatFor(output) {
143
397
  //#endregion
144
398
  //#region src/cli/index.ts
145
399
  /**
400
+ * Reuse the compiled form of this tool and its dependencies between runs.
401
+ *
402
+ * Most of what a short audit costs is not the audit. jsdom alone is ~700 ms to
403
+ * import on a warm page cache, and V8 pays to parse and compile it from source
404
+ * on every single run — of a CLI somebody invokes from a build script, over and
405
+ * over, against code that has not changed since the last time. Node can cache
406
+ * the compiled bytecode instead, which is exactly the shape of this problem.
407
+ *
408
+ * Measured over ten pages: 2350 ms without, 2075 ms with. The saving is a fixed
409
+ * cost, so it counts for most on the small sites where fixed cost is most of
410
+ * the run.
411
+ *
412
+ * Best-effort by design. It is a cache: a read-only filesystem, a sandbox that
413
+ * forbids the directory, or a Node built without the feature all mean the run
414
+ * is a little slower, and none of them is a reason to fail an audit. Node's own
415
+ * NODE_DISABLE_COMPILE_CACHE is honoured by the call itself.
416
+ */
417
+ function useCompileCache() {
418
+ try {
419
+ enableCompileCache();
420
+ } catch {}
421
+ }
422
+ useCompileCache();
423
+ /**
146
424
  * A parser for a fixed set of words, listing them when the answer is not one.
147
425
  *
148
426
  * `normalise` is for `--country de`, where the accepted spelling differs from
@@ -178,20 +456,24 @@ const parseConcurrency = wholeNumber(1);
178
456
  const program = new Command();
179
457
  program.exitOverride();
180
458
  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");
181
- 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("--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, flags) => {
182
- const { build, ...options } = flags;
183
- const { exitCode } = await runAuditCommand(dir, {
184
- ...options,
185
- ...build === false ? { noBuild: true } : {}
186
- });
459
+ 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("|")}; default: ${DEFAULT_FAIL_ON})`, parseImpact).option("--format <format>", `output format (${OUTPUT_FORMATS.join("|")}; default: console)`, parseFormat).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("--fast", "skip the rules the browserless engine cannot decide anyway, losing their element lists").option("--concurrency <n>", "pages to audit at once, 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").option("--config <path>", "take defaults from this config file, otherwise it is searched for").action(async (dir, flags) => {
460
+ const defaults = await auditDefaults({ ...flags.config ? { config: flags.config } : {} });
461
+ const invocation = auditInvocation(dir, defaults, flags);
462
+ const { exitCode } = await runAuditCommand(invocation.dir, invocation.options);
187
463
  process.exitCode = exitCode;
188
464
  });
189
- 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, flags) => {
190
- const { exitCode } = await runBaselineCommand(dir, flags);
465
+ 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 (default: ./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>", "pages to audit at once, or 1 for none", parseConcurrency).option("--config <path>", "take defaults from this config file, otherwise it is searched for").action(async (dir, flags) => {
466
+ const defaults = await auditDefaults({ ...flags.config ? { config: flags.config } : {} });
467
+ const invocation = baselineInvocation(dir, defaults, flags);
468
+ const { exitCode } = await runBaselineCommand(invocation.dir, invocation.options);
469
+ process.exitCode = exitCode;
470
+ });
471
+ 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) => {
472
+ const { exitCode } = await runDiffCommand(before, after, flags);
191
473
  process.exitCode = exitCode;
192
474
  });
193
475
  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) => {
194
- const { runInitCommand } = await import("../init-DiLiYvN1.js");
476
+ const { runInitCommand } = await import("../init-CW7LfGT5.js");
195
477
  const { exitCode } = await runInitCommand(flags);
196
478
  process.exitCode = exitCode;
197
479
  });
@@ -206,6 +488,12 @@ program.command("statement").description("Generate an EU accessibility statement
206
488
  try {
207
489
  await program.parseAsync(process.argv);
208
490
  } catch (cause) {
491
+ if (cause instanceof ConfigError) {
492
+ fail(cause.message);
493
+ for (const issue of cause.issues) note(` ${issue}`);
494
+ process.exitCode = 2;
495
+ process.exit(2);
496
+ }
209
497
  const error = cause;
210
498
  if (typeof error.exitCode === "number") process.exitCode = error.exitCode === 0 ? 0 : 2;
211
499
  else {
@@ -46,10 +46,32 @@ async function collectPages(dir, options = {}) {
46
46
  const pages = [];
47
47
  for (let i = 0; i < relativePaths.length; i += READ_CONCURRENCY) {
48
48
  const batch = relativePaths.slice(i, i + READ_CONCURRENCY);
49
- pages.push(...await Promise.all(batch.map((relativePath) => readPage(root, relativePath))));
49
+ const read = await Promise.all(batch.map((relativePath) => readPage(root, relativePath, options.onUnreadable, options.maxBytes ?? 33554432)));
50
+ for (const page of read) if (page !== void 0) pages.push(page);
50
51
  }
51
52
  return pages;
52
53
  }
54
+ /**
55
+ * Whether the directory holds any HTML at all, ignoring include and exclude.
56
+ *
57
+ * Asked when a run collected nothing, to tell two different mistakes apart: a
58
+ * build directory with no pages in it, and a directory full of pages that the
59
+ * caller's own filters excluded. The advice for the first is wrong for the
60
+ * second — it names another directory to audit — so the question has to be
61
+ * answered before it is given.
62
+ *
63
+ * Globs rather than collecting: this only needs to know whether one file
64
+ * exists, and reading every page of a large build to answer that on a path that
65
+ * is about to print a warning and stop would be work for nothing.
66
+ */
67
+ async function holdsHtml(dir) {
68
+ return (await glob([...DEFAULT_INCLUDE], {
69
+ cwd: path.resolve(dir),
70
+ ignore: [...DEFAULT_EXCLUDE],
71
+ onlyFiles: true,
72
+ dot: false
73
+ })).length > 0;
74
+ }
53
75
  async function assertDirectory(root, original) {
54
76
  let stats;
55
77
  try {
@@ -60,13 +82,25 @@ async function assertDirectory(root, original) {
60
82
  }
61
83
  if (!stats.isDirectory()) throw new BuildDirectoryError(`Build path is not a directory: ${original}`, root);
62
84
  }
63
- async function readPage(root, relativePath) {
85
+ async function readPage(root, relativePath, onUnreadable, maxBytes) {
64
86
  const absolutePath = path.join(root, relativePath);
65
- return {
66
- absolutePath,
67
- relativePath,
68
- html: stripBom(await readFile(absolutePath, "utf8"))
69
- };
87
+ try {
88
+ const { size } = await stat(absolutePath);
89
+ if (size > maxBytes) {
90
+ if (onUnreadable === void 0) throw new Error(`${relativePath} is ${size} bytes, over the ${maxBytes} byte limit`);
91
+ onUnreadable(relativePath, `${megabytes(size)} MB, over the ${megabytes(maxBytes)} MB limit for one page`);
92
+ return;
93
+ }
94
+ return {
95
+ absolutePath,
96
+ relativePath,
97
+ html: stripBom(await readFile(absolutePath, "utf8"))
98
+ };
99
+ } catch (cause) {
100
+ if (onUnreadable === void 0) throw cause;
101
+ onUnreadable(relativePath, cause.message);
102
+ return;
103
+ }
70
104
  }
71
105
  /**
72
106
  * What to suggest when a directory holds no HTML, or is not there at all.
@@ -79,8 +113,8 @@ async function readPage(root, relativePath) {
79
113
  */
80
114
  async function emptyDirectoryHint(dir, cwd = process.cwd()) {
81
115
  const head = await exists(dir, cwd) ? `${dir} holds no HTML files.` : `${dir} does not exist.`;
82
- const { detectFramework } = await import("./frameworks-DaDqrJOw.js");
83
- const { readPackageJson } = await import("./project-DW08TseF.js");
116
+ const { detectFramework } = await import("./frameworks-etFg_O8K.js");
117
+ const { readPackageJson } = await import("./project-MFrXcw1M.js");
84
118
  const detected = await detectFramework(cwd, await readPackageJson(cwd));
85
119
  if (detected !== void 0) return frameworkAdvice(head, detected, cwd, dir);
86
120
  return await siblingAdvice(head, cwd, dir) ?? generic(head);
@@ -91,6 +125,10 @@ async function frameworkAdvice(head, detected, cwd, dir) {
91
125
  const given = dir.replace(/^\.\//, "");
92
126
  for (const output of outputs) if (output !== given && await exists(output, cwd)) return `${head} ${framework.name} writes to ${output}/, not ${dir}.\n Try: eaa-kit audit ./${output}`;
93
127
  const lines = [`${head} This is ${article(framework.name)} ${framework.name} project.`];
128
+ if (outputs.length === 0) {
129
+ lines.push(` It renders every page on a server and writes no HTML to disk, so there is`, ` no build directory to audit. Start it, then audit what it serves:`, ...framework.serveCommand === void 0 ? [] : [` ${framework.serveCommand}`], " eaa-kit audit --url http://localhost:8000");
130
+ return lines.join("\n");
131
+ }
94
132
  if (framework.staticOutput !== void 0) lines.push(` Static output takes ${framework.staticOutput.needs} — ${framework.staticOutput.how},`, ` then: eaa-kit audit ./${outputs[0]}`);
95
133
  else if (outputs.length === 1 && outputs[0] === given) lines.push(` That is where it writes, so the build has not run yet — or it failed.`);
96
134
  else lines.push(` It writes to ${outputs.map((output) => `${output}/`).join(" or ")}. Run your build, then:`, ` eaa-kit audit ./${outputs[0]}`);
@@ -99,7 +137,7 @@ async function frameworkAdvice(head, detected, cwd, dir) {
99
137
  }
100
138
  /** Naming a build directory that is actually there beats listing the usual ones. */
101
139
  async function siblingAdvice(head, cwd, dir) {
102
- const { FALLBACK_OUTPUTS } = await import("./frameworks-DaDqrJOw.js");
140
+ const { FALLBACK_OUTPUTS } = await import("./frameworks-etFg_O8K.js");
103
141
  const given = dir.replace(/^\.\//, "");
104
142
  const others = (await Promise.all(FALLBACK_OUTPUTS.map(async (name) => name !== given && await exists(name, cwd) ? name : void 0))).filter((name) => name !== void 0);
105
143
  if (others.length === 0) return void 0;
@@ -119,5 +157,9 @@ function generic(head) {
119
157
  function article(name) {
120
158
  return /^[aeiou]/i.test(name) ? "an" : "a";
121
159
  }
160
+ /** Sizes in the megabytes a person reads, not the bytes a computer counts. */
161
+ function megabytes(bytes) {
162
+ return (bytes / 1048576).toFixed(1).replace(/\.0$/, "");
163
+ }
122
164
  //#endregion
123
- export { stripBom as i, collectPages as n, emptyDirectoryHint as r, BuildDirectoryError as t };
165
+ export { stripBom as a, holdsHtml as i, collectPages as n, emptyDirectoryHint as r, BuildDirectoryError as t };
@@ -0,0 +1,160 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import pc from "picocolors";
4
+ //#region src/cli/command.ts
5
+ /**
6
+ * What every command does around the audit itself: say what is happening, run
7
+ * the engine, and put the document somewhere.
8
+ *
9
+ * `audit` and `baseline` are siblings — one reports what a run found and the
10
+ * other writes it down — so they take the same flags, choose between the same
11
+ * two engines and fail on the same setup problems. Keeping that in one place is
12
+ * what stops the two commands drifting into disagreeing about what a run is.
13
+ */
14
+ /** Progress and diagnostics go to stderr, so the report can be piped away. */
15
+ function note(message) {
16
+ process.stderr.write(pc.dim(`${message}\n`));
17
+ }
18
+ function warn(message) {
19
+ process.stderr.write(`${pc.yellow("warning")} ${message}\n`);
20
+ }
21
+ function fail(message) {
22
+ process.stderr.write(`${pc.red("error")} ${message}\n`);
23
+ }
24
+ /**
25
+ * Advice the reader should not miss, without the `warning` prefix: nothing has
26
+ * gone wrong, but what happens next is theirs to get right.
27
+ */
28
+ function advise(message) {
29
+ process.stderr.write(pc.yellow(`${message}\n`));
30
+ }
31
+ /**
32
+ * Audit the pages with whichever engine was asked for.
33
+ *
34
+ * Returns undefined when the browser was asked for and is not usable, having
35
+ * already said so: Playwright missing is a setup problem with a specific fix,
36
+ * not a crash, and both commands turn it into exit 2.
37
+ */
38
+ async function runEngine(pages, options) {
39
+ const runnerOptions = {
40
+ cwd: options.cwd,
41
+ ...options.baseUrl === void 0 ? {} : { baseUrl: options.baseUrl },
42
+ ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
43
+ };
44
+ if (!options.browser) {
45
+ const { runPooledAudit } = await import("./pool-BO25OIez.js");
46
+ return runPooledAudit(pages, {
47
+ ...runnerOptions,
48
+ ...options.fast ? { fast: true } : {},
49
+ ...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
50
+ });
51
+ }
52
+ const { BrowserUnavailableError, runBrowserAudit } = await import("./playwright-BojtYVUa.js");
53
+ try {
54
+ return await runBrowserAudit(options.directory, pages, {
55
+ ...runnerOptions,
56
+ ...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
57
+ });
58
+ } catch (cause) {
59
+ if (cause instanceof BrowserUnavailableError) {
60
+ fail(cause.message);
61
+ return;
62
+ }
63
+ throw cause;
64
+ }
65
+ }
66
+ /**
67
+ * Write a document to `output`, or to stdout when there is none. Parent
68
+ * directories are created, since a report path in CI usually names one that is
69
+ * not there yet.
70
+ */
71
+ async function emitDocument(body, output, cwd) {
72
+ if (output === void 0) {
73
+ process.stdout.write(body);
74
+ return;
75
+ }
76
+ const target = path.resolve(cwd, output);
77
+ await mkdir(path.dirname(target), { recursive: true });
78
+ await writeFile(target, body, "utf8");
79
+ }
80
+ /**
81
+ * Audit defaults from the project's config file.
82
+ *
83
+ * `audit` and `baseline` are run from a build script over and over with the
84
+ * same six flags, and the flags are the only place to say them: the config file
85
+ * has existed since 0.2 and served the statement alone. An `audit` block there
86
+ * is that list written once.
87
+ *
88
+ * Everything it returns is a default. The flags are merged over it by the
89
+ * caller, because the file is the project's usual answer and a flag is somebody
90
+ * asking for something else on this run.
91
+ *
92
+ * No config file at all is not an error, unlike for `statement`: this command
93
+ * has always run against projects that have never heard of one. A file that
94
+ * exists and cannot be read is exit 2 — it was written to be used, and running
95
+ * on different settings than it names would be worse than stopping.
96
+ */
97
+ async function auditDefaults(options = {}) {
98
+ const { loadAuditConfig } = await import("./load-CFq2VQtT.js");
99
+ const loaded = await loadAuditConfig({
100
+ ...options.cwd ? { cwd: options.cwd } : {},
101
+ ...options.config ? { path: options.config } : {}
102
+ });
103
+ if (!loaded?.audit) return {};
104
+ note(`Defaults from ${path.basename(loaded.path)}`);
105
+ return loaded.audit;
106
+ }
107
+ function auditInvocation(dir, defaults, flags) {
108
+ const { build, config: _config, ...typed } = flags;
109
+ const { dir: configDir, build: configBuild, ...fromConfig } = defaults;
110
+ return {
111
+ dir: dir ?? configDir,
112
+ options: {
113
+ ...fromConfig,
114
+ ...typed,
115
+ ...build === false || configBuild === false ? { noBuild: true } : {}
116
+ }
117
+ };
118
+ }
119
+ /**
120
+ * The same, for `baseline`, which reads the defaults that mean the same thing
121
+ * to it.
122
+ *
123
+ * Deliberately a subset. `output` names where the report goes for one command
124
+ * and where the baseline goes for the other, so carrying it across would write
125
+ * a baseline over the path somebody set aside for a report; `format`, `failOn`
126
+ * and `baseline` all describe a verdict this command does not reach.
127
+ */
128
+ function baselineInvocation(dir, defaults, flags) {
129
+ const { config: _config, ...typed } = flags;
130
+ return {
131
+ dir: dir ?? defaults.dir ?? "./dist",
132
+ options: {
133
+ ...baselineDefaults(defaults),
134
+ ...typed
135
+ }
136
+ };
137
+ }
138
+ function baselineDefaults(config) {
139
+ return pick(config, [
140
+ "include",
141
+ "exclude",
142
+ "baseUrl",
143
+ "url",
144
+ "allowRemote",
145
+ "ignoreRobots",
146
+ "sitemap",
147
+ "maxPages",
148
+ "maxDepth",
149
+ "browser",
150
+ "concurrency"
151
+ ]);
152
+ }
153
+ /** Copies the keys that are actually set, so nothing spreads an undefined over a real value. */
154
+ function pick(source, keys) {
155
+ const out = {};
156
+ for (const key of keys) if (source[key] !== void 0) out[key] = source[key];
157
+ return out;
158
+ }
159
+ //#endregion
160
+ export { emitDocument as a, runEngine as c, baselineInvocation as i, warn as l, auditDefaults as n, fail as o, auditInvocation as r, note as s, advise as t };