eaa-kit 0.2.0 → 0.3.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 (43) hide show
  1. package/README.md +2 -1
  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-CuG2hYyo.js +2 -0
  6. package/dist/audit-DcL73mOC.js +635 -0
  7. package/dist/{baseline-CV_3lbER.js → baseline-CuKFq4IF.js} +1 -1
  8. package/dist/{baseline-CgBmzFTr.js → baseline-s9F3fXTN.js} +16 -26
  9. package/dist/cli/index.js +81 -143
  10. package/dist/collect-BkAQ0viT.js +123 -0
  11. package/dist/command-Dxpa00Ha.js +77 -0
  12. package/dist/component-7kEBjv_y.js +111 -0
  13. package/dist/{crawl-CtJbMNNb.js → crawl-Oxt2Gaqo.js} +13 -23
  14. package/dist/frameworks-BYa3tULg.js +243 -0
  15. package/dist/frameworks-DaDqrJOw.js +2 -0
  16. package/dist/fs-BmPtmFke.js +40 -0
  17. package/dist/{html-BLEuzep6.js → html-DKiI_3gs.js} +167 -86
  18. package/dist/{impact-DvgBjupx.js → impact-YdoOtFqm.js} +15 -1
  19. package/dist/index.js +2 -2
  20. package/dist/{init-DRKIdpK1.js → init-DiLiYvN1.js} +13 -10
  21. package/dist/{jsdom-BEu6Ra_2.js → jsdom-BjpF-2V-.js} +2 -7
  22. package/dist/jsdom-X4KYfTp8.js +3 -0
  23. package/dist/json-B0Y7rNjt.js +2 -0
  24. package/dist/{json-C9xS1PNC.js → json-Cnv9nd6U.js} +13 -21
  25. package/dist/{load-sCkKsvGQ.js → load-UYXLqGV9.js} +2 -8
  26. package/dist/manual-Vz-oX1I_.js +239 -0
  27. package/dist/{playwright-DWux49V3.js → playwright-DYFsGUNd.js} +79 -21
  28. package/dist/{pool-DixLeu8L.js → pool-BWkWZiJW.js} +4 -12
  29. package/dist/{project-CiyzKQud.js → project-DW08TseF.js} +37 -27
  30. package/dist/{render-BO0nVrrZ.js → render-DI_aCnAZ.js} +7 -15
  31. package/dist/{result-2aZPfM8w.js → result-DLxd2Eip.js} +38 -1
  32. package/dist/{routes-BxbSZKXC.js → routes-C2Cgf6Ko.js} +4 -8
  33. package/dist/run-BMASMmwO.d.ts +45 -0
  34. package/dist/run-BW6CVuND.js +36 -0
  35. package/dist/{sarif-eSCuI0eX.js → sarif-DB3WG7T9.js} +11 -33
  36. package/dist/text-BFmNtMsV.js +43 -0
  37. package/dist/vite/index.d.ts +39 -0
  38. package/dist/vite/index.js +40 -0
  39. package/package.json +8 -3
  40. package/dist/audit-CqrR9pIO.js +0 -2
  41. package/dist/audit-D4gju2BT.js +0 -949
  42. package/dist/escape-Dm1o_RAk.js +0 -21
  43. package/dist/jsdom-C6dIyaxN.js +0 -3
