eaa-kit 0.3.0 → 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 (50) hide show
  1. package/README.md +51 -5
  2. package/dist/astro/index.js +1 -1
  3. package/dist/audit/runners/worker.js +1 -1
  4. package/dist/{audit-DcL73mOC.js → audit-CPoZMXGM.js} +180 -36
  5. package/dist/audit-CpXH2Mk8.js +2 -0
  6. package/dist/{baseline-CuKFq4IF.js → baseline-22Y1NWxM.js} +1 -1
  7. package/dist/{baseline-s9F3fXTN.js → baseline-DB9CZGnV.js} +2 -2
  8. package/dist/cli/index.js +267 -10
  9. package/dist/{collect-BkAQ0viT.js → collect-CFM8gEVv.js} +21 -10
  10. package/dist/{command-Dxpa00Ha.js → command-D8l_oYbV.js} +2 -2
  11. package/dist/{component-7kEBjv_y.js → component-C3GL1Mnu.js} +41 -6
  12. package/dist/component-DKd3EHOg.js +2 -0
  13. package/dist/coverage-B4IBKMO8.js +521 -0
  14. package/dist/{crawl-Oxt2Gaqo.js → crawl-BiI1Lau_.js} +2 -2
  15. package/dist/eleventy/index.d.ts +28 -0
  16. package/dist/eleventy/index.js +19 -0
  17. package/dist/{frameworks-BYa3tULg.js → frameworks-B4ClIJgE.js} +71 -0
  18. package/dist/{frameworks-DaDqrJOw.js → frameworks-etFg_O8K.js} +1 -1
  19. package/dist/{html-DKiI_3gs.js → html-BGTO3ypW.js} +130 -5
  20. package/dist/{impact-YdoOtFqm.js → impact-DZt2oBCP.js} +1 -1
  21. package/dist/index.js +2 -2
  22. package/dist/{init-DiLiYvN1.js → init-DIWDE35F.js} +2 -2
  23. package/dist/jsdom-4IMzv0eE.js +3 -0
  24. package/dist/jsdom-DCpGSLfW.js +81 -0
  25. package/dist/{json-Cnv9nd6U.js → json-DjEvy1nX.js} +8 -3
  26. package/dist/{json-B0Y7rNjt.js → json-QQuFIw1W.js} +1 -1
  27. package/dist/{load-UYXLqGV9.js → load-5wRGLvub.js} +1 -1
  28. package/dist/nuxt/index.d.ts +35 -0
  29. package/dist/nuxt/index.js +23 -0
  30. package/dist/{playwright-DYFsGUNd.js → playwright-BWniOain.js} +1 -1
  31. package/dist/{pool-BWkWZiJW.js → pool-BMevaLWD.js} +2 -2
  32. package/dist/{project-DW08TseF.js → project-CzOnkLH6.js} +8 -2
  33. package/dist/project-MFrXcw1M.js +2 -0
  34. package/dist/remediation-Dtowi2EC.js +321 -0
  35. package/dist/{render-DI_aCnAZ.js → render-DrvXRCEn.js} +3 -3
  36. package/dist/{result-DLxd2Eip.js → result-DoamKFsp.js} +78 -1
  37. package/dist/routes-CmdRUuOs.js +265 -0
  38. package/dist/{run-BW6CVuND.js → run-DB34BSOZ.js} +19 -2
  39. package/dist/{sarif-DB3WG7T9.js → sarif-SR3_lLYd.js} +14 -6
  40. package/dist/{schema-CMZ8ItGk.js → schema-is6CGX2D.js} +4 -1
  41. package/dist/{text-BFmNtMsV.js → text-CKKpzkYM.js} +1 -1
  42. package/dist/vite/index.js +1 -1
  43. package/dist/webpack/index.d.ts +33 -0
  44. package/dist/webpack/index.js +20 -0
  45. package/package.json +30 -8
  46. package/dist/audit-CuG2hYyo.js +0 -2
  47. package/dist/jsdom-BjpF-2V-.js +0 -158
  48. package/dist/jsdom-X4KYfTp8.js +0 -3
  49. package/dist/manual-Vz-oX1I_.js +0 -239
  50. package/dist/routes-C2Cgf6Ko.js +0 -119
