eaa-kit 0.1.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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/astro/index.d.ts +90 -0
- package/dist/astro/index.js +48 -0
- package/dist/audit/runners/worker.d.ts +1 -0
- package/dist/audit/runners/worker.js +27 -0
- package/dist/audit-6gbV0Zjd.js +582 -0
- package/dist/audit-VtuUOuyX.js +2 -0
- package/dist/baseline-DQTnNlc4.js +169 -0
- package/dist/baseline-Itspu3-Y.js +2 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +259 -0
- package/dist/escape-Dm1o_RAk.js +21 -0
- package/dist/fingerprint-DRoneAjj.js +20 -0
- package/dist/html-BLEuzep6.js +337 -0
- package/dist/impact-DvgBjupx.js +32 -0
- package/dist/impact-EEB9ZXmC.d.ts +7 -0
- package/dist/index.d.ts +264 -0
- package/dist/index.js +2 -0
- package/dist/jsdom-BEu6Ra_2.js +163 -0
- package/dist/jsdom-C6dIyaxN.js +3 -0
- package/dist/json-1ESNIiHY.js +139 -0
- package/dist/playwright-BfWuTG_u.js +235 -0
- package/dist/pool-DixLeu8L.js +188 -0
- package/dist/render-K9KxDDSA.js +774 -0
- package/dist/result-2aZPfM8w.js +168 -0
- package/dist/sarif-eSCuI0eX.js +192 -0
- package/dist/statement/templates/at.de.md +127 -0
- package/dist/statement/templates/at.en.md +121 -0
- package/dist/statement/templates/ch.de.md +138 -0
- package/dist/statement/templates/ch.en.md +135 -0
- package/dist/statement/templates/de.de.md +129 -0
- package/dist/statement/templates/de.en.md +125 -0
- package/dist/version-B3v4rNoG.js +15 -0
- package/package.json +95 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { n as IMPACT_LEVELS, r as countAtOrAbove } from "./impact-DvgBjupx.js";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import { glob } from "tinyglobby";
|
|
6
|
+
//#region src/audit/collect.ts
|
|
7
|
+
/** Every HTML document a static build is expected to emit. */
|
|
8
|
+
const DEFAULT_INCLUDE = ["**/*.html", "**/*.htm"];
|
|
9
|
+
/** Vendored and tooling directories are never part of the shipped site. */
|
|
10
|
+
const DEFAULT_EXCLUDE = ["**/node_modules/**", "**/.git/**"];
|
|
11
|
+
/** Number of files read in parallel; keeps large builds under the fd limit. */
|
|
12
|
+
const READ_CONCURRENCY = 24;
|
|
13
|
+
/**
|
|
14
|
+
* Thrown when the build directory itself is unusable. A missing or wrong
|
|
15
|
+
* `dist/` is a user mistake worth reporting loudly, unlike a directory that
|
|
16
|
+
* simply holds no HTML.
|
|
17
|
+
*/
|
|
18
|
+
var BuildDirectoryError = class extends Error {
|
|
19
|
+
dir;
|
|
20
|
+
name = "BuildDirectoryError";
|
|
21
|
+
constructor(message, dir) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.dir = dir;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Glob HTML files out of a build directory and read them.
|
|
28
|
+
*
|
|
29
|
+
* Returns pages sorted by relative path so reports and snapshots are stable
|
|
30
|
+
* across platforms. An empty array means "no HTML found" — the caller decides
|
|
31
|
+
* whether that is an error.
|
|
32
|
+
*/
|
|
33
|
+
async function collectPages(dir, options = {}) {
|
|
34
|
+
const root = path.resolve(dir);
|
|
35
|
+
await assertDirectory(root, dir);
|
|
36
|
+
const relativePaths = (await glob(options.include ?? DEFAULT_INCLUDE, {
|
|
37
|
+
cwd: root,
|
|
38
|
+
ignore: options.exclude ?? DEFAULT_EXCLUDE,
|
|
39
|
+
onlyFiles: true,
|
|
40
|
+
dot: false,
|
|
41
|
+
absolute: false
|
|
42
|
+
})).map(toPosix).sort();
|
|
43
|
+
const pages = [];
|
|
44
|
+
for (let i = 0; i < relativePaths.length; i += READ_CONCURRENCY) {
|
|
45
|
+
const batch = relativePaths.slice(i, i + READ_CONCURRENCY);
|
|
46
|
+
pages.push(...await Promise.all(batch.map((relativePath) => readPage(root, relativePath))));
|
|
47
|
+
}
|
|
48
|
+
return pages;
|
|
49
|
+
}
|
|
50
|
+
async function assertDirectory(root, original) {
|
|
51
|
+
let stats;
|
|
52
|
+
try {
|
|
53
|
+
stats = await stat(root);
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
if (cause.code === "ENOENT") throw new BuildDirectoryError(`Build directory not found: ${original}`, root);
|
|
56
|
+
throw new BuildDirectoryError(`Build directory is not readable: ${original} (${cause.message})`, root);
|
|
57
|
+
}
|
|
58
|
+
if (!stats.isDirectory()) throw new BuildDirectoryError(`Build path is not a directory: ${original}`, root);
|
|
59
|
+
}
|
|
60
|
+
async function readPage(root, relativePath) {
|
|
61
|
+
const absolutePath = path.join(root, relativePath);
|
|
62
|
+
const html = await readFile(absolutePath, "utf8");
|
|
63
|
+
return {
|
|
64
|
+
absolutePath,
|
|
65
|
+
relativePath,
|
|
66
|
+
html: html.charCodeAt(0) === 65279 ? html.slice(1) : html
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function toPosix(filePath) {
|
|
70
|
+
return filePath.split(path.sep).join("/");
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/audit/report/console.ts
|
|
74
|
+
const DEFAULT_MAX_NODES = 3;
|
|
75
|
+
const MIN_WIDTH = 40;
|
|
76
|
+
const MAX_WIDTH = 100;
|
|
77
|
+
/**
|
|
78
|
+
* Human-readable audit report.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately not a columnar table: rule ids, help text and selectors are all
|
|
81
|
+
* variable-length, so a real table either wraps into soup or scrolls sideways
|
|
82
|
+
* on a narrow terminal. Everything is left-aligned and indented instead, and
|
|
83
|
+
* every line is assembled from segments trimmed as a group, so the width
|
|
84
|
+
* guarantee holds however long a selector or rule id turns out to be.
|
|
85
|
+
*
|
|
86
|
+
* Returns a string rather than printing, so the format is testable.
|
|
87
|
+
*/
|
|
88
|
+
function formatConsoleReport(audits, options = {}) {
|
|
89
|
+
const ctx = context(options);
|
|
90
|
+
const lines = [
|
|
91
|
+
"",
|
|
92
|
+
...headerLines(audits, ctx),
|
|
93
|
+
""
|
|
94
|
+
];
|
|
95
|
+
for (const audit of audits) lines.push(...pageSection(audit, ctx));
|
|
96
|
+
lines.push(...summary(audits, ctx));
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
function context(options) {
|
|
100
|
+
const detected = process.stdout.columns ?? 80;
|
|
101
|
+
const width = Math.min(Math.max(options.width ?? detected, MIN_WIDTH), MAX_WIDTH);
|
|
102
|
+
const c = pc.createColors(options.color ?? pc.isColorSupported);
|
|
103
|
+
const unicode = supportsUnicode();
|
|
104
|
+
return {
|
|
105
|
+
width,
|
|
106
|
+
maxNodes: options.maxNodes ?? DEFAULT_MAX_NODES,
|
|
107
|
+
failOn: options.failOn ?? "serious",
|
|
108
|
+
c,
|
|
109
|
+
symbol: (kind) => {
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case "violation": return unicode ? "✗" : "x";
|
|
112
|
+
case "review": return "?";
|
|
113
|
+
case "blind": return unicode ? "·" : "-";
|
|
114
|
+
case "clean": return unicode ? "✓" : "+";
|
|
115
|
+
case "error": return "!";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** cmd.exe and older Windows consoles render these glyphs as mojibake. */
|
|
121
|
+
function supportsUnicode() {
|
|
122
|
+
if (process.platform !== "win32") return true;
|
|
123
|
+
return Boolean(process.env["WT_SESSION"] || process.env["TERM_PROGRAM"] || process.env["ConEmuTask"] || process.env["TERM"]);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Joins segments into one line, trimming the group to the terminal width.
|
|
127
|
+
* Colour codes are applied after trimming, so they never count towards it.
|
|
128
|
+
*/
|
|
129
|
+
function render(ctx, segments) {
|
|
130
|
+
let remaining = ctx.width;
|
|
131
|
+
const parts = [];
|
|
132
|
+
for (const segment of segments) {
|
|
133
|
+
if (remaining <= 0) break;
|
|
134
|
+
const text = segment.text.length <= remaining ? segment.text : `${segment.text.slice(0, Math.max(remaining - 1, 0))}…`;
|
|
135
|
+
remaining -= text.length;
|
|
136
|
+
parts.push(segment.paint ? segment.paint(text) : text);
|
|
137
|
+
}
|
|
138
|
+
return parts.join("");
|
|
139
|
+
}
|
|
140
|
+
function headerLines(audits, ctx) {
|
|
141
|
+
const engineLabel = (audits[0]?.engine ?? "jsdom") === "browser" ? "chromium" : "jsdom (browserless)";
|
|
142
|
+
const pageCount = `${audits.length} ${plural(audits.length, "page")}`;
|
|
143
|
+
return [render(ctx, [{
|
|
144
|
+
text: "eaa-kit audit",
|
|
145
|
+
paint: ctx.c.bold
|
|
146
|
+
}, {
|
|
147
|
+
text: ` ${pageCount} · ${engineLabel}`,
|
|
148
|
+
paint: ctx.c.dim
|
|
149
|
+
}]), render(ctx, [{
|
|
150
|
+
text: "passed = checked and met · not applicable = nothing to check",
|
|
151
|
+
paint: ctx.c.dim
|
|
152
|
+
}])];
|
|
153
|
+
}
|
|
154
|
+
function pageSection(audit, ctx) {
|
|
155
|
+
const lines = [render(ctx, [{
|
|
156
|
+
text: audit.relativePath,
|
|
157
|
+
paint: ctx.c.underline
|
|
158
|
+
}])];
|
|
159
|
+
if (audit.error) {
|
|
160
|
+
lines.push(render(ctx, [{
|
|
161
|
+
text: ` ${ctx.symbol("error")} not audited: `,
|
|
162
|
+
paint: ctx.c.red
|
|
163
|
+
}, { text: audit.error }]), "");
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
166
|
+
for (const finding of sortByImpact(audit.violations)) lines.push(...violationLines(finding, ctx));
|
|
167
|
+
for (const finding of audit.incomplete.filter((item) => item.reason === "needs-review")) lines.push(render(ctx, [
|
|
168
|
+
{
|
|
169
|
+
text: ` ${ctx.symbol("review")} `,
|
|
170
|
+
paint: ctx.c.yellow
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
text: finding.ruleId,
|
|
174
|
+
paint: ctx.c.yellow
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
text: ` needs manual review${criteria(finding)}`,
|
|
178
|
+
paint: ctx.c.dim
|
|
179
|
+
}
|
|
180
|
+
]));
|
|
181
|
+
if (audit.violations.length === 0) {
|
|
182
|
+
const clean = (audit.accepted ?? []).length === 0;
|
|
183
|
+
lines.push(render(ctx, [{
|
|
184
|
+
text: ` ${ctx.symbol("clean")} `,
|
|
185
|
+
paint: clean ? ctx.c.green : ctx.c.dim
|
|
186
|
+
}, {
|
|
187
|
+
text: clean ? "no violations" : "no new violations",
|
|
188
|
+
paint: clean ? ctx.c.green : ctx.c.dim
|
|
189
|
+
}]));
|
|
190
|
+
}
|
|
191
|
+
for (const finding of sortByImpact(audit.accepted ?? [])) {
|
|
192
|
+
const elements = finding.nodes.length;
|
|
193
|
+
lines.push(render(ctx, [
|
|
194
|
+
{
|
|
195
|
+
text: " · ",
|
|
196
|
+
paint: ctx.c.dim
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
text: finding.ruleId,
|
|
200
|
+
paint: ctx.c.dim
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
text: ` accepted by the baseline (${elements} ${plural(elements, "element")})`,
|
|
204
|
+
paint: ctx.c.dim
|
|
205
|
+
}
|
|
206
|
+
]));
|
|
207
|
+
}
|
|
208
|
+
lines.push(coverageLine(audit, ctx));
|
|
209
|
+
lines.push("");
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* What this page's result actually rests on.
|
|
214
|
+
*
|
|
215
|
+
* The four counts stay separate on purpose. Only `passed` is evidence that a
|
|
216
|
+
* criterion was met here; `not applicable` means the rule found nothing to
|
|
217
|
+
* check, and adding the two together would turn an empty page into a
|
|
218
|
+
* near-perfect score.
|
|
219
|
+
*/
|
|
220
|
+
function coverageLine(audit, ctx) {
|
|
221
|
+
const blind = audit.incomplete.filter((finding) => finding.reason === "engine-limitation").length;
|
|
222
|
+
const review = audit.incomplete.length - blind;
|
|
223
|
+
const parts = [`${audit.passes.length} passed`, `${audit.inapplicable.length} not applicable`];
|
|
224
|
+
if (review > 0) parts.push(`${review} to review`);
|
|
225
|
+
if (blind > 0) parts.push(`${blind} not evaluated`);
|
|
226
|
+
return render(ctx, [{
|
|
227
|
+
text: ` ${parts.join(" · ")}`,
|
|
228
|
+
paint: ctx.c.dim
|
|
229
|
+
}]);
|
|
230
|
+
}
|
|
231
|
+
function violationLines(finding, ctx) {
|
|
232
|
+
const impact = finding.impact ?? "unknown";
|
|
233
|
+
const lines = [render(ctx, [
|
|
234
|
+
{
|
|
235
|
+
text: ` ${ctx.symbol("violation")} `,
|
|
236
|
+
paint: ctx.c.red
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
text: finding.ruleId,
|
|
240
|
+
paint: ctx.c.bold
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
text: ` ${impact}${criteria(finding)}`,
|
|
244
|
+
paint: ctx.c.dim
|
|
245
|
+
}
|
|
246
|
+
]), render(ctx, [{ text: ` ${finding.help}` }])];
|
|
247
|
+
for (const node of finding.nodes.slice(0, ctx.maxNodes)) {
|
|
248
|
+
lines.push(render(ctx, [{
|
|
249
|
+
text: ` ${node.target.join(" ")}`,
|
|
250
|
+
paint: ctx.c.cyan
|
|
251
|
+
}]));
|
|
252
|
+
lines.push(render(ctx, [{
|
|
253
|
+
text: ` ${collapse(node.html)}`,
|
|
254
|
+
paint: ctx.c.dim
|
|
255
|
+
}]));
|
|
256
|
+
}
|
|
257
|
+
const hidden = finding.nodes.length - ctx.maxNodes;
|
|
258
|
+
if (hidden > 0) lines.push(render(ctx, [{
|
|
259
|
+
text: ` + ${hidden} more ${plural(hidden, "element")}`,
|
|
260
|
+
paint: ctx.c.dim
|
|
261
|
+
}]));
|
|
262
|
+
return lines;
|
|
263
|
+
}
|
|
264
|
+
function summary(audits, ctx) {
|
|
265
|
+
const withViolations = audits.filter((audit) => audit.violations.length > 0);
|
|
266
|
+
const errored = audits.filter((audit) => audit.error);
|
|
267
|
+
const ruleCount = audits.reduce((total, audit) => total + audit.violations.length, 0);
|
|
268
|
+
const elementCount = audits.reduce((total, audit) => total + audit.violations.reduce((sum, finding) => sum + finding.nodes.length, 0), 0);
|
|
269
|
+
const reviewCount = countRules(audits, "needs-review");
|
|
270
|
+
const pages = `${audits.length} ${plural(audits.length, "page")}`;
|
|
271
|
+
const lines = [render(ctx, [{
|
|
272
|
+
text: "Summary",
|
|
273
|
+
paint: ctx.c.bold
|
|
274
|
+
}])];
|
|
275
|
+
if (ruleCount === 0) lines.push(render(ctx, [{
|
|
276
|
+
text: ` No violations across ${pages}.`,
|
|
277
|
+
paint: ctx.c.green
|
|
278
|
+
}]));
|
|
279
|
+
else {
|
|
280
|
+
lines.push(render(ctx, [{
|
|
281
|
+
text: ` ${ruleCount} ${plural(ruleCount, "violation")} on ${withViolations.length} of ${pages}`,
|
|
282
|
+
paint: ctx.c.red
|
|
283
|
+
}, {
|
|
284
|
+
text: ` (${elementCount} ${plural(elementCount, "element")})`,
|
|
285
|
+
paint: ctx.c.dim
|
|
286
|
+
}]));
|
|
287
|
+
lines.push(thresholdLine(audits, ctx));
|
|
288
|
+
}
|
|
289
|
+
if (reviewCount > 0) lines.push(render(ctx, [{
|
|
290
|
+
text: ` ${reviewCount} ${plural(reviewCount, "rule")} ${reviewCount === 1 ? "needs" : "need"} manual review`,
|
|
291
|
+
paint: ctx.c.yellow
|
|
292
|
+
}]));
|
|
293
|
+
if (errored.length > 0) lines.push(render(ctx, [{
|
|
294
|
+
text: ` ${errored.length} ${plural(errored.length, "page")} could not be audited`,
|
|
295
|
+
paint: ctx.c.red
|
|
296
|
+
}]));
|
|
297
|
+
const accepted = audits.reduce((total, audit) => total + (audit.accepted ?? []).reduce((sum, finding) => sum + finding.nodes.length, 0), 0);
|
|
298
|
+
if (accepted > 0) lines.push(render(ctx, [{
|
|
299
|
+
text: ` ${accepted} ${plural(accepted, "element")} accepted by the baseline, not counted above`,
|
|
300
|
+
paint: ctx.c.dim
|
|
301
|
+
}]));
|
|
302
|
+
lines.push(...blindSection(audits, ctx));
|
|
303
|
+
return lines;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Why the run passed or failed. Without this, a build that exits 0 while the
|
|
307
|
+
* report lists violations looks like a bug rather than a threshold choice.
|
|
308
|
+
*/
|
|
309
|
+
function thresholdLine(audits, ctx) {
|
|
310
|
+
const failOn = ctx.failOn;
|
|
311
|
+
const failing = countAtOrAbove(audits, failOn);
|
|
312
|
+
if (failing === 0) return render(ctx, [{
|
|
313
|
+
text: ` none at or above ${failOn} (--fail-on ${failOn}), so this run passes`,
|
|
314
|
+
paint: ctx.c.green
|
|
315
|
+
}]);
|
|
316
|
+
return render(ctx, [{
|
|
317
|
+
text: ` ${failing} at or above ${failOn} (--fail-on ${failOn})`,
|
|
318
|
+
paint: ctx.c.red
|
|
319
|
+
}]);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The unevaluated rules are listed once, at the end, rather than repeated under
|
|
323
|
+
* every page: on a large site the same handful recurs on each one, and a wall
|
|
324
|
+
* of "not evaluated" would bury the findings that are real.
|
|
325
|
+
*/
|
|
326
|
+
function blindSection(audits, ctx) {
|
|
327
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
328
|
+
for (const audit of audits) for (const finding of audit.incomplete) {
|
|
329
|
+
if (finding.reason !== "engine-limitation") continue;
|
|
330
|
+
const entry = byRule.get(finding.ruleId);
|
|
331
|
+
if (entry) entry.pages += 1;
|
|
332
|
+
else byRule.set(finding.ruleId, {
|
|
333
|
+
pages: 1,
|
|
334
|
+
finding
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (byRule.size === 0) return [];
|
|
338
|
+
const lines = [
|
|
339
|
+
"",
|
|
340
|
+
render(ctx, [{
|
|
341
|
+
text: "Not evaluated",
|
|
342
|
+
paint: ctx.c.bold
|
|
343
|
+
}]),
|
|
344
|
+
render(ctx, [{
|
|
345
|
+
text: " This engine reached no verdict on these.",
|
|
346
|
+
paint: ctx.c.dim
|
|
347
|
+
}]),
|
|
348
|
+
render(ctx, [{
|
|
349
|
+
text: " They are never reported as passing.",
|
|
350
|
+
paint: ctx.c.dim
|
|
351
|
+
}])
|
|
352
|
+
];
|
|
353
|
+
for (const [ruleId, { pages, finding }] of [...byRule].sort((a, b) => b[1].pages - a[1].pages)) {
|
|
354
|
+
lines.push(render(ctx, [
|
|
355
|
+
{ text: ` ${ctx.symbol("blind")} ` },
|
|
356
|
+
{ text: ruleId },
|
|
357
|
+
{
|
|
358
|
+
text: ` ${pages} ${plural(pages, "page")}${criteria(finding)}`,
|
|
359
|
+
paint: ctx.c.dim
|
|
360
|
+
}
|
|
361
|
+
]));
|
|
362
|
+
lines.push(render(ctx, [{
|
|
363
|
+
text: ` ${finding.reasonDetail}`,
|
|
364
|
+
paint: ctx.c.dim
|
|
365
|
+
}]));
|
|
366
|
+
}
|
|
367
|
+
return lines;
|
|
368
|
+
}
|
|
369
|
+
function countRules(audits, reason) {
|
|
370
|
+
const ruleIds = /* @__PURE__ */ new Set();
|
|
371
|
+
for (const audit of audits) for (const finding of audit.incomplete) if (finding.reason === reason) ruleIds.add(finding.ruleId);
|
|
372
|
+
return ruleIds.size;
|
|
373
|
+
}
|
|
374
|
+
function sortByImpact(findings) {
|
|
375
|
+
return [...findings].sort((a, b) => {
|
|
376
|
+
const rank = impactRank(a) - impactRank(b);
|
|
377
|
+
return rank === 0 ? a.ruleId.localeCompare(b.ruleId) : rank;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/** Most severe first; anything axe-core left unclassified sorts last. */
|
|
381
|
+
function impactRank(finding) {
|
|
382
|
+
const index = IMPACT_LEVELS.indexOf(finding.impact);
|
|
383
|
+
return index === -1 ? IMPACT_LEVELS.length : IMPACT_LEVELS.length - index;
|
|
384
|
+
}
|
|
385
|
+
function criteria(finding) {
|
|
386
|
+
return finding.successCriteria.length > 0 ? `, WCAG ${finding.successCriteria.join(" ")}` : "";
|
|
387
|
+
}
|
|
388
|
+
function collapse(html) {
|
|
389
|
+
return html.replace(/\s+/g, " ").trim();
|
|
390
|
+
}
|
|
391
|
+
function plural(count, word) {
|
|
392
|
+
return count === 1 ? word : `${word}s`;
|
|
393
|
+
}
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/cli/audit.ts
|
|
396
|
+
/**
|
|
397
|
+
* The engines and the machine-readable reporters are imported where they are
|
|
398
|
+
* used, not at the top of the file.
|
|
399
|
+
*
|
|
400
|
+
* jsdom costs 630 ms to load and axe-core another 94 ms, and a static import
|
|
401
|
+
* here charges that to every invocation — `eaa-kit statement`, `--help` and a
|
|
402
|
+
* mistyped flag included, none of which parse a single page. The audit path
|
|
403
|
+
* pays the same cost either way, a few milliseconds later.
|
|
404
|
+
*/
|
|
405
|
+
const OUTPUT_FORMATS = [
|
|
406
|
+
"console",
|
|
407
|
+
"json",
|
|
408
|
+
"sarif",
|
|
409
|
+
"html"
|
|
410
|
+
];
|
|
411
|
+
function isOutputFormat(value) {
|
|
412
|
+
return OUTPUT_FORMATS.includes(value);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* `eaa-kit audit [dir]`.
|
|
416
|
+
*
|
|
417
|
+
* Writes progress to stderr and the report to stdout, so the report can be
|
|
418
|
+
* piped somewhere without the chatter coming along.
|
|
419
|
+
*/
|
|
420
|
+
async function runAuditCommand(dir, options = {}) {
|
|
421
|
+
let pages;
|
|
422
|
+
try {
|
|
423
|
+
pages = await collectPages(dir, {
|
|
424
|
+
...options.include ? { include: options.include } : {},
|
|
425
|
+
...options.exclude ? { exclude: options.exclude } : {}
|
|
426
|
+
});
|
|
427
|
+
} catch (cause) {
|
|
428
|
+
if (cause instanceof BuildDirectoryError) {
|
|
429
|
+
process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
|
|
430
|
+
process.stderr.write(pc.dim("Point eaa-kit at your build output, e.g. eaa-kit audit ./dist\n"));
|
|
431
|
+
return {
|
|
432
|
+
audits: [],
|
|
433
|
+
exitCode: 2
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
throw cause;
|
|
437
|
+
}
|
|
438
|
+
if (pages.length === 0) {
|
|
439
|
+
process.stderr.write(`${pc.yellow("warning")} No HTML files found in ${dir}\n`);
|
|
440
|
+
return {
|
|
441
|
+
audits: [],
|
|
442
|
+
exitCode: 2
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const engineNote = await describeEngine(pages, options);
|
|
446
|
+
process.stderr.write(pc.dim(`Auditing ${pages.length} ${pages.length === 1 ? "page" : "pages"} in ${dir}${engineNote}…\n`));
|
|
447
|
+
const runnerOptions = {
|
|
448
|
+
...options.baseUrl ? { baseUrl: options.baseUrl } : {},
|
|
449
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
450
|
+
};
|
|
451
|
+
let audits;
|
|
452
|
+
if (options.browser) {
|
|
453
|
+
const { BrowserUnavailableError, runBrowserAudit } = await import("./playwright-BfWuTG_u.js");
|
|
454
|
+
try {
|
|
455
|
+
audits = await runBrowserAudit(dir, pages, runnerOptions);
|
|
456
|
+
} catch (cause) {
|
|
457
|
+
if (cause instanceof BrowserUnavailableError) {
|
|
458
|
+
process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
|
|
459
|
+
return {
|
|
460
|
+
audits: [],
|
|
461
|
+
exitCode: 2
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
throw cause;
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
const { runPooledAudit } = await import("./pool-DixLeu8L.js");
|
|
468
|
+
audits = await runPooledAudit(pages, {
|
|
469
|
+
...runnerOptions,
|
|
470
|
+
...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
const failOn = options.failOn ?? "serious";
|
|
474
|
+
if (options.baseline) {
|
|
475
|
+
const applied = await acceptBaseline(audits, options);
|
|
476
|
+
if (!applied) return {
|
|
477
|
+
audits,
|
|
478
|
+
exitCode: 2
|
|
479
|
+
};
|
|
480
|
+
audits = applied;
|
|
481
|
+
}
|
|
482
|
+
await emit(audits, dir, failOn, options);
|
|
483
|
+
const unaudited = audits.filter((audit) => audit.error);
|
|
484
|
+
if (unaudited.length > 0) {
|
|
485
|
+
process.stderr.write(`${pc.red("error")} ${unaudited.length} of ${audits.length} pages could not be audited\n`);
|
|
486
|
+
return {
|
|
487
|
+
audits,
|
|
488
|
+
exitCode: 2
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
audits,
|
|
493
|
+
exitCode: countAtOrAbove(audits, failOn) > 0 ? 1 : 0
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Move the violations the baseline accounts for out of the failing set.
|
|
498
|
+
*
|
|
499
|
+
* Returns undefined when the baseline could not be read, which the caller
|
|
500
|
+
* turns into exit 2: a run asked to use a baseline it cannot find has not
|
|
501
|
+
* measured what it was told to measure, and silently failing on everything
|
|
502
|
+
* would be as wrong as silently passing.
|
|
503
|
+
*/
|
|
504
|
+
async function acceptBaseline(audits, options) {
|
|
505
|
+
const { applyBaseline, BaselineError, readBaseline } = await import("./baseline-Itspu3-Y.js");
|
|
506
|
+
try {
|
|
507
|
+
const outcome = applyBaseline(audits, await readBaseline(options.baseline, options.cwd ?? process.cwd()));
|
|
508
|
+
if (outcome.accepted > 0) process.stderr.write(pc.dim(`Baseline accepted ${outcome.accepted} violating elements\n`));
|
|
509
|
+
if (outcome.stale.length > 0) {
|
|
510
|
+
const count = outcome.stale.length;
|
|
511
|
+
process.stderr.write(pc.dim(`${count} baseline ${count === 1 ? "entry no longer matches" : "entries no longer match"} and can be removed\n`));
|
|
512
|
+
}
|
|
513
|
+
if (outcome.expired.length > 0) process.stderr.write(pc.yellow(`${outcome.expired.length} baseline entries have expired and no longer suppress anything\n`));
|
|
514
|
+
return outcome.audits;
|
|
515
|
+
} catch (cause) {
|
|
516
|
+
if (cause instanceof BaselineError) {
|
|
517
|
+
process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
throw cause;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* What the progress line says about the engine.
|
|
525
|
+
*
|
|
526
|
+
* The thread count is on it because it is the difference between a run that
|
|
527
|
+
* looks stalled and one that is working, and because a user comparing two
|
|
528
|
+
* timings deserves to know which one used the machine.
|
|
529
|
+
*/
|
|
530
|
+
async function describeEngine(pages, options) {
|
|
531
|
+
if (options.browser) return " with Chromium";
|
|
532
|
+
const { plannedWorkers } = await import("./pool-DixLeu8L.js");
|
|
533
|
+
const workers = options.concurrency ?? plannedWorkers(pages);
|
|
534
|
+
return workers > 1 ? ` across ${workers} threads` : "";
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Emit the chosen format, to a file when --output is given and to stdout
|
|
538
|
+
* otherwise. Colour is dropped when writing to a file, since escape codes in a
|
|
539
|
+
* saved report are noise.
|
|
540
|
+
*/
|
|
541
|
+
async function emit(audits, dir, failOn, options) {
|
|
542
|
+
const body = await renderReport(audits, dir, failOn, options.format ?? "console", typeof options.output === "string", options);
|
|
543
|
+
if (!options.output) {
|
|
544
|
+
process.stdout.write(body);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const target = path.resolve(options.cwd ?? process.cwd(), options.output);
|
|
548
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
549
|
+
await writeFile(target, body, "utf8");
|
|
550
|
+
process.stderr.write(pc.dim(`Report written to ${options.output}\n`));
|
|
551
|
+
}
|
|
552
|
+
async function renderReport(audits, dir, failOn, format, toFile, options) {
|
|
553
|
+
switch (format) {
|
|
554
|
+
case "json": {
|
|
555
|
+
const { buildJsonReport, serialiseJsonReport } = await import("./json-1ESNIiHY.js");
|
|
556
|
+
return serialiseJsonReport(buildJsonReport(audits, {
|
|
557
|
+
directory: dir,
|
|
558
|
+
failOn,
|
|
559
|
+
...options.baseUrl ? { baseUrl: options.baseUrl } : {}
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
case "sarif": {
|
|
563
|
+
const { buildSarifReport, serialiseSarifReport } = await import("./sarif-eSCuI0eX.js");
|
|
564
|
+
return serialiseSarifReport(buildSarifReport(audits, { directory: dir }));
|
|
565
|
+
}
|
|
566
|
+
case "html": {
|
|
567
|
+
const { buildHtmlReport } = await import("./html-BLEuzep6.js");
|
|
568
|
+
return buildHtmlReport(audits, {
|
|
569
|
+
directory: dir,
|
|
570
|
+
failOn,
|
|
571
|
+
...options.baseUrl ? { baseUrl: options.baseUrl } : {}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
case "console": return `${formatConsoleReport(audits, {
|
|
575
|
+
dir,
|
|
576
|
+
failOn,
|
|
577
|
+
...toFile ? { color: false } : {}
|
|
578
|
+
})}\n`;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
//#endregion
|
|
582
|
+
export { collectPages as a, BuildDirectoryError as i, isOutputFormat as n, runAuditCommand as r, OUTPUT_FORMATS as t };
|