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,6 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as stripBom } from "./collect-Cu1q9NTB.js";
|
|
2
2
|
/** Requests in flight at once. Politeness, not throughput. */
|
|
3
3
|
const REQUEST_CONCURRENCY = 4;
|
|
4
|
+
/**
|
|
5
|
+
* Largest response body this will read, in bytes.
|
|
6
|
+
*
|
|
7
|
+
* A crawl reads whatever the server sends, and nothing obliges a server to send
|
|
8
|
+
* something reasonable — a misconfigured export endpoint, a log streamed as
|
|
9
|
+
* text/html, or a host that simply does not stop. `response.text()` buffers all
|
|
10
|
+
* of it before anyone can object, so the ceiling has to be applied while the
|
|
11
|
+
* body is still arriving.
|
|
12
|
+
*
|
|
13
|
+
* Matches the on-disk page limit: the two paths audit the same kind of
|
|
14
|
+
* document, and a page that would be declined off disk should not be accepted
|
|
15
|
+
* because it arrived over HTTP instead.
|
|
16
|
+
*/
|
|
17
|
+
const MAX_BODY_BYTES = 33554432;
|
|
4
18
|
var CrawlError = class extends Error {
|
|
5
19
|
name = "CrawlError";
|
|
6
20
|
};
|
|
@@ -79,7 +93,7 @@ function urlsFromSitemap(xml, origin) {
|
|
|
79
93
|
return urls;
|
|
80
94
|
}
|
|
81
95
|
/** One request, with a timeout, returning HTML or a reason it is not a page. */
|
|
82
|
-
async function fetchPage(url, impl, timeoutMs, origin) {
|
|
96
|
+
async function fetchPage(url, impl, timeoutMs, origin, maxBodyBytes) {
|
|
83
97
|
const controller = new AbortController();
|
|
84
98
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
85
99
|
try {
|
|
@@ -106,11 +120,16 @@ async function fetchPage(url, impl, timeoutMs, origin) {
|
|
|
106
120
|
ok: false,
|
|
107
121
|
reason: `not HTML (${type.split(";")[0] || "no content-type"})`
|
|
108
122
|
};
|
|
123
|
+
const body = await readCapped(response, maxBodyBytes);
|
|
124
|
+
if (body === void 0) return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason: `larger than the ${maxBodyBytes} byte limit for one page`
|
|
127
|
+
};
|
|
109
128
|
return {
|
|
110
129
|
ok: true,
|
|
111
130
|
value: {
|
|
112
131
|
url: finalUrl,
|
|
113
|
-
html:
|
|
132
|
+
html: body
|
|
114
133
|
}
|
|
115
134
|
};
|
|
116
135
|
} catch (cause) {
|
|
@@ -123,6 +142,51 @@ async function fetchPage(url, impl, timeoutMs, origin) {
|
|
|
123
142
|
clearTimeout(timer);
|
|
124
143
|
}
|
|
125
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* A response body, up to a limit, or undefined when it runs past it.
|
|
147
|
+
*
|
|
148
|
+
* Streamed rather than buffered, because `response.text()` has already read
|
|
149
|
+
* everything by the time it could be checked, and a content-length header is
|
|
150
|
+
* both optional and unverified — a chunked response carries no length at all,
|
|
151
|
+
* and one that carries a length is not obliged to tell the truth. Counting the
|
|
152
|
+
* bytes as they arrive is the only check that holds either way, and the body is
|
|
153
|
+
* cancelled the moment it goes over so nothing keeps arriving.
|
|
154
|
+
*/
|
|
155
|
+
async function readCapped(response, limit) {
|
|
156
|
+
const body = response.body;
|
|
157
|
+
if (!body) {
|
|
158
|
+
const text = await response.text();
|
|
159
|
+
return text.length > limit ? void 0 : text;
|
|
160
|
+
}
|
|
161
|
+
const reader = body.getReader();
|
|
162
|
+
const chunks = [];
|
|
163
|
+
let total = 0;
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) break;
|
|
168
|
+
if (value === void 0) continue;
|
|
169
|
+
total += value.byteLength;
|
|
170
|
+
if (total > limit) {
|
|
171
|
+
await reader.cancel();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
chunks.push(value);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
reader.releaseLock();
|
|
178
|
+
}
|
|
179
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
180
|
+
}
|
|
181
|
+
function concat(chunks, total) {
|
|
182
|
+
const joined = new Uint8Array(total);
|
|
183
|
+
let at = 0;
|
|
184
|
+
for (const chunk of chunks) {
|
|
185
|
+
joined.set(chunk, at);
|
|
186
|
+
at += chunk.byteLength;
|
|
187
|
+
}
|
|
188
|
+
return joined;
|
|
189
|
+
}
|
|
126
190
|
/** Paths robots.txt disallows for us. Only the wildcard group is read. */
|
|
127
191
|
function disallowedPaths(robots) {
|
|
128
192
|
const lines = robots.split(/\r?\n/).map((line) => line.replace(/#.*$/, "").trim());
|
|
@@ -149,12 +213,21 @@ function disallowedPaths(robots) {
|
|
|
149
213
|
* that is not a page. A site that does not publish one is the ordinary case,
|
|
150
214
|
* not a failure, so it comes back empty either way.
|
|
151
215
|
*/
|
|
152
|
-
async function fetchSiteFile(entry, impl, name) {
|
|
216
|
+
async function fetchSiteFile(entry, impl, name, timeoutMs, maxBodyBytes) {
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
153
219
|
try {
|
|
154
|
-
const response = await impl(new URL(name, entry).href, {
|
|
155
|
-
|
|
220
|
+
const response = await impl(new URL(name, entry).href, {
|
|
221
|
+
redirect: "follow",
|
|
222
|
+
signal: controller.signal
|
|
223
|
+
});
|
|
224
|
+
if (!response.ok) return void 0;
|
|
225
|
+
if (new URL(response.url || new URL(name, entry).href).origin !== entry.origin) return void 0;
|
|
226
|
+
return await readCapped(response, maxBodyBytes);
|
|
156
227
|
} catch {
|
|
157
228
|
return;
|
|
229
|
+
} finally {
|
|
230
|
+
clearTimeout(timer);
|
|
158
231
|
}
|
|
159
232
|
}
|
|
160
233
|
/**
|
|
@@ -169,8 +242,9 @@ async function crawlSite(entry, options = {}) {
|
|
|
169
242
|
const maxPages = options.maxPages ?? 200;
|
|
170
243
|
const maxDepth = options.maxDepth ?? 3;
|
|
171
244
|
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
245
|
+
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
172
246
|
const failures = [];
|
|
173
|
-
const robots = options.ignoreRobots ? void 0 : await fetchSiteFile(entry, impl, "/robots.txt");
|
|
247
|
+
const robots = options.ignoreRobots ? void 0 : await fetchSiteFile(entry, impl, "/robots.txt", timeoutMs, maxBodyBytes);
|
|
174
248
|
const blocked = robots === void 0 ? [] : disallowedPaths(robots);
|
|
175
249
|
const allowed = (url) => !blocked.some((path) => url.pathname.startsWith(path));
|
|
176
250
|
let discovery = "links";
|
|
@@ -184,7 +258,7 @@ async function crawlSite(entry, options = {}) {
|
|
|
184
258
|
depth
|
|
185
259
|
});
|
|
186
260
|
};
|
|
187
|
-
const sitemap = await fetchSiteFile(entry, impl, "/sitemap.xml");
|
|
261
|
+
const sitemap = await fetchSiteFile(entry, impl, options.sitemap ?? "/sitemap.xml", timeoutMs, maxBodyBytes);
|
|
188
262
|
const listed = sitemap === void 0 ? [] : urlsFromSitemap(sitemap, entry);
|
|
189
263
|
if (listed.length > 0) {
|
|
190
264
|
discovery = "sitemap";
|
|
@@ -196,7 +270,7 @@ async function crawlSite(entry, options = {}) {
|
|
|
196
270
|
const batch = queue.splice(0, Math.min(REQUEST_CONCURRENCY, maxPages - pages.length));
|
|
197
271
|
const results = await Promise.all(batch.map(async (item) => ({
|
|
198
272
|
item,
|
|
199
|
-
result: await fetchPage(item.url, impl, timeoutMs, entry.origin)
|
|
273
|
+
result: await fetchPage(item.url, impl, timeoutMs, entry.origin, maxBodyBytes)
|
|
200
274
|
})));
|
|
201
275
|
for (const { item, result } of results) {
|
|
202
276
|
if (!result.ok) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { n as IntegrationOptions, t as BuildAuditError } from "../run-CtcEUhbe.js";
|
|
2
|
+
//#region src/eleventy/index.d.ts
|
|
3
|
+
/** What `eleventy.after` is given. Only two of its fields are read. */
|
|
4
|
+
interface EleventyAfterEvent {
|
|
5
|
+
dir: {
|
|
6
|
+
input: string;
|
|
7
|
+
output: string;
|
|
8
|
+
};
|
|
9
|
+
/** `build`, `watch` or `serve`. Only a one-shot build is audited. */
|
|
10
|
+
runMode?: string;
|
|
11
|
+
/** `fs`, `json`, `ndjson` or `text`. Only `fs` writes files to audit. */
|
|
12
|
+
outputMode?: string;
|
|
13
|
+
}
|
|
14
|
+
/** The part of Eleventy's config object this plugin touches. */
|
|
15
|
+
interface EleventyConfigLike {
|
|
16
|
+
on(event: 'eleventy.after', handler: (event: EleventyAfterEvent) => Promise<void>): void;
|
|
17
|
+
}
|
|
18
|
+
type EaaKitEleventyOptions = IntegrationOptions;
|
|
19
|
+
/**
|
|
20
|
+
* Usable both as a bare plugin and as a configured one.
|
|
21
|
+
*
|
|
22
|
+
* `addPlugin(eaaKit)` and `addPlugin(eaaKit, { failOn: 'moderate' })` are both
|
|
23
|
+
* how Eleventy plugins are written, and a plugin that only supported the second
|
|
24
|
+
* would be the one people got wrong.
|
|
25
|
+
*/
|
|
26
|
+
declare function eaaKit(eleventyConfig: EleventyConfigLike, options?: EaaKitEleventyOptions): void;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { BuildAuditError, EaaKitEleventyOptions, EleventyAfterEvent, EleventyConfigLike, eaaKit as default };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { n as auditBuild, r as stderrLogger, t as BuildAuditError } from "../run-C2nKFcb-.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
//#region src/eleventy/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Usable both as a bare plugin and as a configured one.
|
|
6
|
+
*
|
|
7
|
+
* `addPlugin(eaaKit)` and `addPlugin(eaaKit, { failOn: 'moderate' })` are both
|
|
8
|
+
* how Eleventy plugins are written, and a plugin that only supported the second
|
|
9
|
+
* would be the one people got wrong.
|
|
10
|
+
*/
|
|
11
|
+
function eaaKit(eleventyConfig, options = {}) {
|
|
12
|
+
eleventyConfig.on("eleventy.after", async (event) => {
|
|
13
|
+
if (event.runMode !== void 0 && event.runMode !== "build") return;
|
|
14
|
+
if (event.outputMode !== void 0 && event.outputMode !== "fs") return;
|
|
15
|
+
await auditBuild(path.resolve(event.dir.output), options, stderrLogger());
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { BuildAuditError, eaaKit as default };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/audit/fingerprint.ts
|
|
3
|
+
/**
|
|
4
|
+
* A stable identity for one violating element.
|
|
5
|
+
*
|
|
6
|
+
* Derived from the rule, the selector and the element's own opening tag, and
|
|
7
|
+
* deliberately not from the file it was found in. Two consumers need this and
|
|
8
|
+
* they need it to agree: SARIF, so that moving a page does not close one code
|
|
9
|
+
* scanning alert and open an identical one, and the baseline, so that an
|
|
10
|
+
* accepted violation stays accepted when the surrounding page changes.
|
|
11
|
+
*
|
|
12
|
+
* Sixteen hex characters. This identifies a defect for humans and tooling, not
|
|
13
|
+
* a secret, and a full digest in every entry would make a baseline file for a
|
|
14
|
+
* large site unreadable.
|
|
15
|
+
*/
|
|
16
|
+
function elementFingerprint(ruleId, selector, html) {
|
|
17
|
+
return createHash("sha256").update(`${ruleId}\n${selector}\n${openingTag(html)}`).digest("hex").slice(0, 16);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The element's own tag, without anything nested inside it.
|
|
21
|
+
*
|
|
22
|
+
* axe-core hands back the failing element's outerHTML, which for a leaf like
|
|
23
|
+
* `<img>` is the element and for a container is the element and every
|
|
24
|
+
* descendant it has. Hashing all of that made the identity of a container
|
|
25
|
+
* depend on its contents — and the container that matters here is `<html>`,
|
|
26
|
+
* which every document-level rule fails against: `html-has-lang`,
|
|
27
|
+
* `document-title`, `landmark-one-main`, `page-has-heading-one`.
|
|
28
|
+
*
|
|
29
|
+
* Their outerHTML is the whole page. So adding one paragraph anywhere changed
|
|
30
|
+
* the fingerprint of every document-level violation on that page, and all three
|
|
31
|
+
* consumers believed it:
|
|
32
|
+
*
|
|
33
|
+
* - the baseline stopped suppressing barriers it had accepted, and the build
|
|
34
|
+
* went red on a page whose only change was a typo fix;
|
|
35
|
+
* - `diff` reported the same untouched barrier as both new and **fixed**, which
|
|
36
|
+
* is the one thing it exists to refuse to do;
|
|
37
|
+
* - SARIF churned its partialFingerprints, so code scanning closed an alert and
|
|
38
|
+
* opened an identical one on every edit.
|
|
39
|
+
*
|
|
40
|
+
* The identity of an element is its own tag and attributes. Where two elements
|
|
41
|
+
* share those, axe-core's selector already tells them apart — it qualifies
|
|
42
|
+
* ambiguous matches with `:nth-child(…)` — so nothing that was distinguishable
|
|
43
|
+
* before stops being distinguishable now.
|
|
44
|
+
*
|
|
45
|
+
* Anything that is not an element — the empty string a rule with no attached
|
|
46
|
+
* node carries — is returned unchanged, so those keep the identity they had.
|
|
47
|
+
*/
|
|
48
|
+
function openingTag(html) {
|
|
49
|
+
const trimmed = html.trim();
|
|
50
|
+
if (!trimmed.startsWith("<")) return trimmed;
|
|
51
|
+
let quote;
|
|
52
|
+
for (let i = 1; i < trimmed.length; i += 1) {
|
|
53
|
+
const char = trimmed[i];
|
|
54
|
+
if (quote !== void 0) {
|
|
55
|
+
if (char === quote) quote = void 0;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === "\"" || char === "'") {
|
|
59
|
+
quote = char;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (char === ">") return trimmed.slice(0, i + 1);
|
|
63
|
+
}
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { elementFingerprint as t };
|
|
@@ -156,6 +156,77 @@ const FRAMEWORKS = [
|
|
|
156
156
|
outputs: ["_site"],
|
|
157
157
|
serves: false
|
|
158
158
|
},
|
|
159
|
+
{
|
|
160
|
+
id: "wordpress",
|
|
161
|
+
name: "WordPress",
|
|
162
|
+
packages: [],
|
|
163
|
+
files: [
|
|
164
|
+
"wp-config.php",
|
|
165
|
+
"wp-config-sample.php",
|
|
166
|
+
"wp-load.php"
|
|
167
|
+
],
|
|
168
|
+
outputs: [],
|
|
169
|
+
serves: true,
|
|
170
|
+
serveCommand: "wp-env start, ddev start, or whichever local stack this site uses"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "typo3",
|
|
174
|
+
name: "TYPO3",
|
|
175
|
+
packages: [],
|
|
176
|
+
files: [
|
|
177
|
+
"typo3conf",
|
|
178
|
+
"public/typo3conf",
|
|
179
|
+
"typo3"
|
|
180
|
+
],
|
|
181
|
+
outputs: [],
|
|
182
|
+
serves: true,
|
|
183
|
+
serveCommand: "ddev start, or your usual local stack"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "craft",
|
|
187
|
+
name: "Craft CMS",
|
|
188
|
+
packages: [],
|
|
189
|
+
files: ["craft"],
|
|
190
|
+
outputs: [],
|
|
191
|
+
serves: true,
|
|
192
|
+
serveCommand: "ddev start, or php craft serve"
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "laravel",
|
|
196
|
+
name: "Laravel",
|
|
197
|
+
packages: [],
|
|
198
|
+
files: ["artisan"],
|
|
199
|
+
outputs: [],
|
|
200
|
+
serves: true,
|
|
201
|
+
serveCommand: "php artisan serve"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: "symfony",
|
|
205
|
+
name: "Symfony",
|
|
206
|
+
packages: [],
|
|
207
|
+
files: ["bin/console", "symfony.lock"],
|
|
208
|
+
outputs: [],
|
|
209
|
+
serves: true,
|
|
210
|
+
serveCommand: "symfony serve, or php -S localhost:8000 -t public"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: "rails",
|
|
214
|
+
name: "Ruby on Rails",
|
|
215
|
+
packages: [],
|
|
216
|
+
files: ["bin/rails", "config.ru"],
|
|
217
|
+
outputs: [],
|
|
218
|
+
serves: true,
|
|
219
|
+
serveCommand: "bin/rails server"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "django",
|
|
223
|
+
name: "Django",
|
|
224
|
+
packages: [],
|
|
225
|
+
files: ["manage.py"],
|
|
226
|
+
outputs: [],
|
|
227
|
+
serves: true,
|
|
228
|
+
serveCommand: "python manage.py runserver"
|
|
229
|
+
},
|
|
159
230
|
{
|
|
160
231
|
id: "vite",
|
|
161
232
|
name: "Vite",
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { i as detectFramework, t as FALLBACK_OUTPUTS } from "./frameworks-
|
|
1
|
+
import { i as detectFramework, t as FALLBACK_OUTPUTS } from "./frameworks-B4ClIJgE.js";
|
|
2
2
|
export { FALLBACK_OUTPUTS, detectFramework };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { a as impactRank, i as impactLabel, r as countAtOrAbove } from "./impact-
|
|
2
|
-
import {
|
|
1
|
+
import { a as impactRank, i as impactLabel, r as countAtOrAbove } from "./impact-DZt2oBCP.js";
|
|
2
|
+
import { i as escapeText, n as count, o as standardsReference, r as escapeAttribute, t as collapse } from "./text-CKKpzkYM.js";
|
|
3
3
|
import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
|
|
4
|
-
import { a as
|
|
5
|
-
import {
|
|
4
|
+
import { a as isShared, i as groupIssues, n as blindRules, o as discoveryLabel, r as coverageParts, s as missedParts, t as remediationFor } from "./remediation-CMBIrnpN.js";
|
|
5
|
+
import { r as componentPath } from "./component-C3GL1Mnu.js";
|
|
6
|
+
import { i as understandingUrl, r as manualCheckFor, t as buildCoverage } from "./coverage-B_Y6l-Ra.js";
|
|
7
|
+
import { n as buildSummary } from "./json-DROX33kh.js";
|
|
6
8
|
import axe from "axe-core";
|
|
7
9
|
//#region src/audit/report/html.ts
|
|
8
10
|
/**
|
|
@@ -56,9 +58,11 @@ ${verdict(audits, failing, options)}
|
|
|
56
58
|
${scoreboard(audits)}
|
|
57
59
|
${issues(audits, options)}
|
|
58
60
|
${runDetails(audits, engine, generatedAt, options)}
|
|
61
|
+
${notMeasured(options)}
|
|
59
62
|
${summary(audits, failing, options)}
|
|
60
63
|
${pages(audits)}
|
|
61
64
|
${notEvaluated(audits)}
|
|
65
|
+
${coverageSection(audits)}
|
|
62
66
|
${footer()}
|
|
63
67
|
</main>
|
|
64
68
|
</body>
|
|
@@ -79,6 +83,8 @@ function verdict(audits, failing, options) {
|
|
|
79
83
|
if (failing > 0) return banner("fail", "Violations found", `${count(failing, "violation")} at or above ${escapeText(options.failOn)}.`);
|
|
80
84
|
const below = totalViolations(audits) - failing;
|
|
81
85
|
if (below > 0) return banner("pass", "No violations at the threshold", `${count(below, "violation")} below ${escapeText(options.failOn)}, which do not fail the run.`);
|
|
86
|
+
const completeness = options.completeness;
|
|
87
|
+
if (completeness && !completeness.complete) return banner("partial", "No violations found, but the whole site was not measured", `${escapeText(missedParts(completeness).join(", "))}. This report describes ${count(completeness.audited, "page")}, not the site.`);
|
|
82
88
|
return banner("pass", "No violations found", "Automated testing found nothing to report.");
|
|
83
89
|
}
|
|
84
90
|
function banner(kind, heading, detail) {
|
|
@@ -94,8 +100,36 @@ function runDetails(audits, engine, generatedAt, options) {
|
|
|
94
100
|
["eaa-kit", `${TOOL_VERSION} · axe-core ${axe.version}`]
|
|
95
101
|
];
|
|
96
102
|
if (options.baseUrl) rows.splice(1, 0, ["Base URL", options.baseUrl]);
|
|
103
|
+
if (options.completeness) rows.splice(2, 0, ["Pages found via", discoveryLabel(options.completeness.discovery)]);
|
|
97
104
|
return `<h2>The run</h2>\n<dl class="run">\n${rows.map(([term, value]) => ` <div><dt>${escapeText(term)}</dt><dd>${escapeText(value)}</dd></div>`).join("\n")}\n</dl>`;
|
|
98
105
|
}
|
|
106
|
+
/** Pages named individually before the rest are counted. */
|
|
107
|
+
const MAX_UNREACHABLE = 20;
|
|
108
|
+
/**
|
|
109
|
+
* The pages this run never reached, named rather than counted away.
|
|
110
|
+
*
|
|
111
|
+
* Named, because a count is not actionable: forty URLs that failed are a broken
|
|
112
|
+
* link check, a server that fell over halfway, or a section behind a login, and
|
|
113
|
+
* which of the three it was is only visible from the addresses. The reasons come
|
|
114
|
+
* straight from the crawler, so a reader can tell a 404 from a timeout.
|
|
115
|
+
*/
|
|
116
|
+
function notMeasured(options) {
|
|
117
|
+
const completeness = options.completeness;
|
|
118
|
+
if (completeness === void 0 || completeness.complete) return "";
|
|
119
|
+
const parts = ["<h2>What this run did not measure</h2>", `<p>This report covers ${count(completeness.audited, "page")}. It is not a description of
|
|
120
|
+
the whole site, and a clean result here does not extend to anything listed below.</p>`];
|
|
121
|
+
if (completeness.truncated) parts.push(`<p class="note">The run stopped at its page limit, so pages beyond ${count(completeness.collected, "page")}
|
|
122
|
+
were never fetched. Raise <code>--max-pages</code> to go further.</p>`);
|
|
123
|
+
if (completeness.errored > 0) parts.push(`<p class="note">${count(completeness.errored, "page")} could not be audited after being
|
|
124
|
+
fetched, and ${completeness.errored === 1 ? "is" : "are"} not counted anywhere in this report.</p>`);
|
|
125
|
+
if (completeness.unreachable.length > 0) {
|
|
126
|
+
const shown = completeness.unreachable.slice(0, MAX_UNREACHABLE).map((item) => ` <li><code>${escapeText(item.location)}</code> — ${escapeText(item.reason)}</li>`).join("\n");
|
|
127
|
+
const rest = completeness.unreachable.length - MAX_UNREACHABLE;
|
|
128
|
+
parts.push(`<p>${count(completeness.unreachable.length, "page")} could not be reached:</p>`, `<ul class="unreachable">\n${shown}\n</ul>`);
|
|
129
|
+
if (rest > 0) parts.push(`<p class="note">…and ${count(rest, "more page")}.</p>`);
|
|
130
|
+
}
|
|
131
|
+
return parts.join("\n");
|
|
132
|
+
}
|
|
99
133
|
/**
|
|
100
134
|
* The counts, taken from the JSON report's own tally rather than recomputed.
|
|
101
135
|
*
|
|
@@ -193,6 +227,60 @@ ${manualBlock(ruleId, finding)}
|
|
|
193
227
|
</li>`).join("\n")}
|
|
194
228
|
</ul>`;
|
|
195
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* How much of the standard this run could reach.
|
|
232
|
+
*
|
|
233
|
+
* Always included, and placed last, where a reader who has been through the
|
|
234
|
+
* findings arrives at the limits of what produced them. The counts are shown as
|
|
235
|
+
* four separate figures and never as a ratio: `no automated rule` is the
|
|
236
|
+
* majority of WCAG, and a percentage would present a limit of automated testing
|
|
237
|
+
* as a property of this site.
|
|
238
|
+
*/
|
|
239
|
+
function coverageSection(audits) {
|
|
240
|
+
const coverage = buildCoverage(audits);
|
|
241
|
+
const rows = coverage.criteria.map((criterion) => {
|
|
242
|
+
const url = understandingUrl(criterion.number);
|
|
243
|
+
const name = escapeText(`${criterion.number} ${criterion.title}`);
|
|
244
|
+
const linked = url === void 0 ? name : `<a href="${escapeAttribute(url)}">${name}</a>`;
|
|
245
|
+
return ` <tr class="${escapeAttribute(criterion.status)}">
|
|
246
|
+
<td>${linked}</td>
|
|
247
|
+
<td>${escapeText(criterion.level)}</td>
|
|
248
|
+
<td>${statusText(criterion)}</td>
|
|
249
|
+
</tr>`;
|
|
250
|
+
}).join("\n");
|
|
251
|
+
return `<h2>Coverage of WCAG 2.2 AA</h2>
|
|
252
|
+
<p>Of the ${coverage.total} success criteria at Levels A and AA,
|
|
253
|
+
<strong>${coverage.noAutomatedRule}</strong> cannot be checked by any automated engine and
|
|
254
|
+
need a person. This run reached a verdict on <strong>${coverage.evaluated}</strong>.</p>
|
|
255
|
+
<ul class="counts">
|
|
256
|
+
<li><strong>${coverage.evaluated}</strong> evaluated here</li>
|
|
257
|
+
<li><strong>${coverage.notEvaluated}</strong> not evaluated by this engine${coverage.browserWouldAnswer > 0 ? ` — a run with a real browser would answer ${coverage.browserWouldAnswer} of them` : ""}</li>
|
|
258
|
+
<li><strong>${coverage.nothingToCheck}</strong> had rules that ran and found nothing on this site to check</li>
|
|
259
|
+
<li><strong>${coverage.noAutomatedRule}</strong> have no automated rule at all</li>
|
|
260
|
+
</ul>
|
|
261
|
+
<p class="note">
|
|
262
|
+
These four are counted separately and never added together or divided into a score. Most
|
|
263
|
+
of WCAG cannot be automated, and a percentage here would present that limit of automated
|
|
264
|
+
testing as though it were a measurement of this site.
|
|
265
|
+
</p>
|
|
266
|
+
<div class="scroll">
|
|
267
|
+
<table class="coverage">
|
|
268
|
+
<thead><tr><th>Success criterion</th><th>Level</th><th>This run</th></tr></thead>
|
|
269
|
+
<tbody>
|
|
270
|
+
${rows}
|
|
271
|
+
</tbody>
|
|
272
|
+
</table>
|
|
273
|
+
</div>`;
|
|
274
|
+
}
|
|
275
|
+
function statusText(criterion) {
|
|
276
|
+
const browser = criterion.browserWouldAnswer ? " <em>(a real browser would answer this)</em>" : "";
|
|
277
|
+
switch (criterion.status) {
|
|
278
|
+
case "evaluated": return `Evaluated${browser}`;
|
|
279
|
+
case "not-evaluated": return `This engine could not evaluate it${browser}`;
|
|
280
|
+
case "nothing-to-check": return "Rules ran and found nothing on this site to check";
|
|
281
|
+
case "no-automated-rule": return "No automated rule exists; a person must check it";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
196
284
|
function footer() {
|
|
197
285
|
return `<hr>
|
|
198
286
|
<footer>
|
|
@@ -242,6 +330,7 @@ li + li { margin-top: 0.5rem; }
|
|
|
242
330
|
.verdict.pass { background: #eef7ee; border-color: #216e39; }
|
|
243
331
|
.verdict.fail { background: #fdeeee; border-color: #a01b1b; }
|
|
244
332
|
.verdict.broken { background: #fdf4e3; border-color: #8a5a00; }
|
|
333
|
+
.verdict.partial { background: #fdf4e3; border-color: #8a5a00; }
|
|
245
334
|
.badge { display: inline-block; padding: 0 0.45rem; border-radius: 3px; font-size: 0.8rem;
|
|
246
335
|
font-weight: 600; border: 1px solid; }
|
|
247
336
|
.badge.critical { background: #fdeeee; border-color: #a01b1b; color: #7a1414; }
|
|
@@ -261,6 +350,17 @@ p.rule { margin-bottom: 0.25rem; }
|
|
|
261
350
|
p.standards, p.coverage, .reason, li.more, p.accepted-heading, ul.accepted { color: #4a4a4a; font-size: 0.9rem; }
|
|
262
351
|
p.coverage { margin-top: 0.5rem; }
|
|
263
352
|
ul.nodes { list-style: none; padding-left: 0; }
|
|
353
|
+
ul.unreachable { padding-left: 1.25rem; }
|
|
354
|
+
.remediation { border-left: 3px solid #c9c9c9; padding: 0.1rem 0 0.1rem 0.75rem; margin: 0.5rem 0; }
|
|
355
|
+
.remediation .why { margin: 0.3rem 0; opacity: 0.85; }
|
|
356
|
+
.remediation .fix { margin: 0.3rem 0; }
|
|
357
|
+
pre.suggested { background: #eef7ee; border-left: 3px solid #216e39; padding: 0.5rem; overflow-x: auto; }
|
|
358
|
+
.scroll { overflow-x: auto; }
|
|
359
|
+
table.coverage { border-collapse: collapse; width: 100%; font-size: 0.95em; }
|
|
360
|
+
table.coverage th, table.coverage td { text-align: left; padding: 0.3rem 0.6rem; border-bottom: 1px solid #e3e3e3; vertical-align: top; }
|
|
361
|
+
table.coverage tr.evaluated td:last-child { color: #216e39; }
|
|
362
|
+
table.coverage em { font-style: normal; opacity: 0.75; }
|
|
363
|
+
ul.unreachable li { margin-bottom: 0.2rem; overflow-wrap: anywhere; }
|
|
264
364
|
code.selector { color: #4a4a4a; }
|
|
265
365
|
p.clean { color: #216e39; }
|
|
266
366
|
p.note { font-size: 0.95rem; }
|
|
@@ -306,6 +406,11 @@ ul.page-list { margin: 0.35rem 0 0; padding-left: 1.25rem; }
|
|
|
306
406
|
.verdict.pass { background: #10240f; border-color: #7ee2a8; }
|
|
307
407
|
.verdict.fail { background: #2b1111; border-color: #ff9d9d; }
|
|
308
408
|
.verdict.broken { background: #2b2310; border-color: #ffd28a; }
|
|
409
|
+
.verdict.partial { background: #2b2310; border-color: #ffd28a; }
|
|
410
|
+
table.coverage th, table.coverage td { border-bottom-color: #333; }
|
|
411
|
+
table.coverage tr.evaluated td:last-child { color: #7ee2a8; }
|
|
412
|
+
.remediation { border-left-color: #555; }
|
|
413
|
+
pre.suggested { background: #10240f; border-left-color: #7ee2a8; }
|
|
309
414
|
.tile.critical { background: #2b1111; border-color: #ff9d9d; color: #ffc9c9; }
|
|
310
415
|
.tile.serious { background: #2b1d11; border-color: #ffb98a; color: #ffd7bd; }
|
|
311
416
|
.tile.moderate { background: #2b2610; border-color: #f0d264; color: #f5e3a4; }
|
|
@@ -377,17 +482,37 @@ function issueSection(issue, options) {
|
|
|
377
482
|
return `<li class="issue">
|
|
378
483
|
<h3><span class="badge ${impact}">${impact}</span> <code>${escapeText(issue.ruleId)}</code> ${criteria}</h3>
|
|
379
484
|
<p class="help">${escapeText(issue.help)}</p>
|
|
485
|
+
${remediationBlock(issue.ruleId, issue.elements[0]?.html, options)}
|
|
380
486
|
${issue.elements.slice(0, MAX_NODES).map((element) => issueElement(element, options)).join("\n")}
|
|
381
487
|
${issue.elements.length > MAX_NODES ? `<p class="more">…and ${count(issue.elements.length - MAX_NODES, "more element")}</p>` : ""}
|
|
382
488
|
</li>`;
|
|
383
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* What to do about the rule, beside the finding rather than behind a link.
|
|
492
|
+
*
|
|
493
|
+
* The corrected line is built from the element that actually failed. A textbook
|
|
494
|
+
* snippet is a second thing to translate before anybody can use it, and this
|
|
495
|
+
* document is read by people who did not run the audit and may not write the
|
|
496
|
+
* code either — the closer it gets to the line as it should have been, the less
|
|
497
|
+
* of that translation is left to them.
|
|
498
|
+
*/
|
|
499
|
+
function remediationBlock(ruleId, html, options) {
|
|
500
|
+
const remediation = remediationFor(ruleId, options.framework);
|
|
501
|
+
if (remediation === void 0) return "";
|
|
502
|
+
const example = html === void 0 ? void 0 : remediation.example?.(html);
|
|
503
|
+
return `<div class="remediation">
|
|
504
|
+
<p class="why">${escapeText(remediation.why)}</p>
|
|
505
|
+
<p class="fix"><strong>Fix.</strong> ${escapeText(remediation.fix)}</p>
|
|
506
|
+
${example === void 0 ? "" : `<pre class="suggested"><code>${escapeText(collapse(example, MAX_SNIPPET))}</code></pre>`}
|
|
507
|
+
</div>`;
|
|
508
|
+
}
|
|
384
509
|
function issueElement(element, options) {
|
|
385
510
|
const sources = [...new Set(element.pages.map((page) => options.sourceFor?.(page)).filter((source) => source !== void 0))];
|
|
386
511
|
const list = element.pages.map((page) => `<li>${escapeText(page)}</li>`).join("");
|
|
387
512
|
const component = options.componentFor?.(element.html);
|
|
388
513
|
return `<div class="element">
|
|
389
514
|
<pre><code>${escapeText(collapse(element.html, MAX_SNIPPET))}</code></pre>
|
|
390
|
-
${component === void 0 ? "" : `<p class="written-in">Written in <code>${escapeText(component)}</code></p>`}
|
|
515
|
+
${component === void 0 ? "" : `<p class="written-in">Written in <code>${escapeText(componentPath(component))}</code></p>`}
|
|
391
516
|
${element.selector === "" ? "" : `<p class="selector"><code>${escapeText(element.selector)}</code></p>`}
|
|
392
517
|
<p class="reach">Found on ${count(element.pages.length, "page")}.${isShared(element) ? " <strong>Identical on each — likely one shared component.</strong>" : ""}</p>
|
|
393
518
|
${sources.length > 0 ? `<p class="sources">Source: ${sources.map((source) => `<code>${escapeText(source)}</code>`).join(", ")}</p>` : ""}
|
|
@@ -43,4 +43,4 @@ function countAtOrAbove(audits, threshold) {
|
|
|
43
43
|
return total;
|
|
44
44
|
}
|
|
45
45
|
//#endregion
|
|
46
|
-
export { impactRank as a, impactLabel as i, IMPACT_LEVELS as n, isImpactLevel as o, countAtOrAbove as r, DEFAULT_FAIL_ON as t };
|
|
46
|
+
export { impactRank as a, impactLabel as i, IMPACT_LEVELS as n, isImpactLevel as o, countAtOrAbove as r, meetsThreshold as s, DEFAULT_FAIL_ON as t };
|