eaa-kit 0.1.1 → 0.2.1
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/README.md +10 -2
- package/dist/astro/index.d.ts +0 -9
- package/dist/astro/index.js +1 -1
- package/dist/audit-B2dIKpJ5.js +2 -0
- package/dist/{audit-CApiPaiG.js → audit-DXpKkXsC.js} +505 -161
- package/dist/{baseline-Itspu3-Y.js → baseline-CV_3lbER.js} +1 -1
- package/dist/{baseline-DQTnNlc4.js → baseline-CgBmzFTr.js} +16 -16
- package/dist/cli/index.js +55 -34
- package/dist/crawl-CtJbMNNb.js +254 -0
- package/dist/index.d.ts +155 -65
- package/dist/index.js +2 -1
- package/dist/init-DRKIdpK1.js +110 -0
- package/dist/{json-1ESNIiHY.js → json-C9xS1PNC.js} +3 -1
- package/dist/load-sCkKsvGQ.js +192 -0
- package/dist/{playwright-BfWuTG_u.js → playwright-DSRnXmcd.js} +19 -8
- package/dist/project-CufCqIE2.js +281 -0
- package/dist/{render-K9KxDDSA.js → render-BO0nVrrZ.js} +21 -208
- package/dist/routes-BxbSZKXC.js +123 -0
- package/dist/schema-CMZ8ItGk.js +192 -0
- package/package.json +2 -3
- package/dist/audit-47fMewVm.js +0 -2
|
@@ -1,97 +1,83 @@
|
|
|
1
1
|
import { n as IMPACT_LEVELS, r as countAtOrAbove } from "./impact-DvgBjupx.js";
|
|
2
|
+
import { t as elementFingerprint } from "./fingerprint-DRoneAjj.js";
|
|
2
3
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import pc from "picocolors";
|
|
5
6
|
import { glob } from "tinyglobby";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
};
|
|
7
|
+
/** Whether this element looks like one component rendered on many pages. */
|
|
8
|
+
function isShared(element) {
|
|
9
|
+
return element.pages.length >= 3;
|
|
10
|
+
}
|
|
26
11
|
/**
|
|
27
|
-
*
|
|
12
|
+
* Fold a run's violations into one entry per rule, and one per element within it.
|
|
28
13
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* whether that is an error.
|
|
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.
|
|
32
16
|
*/
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
}
|
|
47
58
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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);
|
|
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);
|
|
57
64
|
}
|
|
58
|
-
|
|
65
|
+
issues.sort(bySeverityThenReach);
|
|
66
|
+
return issues;
|
|
59
67
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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("/");
|
|
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
71
|
}
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
73
|
+
* Worst first, then widest reach, then by rule id.
|
|
74
74
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* HTML at all. Naming the one in front of the user beats a generic "check the
|
|
78
|
-
* path", so the project is sniffed for the frameworks that mislead people this
|
|
79
|
-
* way — `./dist` is every tutorial's answer and is wrong for all of them.
|
|
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.
|
|
80
77
|
*/
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
await stat(path.resolve(cwd, name));
|
|
85
|
-
return true;
|
|
86
|
-
} catch {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
if (await has("next.config.js") || await has("next.config.mjs") || await has("next.config.ts")) return `${dir} holds no HTML, and a Next.js build does not put any there.\n Static export writes to out/: set output: 'export' in next.config, run next build,
|
|
91
|
-
then: eaa-kit audit ./out
|
|
92
|
-
A site with SSR, API routes or middleware cannot be exported this way.`;
|
|
93
|
-
if (await has("nuxt.config.ts")) return `${dir} holds no HTML. Nuxt writes a static build to .output/public — try: eaa-kit audit ./.output/public`;
|
|
94
|
-
return `${dir} holds no HTML. Point eaa-kit at the directory your build fills with .html files\n — commonly dist/, build/, out/ or _site/, depending on the builder.`;
|
|
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);
|
|
95
81
|
}
|
|
96
82
|
//#endregion
|
|
97
83
|
//#region src/audit/report/console.ts
|
|
@@ -116,7 +102,11 @@ function formatConsoleReport(audits, options = {}) {
|
|
|
116
102
|
...headerLines(audits, ctx),
|
|
117
103
|
""
|
|
118
104
|
];
|
|
119
|
-
|
|
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
|
+
}
|
|
120
110
|
lines.push(...summary(audits, ctx));
|
|
121
111
|
return lines.join("\n");
|
|
122
112
|
}
|
|
@@ -129,6 +119,7 @@ function context(options) {
|
|
|
129
119
|
width,
|
|
130
120
|
maxNodes: options.maxNodes ?? DEFAULT_MAX_NODES,
|
|
131
121
|
failOn: options.failOn ?? "serious",
|
|
122
|
+
sourceFor: options.sourceFor ?? (() => void 0),
|
|
132
123
|
c,
|
|
133
124
|
symbol: (kind) => {
|
|
134
125
|
switch (kind) {
|
|
@@ -170,7 +161,17 @@ function headerLines(audits, ctx) {
|
|
|
170
161
|
}, {
|
|
171
162
|
text: ` ${pageCount} · ${engineLabel}`,
|
|
172
163
|
paint: ctx.c.dim
|
|
173
|
-
}])
|
|
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, [{
|
|
174
175
|
text: "passed = checked and met · not applicable = nothing to check",
|
|
175
176
|
paint: ctx.c.dim
|
|
176
177
|
}])];
|
|
@@ -415,107 +416,443 @@ function collapse(html) {
|
|
|
415
416
|
function plural(count, word) {
|
|
416
417
|
return count === 1 ? word : `${word}s`;
|
|
417
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
|
+
}
|
|
418
501
|
//#endregion
|
|
419
|
-
//#region src/
|
|
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
|
+
};
|
|
420
522
|
/**
|
|
421
|
-
*
|
|
422
|
-
* used, not at the top of the file.
|
|
523
|
+
* Glob HTML files out of a build directory and read them.
|
|
423
524
|
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* pays the same cost either way, a few milliseconds later.
|
|
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.
|
|
428
528
|
*/
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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"
|
|
434
589
|
];
|
|
435
|
-
|
|
436
|
-
|
|
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`;
|
|
437
624
|
}
|
|
438
625
|
/**
|
|
439
|
-
*
|
|
626
|
+
* Advice from whatever build directories are lying around.
|
|
440
627
|
*
|
|
441
|
-
*
|
|
442
|
-
* piped somewhere without the chatter coming along.
|
|
628
|
+
* Naming one that is actually there beats listing the ones that usually are.
|
|
443
629
|
*/
|
|
444
|
-
async function
|
|
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;
|
|
445
677
|
let pages;
|
|
446
678
|
try {
|
|
447
|
-
pages = await collectPages(
|
|
679
|
+
pages = await collectPages(directory, {
|
|
448
680
|
...options.include ? { include: options.include } : {},
|
|
449
681
|
...options.exclude ? { exclude: options.exclude } : {}
|
|
450
682
|
});
|
|
451
683
|
} catch (cause) {
|
|
452
|
-
if (cause instanceof BuildDirectoryError)
|
|
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) {
|
|
453
711
|
process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
|
|
454
|
-
|
|
455
|
-
return {
|
|
456
|
-
audits: [],
|
|
457
|
-
exitCode: 2
|
|
458
|
-
};
|
|
712
|
+
return;
|
|
459
713
|
}
|
|
460
714
|
throw cause;
|
|
461
715
|
}
|
|
462
|
-
|
|
463
|
-
|
|
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-CufCqIE2.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
|
+
}
|
|
464
768
|
return {
|
|
465
|
-
|
|
466
|
-
|
|
769
|
+
...resolved,
|
|
770
|
+
...detected.cleanup ? { cleanup: detected.cleanup } : {}
|
|
467
771
|
};
|
|
468
772
|
}
|
|
469
|
-
|
|
470
|
-
process.stderr.write(pc.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
474
798
|
};
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
+
cwd: options.cwd ?? process.cwd(),
|
|
806
|
+
...effectiveBaseUrl === void 0 ? {} : { baseUrl: effectiveBaseUrl },
|
|
807
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
|
|
808
|
+
};
|
|
809
|
+
let audits;
|
|
810
|
+
if (options.browser) {
|
|
811
|
+
const { BrowserUnavailableError, runBrowserAudit } = await import("./playwright-DSRnXmcd.js");
|
|
812
|
+
try {
|
|
813
|
+
audits = await runBrowserAudit(options.url === void 0 ? dir : void 0, pages, runnerOptions);
|
|
814
|
+
} catch (cause) {
|
|
815
|
+
if (cause instanceof BrowserUnavailableError) {
|
|
816
|
+
process.stderr.write(`${pc.red("error")} ${cause.message}\n`);
|
|
817
|
+
return {
|
|
818
|
+
audits: [],
|
|
819
|
+
exitCode: 2
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
throw cause;
|
|
487
823
|
}
|
|
488
|
-
|
|
824
|
+
} else {
|
|
825
|
+
const { runPooledAudit } = await import("./pool-DixLeu8L.js");
|
|
826
|
+
audits = await runPooledAudit(pages, {
|
|
827
|
+
...runnerOptions,
|
|
828
|
+
...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
const failOn = options.failOn ?? "serious";
|
|
832
|
+
if (options.baseline) {
|
|
833
|
+
const applied = await acceptBaseline(audits, options);
|
|
834
|
+
if (!applied) return {
|
|
835
|
+
audits,
|
|
836
|
+
exitCode: 2
|
|
837
|
+
};
|
|
838
|
+
audits = applied;
|
|
839
|
+
}
|
|
840
|
+
await emit(audits, label, failOn, options);
|
|
841
|
+
const unaudited = audits.filter((audit) => audit.error);
|
|
842
|
+
if (unaudited.length > 0) {
|
|
843
|
+
process.stderr.write(`${pc.red("error")} ${unaudited.length} of ${audits.length} pages could not be audited\n`);
|
|
844
|
+
return {
|
|
845
|
+
audits,
|
|
846
|
+
exitCode: 2
|
|
847
|
+
};
|
|
489
848
|
}
|
|
490
|
-
} else {
|
|
491
|
-
const { runPooledAudit } = await import("./pool-DixLeu8L.js");
|
|
492
|
-
audits = await runPooledAudit(pages, {
|
|
493
|
-
...runnerOptions,
|
|
494
|
-
...options.concurrency === void 0 ? {} : { concurrency: options.concurrency }
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
const failOn = options.failOn ?? "serious";
|
|
498
|
-
if (options.baseline) {
|
|
499
|
-
const applied = await acceptBaseline(audits, options);
|
|
500
|
-
if (!applied) return {
|
|
501
|
-
audits,
|
|
502
|
-
exitCode: 2
|
|
503
|
-
};
|
|
504
|
-
audits = applied;
|
|
505
|
-
}
|
|
506
|
-
await emit(audits, dir, failOn, options);
|
|
507
|
-
const unaudited = audits.filter((audit) => audit.error);
|
|
508
|
-
if (unaudited.length > 0) {
|
|
509
|
-
process.stderr.write(`${pc.red("error")} ${unaudited.length} of ${audits.length} pages could not be audited\n`);
|
|
510
849
|
return {
|
|
511
850
|
audits,
|
|
512
|
-
exitCode:
|
|
851
|
+
exitCode: countAtOrAbove(audits, failOn) > 0 ? 1 : 0
|
|
513
852
|
};
|
|
853
|
+
} finally {
|
|
854
|
+
await cleanup?.();
|
|
514
855
|
}
|
|
515
|
-
return {
|
|
516
|
-
audits,
|
|
517
|
-
exitCode: countAtOrAbove(audits, failOn) > 0 ? 1 : 0
|
|
518
|
-
};
|
|
519
856
|
}
|
|
520
857
|
/**
|
|
521
858
|
* Move the violations the baseline accounts for out of the failing set.
|
|
@@ -526,7 +863,7 @@ async function runAuditCommand(dir, options = {}) {
|
|
|
526
863
|
* would be as wrong as silently passing.
|
|
527
864
|
*/
|
|
528
865
|
async function acceptBaseline(audits, options) {
|
|
529
|
-
const { applyBaseline, BaselineError, readBaseline } = await import("./baseline-
|
|
866
|
+
const { applyBaseline, BaselineError, readBaseline } = await import("./baseline-CV_3lbER.js");
|
|
530
867
|
try {
|
|
531
868
|
const outcome = applyBaseline(audits, await readBaseline(options.baseline, options.cwd ?? process.cwd()));
|
|
532
869
|
if (outcome.accepted > 0) process.stderr.write(pc.dim(`Baseline accepted ${outcome.accepted} violating elements\n`));
|
|
@@ -576,9 +913,10 @@ async function emit(audits, dir, failOn, options) {
|
|
|
576
913
|
async function renderReport(audits, dir, failOn, format, toFile, options) {
|
|
577
914
|
switch (format) {
|
|
578
915
|
case "json": {
|
|
579
|
-
const { buildJsonReport, serialiseJsonReport } = await import("./json-
|
|
916
|
+
const { buildJsonReport, serialiseJsonReport } = await import("./json-C9xS1PNC.js");
|
|
580
917
|
return serialiseJsonReport(buildJsonReport(audits, {
|
|
581
918
|
directory: dir,
|
|
919
|
+
...options.url === void 0 ? {} : { sourceKind: "url" },
|
|
582
920
|
failOn,
|
|
583
921
|
...options.baseUrl ? { baseUrl: options.baseUrl } : {}
|
|
584
922
|
}));
|
|
@@ -595,12 +933,18 @@ async function renderReport(audits, dir, failOn, format, toFile, options) {
|
|
|
595
933
|
...options.baseUrl ? { baseUrl: options.baseUrl } : {}
|
|
596
934
|
});
|
|
597
935
|
}
|
|
598
|
-
case "console":
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
936
|
+
case "console": {
|
|
937
|
+
const { buildRouteMap, sourceFor } = await import("./routes-BxbSZKXC.js");
|
|
938
|
+
const routes = await buildRouteMap(options.cwd ?? process.cwd());
|
|
939
|
+
return `${formatConsoleReport(audits, {
|
|
940
|
+
dir,
|
|
941
|
+
failOn,
|
|
942
|
+
sourceFor: (page) => sourceFor(routes, page),
|
|
943
|
+
...options.perPage ? { perPage: true } : {},
|
|
944
|
+
...toFile ? { color: false } : {}
|
|
945
|
+
})}\n`;
|
|
946
|
+
}
|
|
603
947
|
}
|
|
604
948
|
}
|
|
605
949
|
//#endregion
|
|
606
|
-
export {
|
|
950
|
+
export { resolvePages as i, isOutputFormat as n, runAuditCommand as r, OUTPUT_FORMATS as t };
|