@@ -1,949 +0,0 @@
1
- import { n as IMPACT_LEVELS, r as countAtOrAbove } from "./impact-DvgBjupx.js";
2
- import { t as elementFingerprint } from "./fingerprint-DRoneAjj.js";
3
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
4
- import path from "node:path";
5
- import pc from "picocolors";
6
- import { glob } from "tinyglobby";
7
- /** Whether this element looks like one component rendered on many pages. */
8
- function isShared(element) {
9
- return element.pages.length >= 3;
10
- }
11
- /**
12
- * Fold a run's violations into one entry per rule, and one per element within it.
13
- *
14
- * Accepted violations are not included: a baseline moves them out of what fails
15
- * the build, and this is a view of what fails.
16
- */
17
- function groupIssues(audits) {
18
- const byRule = /* @__PURE__ */ new Map();
19
- for (const audit of audits) for (const finding of audit.violations) {
20
- let issue = byRule.get(finding.ruleId);
21
- if (issue === void 0) {
22
- issue = {
23
- ruleId: finding.ruleId,
24
- help: finding.help,
25
- impact: finding.impact ?? null,
26
- successCriteria: finding.successCriteria,
27
- enClauses: finding.enClauses,
28
- helpUrl: finding.helpUrl,
29
- elements: [],
30
- pages: [],
31
- occurrences: 0
32
- };
33
- byRule.set(finding.ruleId, issue);
34
- }
35
- if (!issue.pages.includes(audit.relativePath)) issue.pages.push(audit.relativePath);
36
- const nodes = finding.nodes.length > 0 ? finding.nodes.map((node) => ({
37
- selector: node.target.join(" "),
38
- html: node.html
39
- })) : [{
40
- selector: "",
41
- html: ""
42
- }];
43
- for (const node of nodes) {
44
- issue.occurrences += 1;
45
- const fingerprint = elementFingerprint(finding.ruleId, node.selector, node.html);
46
- const existing = issue.elements.find((element) => element.fingerprint === fingerprint);
47
- if (existing) {
48
- if (!existing.pages.includes(audit.relativePath)) existing.pages.push(audit.relativePath);
49
- continue;
50
- }
51
- issue.elements.push({
52
- fingerprint,
53
- selector: node.selector,
54
- html: node.html,
55
- pages: [audit.relativePath]
56
- });
57
- }
58
- }
59
- const issues = [...byRule.values()];
60
- for (const issue of issues) {
61
- issue.pages.sort();
62
- for (const element of issue.elements) element.pages.sort();
63
- issue.elements.sort(byReachThenSelector);
64
- }
65
- issues.sort(bySeverityThenReach);
66
- return issues;
67
- }
68
- /** Widest reach first, then by selector so two runs agree. */
69
- function byReachThenSelector(a, b) {
70
- return b.pages.length - a.pages.length || a.selector.localeCompare(b.selector);
71
- }
72
- /**
73
- * Worst first, then widest reach, then by rule id.
74
- *
75
- * An unclassified impact sorts with the most severe, on the same reasoning as
76
- * `--fail-on`: not knowing how bad a barrier is is not evidence that it is mild.
77
- */
78
- function bySeverityThenReach(a, b) {
79
- const rank = (issue) => issue.impact === null ? IMPACT_LEVELS.length : IMPACT_LEVELS.indexOf(issue.impact);
80
- return rank(b) - rank(a) || b.pages.length - a.pages.length || a.ruleId.localeCompare(b.ruleId);
81
- }
82
- //#endregion
83
- //#region src/audit/report/console.ts
84
- const DEFAULT_MAX_NODES = 3;
85
- const MIN_WIDTH = 40;
86
- const MAX_WIDTH = 100;
87
- /**
88
- * Human-readable audit report.
89
- *
90
- * Deliberately not a columnar table: rule ids, help text and selectors are all
91
- * variable-length, so a real table either wraps into soup or scrolls sideways
92
- * on a narrow terminal. Everything is left-aligned and indented instead, and
93
- * every line is assembled from segments trimmed as a group, so the width
94
- * guarantee holds however long a selector or rule id turns out to be.
95
- *
96
- * Returns a string rather than printing, so the format is testable.
97
- */
98
- function formatConsoleReport(audits, options = {}) {
99
- const ctx = context(options);
100
- const lines = [
101
- "",
102
- ...headerLines(audits, ctx),
103
- ""
104
- ];
105
- lines.push(...issuesSection(audits, ctx));
106
- if (options.perPage) {
107
- lines.push("", ...legendLines(ctx), "");
108
- for (const audit of audits) lines.push(...pageSection(audit, ctx));
109
- }
110
- lines.push(...summary(audits, ctx));
111
- return lines.join("\n");
112
- }
113
- function context(options) {
114
- const detected = process.stdout.columns ?? 80;
115
- const width = Math.min(Math.max(options.width ?? detected, MIN_WIDTH), MAX_WIDTH);
116
- const c = pc.createColors(options.color ?? pc.isColorSupported);
117
- const unicode = supportsUnicode();
118
- return {
119
- width,
120
- maxNodes: options.maxNodes ?? DEFAULT_MAX_NODES,
121
- failOn: options.failOn ?? "serious",
122
- sourceFor: options.sourceFor ?? (() => void 0),
123
- c,
124
- symbol: (kind) => {
125
- switch (kind) {
126
- case "violation": return unicode ? "✗" : "x";
127
- case "review": return "?";
128
- case "blind": return unicode ? "·" : "-";
129
- case "clean": return unicode ? "✓" : "+";
130
- case "error": return "!";
131
- }
132
- }
133
- };
134
- }
135
- /** cmd.exe and older Windows consoles render these glyphs as mojibake. */
136
- function supportsUnicode() {
137
- if (process.platform !== "win32") return true;
138
- return Boolean(process.env["WT_SESSION"] || process.env["TERM_PROGRAM"] || process.env["ConEmuTask"] || process.env["TERM"]);
139
- }
140
- /**
141
- * Joins segments into one line, trimming the group to the terminal width.
142
- * Colour codes are applied after trimming, so they never count towards it.
143
- */
144
- function render(ctx, segments) {
145
- let remaining = ctx.width;
146
- const parts = [];
147
- for (const segment of segments) {
148
- if (remaining <= 0) break;
149
- const text = segment.text.length <= remaining ? segment.text : `${segment.text.slice(0, Math.max(remaining - 1, 0))}…`;
150
- remaining -= text.length;
151
- parts.push(segment.paint ? segment.paint(text) : text);
152
- }
153
- return parts.join("");
154
- }
155
- function headerLines(audits, ctx) {
156
- const engineLabel = (audits[0]?.engine ?? "jsdom") === "browser" ? "chromium" : "jsdom (browserless)";
157
- const pageCount = `${audits.length} ${plural(audits.length, "page")}`;
158
- return [render(ctx, [{
159
- text: "eaa-kit audit",
160
- paint: ctx.c.bold
161
- }, {
162
- text: ` ${pageCount} · ${engineLabel}`,
163
- paint: ctx.c.dim
164
- }])];
165
- }
166
- /**
167
- * What the per-page counts mean.
168
- *
169
- * Only with the per-page listing, which is the only place those words appear:
170
- * "not applicable" reads like good news unless it is spelled out, and printing
171
- * the gloss for a section that is not there is noise.
172
- */
173
- function legendLines(ctx) {
174
- return [render(ctx, [{
175
- text: "passed = checked and met · not applicable = nothing to check",
176
- paint: ctx.c.dim
177
- }])];
178
- }
179
- function pageSection(audit, ctx) {
180
- const lines = [render(ctx, [{
181
- text: audit.relativePath,
182
- paint: ctx.c.underline
183
- }])];
184
- if (audit.error) {
185
- lines.push(render(ctx, [{
186
- text: ` ${ctx.symbol("error")} not audited: `,
187
- paint: ctx.c.red
188
- }, { text: audit.error }]), "");
189
- return lines;
190
- }
191
- for (const finding of sortByImpact(audit.violations)) lines.push(...violationLines(finding, ctx));
192
- for (const finding of audit.incomplete.filter((item) => item.reason === "needs-review")) lines.push(render(ctx, [
193
- {
194
- text: ` ${ctx.symbol("review")} `,
195
- paint: ctx.c.yellow
196
- },
197
- {
198
- text: finding.ruleId,
199
- paint: ctx.c.yellow
200
- },
201
- {
202
- text: ` needs manual review${criteria(finding)}`,
203
- paint: ctx.c.dim
204
- }
205
- ]));
206
- if (audit.violations.length === 0) {
207
- const clean = (audit.accepted ?? []).length === 0;
208
- lines.push(render(ctx, [{
209
- text: ` ${ctx.symbol("clean")} `,
210
- paint: clean ? ctx.c.green : ctx.c.dim
211
- }, {
212
- text: clean ? "no violations" : "no new violations",
213
- paint: clean ? ctx.c.green : ctx.c.dim
214
- }]));
215
- }
216
- for (const finding of sortByImpact(audit.accepted ?? [])) {
217
- const elements = finding.nodes.length;
218
- lines.push(render(ctx, [
219
- {
220
- text: " · ",
221
- paint: ctx.c.dim
222
- },
223
- {
224
- text: finding.ruleId,
225
- paint: ctx.c.dim
226
- },
227
- {
228
- text: ` accepted by the baseline (${elements} ${plural(elements, "element")})`,
229
- paint: ctx.c.dim
230
- }
231
- ]));
232
- }
233
- lines.push(coverageLine(audit, ctx));
234
- lines.push("");
235
- return lines;
236
- }
237
- /**
238
- * What this page's result actually rests on.
239
- *
240
- * The four counts stay separate on purpose. Only `passed` is evidence that a
241
- * criterion was met here; `not applicable` means the rule found nothing to
242
- * check, and adding the two together would turn an empty page into a
243
- * near-perfect score.
244
- */
245
- function coverageLine(audit, ctx) {
246
- const blind = audit.incomplete.filter((finding) => finding.reason === "engine-limitation").length;
247
- const review = audit.incomplete.length - blind;
248
- const parts = [`${audit.passes.length} passed`, `${audit.inapplicable.length} not applicable`];
249
- if (review > 0) parts.push(`${review} to review`);
250
- if (blind > 0) parts.push(`${blind} not evaluated`);
251
- return render(ctx, [{
252
- text: ` ${parts.join(" · ")}`,
253
- paint: ctx.c.dim
254
- }]);
255
- }
256
- function violationLines(finding, ctx) {
257
- const impact = finding.impact ?? "unknown";
258
- const lines = [render(ctx, [
259
- {
260
- text: ` ${ctx.symbol("violation")} `,
261
- paint: ctx.c.red
262
- },
263
- {
264
- text: finding.ruleId,
265
- paint: ctx.c.bold
266
- },
267
- {
268
- text: ` ${impact}${criteria(finding)}`,
269
- paint: ctx.c.dim
270
- }
271
- ]), render(ctx, [{ text: ` ${finding.help}` }])];
272
- for (const node of finding.nodes.slice(0, ctx.maxNodes)) {
273
- lines.push(render(ctx, [{
274
- text: ` ${node.target.join(" ")}`,
275
- paint: ctx.c.cyan
276
- }]));
277
- lines.push(render(ctx, [{
278
- text: ` ${collapse(node.html)}`,
279
- paint: ctx.c.dim
280
- }]));
281
- }
282
- const hidden = finding.nodes.length - ctx.maxNodes;
283
- if (hidden > 0) lines.push(render(ctx, [{
284
- text: ` + ${hidden} more ${plural(hidden, "element")}`,
285
- paint: ctx.c.dim
286
- }]));
287
- return lines;
288
- }
289
- function summary(audits, ctx) {
290
- const withViolations = audits.filter((audit) => audit.violations.length > 0);
291
- const errored = audits.filter((audit) => audit.error);
292
- const ruleCount = audits.reduce((total, audit) => total + audit.violations.length, 0);
293
- const elementCount = audits.reduce((total, audit) => total + audit.violations.reduce((sum, finding) => sum + finding.nodes.length, 0), 0);
294
- const reviewCount = countRules(audits, "needs-review");
295
- const pages = `${audits.length} ${plural(audits.length, "page")}`;
296
- const lines = [render(ctx, [{
297
- text: "Summary",
298
- paint: ctx.c.bold
299
- }])];
300
- if (ruleCount === 0) lines.push(render(ctx, [{
301
- text: ` No violations across ${pages}.`,
302
- paint: ctx.c.green
303
- }]));
304
- else {
305
- lines.push(render(ctx, [{
306
- text: ` ${ruleCount} ${plural(ruleCount, "violation")} on ${withViolations.length} of ${pages}`,
307
- paint: ctx.c.red
308
- }, {
309
- text: ` (${elementCount} ${plural(elementCount, "element")})`,
310
- paint: ctx.c.dim
311
- }]));
312
- lines.push(thresholdLine(audits, ctx));
313
- }
314
- if (reviewCount > 0) lines.push(render(ctx, [{
315
- text: ` ${reviewCount} ${plural(reviewCount, "rule")} ${reviewCount === 1 ? "needs" : "need"} manual review`,
316
- paint: ctx.c.yellow
317
- }]));
318
- if (errored.length > 0) lines.push(render(ctx, [{
319
- text: ` ${errored.length} ${plural(errored.length, "page")} could not be audited`,
320
- paint: ctx.c.red
321
- }]));
322
- const accepted = audits.reduce((total, audit) => total + (audit.accepted ?? []).reduce((sum, finding) => sum + finding.nodes.length, 0), 0);
323
- if (accepted > 0) lines.push(render(ctx, [{
324
- text: ` ${accepted} ${plural(accepted, "element")} accepted by the baseline, not counted above`,
325
- paint: ctx.c.dim
326
- }]));
327
- lines.push(...blindSection(audits, ctx));
328
- return lines;
329
- }
330
- /**
331
- * Why the run passed or failed. Without this, a build that exits 0 while the
332
- * report lists violations looks like a bug rather than a threshold choice.
333
- */
334
- function thresholdLine(audits, ctx) {
335
- const failOn = ctx.failOn;
336
- const failing = countAtOrAbove(audits, failOn);
337
- if (failing === 0) return render(ctx, [{
338
- text: ` none at or above ${failOn} (--fail-on ${failOn}), so this run passes`,
339
- paint: ctx.c.green
340
- }]);
341
- return render(ctx, [{
342
- text: ` ${failing} at or above ${failOn} (--fail-on ${failOn})`,
343
- paint: ctx.c.red
344
- }]);
345
- }
346
- /**
347
- * The unevaluated rules are listed once, at the end, rather than repeated under
348
- * every page: on a large site the same handful recurs on each one, and a wall
349
- * of "not evaluated" would bury the findings that are real.
350
- */
351
- function blindSection(audits, ctx) {
352
- const byRule = /* @__PURE__ */ new Map();
353
- for (const audit of audits) for (const finding of audit.incomplete) {
354
- if (finding.reason !== "engine-limitation") continue;
355
- const entry = byRule.get(finding.ruleId);
356
- if (entry) entry.pages += 1;
357
- else byRule.set(finding.ruleId, {
358
- pages: 1,
359
- finding
360
- });
361
- }
362
- if (byRule.size === 0) return [];
363
- const lines = [
364
- "",
365
- render(ctx, [{
366
- text: "Not evaluated",
367
- paint: ctx.c.bold
368
- }]),
369
- render(ctx, [{
370
- text: " This engine reached no verdict on these.",
371
- paint: ctx.c.dim
372
- }]),
373
- render(ctx, [{
374
- text: " They are never reported as passing.",
375
- paint: ctx.c.dim
376
- }])
377
- ];
378
- for (const [ruleId, { pages, finding }] of [...byRule].sort((a, b) => b[1].pages - a[1].pages)) {
379
- lines.push(render(ctx, [
380
- { text: ` ${ctx.symbol("blind")} ` },
381
- { text: ruleId },
382
- {
383
- text: ` ${pages} ${plural(pages, "page")}${criteria(finding)}`,
384
- paint: ctx.c.dim
385
- }
386
- ]));
387
- lines.push(render(ctx, [{
388
- text: ` ${finding.reasonDetail}`,
389
- paint: ctx.c.dim
390
- }]));
391
- }
392
- return lines;
393
- }
394
- function countRules(audits, reason) {
395
- const ruleIds = /* @__PURE__ */ new Set();
396
- for (const audit of audits) for (const finding of audit.incomplete) if (finding.reason === reason) ruleIds.add(finding.ruleId);
397
- return ruleIds.size;
398
- }
399
- function sortByImpact(findings) {
400
- return [...findings].sort((a, b) => {
401
- const rank = impactRank(a) - impactRank(b);
402
- return rank === 0 ? a.ruleId.localeCompare(b.ruleId) : rank;
403
- });
404
- }
405
- /** Most severe first; anything axe-core left unclassified sorts last. */
406
- function impactRank(finding) {
407
- const index = IMPACT_LEVELS.indexOf(finding.impact);
408
- return index === -1 ? IMPACT_LEVELS.length : IMPACT_LEVELS.length - index;
409
- }
410
- function criteria(finding) {
411
- return finding.successCriteria.length > 0 ? `, WCAG ${finding.successCriteria.join(" ")}` : "";
412
- }
413
- function collapse(html) {
414
- return html.replace(/\s+/g, " ").trim();
415
- }
416
- function plural(count, word) {
417
- return count === 1 ? word : `${word}s`;
418
- }
419
- /**
420
- * What is actually broken, once per element rather than once per page.
421
- *
422
- * The page-by-page listing above is the truth, and on a site built from
423
- * components it is not the work: one header with a missing `alt` reappears on
424
- * every page that renders it, and nothing in a per-page report says those are
425
- * one line in one file. This section says it, and orders the result by what
426
- * fixing it would buy.
427
- */
428
- function issuesSection(audits, ctx) {
429
- const issues = groupIssues(audits);
430
- if (issues.length === 0) return [];
431
- const elements = issues.reduce((total, issue) => total + issue.elements.length, 0);
432
- const occurrences = issues.reduce((total, issue) => total + issue.occurrences, 0);
433
- const lines = [render(ctx, [{
434
- text: "Issues",
435
- paint: ctx.c.bold
436
- }])];
437
- lines.push(render(ctx, [{
438
- text: occurrences === elements ? ` ${elements} ${plural(elements, "distinct element")} to fix.` : ` ${occurrences} ${plural(occurrences, "violation")} across the site come from ${elements} ${plural(elements, "distinct element")}.`,
439
- paint: ctx.c.dim
440
- }]));
441
- for (const issue of issues) {
442
- lines.push("");
443
- lines.push(render(ctx, [
444
- { text: ` ${ctx.symbol("violation")} ` },
445
- {
446
- text: issue.ruleId,
447
- paint: ctx.c.bold
448
- },
449
- {
450
- text: ` ${issue.impact ?? "unclassified"}`,
451
- paint: ctx.c.dim
452
- },
453
- ...issue.successCriteria.length > 0 ? [{
454
- text: `, WCAG ${issue.successCriteria.join(" ")}`,
455
- paint: ctx.c.dim
456
- }] : []
457
- ]));
458
- lines.push(render(ctx, [{
459
- text: ` ${issue.help}`,
460
- paint: ctx.c.dim
461
- }]));
462
- for (const element of issue.elements.slice(0, ctx.maxNodes)) {
463
- lines.push(render(ctx, [{ text: ` ${collapse(element.html)}` }]));
464
- lines.push(...whereLines(element, ctx));
465
- }
466
- if (issue.elements.length > ctx.maxNodes) lines.push(render(ctx, [{
467
- text: ` …and ${issue.elements.length - ctx.maxNodes} more ${plural(issue.elements.length - ctx.maxNodes, "element")}`,
468
- paint: ctx.c.dim
469
- }]));
470
- }
471
- return lines;
472
- }
473
- /** Where one element appears, and what that says about where the fix goes. */
474
- function whereLines(element, ctx) {
475
- const shown = element.pages.slice(0, ctx.maxNodes);
476
- const rest = element.pages.length - shown.length;
477
- const lines = [render(ctx, [{
478
- text: ` on ${element.pages.length} ${plural(element.pages.length, "page")}:`,
479
- paint: ctx.c.dim
480
- }])];
481
- for (const page of shown) {
482
- const source = ctx.sourceFor(page);
483
- lines.push(render(ctx, [{
484
- text: ` ${page}`,
485
- paint: ctx.c.dim
486
- }, ...source === void 0 ? [] : [{
487
- text: ` ${source}`,
488
- paint: ctx.c.dim
489
- }]]));
490
- }
491
- if (rest > 0) lines.push(render(ctx, [{
492
- text: ` …and ${rest} more ${plural(rest, "page")}`,
493
- paint: ctx.c.dim
494
- }]));
495
- if (isShared(element)) lines.push(render(ctx, [{
496
- text: " identical on each — likely one shared component",
497
- paint: ctx.c.dim
498
- }]));
499
- return lines;
500
- }
501
- //#endregion
502
- //#region src/audit/collect.ts
503
- /** Every HTML document a static build is expected to emit. */
504
- const DEFAULT_INCLUDE = ["**/*.html", "**/*.htm"];
505
- /** Vendored and tooling directories are never part of the shipped site. */
506
- const DEFAULT_EXCLUDE = ["**/node_modules/**", "**/.git/**"];
507
- /** Number of files read in parallel; keeps large builds under the fd limit. */
508
- const READ_CONCURRENCY = 24;
509
- /**
510
- * Thrown when the build directory itself is unusable. A missing or wrong
511
- * `dist/` is a user mistake worth reporting loudly, unlike a directory that
512
- * simply holds no HTML.
513
- */
514
- var BuildDirectoryError = class extends Error {
515
- dir;
516
- name = "BuildDirectoryError";
517
- constructor(message, dir) {
518
- super(message);
519
- this.dir = dir;
520
- }
521
- };
522
- /**
523
- * Glob HTML files out of a build directory and read them.
524
- *
525
- * Returns pages sorted by relative path so reports and snapshots are stable
526
- * across platforms. An empty array means "no HTML found" — the caller decides
527
- * whether that is an error.
528
- */
529
- async function collectPages(dir, options = {}) {
530
- const root = path.resolve(dir);
531
- await assertDirectory(root, dir);
532
- const relativePaths = (await glob(options.include ?? DEFAULT_INCLUDE, {
533
- cwd: root,
534
- ignore: options.exclude ?? DEFAULT_EXCLUDE,
535
- onlyFiles: true,
536
- dot: false,
537
- absolute: false
538
- })).map(toPosix).sort();
539
- const pages = [];
540
- for (let i = 0; i < relativePaths.length; i += READ_CONCURRENCY) {
541
- const batch = relativePaths.slice(i, i + READ_CONCURRENCY);
542
- pages.push(...await Promise.all(batch.map((relativePath) => readPage(root, relativePath))));
543
- }
544
- return pages;
545
- }
546
- async function assertDirectory(root, original) {
547
- let stats;
548
- try {
549
- stats = await stat(root);
550
- } catch (cause) {
551
- if (cause.code === "ENOENT") throw new BuildDirectoryError(`Build directory not found: ${original}`, root);
552
- throw new BuildDirectoryError(`Build directory is not readable: ${original} (${cause.message})`, root);
553
- }
554
- if (!stats.isDirectory()) throw new BuildDirectoryError(`Build path is not a directory: ${original}`, root);
555
- }
556
- async function readPage(root, relativePath) {
557
- const absolutePath = path.join(root, relativePath);
558
- const html = await readFile(absolutePath, "utf8");
559
- return {
560
- absolutePath,
561
- relativePath,
562
- html: html.charCodeAt(0) === 65279 ? html.slice(1) : html
563
- };
564
- }
565
- function toPosix(filePath) {
566
- return filePath.split(path.sep).join("/");
567
- }
568
- /** Whether a path exists, relative to a project root. */
569
- async function present(root, name) {
570
- try {
571
- await stat(path.resolve(root, name));
572
- return true;
573
- } catch {
574
- return false;
575
- }
576
- }
577
- /** A file's contents, or undefined if it is not there. */
578
- async function contents(root, name) {
579
- try {
580
- return await readFile(path.resolve(root, name), "utf8");
581
- } catch {
582
- return;
583
- }
584
- }
585
- const NEXT_CONFIGS = [
586
- "next.config.js",
587
- "next.config.mjs",
588
- "next.config.ts"
589
- ];
590
- /** Common build output directories, in the order worth suggesting them. */
591
- const BUILD_DIRECTORIES = [
592
- "dist",
593
- "build",
594
- "out",
595
- "_site",
596
- "public",
597
- ".output/public"
598
- ];
599
- /**
600
- * Advice for a Next.js project, or undefined if this is not one.
601
- *
602
- * Worth a branch of its own because it is the commonest way to arrive here at
603
- * all: a default `next build` writes a server bundle rather than browsable
604
- * HTML, and `./dist` — every tutorial's answer — is a directory it never uses.
605
- */
606
- async function nextJsAdvice(root, dir, head) {
607
- const config = (await Promise.all(NEXT_CONFIGS.map(async (name) => await present(root, name) ? name : void 0))).find((name) => name !== void 0);
608
- if (config === void 0) return void 0;
609
- if (await present(root, "out")) return `${head} A Next.js static export writes to out/, not ${dir}.\n Try: eaa-kit audit ./out`;
610
- const source = await contents(root, config) ?? "";
611
- if (!/output\s*:\s*['"`]export['"`]/.test(source)) return `${head} A Next.js build writes a server bundle, not browsable HTML.\n To audit it, add output: 'export' to ${config}, run your build, then:\n eaa-kit audit ./out
612
- A site with SSR, API routes, middleware or ISR cannot be exported. Audit it
613
- running instead:
614
- eaa-kit audit --url http://localhost:3000`;
615
- return `${head} ${config} sets output: 'export', but there is no out/ directory.\n Run your build first, then: eaa-kit audit ./out
616
- If the build failed, it names what blocks the export — an API route,
617
- middleware, getServerSideProps or a revalidate.`;
618
- }
619
- /** Advice for a Nuxt project, or undefined if this is not one. */
620
- async function nuxtAdvice(root, head) {
621
- if (!await present(root, "nuxt.config.ts")) return void 0;
622
- return `${head} Nuxt writes a static build to .output/public.\n Try: eaa-kit audit ./.output/public
623
- Or audit it running: eaa-kit audit --url http://localhost:3000`;
624
- }
625
- /**
626
- * Advice from whatever build directories are lying around.
627
- *
628
- * Naming one that is actually there beats listing the ones that usually are.
629
- */
630
- async function siblingDirectoryAdvice(root, dir, head) {
631
- const given = dir.replace(/^\.\//, "");
632
- const others = (await Promise.all(BUILD_DIRECTORIES.map(async (name) => name !== given && await present(root, name) ? name : void 0))).filter((name) => name !== void 0);
633
- if (others.length === 0) return void 0;
634
- return `${head} This project also has ${others.map((name) => `${name}/`).join(", ")} — try one of those.`;
635
- }
636
- /**
637
- * What to suggest when a directory holds no HTML, or is not there at all.
638
- *
639
- * Nearly always the wrong directory rather than a site with no pages, and the
640
- * commonest way to arrive is a framework whose build emits no browsable HTML —
641
- * so rather than repeating "check the path", each branch names the next step
642
- * for the project actually in front of the reader. Where a static export cannot
643
- * work at all, that step is `--url` rather than advice that cannot apply.
644
- */
645
- async function emptyDirectoryHint(dir, cwd = process.cwd()) {
646
- const head = await present(cwd, dir) ? `${dir} holds no HTML files.` : `${dir} does not exist.`;
647
- return await nextJsAdvice(cwd, dir, head) ?? await nuxtAdvice(cwd, head) ?? await siblingDirectoryAdvice(cwd, dir, head) ?? `${head} Point eaa-kit at the directory your build fills with .html files —\n commonly dist/, build/, out/ or _site/, depending on the builder.
648
- If your site renders on a server and never writes HTML, audit it running:
649
- eaa-kit audit --url http://localhost:3000`;
650
- }
651
- //#endregion
652
- //#region src/cli/pages.ts
653
- /**
654
- * Collect the pages to audit, reporting to stderr on the way.
655
- *
656
- * Returns undefined when there is nothing to audit, having already explained
657
- * why. Every caller turns that into exit 2 — a run that reached no verdict,
658
- * which is not the same as a clean one.
659
- */
660
- async function resolvePages(directory, options = {}) {
661
- if (directory === void 0 && options.url === void 0) return resolveAutomatically(options);
662
- if (options.url !== void 0) {
663
- const crawled = await crawlPages(options.url, options);
664
- if (!crawled) return void 0;
665
- if (crawled.pages.length === 0) {
666
- process.stderr.write(`${pc.yellow("warning")} No pages could be fetched from ${options.url}\n`);
667
- return;
668
- }
669
- return {
670
- pages: crawled.pages,
671
- origin: crawled.origin,
672
- label: options.url
673
- };
674
- }
675
- const cwd = options.cwd ?? process.cwd();
676
- const shown = options.label ?? directory;
677
- let pages;
678
- try {
679
- pages = await collectPages(directory, {
680
- ...options.include ? { include: options.include } : {},
681
- ...options.exclude ? { exclude: options.exclude } : {}
682
- });
683
- } catch (cause) {
684
- if (!(cause instanceof BuildDirectoryError)) throw cause;
685
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
686
- process.stderr.write(pc.dim(`${await emptyDirectoryHint(shown, cwd)}\n`));
687
- return;
688
- }
689
- if (pages.length === 0) {
690
- process.stderr.write(`${pc.yellow("warning")} ${await emptyDirectoryHint(shown, cwd)}\n`);
691
- return;
692
- }
693
- return {
694
- pages,
695
- label: shown
696
- };
697
- }
698
- /**
699
- * Fetch the pages of a running site, reporting what happened on the way.
700
- *
701
- * Returns undefined when the crawl could not start, which the caller turns into
702
- * exit 2 — a run that reached no verdict, not a clean one.
703
- */
704
- async function crawlPages(url, options) {
705
- const { crawlSite, CrawlError, parseEntryUrl } = await import("./crawl-CtJbMNNb.js");
706
- let entry;
707
- try {
708
- entry = parseEntryUrl(url, options.allowRemote ?? false);
709
- } catch (cause) {
710
- if (cause instanceof CrawlError) {
711
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
712
- return;
713
- }
714
- throw cause;
715
- }
716
- process.stderr.write(pc.dim(`Crawling ${entry.origin}…\n`));
717
- const result = await crawlSite(entry, {
718
- ...options.allowRemote ? { allowRemote: true } : {},
719
- ...options.ignoreRobots ? { ignoreRobots: true } : {},
720
- ...options.maxPages === void 0 ? {} : { maxPages: options.maxPages },
721
- ...options.maxDepth === void 0 ? {} : { maxDepth: options.maxDepth },
722
- ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
723
- });
724
- if (result.pages.length === 0 && result.failures.length > 0) {
725
- process.stderr.write(`${pc.red("error")} Could not fetch ${entry.href} (${result.failures[0]?.reason})\n`);
726
- process.stderr.write(pc.dim(" Is the site running at that address?\n"));
727
- return;
728
- }
729
- process.stderr.write(pc.dim(`Found ${result.pages.length} ${result.pages.length === 1 ? "page" : "pages"} from ${result.discovery === "sitemap" ? "sitemap.xml and links" : "links"}\n`));
730
- if (result.failures.length > 0) {
731
- process.stderr.write(`${pc.yellow("warning")} ${result.failures.length} ${result.failures.length === 1 ? "URL was" : "URLs were"} not fetched, and so not audited:\n`);
732
- for (const failure of result.failures.slice(0, 10)) process.stderr.write(pc.dim(` ${failure.url} — ${failure.reason}\n`));
733
- if (result.failures.length > 10) process.stderr.write(pc.dim(` …and ${result.failures.length - 10} more\n`));
734
- }
735
- if (result.truncated) process.stderr.write(`${pc.yellow("warning")} Stopped at ${result.pages.length} ${result.pages.length === 1 ? "page" : "pages"}; the site has more. Raise --max-pages to go further.\n`);
736
- return {
737
- pages: result.pages,
738
- origin: result.origin
739
- };
740
- }
741
- /**
742
- * No directory and no URL: work out what this project needs.
743
- *
744
- * The point is that `eaa-kit audit` on its own does something useful. Anything
745
- * this starts is handed back as `cleanup` so the caller can stop it once the
746
- * report is written.
747
- */
748
- async function resolveAutomatically(options) {
749
- const cwd = options.cwd ?? process.cwd();
750
- const { autoDetectSource } = await import("./project-CiyzKQud.js");
751
- const detected = await autoDetectSource(cwd, {
752
- ...options.noBuild ? { noBuild: true } : {},
753
- onStep: (message) => process.stderr.write(pc.dim(`${message}\n`))
754
- });
755
- if (detected?.directory !== void 0) return await resolvePages(detected.directory, {
756
- ...options,
757
- label: path.relative(cwd, detected.directory) || "."
758
- });
759
- if (detected?.url !== void 0) {
760
- const resolved = await resolvePages(void 0, {
761
- ...options,
762
- url: detected.url
763
- });
764
- if (resolved === void 0) {
765
- await detected.cleanup?.();
766
- return;
767
- }
768
- return {
769
- ...resolved,
770
- ...detected.cleanup ? { cleanup: detected.cleanup } : {}
771
- };
772
- }
773
- await detected?.cleanup?.();
774
- process.stderr.write(`${pc.yellow("warning")} ${await emptyDirectoryHint("./dist", cwd)}\n`);
775
- }
776
- //#endregion
777
- //#region src/cli/audit.ts
778
- const OUTPUT_FORMATS = [
779
- "console",
780
- "json",
781
- "sarif",
782
- "html"
783
- ];
784
- function isOutputFormat(value) {
785
- return OUTPUT_FORMATS.includes(value);
786
- }
787
- /**
788
- * `eaa-kit audit [dir]`.
789
- *
790
- * Writes progress to stderr and the report to stdout, so the report can be
791
- * piped somewhere without the chatter coming along.
792
- */
793
- async function runAuditCommand(dir, options = {}) {
794
- const resolved = await resolvePages(dir, options);
795
- if (!resolved) return {
796
- audits: [],
797
- exitCode: 2
798
- };
799
- const { pages, origin, label, cleanup } = resolved;
800
- try {
801
- const engineNote = await describeEngine(pages, options);
802
- process.stderr.write(pc.dim(`Auditing ${pages.length} ${pages.length === 1 ? "page" : "pages"} in ${label}${engineNote}…\n`));
803
- const effectiveBaseUrl = options.baseUrl ?? origin;
804
- const runnerOptions = {
805
- ...effectiveBaseUrl === void 0 ? {} : { baseUrl: effectiveBaseUrl },
806
- ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
807
- };
808
- let audits;
809
- if (options.browser) {
810
- const { BrowserUnavailableError, runBrowserAudit } = await import("./playwright-DWux49V3.js");
811
- try {
812
- audits = await runBrowserAudit(options.url === void 0 ? dir : void 0, pages, runnerOptions);
813
- } catch (cause) {
814
- if (cause instanceof BrowserUnavailableError) {
815
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
816
- return {
817
- audits: [],
818
- exitCode: 2
819
- };
820
- }
821
- throw cause;
822
- }
823
- } else {
824
- const { runPooledAudit } = await import("./pool-DixLeu8L.js");
825
- audits = await runPooledAudit(pages, {
826
- ...runnerOptions,
827
- ...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
828
- });
829
- }
830
- const failOn = options.failOn ?? "serious";
831
- if (options.baseline) {
832
- const applied = await acceptBaseline(audits, options);
833
- if (!applied) return {
834
- audits,
835
- exitCode: 2
836
- };
837
- audits = applied;
838
- }
839
- await emit(audits, label, failOn, options);
840
- const unaudited = audits.filter((audit) => audit.error);
841
- if (unaudited.length > 0) {
842
- process.stderr.write(`${pc.red("error")} ${unaudited.length} of ${audits.length} pages could not be audited\n`);
843
- return {
844
- audits,
845
- exitCode: 2
846
- };
847
- }
848
- return {
849
- audits,
850
- exitCode: countAtOrAbove(audits, failOn) > 0 ? 1 : 0
851
- };
852
- } finally {
853
- await cleanup?.();
854
- }
855
- }
856
- /**
857
- * Move the violations the baseline accounts for out of the failing set.
858
- *
859
- * Returns undefined when the baseline could not be read, which the caller
860
- * turns into exit 2: a run asked to use a baseline it cannot find has not
861
- * measured what it was told to measure, and silently failing on everything
862
- * would be as wrong as silently passing.
863
- */
864
- async function acceptBaseline(audits, options) {
865
- const { applyBaseline, BaselineError, readBaseline } = await import("./baseline-CV_3lbER.js");
866
- try {
867
- const outcome = applyBaseline(audits, await readBaseline(options.baseline, options.cwd ?? process.cwd()));
868
- if (outcome.accepted > 0) process.stderr.write(pc.dim(`Baseline accepted ${outcome.accepted} violating elements\n`));
869
- if (outcome.stale.length > 0) {
870
- const count = outcome.stale.length;
871
- process.stderr.write(pc.dim(`${count} baseline ${count === 1 ? "entry no longer matches" : "entries no longer match"} and can be removed\n`));
872
- }
873
- if (outcome.expired.length > 0) process.stderr.write(pc.yellow(`${outcome.expired.length} baseline entries have expired and no longer suppress anything\n`));
874
- return outcome.audits;
875
- } catch (cause) {
876
- if (cause instanceof BaselineError) {
877
- process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
878
- return;
879
- }
880
- throw cause;
881
- }
882
- }
883
- /**
884
- * What the progress line says about the engine.
885
- *
886
- * The thread count is on it because it is the difference between a run that
887
- * looks stalled and one that is working, and because a user comparing two
888
- * timings deserves to know which one used the machine.
889
- */
890
- async function describeEngine(pages, options) {
891
- if (options.browser) return " with Chromium";
892
- const { plannedWorkers } = await import("./pool-DixLeu8L.js");
893
- const workers = options.concurrency ?? plannedWorkers(pages);
894
- return workers > 1 ? ` across ${workers} threads` : "";
895
- }
896
- /**
897
- * Emit the chosen format, to a file when --output is given and to stdout
898
- * otherwise. Colour is dropped when writing to a file, since escape codes in a
899
- * saved report are noise.
900
- */
901
- async function emit(audits, dir, failOn, options) {
902
- const body = await renderReport(audits, dir, failOn, options.format ?? "console", typeof options.output === "string", options);
903
- if (!options.output) {
904
- process.stdout.write(body);
905
- return;
906
- }
907
- const target = path.resolve(options.cwd ?? process.cwd(), options.output);
908
- await mkdir(path.dirname(target), { recursive: true });
909
- await writeFile(target, body, "utf8");
910
- process.stderr.write(pc.dim(`Report written to ${options.output}\n`));
911
- }
912
- async function renderReport(audits, dir, failOn, format, toFile, options) {
913
- switch (format) {
914
- case "json": {
915
- const { buildJsonReport, serialiseJsonReport } = await import("./json-C9xS1PNC.js");
916
- return serialiseJsonReport(buildJsonReport(audits, {
917
- directory: dir,
918
- ...options.url === void 0 ? {} : { sourceKind: "url" },
919
- failOn,
920
- ...options.baseUrl ? { baseUrl: options.baseUrl } : {}
921
- }));
922
- }
923
- case "sarif": {
924
- const { buildSarifReport, serialiseSarifReport } = await import("./sarif-eSCuI0eX.js");
925
- return serialiseSarifReport(buildSarifReport(audits, { directory: dir }));
926
- }
927
- case "html": {
928
- const { buildHtmlReport } = await import("./html-BLEuzep6.js");
929
- return buildHtmlReport(audits, {
930
- directory: dir,
931
- failOn,
932
- ...options.baseUrl ? { baseUrl: options.baseUrl } : {}
933
- });
934
- }
935
- case "console": {
936
- const { buildRouteMap, sourceFor } = await import("./routes-BxbSZKXC.js");
937
- const routes = await buildRouteMap(options.cwd ?? process.cwd());
938
- return `${formatConsoleReport(audits, {
939
- dir,
940
- failOn,
941
- sourceFor: (page) => sourceFor(routes, page),
942
- ...options.perPage ? { perPage: true } : {},
943
- ...toFile ? { color: false } : {}
944
- })}\n`;
945
- }
946
- }
947
- }
948
- //#endregion
949
- export { resolvePages as i, isOutputFormat as n, runAuditCommand as r, OUTPUT_FORMATS as t };