eaa-kit 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -14
- package/dist/astro/index.d.ts +1 -1
- package/dist/astro/index.js +1 -1
- package/dist/audit/runners/worker.js +5 -1
- package/dist/{audit-DcL73mOC.js → audit-B282A3EA.js} +207 -41
- package/dist/audit-DyHPmGeD.js +2 -0
- package/dist/{baseline-CuKFq4IF.js → baseline-0u4df12v.js} +1 -1
- package/dist/{baseline-s9F3fXTN.js → baseline-CuAS2lMk.js} +6 -5
- package/dist/cli/index.js +306 -18
- package/dist/{collect-BkAQ0viT.js → collect-Cu1q9NTB.js} +53 -11
- package/dist/command-C3D7JWn6.js +160 -0
- package/dist/{component-7kEBjv_y.js → component-C3GL1Mnu.js} +41 -6
- package/dist/component-DKd3EHOg.js +2 -0
- package/dist/coverage-B_Y6l-Ra.js +522 -0
- package/dist/{crawl-Oxt2Gaqo.js → crawl-BUWPgaGW.js} +83 -9
- package/dist/eleventy/index.d.ts +28 -0
- package/dist/eleventy/index.js +19 -0
- package/dist/fingerprint-BjYV_0F7.js +67 -0
- package/dist/{frameworks-BYa3tULg.js → frameworks-B4ClIJgE.js} +71 -0
- package/dist/{frameworks-DaDqrJOw.js → frameworks-etFg_O8K.js} +1 -1
- package/dist/{html-DKiI_3gs.js → html-C4vktg0A.js} +130 -5
- package/dist/{impact-YdoOtFqm.js → impact-DZt2oBCP.js} +1 -1
- package/dist/index.d.ts +119 -4
- package/dist/index.js +2 -2
- package/dist/{init-DiLiYvN1.js → init-CW7LfGT5.js} +10 -4
- package/dist/jsdom-22Bkt65v.js +3 -0
- package/dist/jsdom-B--cEH-G.js +89 -0
- package/dist/{json-Cnv9nd6U.js → json-DROX33kh.js} +9 -4
- package/dist/{json-B0Y7rNjt.js → json-D_Mnnft5.js} +1 -1
- package/dist/load-CFq2VQtT.js +2 -0
- package/dist/{load-UYXLqGV9.js → load-yAR4wzez.js} +133 -8
- package/dist/nuxt/index.d.ts +35 -0
- package/dist/nuxt/index.js +23 -0
- package/dist/{playwright-DYFsGUNd.js → playwright-BojtYVUa.js} +30 -6
- package/dist/{pool-BWkWZiJW.js → pool-BO25OIez.js} +52 -2
- package/dist/{project-DW08TseF.js → project-CzOnkLH6.js} +8 -2
- package/dist/project-MFrXcw1M.js +2 -0
- package/dist/remediation-CMBIrnpN.js +321 -0
- package/dist/{render-DI_aCnAZ.js → render-DbGOVmhx.js} +48 -10
- package/dist/{result-DLxd2Eip.js → result-BWcYXeRs.js} +114 -3
- package/dist/routes-CmdRUuOs.js +265 -0
- package/dist/{run-BW6CVuND.js → run-C2nKFcb-.js} +19 -2
- package/dist/{run-BMASMmwO.d.ts → run-CtcEUhbe.d.ts} +7 -0
- package/dist/{sarif-DB3WG7T9.js → sarif-B-UBcVu8.js} +16 -8
- package/dist/{schema-CMZ8ItGk.js → schema-DJSF4K05.js} +18 -1
- package/dist/statement/templates/es.en.md +125 -0
- package/dist/statement/templates/es.es.md +127 -0
- package/dist/statement/templates/fr.en.md +128 -0
- package/dist/statement/templates/fr.fr.md +131 -0
- package/dist/statement/templates/it.en.md +127 -0
- package/dist/statement/templates/it.it.md +130 -0
- package/dist/statement/templates/nl.en.md +125 -0
- package/dist/statement/templates/nl.nl.md +127 -0
- package/dist/{text-BFmNtMsV.js → text-CKKpzkYM.js} +1 -1
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/webpack/index.d.ts +33 -0
- package/dist/webpack/index.js +20 -0
- package/package.json +35 -9
- package/dist/audit-CuG2hYyo.js +0 -2
- package/dist/command-Dxpa00Ha.js +0 -77
- package/dist/fingerprint-DRoneAjj.js +0 -20
- package/dist/jsdom-BjpF-2V-.js +0 -158
- package/dist/jsdom-X4KYfTp8.js +0 -3
- package/dist/manual-Vz-oX1I_.js +0 -239
- package/dist/routes-C2Cgf6Ko.js +0 -119
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as isFile } from "./fs-BmPtmFke.js";
|
|
2
|
-
import {
|
|
2
|
+
import { a as failedPage, s as pageUrl } from "./result-BWcYXeRs.js";
|
|
3
3
|
import { Worker } from "node:worker_threads";
|
|
4
4
|
import { availableParallelism } from "node:os";
|
|
5
5
|
//#region src/audit/runners/pool.ts
|
|
@@ -52,6 +52,16 @@ const WORK_PER_WORKER_MS = 1600;
|
|
|
52
52
|
/** Threading at all means at least two, or there is nothing to overlap with. */
|
|
53
53
|
const MIN_WORKERS = 2;
|
|
54
54
|
/**
|
|
55
|
+
* Grace above the runner's own per-page timeout before a worker is killed.
|
|
56
|
+
*
|
|
57
|
+
* The runner races axe-core against a timer, which works whenever the work
|
|
58
|
+
* yields to the event loop: the page is reported with an error and the thread
|
|
59
|
+
* lives on to take the next one. That is the better outcome, so it is given
|
|
60
|
+
* room to happen first. This is the backstop for when it cannot — see
|
|
61
|
+
* `watchdog` below.
|
|
62
|
+
*/
|
|
63
|
+
const HARD_TIMEOUT_GRACE_MS = 5e3;
|
|
64
|
+
/**
|
|
55
65
|
* Ceiling on workers regardless of core count. Past this, the run is bounded by
|
|
56
66
|
* memory bandwidth and by each worker's own start-up rather than by cores, and
|
|
57
67
|
* every extra thread holds another jsdom document tree.
|
|
@@ -96,10 +106,33 @@ async function runPooledAudit(pages, options = {}) {
|
|
|
96
106
|
return runWorkers(pages, runnerOptions, Math.min(workers, pages.length), entry);
|
|
97
107
|
}
|
|
98
108
|
async function auditHere(pages, options) {
|
|
99
|
-
const { runJsdomAudit } = await import("./jsdom-
|
|
109
|
+
const { runJsdomAudit } = await import("./jsdom-22Bkt65v.js");
|
|
100
110
|
return runJsdomAudit(pages, options);
|
|
101
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* The only place a per-page ceiling can actually be enforced.
|
|
114
|
+
*
|
|
115
|
+
* The runner's own timeout is a `Promise.race`, and a race cannot interrupt
|
|
116
|
+
* synchronous work: jsdom's parse and axe-core's walk of the tree both hold the
|
|
117
|
+
* thread, so the timer that is meant to stop them never gets to run. Measured
|
|
118
|
+
* on a 120,000-element document with a two-second ceiling, the audit was still
|
|
119
|
+
* going more than ten minutes later — and because the pool waits on its
|
|
120
|
+
* workers, the whole run went with it. A CI job hung until the platform killed
|
|
121
|
+
* it, which is the failure the ceiling exists to prevent.
|
|
122
|
+
*
|
|
123
|
+
* `worker.terminate()` is the answer, because it stops the thread whatever it
|
|
124
|
+
* is doing. So the supervisor keeps its own deadline per page and kills the
|
|
125
|
+
* thread that overruns it, records that page as unaudited, and lets the rest of
|
|
126
|
+
* the run carry on. The page is reported as a failure rather than as a clean
|
|
127
|
+
* page, which the CLI already turns into exit 2.
|
|
128
|
+
*
|
|
129
|
+
* Two runs still have no hard ceiling, because both refuse the threads that
|
|
130
|
+
* would carry it: `--concurrency 1`, and a machine with too few cores to spare
|
|
131
|
+
* one. Both are documented rather than papered over, and the size cap in
|
|
132
|
+
* `collectPages` is what keeps them bounded in practice.
|
|
133
|
+
*/
|
|
102
134
|
async function runWorkers(pages, options, count, entry) {
|
|
135
|
+
const deadlineMs = (options.timeoutMs ?? 3e4) + HARD_TIMEOUT_GRACE_MS;
|
|
103
136
|
const audits = Array.from({ length: pages.length });
|
|
104
137
|
let next = 0;
|
|
105
138
|
await Promise.all(Array.from({ length: count }, () => {
|
|
@@ -113,7 +146,13 @@ async function runWorkers(pages, options, count, entry) {
|
|
|
113
146
|
}
|
|
114
147
|
let inFlight;
|
|
115
148
|
let completed = 0;
|
|
149
|
+
let watchdog;
|
|
150
|
+
const disarm = () => {
|
|
151
|
+
if (watchdog !== void 0) clearTimeout(watchdog);
|
|
152
|
+
watchdog = void 0;
|
|
153
|
+
};
|
|
116
154
|
const finish = () => {
|
|
155
|
+
disarm();
|
|
117
156
|
worker.terminate();
|
|
118
157
|
resolve();
|
|
119
158
|
};
|
|
@@ -127,8 +166,18 @@ async function runWorkers(pages, options, count, entry) {
|
|
|
127
166
|
next += 1;
|
|
128
167
|
inFlight = index;
|
|
129
168
|
worker.postMessage(pages[index]);
|
|
169
|
+
watchdog = setTimeout(() => {
|
|
170
|
+
watchdog = void 0;
|
|
171
|
+
if (inFlight !== void 0) {
|
|
172
|
+
audits[inFlight] = failedPage(identity(pages[inFlight], options), `the audit worker was stopped after ${deadlineMs}ms on this page`);
|
|
173
|
+
inFlight = void 0;
|
|
174
|
+
}
|
|
175
|
+
finish();
|
|
176
|
+
}, deadlineMs);
|
|
177
|
+
watchdog.unref?.();
|
|
130
178
|
};
|
|
131
179
|
worker.on("message", (audit) => {
|
|
180
|
+
disarm();
|
|
132
181
|
if (inFlight !== void 0) {
|
|
133
182
|
audits[inFlight] = audit;
|
|
134
183
|
completed += 1;
|
|
@@ -136,6 +185,7 @@ async function runWorkers(pages, options, count, entry) {
|
|
|
136
185
|
feed();
|
|
137
186
|
});
|
|
138
187
|
worker.on("error", (cause) => {
|
|
188
|
+
disarm();
|
|
139
189
|
if (inFlight !== void 0 && completed > 0) audits[inFlight] = failedPage(identity(pages[inFlight], options), `audit worker failed: ${cause.message}`);
|
|
140
190
|
finish();
|
|
141
191
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as isDirectory, t as exists } from "./fs-BmPtmFke.js";
|
|
2
|
-
import { r as candidateOutputs } from "./frameworks-
|
|
2
|
+
import { r as candidateOutputs } from "./frameworks-B4ClIJgE.js";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { glob } from "tinyglobby";
|
|
@@ -226,6 +226,12 @@ async function autoDetectSource(cwd, options = {}) {
|
|
|
226
226
|
};
|
|
227
227
|
}
|
|
228
228
|
const pkg = await readPackageJson(cwd);
|
|
229
|
+
const { detectFramework } = await import("./frameworks-etFg_O8K.js");
|
|
230
|
+
const detected = await detectFramework(cwd, pkg);
|
|
231
|
+
if (detected !== void 0 && detected.framework.outputs.length === 0) {
|
|
232
|
+
step(`${detected.framework.name} renders on a server and writes no HTML to disk`);
|
|
233
|
+
return { steps };
|
|
234
|
+
}
|
|
229
235
|
if (pkg === void 0) return void 0;
|
|
230
236
|
const scripts = pkg.scripts ?? {};
|
|
231
237
|
if (options.noBuild) return void 0;
|
|
@@ -265,4 +271,4 @@ async function autoDetectSource(cwd, options = {}) {
|
|
|
265
271
|
};
|
|
266
272
|
}
|
|
267
273
|
//#endregion
|
|
268
|
-
export {
|
|
274
|
+
export { runScript as a, readPackageJson as i, detectPackageManager as n, startServer as o, findBuildOutput as r, autoDetectSource as t };
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { a as impactRank } from "./impact-DZt2oBCP.js";
|
|
2
|
+
import { t as elementFingerprint } from "./fingerprint-BjYV_0F7.js";
|
|
3
|
+
import { o as findingElements } from "./result-BWcYXeRs.js";
|
|
4
|
+
//#region src/audit/completeness.ts
|
|
5
|
+
/**
|
|
6
|
+
* Fold what the collector knew together with what the engine managed.
|
|
7
|
+
*
|
|
8
|
+
* The audits carry the second half: a page with an `error` was collected and
|
|
9
|
+
* then not audited, and is as unmeasured as one that was never fetched.
|
|
10
|
+
*/
|
|
11
|
+
function runCompleteness(audits, collection) {
|
|
12
|
+
const errored = audits.filter((audit) => audit.error).length;
|
|
13
|
+
return {
|
|
14
|
+
...collection,
|
|
15
|
+
audited: audits.length - errored,
|
|
16
|
+
errored,
|
|
17
|
+
complete: collection.unreachable.length === 0 && !collection.truncated && errored === 0
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* What was missed, as phrases both reports print.
|
|
22
|
+
*
|
|
23
|
+
* Separate clauses rather than one total, for the same reason the coverage
|
|
24
|
+
* parts are: a page that could not be fetched and a page that could not be
|
|
25
|
+
* parsed are different problems with different fixes, and summing them would
|
|
26
|
+
* name neither.
|
|
27
|
+
*/
|
|
28
|
+
function missedParts(completeness) {
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (completeness.unreachable.length > 0) parts.push(`${completeness.unreachable.length} could not be reached`);
|
|
31
|
+
if (completeness.errored > 0) parts.push(`${completeness.errored} could not be audited`);
|
|
32
|
+
if (completeness.truncated) parts.push("the run stopped at its page limit");
|
|
33
|
+
return parts;
|
|
34
|
+
}
|
|
35
|
+
/** How the pages were found, in words, for the run details both reports show. */
|
|
36
|
+
function discoveryLabel(discovery) {
|
|
37
|
+
switch (discovery) {
|
|
38
|
+
case "directory": return "files in the build directory";
|
|
39
|
+
case "sitemap": return "sitemap.xml and links";
|
|
40
|
+
case "links": return "links from the entry page";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Whether this element looks like one component rendered on many pages. */
|
|
44
|
+
function isShared(element) {
|
|
45
|
+
return element.pages.length >= 3;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Fold a run's violations into one entry per rule, and one per element within it.
|
|
49
|
+
*
|
|
50
|
+
* Accepted violations are not included: a baseline moves them out of what fails
|
|
51
|
+
* the build, and this is a view of what fails.
|
|
52
|
+
*/
|
|
53
|
+
function groupIssues(audits) {
|
|
54
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
55
|
+
for (const audit of audits) for (const finding of audit.violations) {
|
|
56
|
+
let issue = byRule.get(finding.ruleId);
|
|
57
|
+
if (issue === void 0) {
|
|
58
|
+
issue = {
|
|
59
|
+
ruleId: finding.ruleId,
|
|
60
|
+
help: finding.help,
|
|
61
|
+
impact: finding.impact ?? null,
|
|
62
|
+
successCriteria: finding.successCriteria,
|
|
63
|
+
enClauses: finding.enClauses,
|
|
64
|
+
helpUrl: finding.helpUrl,
|
|
65
|
+
elements: [],
|
|
66
|
+
pages: [],
|
|
67
|
+
occurrences: 0
|
|
68
|
+
};
|
|
69
|
+
byRule.set(finding.ruleId, issue);
|
|
70
|
+
}
|
|
71
|
+
if (!issue.pages.includes(audit.relativePath)) issue.pages.push(audit.relativePath);
|
|
72
|
+
for (const node of findingElements(finding)) {
|
|
73
|
+
issue.occurrences += 1;
|
|
74
|
+
const fingerprint = elementFingerprint(finding.ruleId, node.selector, node.html);
|
|
75
|
+
const existing = issue.elements.find((element) => element.fingerprint === fingerprint);
|
|
76
|
+
if (existing) {
|
|
77
|
+
if (!existing.pages.includes(audit.relativePath)) existing.pages.push(audit.relativePath);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
issue.elements.push({
|
|
81
|
+
fingerprint,
|
|
82
|
+
selector: node.selector,
|
|
83
|
+
html: node.html,
|
|
84
|
+
pages: [audit.relativePath]
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const issues = [...byRule.values()];
|
|
89
|
+
for (const issue of issues) {
|
|
90
|
+
issue.pages.sort();
|
|
91
|
+
for (const element of issue.elements) element.pages.sort();
|
|
92
|
+
issue.elements.sort(byReachThenSelector);
|
|
93
|
+
}
|
|
94
|
+
issues.sort(bySeverityThenReach);
|
|
95
|
+
return issues;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Unevaluated rules folded across the site, sorted by rule id.
|
|
99
|
+
*
|
|
100
|
+
* Both reports list these once at the end rather than under every page: on a
|
|
101
|
+
* large site the same handful recurs on each one, and a wall of "not evaluated"
|
|
102
|
+
* would bury the findings that are real.
|
|
103
|
+
*/
|
|
104
|
+
function blindRules(audits) {
|
|
105
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
106
|
+
for (const audit of audits) for (const finding of audit.incomplete) {
|
|
107
|
+
if (finding.reason !== "engine-limitation") continue;
|
|
108
|
+
const entry = byRule.get(finding.ruleId);
|
|
109
|
+
if (entry) entry.pages += 1;
|
|
110
|
+
else byRule.set(finding.ruleId, {
|
|
111
|
+
ruleId: finding.ruleId,
|
|
112
|
+
pages: 1,
|
|
113
|
+
finding
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return [...byRule.values()].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* What one page's result actually rests on, as phrases both reports print.
|
|
120
|
+
*
|
|
121
|
+
* The counts stay separate on purpose. Only `passed` is evidence that a
|
|
122
|
+
* criterion was met here; `not applicable` means the rule found nothing to
|
|
123
|
+
* check, and adding the two together would turn an empty page into a
|
|
124
|
+
* near-perfect score.
|
|
125
|
+
*/
|
|
126
|
+
function coverageParts(audit) {
|
|
127
|
+
const blind = audit.incomplete.filter((finding) => finding.reason === "engine-limitation").length;
|
|
128
|
+
const review = audit.incomplete.length - blind;
|
|
129
|
+
const parts = [`${audit.passes.length} passed`, `${audit.inapplicable.length} not applicable`];
|
|
130
|
+
if (review > 0) parts.push(`${review} to review`);
|
|
131
|
+
if (blind > 0) parts.push(`${blind} not evaluated`);
|
|
132
|
+
return parts;
|
|
133
|
+
}
|
|
134
|
+
/** Widest reach first, then by selector so two runs agree. */
|
|
135
|
+
function byReachThenSelector(a, b) {
|
|
136
|
+
return b.pages.length - a.pages.length || a.selector.localeCompare(b.selector);
|
|
137
|
+
}
|
|
138
|
+
/** Worst first, then widest reach, then by rule id. */
|
|
139
|
+
function bySeverityThenReach(a, b) {
|
|
140
|
+
return impactRank(a.impact) - impactRank(b.impact) || b.pages.length - a.pages.length || a.ruleId.localeCompare(b.ruleId);
|
|
141
|
+
}
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/audit/remediation.ts
|
|
144
|
+
/**
|
|
145
|
+
* Swap or add an attribute on the opening tag of the failing element.
|
|
146
|
+
*
|
|
147
|
+
* String surgery rather than a parser: the input is one element as axe-core
|
|
148
|
+
* captured it, the output is shown to a person rather than written to disk, and
|
|
149
|
+
* pulling in a parser to produce a suggestion would cost more than it is worth.
|
|
150
|
+
*/
|
|
151
|
+
function withAttribute(html, attribute, value) {
|
|
152
|
+
const openingTag = /^<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/.exec(html.trim());
|
|
153
|
+
if (openingTag === null) return void 0;
|
|
154
|
+
const [, tag = "", raw = ""] = openingTag;
|
|
155
|
+
const selfClosing = /\/\s*$/.test(raw) ? " /" : "";
|
|
156
|
+
const attributes = raw.replace(/\s*\/\s*$/, "");
|
|
157
|
+
const existing = new RegExp(`\\s${attribute}\\s*=\\s*("[^"]*"|'[^']*')`, "i");
|
|
158
|
+
const replacement = `${attribute}="${value}"`;
|
|
159
|
+
return `<${tag}${existing.test(attributes) ? attributes.replace(existing, ` ${replacement}`) : `${attributes} ${replacement}`}${selfClosing}>`;
|
|
160
|
+
}
|
|
161
|
+
/** Put text between the tags of an element that has none. */
|
|
162
|
+
function withContent(html, content) {
|
|
163
|
+
const opening = /^<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/.exec(html.trim());
|
|
164
|
+
if (opening === null) return void 0;
|
|
165
|
+
const [matched, tag = ""] = opening;
|
|
166
|
+
return `${matched}${content}</${tag}>`;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The generic fix for a rule, whatever built the site.
|
|
170
|
+
*
|
|
171
|
+
* Scoped to the rules that actually fire on real sites. A table covering every
|
|
172
|
+
* axe-core rule would be mostly entries nobody reads, and each one is a claim
|
|
173
|
+
* this project has to keep true.
|
|
174
|
+
*/
|
|
175
|
+
const GENERIC = {
|
|
176
|
+
"image-alt": {
|
|
177
|
+
why: "A screen reader announces this image by its filename, or skips it entirely.",
|
|
178
|
+
fix: "Add alt text describing what the image conveys. If it is decorative and repeats adjacent text, use alt=\"\" so it is skipped deliberately rather than by accident.",
|
|
179
|
+
example: (html) => withAttribute(html, "alt", "What this image shows")
|
|
180
|
+
},
|
|
181
|
+
"link-name": {
|
|
182
|
+
why: "A screen reader announces this as \"link\" with nothing after it, so where it goes is unknowable without following it.",
|
|
183
|
+
fix: "Give the link text. Where the design calls for an icon alone, keep the visible icon and add a visually hidden label or an aria-label.",
|
|
184
|
+
example: (html) => withContent(html, "Where this link goes")
|
|
185
|
+
},
|
|
186
|
+
"button-name": {
|
|
187
|
+
why: "A screen reader announces this as \"button\" with nothing after it, so what it does is unknowable without pressing it.",
|
|
188
|
+
fix: "Give the button text, or an aria-label where it shows only an icon.",
|
|
189
|
+
example: (html) => withContent(html, "What this button does")
|
|
190
|
+
},
|
|
191
|
+
"html-has-lang": {
|
|
192
|
+
why: "A screen reader reads the page in whatever language it defaults to, so German content is read with English pronunciation rules and is close to unintelligible.",
|
|
193
|
+
fix: "Set the lang attribute on <html> to the language the page is written in.",
|
|
194
|
+
example: (html) => withAttribute(html, "lang", "de")
|
|
195
|
+
},
|
|
196
|
+
"html-lang-valid": {
|
|
197
|
+
why: "An unrecognised language tag leaves a screen reader guessing, with the same result as no tag at all.",
|
|
198
|
+
fix: "Use a valid BCP 47 tag: de, de-AT, en-GB.",
|
|
199
|
+
example: (html) => withAttribute(html, "lang", "de-AT")
|
|
200
|
+
},
|
|
201
|
+
"document-title": {
|
|
202
|
+
why: "The title is the first thing a screen reader announces and what a tab and a bookmark show. Without one, every page of the site is indistinguishable from every other.",
|
|
203
|
+
fix: "Add a <title> to the document head, naming this page before the site."
|
|
204
|
+
},
|
|
205
|
+
label: {
|
|
206
|
+
why: "A screen reader announces this field with no name, so what to type in it is unknowable.",
|
|
207
|
+
fix: "Give the field a <label for=\"…\">, or an aria-label where the design has no visible label. A placeholder is not a label: it disappears as soon as somebody types."
|
|
208
|
+
},
|
|
209
|
+
"form-field-multiple-labels": {
|
|
210
|
+
why: "Screen readers disagree about which label to announce, so what somebody hears depends on their software.",
|
|
211
|
+
fix: "Leave one label on the field and fold the rest into it, or into aria-describedby."
|
|
212
|
+
},
|
|
213
|
+
"aria-allowed-attr": {
|
|
214
|
+
why: "An ARIA attribute its role does not permit is ignored or, worse, changes how the element is announced in a way nobody intended.",
|
|
215
|
+
fix: "Remove the attribute, or change the role to one that allows it."
|
|
216
|
+
},
|
|
217
|
+
"aria-required-attr": {
|
|
218
|
+
why: "The role promises state that is not there, so a screen reader announces a control without saying whether it is checked, expanded or selected.",
|
|
219
|
+
fix: "Add the attributes the role requires, and keep them in step with the state as it changes."
|
|
220
|
+
},
|
|
221
|
+
"aria-valid-attr-value": {
|
|
222
|
+
why: "An aria-labelledby or aria-describedby pointing at an id that is not on the page leaves the element with no name at all.",
|
|
223
|
+
fix: "Point it at an element that exists, or drop the attribute and label the element directly."
|
|
224
|
+
},
|
|
225
|
+
"aria-hidden-focus": {
|
|
226
|
+
why: "The element is hidden from screen readers and still reachable by keyboard, so somebody tabbing through the page lands on something their software cannot describe.",
|
|
227
|
+
fix: "Remove aria-hidden, or take the element out of the tab order with tabindex=\"-1\" and by disabling the control."
|
|
228
|
+
},
|
|
229
|
+
"heading-order": {
|
|
230
|
+
why: "Headings are how screen reader users navigate a page. A level skipped reads as a missing section.",
|
|
231
|
+
fix: "Step heading levels one at a time. Where the jump was for visual size, keep the level and set the size in CSS."
|
|
232
|
+
},
|
|
233
|
+
"empty-heading": {
|
|
234
|
+
why: "It appears in the heading list a screen reader user navigates by, with nothing to read.",
|
|
235
|
+
fix: "Give the heading text, or remove it and style the surrounding element instead."
|
|
236
|
+
},
|
|
237
|
+
"landmark-one-main": {
|
|
238
|
+
why: "Without a main landmark there is no \"skip to content\": a screen reader user hears the whole navigation again on every page.",
|
|
239
|
+
fix: "Wrap the page content in <main>, once per page."
|
|
240
|
+
},
|
|
241
|
+
region: {
|
|
242
|
+
why: "Content outside a landmark cannot be reached by landmark navigation, so it is only found by reading the page from the top.",
|
|
243
|
+
fix: "Put the content inside <header>, <nav>, <main> or <footer>."
|
|
244
|
+
},
|
|
245
|
+
list: {
|
|
246
|
+
why: "A screen reader announces \"list, N items\" and lets somebody skip it. Anything else between the <li>s breaks that count.",
|
|
247
|
+
fix: "Make every direct child of <ul> or <ol> an <li>, and move anything else inside one."
|
|
248
|
+
},
|
|
249
|
+
listitem: {
|
|
250
|
+
why: "An <li> outside a list is announced as ordinary text, so the grouping the layout implies is not there for anybody who cannot see it.",
|
|
251
|
+
fix: "Put the item inside a <ul> or <ol>."
|
|
252
|
+
},
|
|
253
|
+
"duplicate-id-aria": {
|
|
254
|
+
why: "ARIA references resolve to the first match, so one of these elements is silently labelled by the wrong thing.",
|
|
255
|
+
fix: "Make the ids unique. Where they come from a component rendered more than once, derive the id from a prop or a generated suffix."
|
|
256
|
+
},
|
|
257
|
+
"color-contrast": {
|
|
258
|
+
why: "Text this close to its background is unreadable for many people with low vision, and for anybody in bright sunlight.",
|
|
259
|
+
fix: "Raise the contrast to 4.5:1 for body text, or 3:1 for large or bold text. Check hover, focus, visited, disabled and placeholder states too — those are the ones usually missed."
|
|
260
|
+
},
|
|
261
|
+
"link-in-text-block": {
|
|
262
|
+
why: "A link distinguished from its paragraph by colour alone is invisible to somebody who cannot distinguish those colours.",
|
|
263
|
+
fix: "Underline links inside paragraphs, or give them a 3:1 contrast difference against the surrounding text as well as against the background."
|
|
264
|
+
},
|
|
265
|
+
"target-size": {
|
|
266
|
+
why: "A target this small is hard to hit for anybody with a tremor, and for everybody on a phone.",
|
|
267
|
+
fix: "Make the clickable area at least 24×24 CSS pixels, with padding rather than a bigger icon."
|
|
268
|
+
},
|
|
269
|
+
"frame-title": {
|
|
270
|
+
why: "A screen reader announces an untitled frame as \"frame\", so what is in it is unknowable without entering it.",
|
|
271
|
+
fix: "Add a title attribute saying what the frame contains.",
|
|
272
|
+
example: (html) => withAttribute(html, "title", "What this frame contains")
|
|
273
|
+
},
|
|
274
|
+
"meta-viewport": {
|
|
275
|
+
why: "Blocking zoom stops anybody who needs larger text from reading the page at all on a phone.",
|
|
276
|
+
fix: "Remove user-scalable=no and any maximum-scale below 5 from the viewport meta tag.",
|
|
277
|
+
example: (html) => withAttribute(html, "content", "width=device-width, initial-scale=1")
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
/**
|
|
281
|
+
* Fixes that genuinely differ by framework.
|
|
282
|
+
*
|
|
283
|
+
* Deliberately short. Everything absent here is covered by the generic entry,
|
|
284
|
+
* because for most rules the correction is identical whatever produced the
|
|
285
|
+
* markup, and a per-framework table full of restatements would be a maintenance
|
|
286
|
+
* cost with no reader.
|
|
287
|
+
*/
|
|
288
|
+
const BY_FRAMEWORK = {
|
|
289
|
+
next: {
|
|
290
|
+
"image-alt": { fix: "next/image requires alt, so an empty one here means it was set to '' or a plain <img> was used. Give it a real alt, or alt=\"\" only where the image repeats adjacent text." },
|
|
291
|
+
"html-has-lang": { fix: "Set lang on the <html> element in app/layout.tsx (App Router) or pages/_document.tsx (Pages Router). Setting it in a page component will not reach the document." }
|
|
292
|
+
},
|
|
293
|
+
nuxt: {
|
|
294
|
+
"html-has-lang": { fix: "Set app.head.htmlAttrs.lang in nuxt.config.ts, or call useHead({ htmlAttrs: { lang: 'de' } }) in app.vue." },
|
|
295
|
+
"link-name": { fix: "Give the <NuxtLink> content, or an aria-label where it renders an icon alone." }
|
|
296
|
+
},
|
|
297
|
+
astro: {
|
|
298
|
+
"html-has-lang": { fix: "Set lang on the <html> element in your layout under src/layouts, not in the individual page." },
|
|
299
|
+
"image-alt": { fix: "Astro's <Image /> requires alt. Give it one describing what the image conveys, or alt=\"\" where it repeats adjacent text." }
|
|
300
|
+
},
|
|
301
|
+
sveltekit: { "html-has-lang": { fix: "Set lang on the <html> element in src/app.html, which is the template every route is rendered into." } },
|
|
302
|
+
remix: { "html-has-lang": { fix: "Set lang on the <html> element in the root route (app/root.tsx), which renders the document shell." } }
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* What to do about a rule, in this project's idiom where that differs.
|
|
306
|
+
*
|
|
307
|
+
* `framework` is the registry id from `detectFramework`. An unknown one, or
|
|
308
|
+
* none, falls through to the generic advice rather than to nothing: the fix for
|
|
309
|
+
* most rules does not depend on what built the page.
|
|
310
|
+
*/
|
|
311
|
+
function remediationFor(ruleId, framework) {
|
|
312
|
+
const generic = GENERIC[ruleId];
|
|
313
|
+
const specific = framework === void 0 ? void 0 : BY_FRAMEWORK[framework]?.[ruleId];
|
|
314
|
+
if (generic === void 0) return void 0;
|
|
315
|
+
return specific === void 0 ? generic : {
|
|
316
|
+
...generic,
|
|
317
|
+
...specific
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
export { isShared as a, runCompleteness as c, groupIssues as i, blindRules as n, discoveryLabel as o, coverageParts as r, missedParts as s, remediationFor as t };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as impactRank, n as IMPACT_LEVELS } from "./impact-DZt2oBCP.js";
|
|
2
|
+
import { c as nullable, h as string, l as number, m as safeParse, p as record, s as isoDateTime, t as array, u as object, y as withDefault } from "./schema-DJSF4K05.js";
|
|
3
|
+
import { u as STATEMENT_LOCALES } from "./load-yAR4wzez.js";
|
|
2
4
|
import { n as isDirectory } from "./fs-BmPtmFke.js";
|
|
3
|
-
import {
|
|
4
|
-
import { a as standardsReference, i as escapeText, r as escapeAttribute } from "./text-BFmNtMsV.js";
|
|
5
|
+
import { i as escapeText, o as standardsReference, r as escapeAttribute } from "./text-CKKpzkYM.js";
|
|
5
6
|
import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
|
|
6
7
|
import { readFile, readdir } from "node:fs/promises";
|
|
7
8
|
import path from "node:path";
|
|
@@ -58,7 +59,7 @@ function summariseAuditReport(value, source = "audit report") {
|
|
|
58
59
|
const result = safeParse(reportSchema, value);
|
|
59
60
|
if (!result.success) throw new StatementError(`${source} is not an eaa-kit JSON report (${result.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).slice(0, 5).join("; ")})`);
|
|
60
61
|
const report = result.data;
|
|
61
|
-
if (report.schemaVersion !==
|
|
62
|
+
if (report.schemaVersion !== 2) throw new StatementError(`${source} has schemaVersion ${report.schemaVersion}; this version of eaa-kit reads 2`);
|
|
62
63
|
const byRule = /* @__PURE__ */ new Map();
|
|
63
64
|
const rules = new Map(Object.entries(report.rules));
|
|
64
65
|
for (const page of report.pages) for (const violation of page.violations) {
|
|
@@ -411,7 +412,7 @@ async function renderStatement(config, options = {}) {
|
|
|
411
412
|
const country = options.country ?? config.enforcement.country;
|
|
412
413
|
const locale = options.locale ?? defaultLocale(config);
|
|
413
414
|
const template = `${country.toLowerCase()}.${locale}`;
|
|
414
|
-
const markdown = tidy(renderTemplate(await loadTemplate(
|
|
415
|
+
const markdown = tidy(renderTemplate(await loadTemplate(country, locale), buildScope(config, locale, options.audit)));
|
|
415
416
|
return {
|
|
416
417
|
markdown,
|
|
417
418
|
html: toHtmlDocument(markdown, {
|
|
@@ -423,9 +424,16 @@ async function renderStatement(config, options = {}) {
|
|
|
423
424
|
template
|
|
424
425
|
};
|
|
425
426
|
}
|
|
426
|
-
/**
|
|
427
|
+
/**
|
|
428
|
+
* A site gets its statement in its own language where there is one for it.
|
|
429
|
+
*
|
|
430
|
+
* From `site.locale`, which is a BCP 47 tag: `de-AT` and `de` both mean the
|
|
431
|
+
* German document. English is the fallback because every country has an English
|
|
432
|
+
* template, being the language a statement is most often also published in.
|
|
433
|
+
*/
|
|
427
434
|
function defaultLocale(config) {
|
|
428
|
-
|
|
435
|
+
const language = config.site.locale.toLowerCase().split("-")[0];
|
|
436
|
+
return STATEMENT_LOCALES.find((candidate) => candidate === language) ?? "en";
|
|
429
437
|
}
|
|
430
438
|
/**
|
|
431
439
|
* Values the templates interpolate.
|
|
@@ -522,6 +530,22 @@ function reasonScope(reason) {
|
|
|
522
530
|
};
|
|
523
531
|
}
|
|
524
532
|
/**
|
|
533
|
+
* Where each statement language formats its dates.
|
|
534
|
+
*
|
|
535
|
+
* A region is named for every one, because a bare language tag leaves the
|
|
536
|
+
* format to whatever ICU picks: `de` is de-DE, and this tool's German documents
|
|
537
|
+
* have always been dated the Austrian way. `en-GB` for the same reason —
|
|
538
|
+
* 20 August 2026, not August 20, 2026, in a European legal document.
|
|
539
|
+
*/
|
|
540
|
+
const DATE_LOCALES = {
|
|
541
|
+
de: "de-AT",
|
|
542
|
+
en: "en-GB",
|
|
543
|
+
es: "es-ES",
|
|
544
|
+
fr: "fr-FR",
|
|
545
|
+
it: "it-IT",
|
|
546
|
+
nl: "nl-NL"
|
|
547
|
+
};
|
|
548
|
+
/**
|
|
525
549
|
* 2026-08-20 becomes 20. August 2026 or 20 August 2026.
|
|
526
550
|
*
|
|
527
551
|
* Every date reaching this has been through a schema that checks it, so the
|
|
@@ -533,7 +557,7 @@ function reasonScope(reason) {
|
|
|
533
557
|
function formatDate(iso, locale) {
|
|
534
558
|
const date = /* @__PURE__ */ new Date(`${iso}T00:00:00Z`);
|
|
535
559
|
if (Number.isNaN(date.getTime())) return iso;
|
|
536
|
-
return new Intl.DateTimeFormat(locale
|
|
560
|
+
return new Intl.DateTimeFormat(DATE_LOCALES[locale], {
|
|
537
561
|
day: "numeric",
|
|
538
562
|
month: "long",
|
|
539
563
|
year: "numeric",
|
|
@@ -549,14 +573,28 @@ function tidy(markdown) {
|
|
|
549
573
|
return `${markdown.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
|
550
574
|
}
|
|
551
575
|
let templateDirectory;
|
|
552
|
-
|
|
576
|
+
/**
|
|
577
|
+
* The document for a country in a language, if there is one.
|
|
578
|
+
*
|
|
579
|
+
* The matrix is deliberately sparse: a country's statement is written under its
|
|
580
|
+
* own law and published in the language the law is administered in, plus
|
|
581
|
+
* English. Asking for a combination nobody wrote is an error naming the
|
|
582
|
+
* languages that country does have — not a fall back to another language, which
|
|
583
|
+
* would hand somebody a document in a language their readers may not have and
|
|
584
|
+
* do it quietly.
|
|
585
|
+
*/
|
|
586
|
+
async function loadTemplate(country, locale) {
|
|
553
587
|
templateDirectory ??= await findTemplateDirectory();
|
|
554
588
|
const directory = templateDirectory;
|
|
589
|
+
const name = `${country.toLowerCase()}.${locale}`;
|
|
555
590
|
const file = path.join(directory, `${name}.md`);
|
|
556
591
|
try {
|
|
557
592
|
return await readFile(file, "utf8");
|
|
558
593
|
} catch {
|
|
559
|
-
|
|
594
|
+
const templates = (await readdir(directory)).filter((entry) => entry.endsWith(".md")).map((entry) => entry.replace(/\.md$/, "")).sort();
|
|
595
|
+
const prefix = `${country.toLowerCase()}.`;
|
|
596
|
+
const forCountry = templates.filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length));
|
|
597
|
+
throw new StatementError(forCountry.length > 0 ? `No ${country} statement in ${locale}. ${country} has: ${forCountry.join(", ")}` : `No statement template for ${name}. Available: ${templates.join(", ")}`);
|
|
560
598
|
}
|
|
561
599
|
}
|
|
562
600
|
/**
|