package/dist/cli/index.js CHANGED
@@ -1,14 +1,18 @@
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 { 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";
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-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";
10
13
  import path from "node:path";
11
14
  import { Command, InvalidArgumentError } from "commander";
15
+ import pc from "picocolors";
12
16
  //#region src/cli/baseline.ts
13
17
  /**
14
18
  * `eaa-kit baseline [dir]`.
@@ -80,6 +84,255 @@ async function runBaselineCommand(dir, options = {}) {
80
84
  exitCode: 0
81
85
  };
82
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
+ }
83
336
  //#endregion
84
337
  //#region src/cli/statement.ts
85
338
  /** Markdown for a content directory, HTML for dropping straight onto a site. */
@@ -178,7 +431,7 @@ const parseConcurrency = wholeNumber(1);
178
431
  const program = new Command();
179
432
  program.exitOverride();
180
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");
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) => {
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) => {
182
435
  const { build, ...options } = flags;
183
436
  const { exitCode } = await runAuditCommand(dir, {
184
437
  ...options,
@@ -186,12 +439,16 @@ program.command("audit").description("Audit built HTML against WCAG 2.2 AA").arg
186
439
  });
187
440
  process.exitCode = exitCode;
188
441
  });
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) => {
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) => {
190
443
  const { exitCode } = await runBaselineCommand(dir, flags);
191
444
  process.exitCode = exitCode;
192
445
  });
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
+ });
193
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) => {
194
- const { runInitCommand } = await import("../init-DiLiYvN1.js");
451
+ const { runInitCommand } = await import("../init-DIWDE35F.js");
195
452
  const { exitCode } = await runInitCommand(flags);
196
453
  process.exitCode = exitCode;
197
454
  });
@@ -46,7 +46,8 @@ 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)));
50
+ for (const page of read) if (page !== void 0) pages.push(page);
50
51
  }
51
52
  return pages;
52
53
  }
@@ -60,13 +61,19 @@ async function assertDirectory(root, original) {
60
61
  }
61
62
  if (!stats.isDirectory()) throw new BuildDirectoryError(`Build path is not a directory: ${original}`, root);
62
63
  }
