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.
Files changed (66) hide show
  1. package/README.md +82 -14
  2. package/dist/astro/index.d.ts +1 -1
  3. package/dist/astro/index.js +1 -1
  4. package/dist/audit/runners/worker.js +5 -1
  5. package/dist/{audit-DcL73mOC.js → audit-B282A3EA.js} +207 -41
  6. package/dist/audit-DyHPmGeD.js +2 -0
  7. package/dist/{baseline-CuKFq4IF.js → baseline-0u4df12v.js} +1 -1
  8. package/dist/{baseline-s9F3fXTN.js → baseline-CuAS2lMk.js} +6 -5
  9. package/dist/cli/index.js +306 -18
  10. package/dist/{collect-BkAQ0viT.js → collect-Cu1q9NTB.js} +53 -11
  11. package/dist/command-C3D7JWn6.js +160 -0
  12. package/dist/{component-7kEBjv_y.js → component-C3GL1Mnu.js} +41 -6
  13. package/dist/component-DKd3EHOg.js +2 -0
  14. package/dist/coverage-B_Y6l-Ra.js +522 -0
  15. package/dist/{crawl-Oxt2Gaqo.js → crawl-BUWPgaGW.js} +83 -9
  16. package/dist/eleventy/index.d.ts +28 -0
  17. package/dist/eleventy/index.js +19 -0
  18. package/dist/fingerprint-BjYV_0F7.js +67 -0
  19. package/dist/{frameworks-BYa3tULg.js → frameworks-B4ClIJgE.js} +71 -0
  20. package/dist/{frameworks-DaDqrJOw.js → frameworks-etFg_O8K.js} +1 -1
  21. package/dist/{html-DKiI_3gs.js → html-C4vktg0A.js} +130 -5
  22. package/dist/{impact-YdoOtFqm.js → impact-DZt2oBCP.js} +1 -1
  23. package/dist/index.d.ts +119 -4
  24. package/dist/index.js +2 -2
  25. package/dist/{init-DiLiYvN1.js → init-CW7LfGT5.js} +10 -4
  26. package/dist/jsdom-22Bkt65v.js +3 -0
  27. package/dist/jsdom-B--cEH-G.js +89 -0
  28. package/dist/{json-Cnv9nd6U.js → json-DROX33kh.js} +9 -4
  29. package/dist/{json-B0Y7rNjt.js → json-D_Mnnft5.js} +1 -1
  30. package/dist/load-CFq2VQtT.js +2 -0
  31. package/dist/{load-UYXLqGV9.js → load-yAR4wzez.js} +133 -8
  32. package/dist/nuxt/index.d.ts +35 -0
  33. package/dist/nuxt/index.js +23 -0
  34. package/dist/{playwright-DYFsGUNd.js → playwright-BojtYVUa.js} +30 -6
  35. package/dist/{pool-BWkWZiJW.js → pool-BO25OIez.js} +52 -2
  36. package/dist/{project-DW08TseF.js → project-CzOnkLH6.js} +8 -2
  37. package/dist/project-MFrXcw1M.js +2 -0
  38. package/dist/remediation-CMBIrnpN.js +321 -0
  39. package/dist/{render-DI_aCnAZ.js → render-DbGOVmhx.js} +48 -10
  40. package/dist/{result-DLxd2Eip.js → result-BWcYXeRs.js} +114 -3
  41. package/dist/routes-CmdRUuOs.js +265 -0
  42. package/dist/{run-BW6CVuND.js → run-C2nKFcb-.js} +19 -2
  43. package/dist/{run-BMASMmwO.d.ts → run-CtcEUhbe.d.ts} +7 -0
  44. package/dist/{sarif-DB3WG7T9.js → sarif-B-UBcVu8.js} +16 -8
  45. package/dist/{schema-CMZ8ItGk.js → schema-DJSF4K05.js} +18 -1
  46. package/dist/statement/templates/es.en.md +125 -0
  47. package/dist/statement/templates/es.es.md +127 -0
  48. package/dist/statement/templates/fr.en.md +128 -0
  49. package/dist/statement/templates/fr.fr.md +131 -0
  50. package/dist/statement/templates/it.en.md +127 -0
  51. package/dist/statement/templates/it.it.md +130 -0
  52. package/dist/statement/templates/nl.en.md +125 -0
  53. package/dist/statement/templates/nl.nl.md +127 -0
  54. package/dist/{text-BFmNtMsV.js → text-CKKpzkYM.js} +1 -1
  55. package/dist/vite/index.d.ts +1 -1
  56. package/dist/vite/index.js +1 -1
  57. package/dist/webpack/index.d.ts +33 -0
  58. package/dist/webpack/index.js +20 -0
  59. package/package.json +35 -9
  60. package/dist/audit-CuG2hYyo.js +0 -2
  61. package/dist/command-Dxpa00Ha.js +0 -77
  62. package/dist/fingerprint-DRoneAjj.js +0 -20
  63. package/dist/jsdom-BjpF-2V-.js +0 -158
  64. package/dist/jsdom-X4KYfTp8.js +0 -3
  65. package/dist/manual-Vz-oX1I_.js +0 -239
  66. package/dist/routes-C2Cgf6Ko.js +0 -119
@@ -1,6 +1,20 @@
1
- import { i as stripBom } from "./collect-BkAQ0viT.js";
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: await response.text()
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, { redirect: "follow" });
155
- return response.ok ? await response.text() : void 0;
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-BYa3tULg.js";
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-YdoOtFqm.js";
2
- import { a as standardsReference, i as escapeText, n as count, r as escapeAttribute, t as collapse } from "./text-BFmNtMsV.js";
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 groupIssues, i as coverageParts, n as understandingUrl, o as isShared, r as blindRules, t as manualCheckFor } from "./manual-Vz-oX1I_.js";
5
- import { n as buildSummary } from "./json-Cnv9nd6U.js";
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 };