eaa-kit 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -5
- package/dist/astro/index.js +1 -1
- package/dist/audit/runners/worker.js +1 -1
- package/dist/{audit-DcL73mOC.js → audit-CPoZMXGM.js} +180 -36
- package/dist/audit-CpXH2Mk8.js +2 -0
- package/dist/{baseline-CuKFq4IF.js → baseline-22Y1NWxM.js} +1 -1
- package/dist/{baseline-s9F3fXTN.js → baseline-DB9CZGnV.js} +2 -2
- package/dist/cli/index.js +267 -10
- package/dist/{collect-BkAQ0viT.js → collect-CFM8gEVv.js} +21 -10
- package/dist/{command-Dxpa00Ha.js → command-D8l_oYbV.js} +2 -2
- package/dist/{component-7kEBjv_y.js → component-C3GL1Mnu.js} +41 -6
- package/dist/component-DKd3EHOg.js +2 -0
- package/dist/coverage-B4IBKMO8.js +521 -0
- package/dist/{crawl-Oxt2Gaqo.js → crawl-BiI1Lau_.js} +2 -2
- package/dist/eleventy/index.d.ts +28 -0
- package/dist/eleventy/index.js +19 -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-BGTO3ypW.js} +130 -5
- package/dist/{impact-YdoOtFqm.js → impact-DZt2oBCP.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/{init-DiLiYvN1.js → init-DIWDE35F.js} +2 -2
- package/dist/jsdom-4IMzv0eE.js +3 -0
- package/dist/jsdom-DCpGSLfW.js +81 -0
- package/dist/{json-Cnv9nd6U.js → json-DjEvy1nX.js} +8 -3
- package/dist/{json-B0Y7rNjt.js → json-QQuFIw1W.js} +1 -1
- package/dist/{load-UYXLqGV9.js → load-5wRGLvub.js} +1 -1
- package/dist/nuxt/index.d.ts +35 -0
- package/dist/nuxt/index.js +23 -0
- package/dist/{playwright-DYFsGUNd.js → playwright-BWniOain.js} +1 -1
- package/dist/{pool-BWkWZiJW.js → pool-BMevaLWD.js} +2 -2
- package/dist/{project-DW08TseF.js → project-CzOnkLH6.js} +8 -2
- package/dist/project-MFrXcw1M.js +2 -0
- package/dist/remediation-Dtowi2EC.js +321 -0
- package/dist/{render-DI_aCnAZ.js → render-DrvXRCEn.js} +3 -3
- package/dist/{result-DLxd2Eip.js → result-DoamKFsp.js} +78 -1
- package/dist/routes-CmdRUuOs.js +265 -0
- package/dist/{run-BW6CVuND.js → run-DB34BSOZ.js} +19 -2
- package/dist/{sarif-DB3WG7T9.js → sarif-SR3_lLYd.js} +14 -6
- package/dist/{schema-CMZ8ItGk.js → schema-is6CGX2D.js} +4 -1
- package/dist/{text-BFmNtMsV.js → text-CKKpzkYM.js} +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 +30 -8
- package/dist/audit-CuG2hYyo.js +0 -2
- 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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { a as impactRank } from "./impact-DZt2oBCP.js";
|
|
2
|
+
import { t as elementFingerprint } from "./fingerprint-DRoneAjj.js";
|
|
3
|
+
import { a as findingElements } from "./result-DoamKFsp.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,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as number, f as record, l as object, m as string, o as isoDateTime, p as safeParse, s as nullable, t as array, v as withDefault } from "./schema-is6CGX2D.js";
|
|
2
2
|
import { n as isDirectory } from "./fs-BmPtmFke.js";
|
|
3
|
-
import { a as impactRank, n as IMPACT_LEVELS } from "./impact-
|
|
4
|
-
import {
|
|
3
|
+
import { a as impactRank, n as IMPACT_LEVELS } from "./impact-DZt2oBCP.js";
|
|
4
|
+
import { i as escapeText, o as standardsReference, r as escapeAttribute } from "./text-CKKpzkYM.js";
|
|
5
5
|
import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
|
|
6
6
|
import { readFile, readdir } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
@@ -10,6 +10,83 @@ const DEFAULT_TAGS = [
|
|
|
10
10
|
"wcag22aa"
|
|
11
11
|
];
|
|
12
12
|
/**
|
|
13
|
+
* Rules this engine structurally cannot decide.
|
|
14
|
+
*
|
|
15
|
+
* jsdom has no layout: every element reports a 0x0 box, computed style is
|
|
16
|
+
* limited to the inline cascade, and nothing is ever fetched. axe-core does not
|
|
17
|
+
* know that, so it happily returns `pass` for some of these — `target-size`
|
|
18
|
+
* passes on any page because a 0x0 target gets measured against nothing, and
|
|
19
|
+
* `color-contrast` passes on pages whose colours were never computed. Both were
|
|
20
|
+
* observed on real sites. Reporting either as a pass would be a false clean
|
|
21
|
+
* bill of health, so every rule listed here is force-reported as incomplete no
|
|
22
|
+
* matter which bucket axe-core put it in.
|
|
23
|
+
*
|
|
24
|
+
* The browser runner passes an empty map instead: with real layout these rules
|
|
25
|
+
* are exactly the ones it exists to answer.
|
|
26
|
+
*
|
|
27
|
+
* Rules tagged `experimental` are listed for completeness but filtered out at
|
|
28
|
+
* scope time: axe-core does not run them by default, so a browser run would not
|
|
29
|
+
* evaluate them either and telling the user to re-run with --browser would be
|
|
30
|
+
* misleading.
|
|
31
|
+
*/
|
|
32
|
+
const ENGINE_BLIND_RULES = {
|
|
33
|
+
"color-contrast": {
|
|
34
|
+
detail: "needs rendered foreground and background colours",
|
|
35
|
+
applicabilityUnreliable: false
|
|
36
|
+
},
|
|
37
|
+
"color-contrast-enhanced": {
|
|
38
|
+
detail: "needs rendered foreground and background colours",
|
|
39
|
+
applicabilityUnreliable: false
|
|
40
|
+
},
|
|
41
|
+
"target-size": {
|
|
42
|
+
detail: "needs element geometry; every box is 0x0 without layout",
|
|
43
|
+
applicabilityUnreliable: false
|
|
44
|
+
},
|
|
45
|
+
"scrollable-region-focusable": {
|
|
46
|
+
detail: "needs computed overflow",
|
|
47
|
+
applicabilityUnreliable: true
|
|
48
|
+
},
|
|
49
|
+
"link-in-text-block": {
|
|
50
|
+
detail: "needs rendered colours and text decoration",
|
|
51
|
+
applicabilityUnreliable: true
|
|
52
|
+
},
|
|
53
|
+
"no-autoplay-audio": {
|
|
54
|
+
detail: "needs media duration, and media is never loaded",
|
|
55
|
+
applicabilityUnreliable: true
|
|
56
|
+
},
|
|
57
|
+
"avoid-inline-spacing": {
|
|
58
|
+
detail: "needs computed spacing after the full cascade",
|
|
59
|
+
applicabilityUnreliable: false
|
|
60
|
+
},
|
|
61
|
+
"p-as-heading": {
|
|
62
|
+
detail: "needs computed font size and weight",
|
|
63
|
+
applicabilityUnreliable: false
|
|
64
|
+
},
|
|
65
|
+
"css-orientation-lock": {
|
|
66
|
+
detail: "needs CSS media query evaluation",
|
|
67
|
+
applicabilityUnreliable: true
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const blindScopeCache = /* @__PURE__ */ new Map();
|
|
71
|
+
/**
|
|
72
|
+
* Blind rules the requested tag filter would actually have run. Experimental
|
|
73
|
+
* rules are excluded: axe-core leaves them off by default, so a browser run
|
|
74
|
+
* would not have evaluated them either.
|
|
75
|
+
*/
|
|
76
|
+
function blindRulesInScope(tags) {
|
|
77
|
+
const key = [...tags].sort().join(",");
|
|
78
|
+
const cached = blindScopeCache.get(key);
|
|
79
|
+
if (cached) return cached;
|
|
80
|
+
const inScope = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const rule of axe.getRules([...tags])) {
|
|
82
|
+
if (rule.tags.includes("experimental")) continue;
|
|
83
|
+
const blind = ENGINE_BLIND_RULES[rule.ruleId];
|
|
84
|
+
if (blind) inScope.set(rule.ruleId, blind);
|
|
85
|
+
}
|
|
86
|
+
blindScopeCache.set(key, inScope);
|
|
87
|
+
return inScope;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
13
90
|
* The URL a page is audited under, shared by every runner so their reports name
|
|
14
91
|
* the same page. Without a base URL that is the file it came off disk as.
|
|
15
92
|
*/
|
|
@@ -202,4 +279,4 @@ function enClauses(tags) {
|
|
|
202
279
|
return [...clauses].sort();
|
|
203
280
|
}
|
|
204
281
|
//#endregion
|
|
205
|
-
export {
|
|
282
|
+
export { findingElements as a, runOptions as c, failedPage as i, shapeResults as l, ENGINE_BLIND_RULES as n, pageUrl as o, blindRulesInScope as r, ruleOutcomes as s, DEFAULT_TAGS as t, successCriteria as u };
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { i as toPosix, n as isDirectory } from "./fs-BmPtmFke.js";
|
|
2
|
+
import { i as detectFramework } from "./frameworks-B4ClIJgE.js";
|
|
3
|
+
import { i as readPackageJson } from "./project-CzOnkLH6.js";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { glob } from "tinyglobby";
|
|
6
|
+
//#region src/audit/routes.ts
|
|
7
|
+
/**
|
|
8
|
+
* A path segment that serves more than one page, or none.
|
|
9
|
+
*
|
|
10
|
+
* Dynamic segments are refused rather than resolved: `[slug]` stands for every
|
|
11
|
+
* post there is, and picking one would name a file that did not produce the
|
|
12
|
+
* page in front of the reader. Private and slot folders contribute no route at
|
|
13
|
+
* all.
|
|
14
|
+
*/
|
|
15
|
+
function classifyBracketSegment(segment) {
|
|
16
|
+
if (/^\(.*\)$/.test(segment)) return "skip";
|
|
17
|
+
if (/^[[(]|^_|^@/.test(segment)) return "refuse";
|
|
18
|
+
return "route";
|
|
19
|
+
}
|
|
20
|
+
/** Drop the extension, and fold an index file into the directory it sits in. */
|
|
21
|
+
function withoutExtension(file) {
|
|
22
|
+
const route = file.replace(/\.[^./]+$/, "").replace(/\/index$/, "");
|
|
23
|
+
return route === "index" ? "" : route;
|
|
24
|
+
}
|
|
25
|
+
/** The directory a route file sits in, for routers that put the route there. */
|
|
26
|
+
function containingDirectory(file) {
|
|
27
|
+
return file.includes("/") ? file.replace(/\/[^/]+$/, "") : "";
|
|
28
|
+
}
|
|
29
|
+
/** Keep the segments that are routes, refusing the file if any is dynamic. */
|
|
30
|
+
function joinSegments(route) {
|
|
31
|
+
const kept = [];
|
|
32
|
+
for (const segment of route.split("/").filter((part) => part !== "")) {
|
|
33
|
+
const kind = classifyBracketSegment(segment);
|
|
34
|
+
if (kind === "refuse") return void 0;
|
|
35
|
+
if (kind === "skip") continue;
|
|
36
|
+
kept.push(segment);
|
|
37
|
+
}
|
|
38
|
+
return kept.join("/");
|
|
39
|
+
}
|
|
40
|
+
/** `pages/kontakt.tsx` serves `/kontakt`; the filename is the route. */
|
|
41
|
+
function fileNamedRoute(file) {
|
|
42
|
+
return joinSegments(withoutExtension(file));
|
|
43
|
+
}
|
|
44
|
+
/** `app/kontakt/page.tsx` serves `/kontakt`; the directory is the route. */
|
|
45
|
+
function directoryNamedRoute(file) {
|
|
46
|
+
return joinSegments(containingDirectory(file));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A documentation tree whose directory layout is the URL layout.
|
|
50
|
+
*
|
|
51
|
+
* No dynamic segments to worry about — these are files an author wrote, one per
|
|
52
|
+
* page — so the only work is folding the index file into its directory.
|
|
53
|
+
*/
|
|
54
|
+
function mirroredRoute(file) {
|
|
55
|
+
return withoutExtension(file);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Remix and React Router flat routes: `blog.post.tsx` serves `/blog/post`.
|
|
59
|
+
*
|
|
60
|
+
* The dot is the separator, `_index` is a directory's own page, and a trailing
|
|
61
|
+
* underscore on a segment escapes a parent layout without changing the URL. A
|
|
62
|
+
* `$` segment is dynamic and refused, as `[slug]` is elsewhere.
|
|
63
|
+
*/
|
|
64
|
+
function remixRoute(file) {
|
|
65
|
+
const withoutRouteFile = file.replace(/\/route\.[^./]+$/, "");
|
|
66
|
+
const flat = withoutRouteFile === file ? withoutExtension(file) : toPosix(withoutRouteFile);
|
|
67
|
+
const leaf = flat.includes("/") ? flat.split("/").pop() : flat;
|
|
68
|
+
if (leaf === "") return void 0;
|
|
69
|
+
const kept = [];
|
|
70
|
+
for (const raw of leaf.split(".")) {
|
|
71
|
+
const segment = raw.replace(/_$/, "");
|
|
72
|
+
if (segment === "") continue;
|
|
73
|
+
if (segment === "_index") continue;
|
|
74
|
+
if (segment.startsWith("$")) return void 0;
|
|
75
|
+
if (segment.includes("[")) return void 0;
|
|
76
|
+
kept.push(segment);
|
|
77
|
+
}
|
|
78
|
+
return kept.join("/");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Hugo content: `content/posts/hello.md` serves `/posts/hello/`.
|
|
82
|
+
*
|
|
83
|
+
* `_index.md` is a section's own page rather than a page called `_index`, which
|
|
84
|
+
* is the one place Hugo's leading underscore does not mean "private".
|
|
85
|
+
*/
|
|
86
|
+
function hugoRoute(file) {
|
|
87
|
+
const withoutIndex = file.replace(/(^|\/)_index\.[^./]+$/, "");
|
|
88
|
+
if (withoutIndex !== file) return toPosix(withoutIndex);
|
|
89
|
+
return withoutExtension(file);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Conventions, most specific first.
|
|
93
|
+
*
|
|
94
|
+
* Order still matters where the registry knows nothing about a project, which
|
|
95
|
+
* is the case for a plain directory of HTML and for anything the registry has
|
|
96
|
+
* not heard of.
|
|
97
|
+
*/
|
|
98
|
+
const CONVENTIONS = [
|
|
99
|
+
{
|
|
100
|
+
framework: "next-app",
|
|
101
|
+
ids: ["next"],
|
|
102
|
+
dir: "app",
|
|
103
|
+
pattern: "**/page.{tsx,ts,jsx,js,mdx}",
|
|
104
|
+
toRoute: directoryNamedRoute
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
framework: "next-app",
|
|
108
|
+
ids: ["next"],
|
|
109
|
+
dir: "src/app",
|
|
110
|
+
pattern: "**/page.{tsx,ts,jsx,js,mdx}",
|
|
111
|
+
toRoute: directoryNamedRoute
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
framework: "remix",
|
|
115
|
+
ids: ["remix"],
|
|
116
|
+
dir: "app/routes",
|
|
117
|
+
pattern: "**/*.{tsx,ts,jsx,js,mdx}",
|
|
118
|
+
toRoute: remixRoute
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
framework: "starlight",
|
|
122
|
+
ids: ["astro"],
|
|
123
|
+
dir: "src/content/docs",
|
|
124
|
+
pattern: "**/*.{md,mdx,mdoc}",
|
|
125
|
+
toRoute: mirroredRoute
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
framework: "astro",
|
|
129
|
+
ids: ["astro"],
|
|
130
|
+
dir: "src/pages",
|
|
131
|
+
pattern: "**/*.{astro,md,mdx,html}",
|
|
132
|
+
toRoute: fileNamedRoute
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
framework: "gatsby",
|
|
136
|
+
ids: ["gatsby"],
|
|
137
|
+
dir: "src/pages",
|
|
138
|
+
pattern: "**/*.{tsx,ts,jsx,js,md,mdx}",
|
|
139
|
+
toRoute: fileNamedRoute
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
framework: "next-pages",
|
|
143
|
+
ids: ["next"],
|
|
144
|
+
dir: "pages",
|
|
145
|
+
pattern: "**/*.{tsx,ts,jsx,js,mdx}",
|
|
146
|
+
toRoute: fileNamedRoute
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
framework: "next-pages",
|
|
150
|
+
ids: ["next"],
|
|
151
|
+
dir: "src/pages",
|
|
152
|
+
pattern: "**/*.{tsx,ts,jsx,js,mdx}",
|
|
153
|
+
toRoute: fileNamedRoute
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
framework: "nuxt",
|
|
157
|
+
ids: ["nuxt"],
|
|
158
|
+
dir: "pages",
|
|
159
|
+
pattern: "**/*.vue",
|
|
160
|
+
toRoute: fileNamedRoute
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
framework: "sveltekit",
|
|
164
|
+
ids: ["sveltekit"],
|
|
165
|
+
dir: "src/routes",
|
|
166
|
+
pattern: "**/+page.{svelte,ts,js}",
|
|
167
|
+
toRoute: directoryNamedRoute
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
framework: "docusaurus",
|
|
171
|
+
ids: ["docusaurus"],
|
|
172
|
+
dir: "docs",
|
|
173
|
+
pattern: "**/*.{md,mdx}",
|
|
174
|
+
toRoute: (file) => {
|
|
175
|
+
const route = mirroredRoute(file);
|
|
176
|
+
return route === void 0 ? void 0 : route === "" ? "docs" : `docs/${route}`;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
framework: "vitepress",
|
|
181
|
+
ids: ["vitepress"],
|
|
182
|
+
dir: "docs",
|
|
183
|
+
pattern: "**/*.md",
|
|
184
|
+
toRoute: mirroredRoute
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
framework: "hugo",
|
|
188
|
+
ids: ["hugo"],
|
|
189
|
+
dir: "content",
|
|
190
|
+
pattern: "**/*.{md,html}",
|
|
191
|
+
toRoute: hugoRoute
|
|
192
|
+
}
|
|
193
|
+
];
|
|
194
|
+
/** Every page path a build could have emitted for one route. */
|
|
195
|
+
function emittedPathsFor(route) {
|
|
196
|
+
if (route === "") return [
|
|
197
|
+
"/",
|
|
198
|
+
"index.html",
|
|
199
|
+
"index.htm"
|
|
200
|
+
];
|
|
201
|
+
return [
|
|
202
|
+
route,
|
|
203
|
+
`${route}/`,
|
|
204
|
+
`${route}.html`,
|
|
205
|
+
`${route}/index.html`
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Build a page-to-source map by reading the project's route files.
|
|
210
|
+
*
|
|
211
|
+
* Returns undefined when the project uses no convention this understands, which
|
|
212
|
+
* is not a failure — most builds are not framework projects.
|
|
213
|
+
*/
|
|
214
|
+
async function buildRouteMap(cwd, pkg) {
|
|
215
|
+
for (const convention of await orderedConventions(cwd, pkg)) {
|
|
216
|
+
const map = await mapConvention(cwd, convention);
|
|
217
|
+
if (map !== void 0) return map;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Conventions to try, the detected framework's first.
|
|
222
|
+
*
|
|
223
|
+
* Without this, `src/pages` is claimed by whichever convention is declared
|
|
224
|
+
* earliest, so a Gatsby project was reported as `next-pages`. The mapping
|
|
225
|
+
* happened to be right and the name was wrong, which is the kind of detail a
|
|
226
|
+
* reader notices and stops trusting the rest of the report over.
|
|
227
|
+
*/
|
|
228
|
+
async function orderedConventions(cwd, pkg) {
|
|
229
|
+
const detected = await detectFramework(cwd, pkg ?? await readPackageJson(cwd));
|
|
230
|
+
if (detected === void 0) return [...CONVENTIONS];
|
|
231
|
+
const id = detected.framework.id;
|
|
232
|
+
const mine = CONVENTIONS.filter((convention) => convention.ids.includes(id));
|
|
233
|
+
const rest = CONVENTIONS.filter((convention) => !convention.ids.includes(id));
|
|
234
|
+
return [...mine, ...rest];
|
|
235
|
+
}
|
|
236
|
+
/** Read one convention's directory, or undefined when it has nothing to say. */
|
|
237
|
+
async function mapConvention(cwd, convention) {
|
|
238
|
+
const directory = path.join(cwd, convention.dir);
|
|
239
|
+
if (!await isDirectory(directory)) return void 0;
|
|
240
|
+
const files = await glob([convention.pattern], {
|
|
241
|
+
cwd: directory,
|
|
242
|
+
ignore: ["**/node_modules/**"],
|
|
243
|
+
onlyFiles: true
|
|
244
|
+
});
|
|
245
|
+
if (files.length === 0) return void 0;
|
|
246
|
+
const sources = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const route = convention.toRoute(toPosix(file));
|
|
249
|
+
if (route === void 0) continue;
|
|
250
|
+
const source = `${convention.dir}/${toPosix(file)}`;
|
|
251
|
+
for (const emitted of emittedPathsFor(route)) if (!sources.has(emitted)) sources.set(emitted, source);
|
|
252
|
+
}
|
|
253
|
+
if (sources.size === 0) return void 0;
|
|
254
|
+
return {
|
|
255
|
+
framework: convention.framework,
|
|
256
|
+
sources
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** The source file for an audited page, if the map knows one. */
|
|
260
|
+
function sourceFor(map, pagePath) {
|
|
261
|
+
if (map === void 0) return void 0;
|
|
262
|
+
return map.sources.get(pagePath) ?? map.sources.get(pagePath.replace(/^\//, ""));
|
|
263
|
+
}
|
|
264
|
+
//#endregion
|
|
265
|
+
export { buildRouteMap, sourceFor };
|