63
- async function readPage(root, relativePath) {
64
+ async function readPage(root, relativePath, onUnreadable) {
64
65
  const absolutePath = path.join(root, relativePath);
65
- return {
66
- absolutePath,
67
- relativePath,
68
- html: stripBom(await readFile(absolutePath, "utf8"))
69
- };
66
+ try {
67
+ return {
68
+ absolutePath,
69
+ relativePath,
70
+ html: stripBom(await readFile(absolutePath, "utf8"))
71
+ };
72
+ } catch (cause) {
73
+ if (onUnreadable === void 0) throw cause;
74
+ onUnreadable(relativePath, cause.message);
75
+ return;
76
+ }
70
77
  }
71
78
  /**
72
79
  * What to suggest when a directory holds no HTML, or is not there at all.
@@ -79,8 +86,8 @@ async function readPage(root, relativePath) {
79
86
  */
80
87
  async function emptyDirectoryHint(dir, cwd = process.cwd()) {
81
88
  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");
89
+ const { detectFramework } = await import("./frameworks-etFg_O8K.js");
90
+ const { readPackageJson } = await import("./project-MFrXcw1M.js");
84
91
  const detected = await detectFramework(cwd, await readPackageJson(cwd));
85
92
  if (detected !== void 0) return frameworkAdvice(head, detected, cwd, dir);
86
93
  return await siblingAdvice(head, cwd, dir) ?? generic(head);
@@ -91,6 +98,10 @@ async function frameworkAdvice(head, detected, cwd, dir) {
91
98
  const given = dir.replace(/^\.\//, "");
92
99
  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
100
  const lines = [`${head} This is ${article(framework.name)} ${framework.name} project.`];
101
+ if (outputs.length === 0) {
102
+ 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");
103
+ return lines.join("\n");
104
+ }
94
105
  if (framework.staticOutput !== void 0) lines.push(` Static output takes ${framework.staticOutput.needs} — ${framework.staticOutput.how},`, ` then: eaa-kit audit ./${outputs[0]}`);
95
106
  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
107
  else lines.push(` It writes to ${outputs.map((output) => `${output}/`).join(" or ")}. Run your build, then:`, ` eaa-kit audit ./${outputs[0]}`);
@@ -99,7 +110,7 @@ async function frameworkAdvice(head, detected, cwd, dir) {
99
110
  }
100
111
  /** Naming a build directory that is actually there beats listing the usual ones. */
101
112
  async function siblingAdvice(head, cwd, dir) {
102
- const { FALLBACK_OUTPUTS } = await import("./frameworks-DaDqrJOw.js");
113
+ const { FALLBACK_OUTPUTS } = await import("./frameworks-etFg_O8K.js");
103
114
  const given = dir.replace(/^\.\//, "");
104
115
  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
116
  if (others.length === 0) return void 0;
@@ -42,13 +42,13 @@ async function runEngine(pages, options) {
42
42
  ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
43
43
  };
44
44
  if (!options.browser) {
45
- const { runPooledAudit } = await import("./pool-BWkWZiJW.js");
45
+ const { runPooledAudit } = await import("./pool-BMevaLWD.js");
46
46
  return runPooledAudit(pages, {
47
47
  ...runnerOptions,
48
48
  ...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
49
49
  });
50
50
  }
51
- const { BrowserUnavailableError, runBrowserAudit } = await import("./playwright-DYFsGUNd.js");
51
+ const { BrowserUnavailableError, runBrowserAudit } = await import("./playwright-BWniOain.js");
52
52
  try {
53
53
  return await runBrowserAudit(options.directory, pages, runnerOptions);
54
54
  } catch (cause) {
@@ -1,5 +1,5 @@
1
1
  import { i as toPosix } from "./fs-BmPtmFke.js";
2
- import { t as collapse } from "./text-BFmNtMsV.js";
2
+ import { t as collapse } from "./text-CKKpzkYM.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { glob } from "tinyglobby";
@@ -96,16 +96,51 @@ function searchTermsFor(html) {
96
96
  * Returns undefined when nothing matches and when more than one does. The
97
97
  * second case is the important one: naming a file that merely happens to
98
98
  * contain the same path sends somebody to edit the wrong component.
99
+ *
100
+ * The position comes free. The search already has to find the literal to know
101
+ * the file contains it, so recording where it found it costs one more scan of
102
+ * one string — and a report that says `Header.astro:12` opens an editor where a
103
+ * report that says `Header.astro` starts a search.
99
104
  */
100
105
  function componentFor(index, html) {
101
106
  for (const term of searchTermsFor(html)) {
102
107
  const matches = [];
103
- for (const [file, source] of index.files) if (source.includes(term)) {
104
- matches.push(file);
105
- if (matches.length > 1) break;
108
+ for (const [file, source] of index.files) {
109
+ const offset = source.indexOf(term);
110
+ if (offset !== -1) {
111
+ matches.push({
112
+ file,
113
+ offset
114
+ });
115
+ if (matches.length > 1) break;
116
+ }
117
+ }
118
+ const only = matches.length === 1 ? matches[0] : void 0;
119
+ if (only !== void 0) {
120
+ const source = index.files.get(only.file);
121
+ return {
122
+ file: only.file,
123
+ ...positionOf(source, only.offset)
124
+ };
106
125
  }
107
- if (matches.length === 1) return matches[0];
108
126
  }
109
127
  }
128
+ /** 1-based line and column of a byte offset in a source file. */
129
+ function positionOf(source, offset) {
130
+ let line = 1;
131
+ let lineStart = 0;
132
+ for (let i = 0; i < offset; i += 1) if (source[i] === "\n") {
133
+ line += 1;
134
+ lineStart = i + 1;
135
+ }
136
+ return {
137
+ line,
138
+ column: offset - lineStart + 1
139
+ };
140
+ }
141
+ /** `components/Header.astro:12`, as both reports name a component. */
142
+ function componentPath(location) {
143
+ return `${location.file}:${location.line}`;
144
+ }
110
145
  //#endregion
111
- export { buildComponentIndex, componentFor };
146
+ export { searchTermsFor as i, componentFor as n, componentPath as r, buildComponentIndex as t };
@@ -0,0 +1,2 @@
1
+ import { n as componentFor, t as buildComponentIndex } from "./component-C3GL1Mnu.js";
2
+ export { buildComponentIndex, componentFor };