eaa-kit 0.2.0 → 0.3.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 (43) hide show
  1. package/README.md +2 -1
  2. package/dist/astro/index.d.ts +7 -34
  3. package/dist/astro/index.js +6 -26
  4. package/dist/audit/runners/worker.js +1 -1
  5. package/dist/audit-CuG2hYyo.js +2 -0
  6. package/dist/audit-DcL73mOC.js +635 -0
  7. package/dist/{baseline-CV_3lbER.js → baseline-CuKFq4IF.js} +1 -1
  8. package/dist/{baseline-CgBmzFTr.js → baseline-s9F3fXTN.js} +16 -26
  9. package/dist/cli/index.js +81 -143
  10. package/dist/collect-BkAQ0viT.js +123 -0
  11. package/dist/command-Dxpa00Ha.js +77 -0
  12. package/dist/component-7kEBjv_y.js +111 -0
  13. package/dist/{crawl-CtJbMNNb.js → crawl-Oxt2Gaqo.js} +13 -23
  14. package/dist/frameworks-BYa3tULg.js +243 -0
  15. package/dist/frameworks-DaDqrJOw.js +2 -0
  16. package/dist/fs-BmPtmFke.js +40 -0
  17. package/dist/{html-BLEuzep6.js → html-DKiI_3gs.js} +167 -86
  18. package/dist/{impact-DvgBjupx.js → impact-YdoOtFqm.js} +15 -1
  19. package/dist/index.js +2 -2
  20. package/dist/{init-DRKIdpK1.js → init-DiLiYvN1.js} +13 -10
  21. package/dist/{jsdom-BEu6Ra_2.js → jsdom-BjpF-2V-.js} +2 -7
  22. package/dist/jsdom-X4KYfTp8.js +3 -0
  23. package/dist/json-B0Y7rNjt.js +2 -0
  24. package/dist/{json-C9xS1PNC.js → json-Cnv9nd6U.js} +13 -21
  25. package/dist/{load-sCkKsvGQ.js → load-UYXLqGV9.js} +2 -8
  26. package/dist/manual-Vz-oX1I_.js +239 -0
  27. package/dist/{playwright-DWux49V3.js → playwright-DYFsGUNd.js} +79 -21
  28. package/dist/{pool-DixLeu8L.js → pool-BWkWZiJW.js} +4 -12
  29. package/dist/{project-CiyzKQud.js → project-DW08TseF.js} +37 -27
  30. package/dist/{render-BO0nVrrZ.js → render-DI_aCnAZ.js} +7 -15
  31. package/dist/{result-2aZPfM8w.js → result-DLxd2Eip.js} +38 -1
  32. package/dist/{routes-BxbSZKXC.js → routes-C2Cgf6Ko.js} +4 -8
  33. package/dist/run-BMASMmwO.d.ts +45 -0
  34. package/dist/run-BW6CVuND.js +36 -0
  35. package/dist/{sarif-eSCuI0eX.js → sarif-DB3WG7T9.js} +11 -33
  36. package/dist/text-BFmNtMsV.js +43 -0
  37. package/dist/vite/index.d.ts +39 -0
  38. package/dist/vite/index.js +40 -0
  39. package/package.json +8 -3
  40. package/dist/audit-CqrR9pIO.js +0 -2
  41. package/dist/audit-D4gju2BT.js +0 -949
  42. package/dist/escape-Dm1o_RAk.js +0 -21
  43. package/dist/jsdom-C6dIyaxN.js +0 -3
@@ -1,6 +1,8 @@
1
- import { i as isImpactLevel, r as countAtOrAbove } from "./impact-DvgBjupx.js";
2
- import { n as escapeText, t as escapeAttribute } from "./escape-Dm1o_RAk.js";
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";
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
6
  import axe from "axe-core";
5
7
  //#region src/audit/report/html.ts
6
8
  /**
@@ -19,6 +21,14 @@ import axe from "axe-core";
19
21
  * found, which is not the same as a site being accessible, and the document
20
22
  * says so in its own footer rather than leaving the reader to infer it.
21
23
  */
24
+ /** Severity levels, worst first, as both the scoreboard and the summary list them. */
25
+ const IMPACT_ORDER = [
26
+ "critical",
27
+ "serious",
28
+ "moderate",
29
+ "minor",
30
+ "unclassified"
31
+ ];
22
32
  /** Elements listed per rule before the rest are summarised. */
23
33
  const MAX_NODES = 5;
24
34
  /** Longest element markup shown before it is truncated. */
@@ -43,6 +53,8 @@ ${STYLES}
43
53
  <main>
44
54
  <h1>Accessibility audit</h1>
45
55
  ${verdict(audits, failing, options)}
56
+ ${scoreboard(audits)}
57
+ ${issues(audits, options)}
46
58
  ${runDetails(audits, engine, generatedAt, options)}
47
59
  ${summary(audits, failing, options)}
48
60
  ${pages(audits)}
@@ -84,45 +96,27 @@ function runDetails(audits, engine, generatedAt, options) {
84
96
  if (options.baseUrl) rows.splice(1, 0, ["Base URL", options.baseUrl]);
85
97
  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>`;
86
98
  }
99
+ /**
100
+ * The counts, taken from the JSON report's own tally rather than recomputed.
101
+ *
102
+ * Two reports of one run disagreeing about how many violations there were is
103
+ * the kind of bug nobody notices until a client does, so there is one place
104
+ * that counts.
105
+ */
87
106
  function summary(audits, failing, options) {
88
- const byImpact = /* @__PURE__ */ new Map();
89
- let needsReview = 0;
90
- let blind = 0;
91
- let passes = 0;
92
- let inapplicable = 0;
93
- let elements = 0;
94
- let accepted = 0;
95
- for (const audit of audits) {
96
- for (const finding of audit.accepted ?? []) accepted += finding.nodes.length;
97
- for (const finding of audit.violations) {
98
- const impact = finding.impact && isImpactLevel(finding.impact) ? finding.impact : "unclassified";
99
- byImpact.set(impact, (byImpact.get(impact) ?? 0) + 1);
100
- elements += finding.nodes.length;
101
- }
102
- for (const finding of audit.incomplete) if (finding.reason === "engine-limitation") blind += 1;
103
- else needsReview += 1;
104
- passes += audit.passes.length;
105
- inapplicable += audit.inapplicable.length;
106
- }
107
- const impacts = [
108
- "critical",
109
- "serious",
110
- "moderate",
111
- "minor",
112
- "unclassified"
113
- ].filter((impact) => (byImpact.get(impact) ?? 0) > 0).map((impact) => ` <li><span class="badge ${escapeAttribute(impact)}">${escapeText(impact)}</span> ${byImpact.get(impact)}</li>`).join("\n");
114
- const withViolations = audits.filter((audit) => audit.violations.length > 0).length;
107
+ const totals = buildSummary(audits, options.failOn);
108
+ const impacts = IMPACT_ORDER.filter((impact) => totals.byImpact[impact] > 0).map((impact) => ` <li><span class="badge ${escapeAttribute(impact)}">${escapeText(impact)}</span> ${totals.byImpact[impact]}</li>`).join("\n");
115
109
  return `<h2>Summary</h2>
116
110
  <ul class="counts">
117
- <li><strong>${count(totalViolations(audits), "violation")}</strong> on ${withViolations} of ${count(audits.length, "page")}, across ${count(elements, "element")}</li>
111
+ <li><strong>${count(totals.violations, "violation")}</strong> on ${totals.pagesWithViolations} of ${count(totals.pages, "page")}, across ${count(totals.violatingElements, "element")}</li>
118
112
  <li><strong>${failing}</strong> at or above ${escapeText(options.failOn)}</li>
119
- <li><strong>${needsReview}</strong> ${needsReview === 1 ? "rule needs" : "rules need"} manual review</li>
120
- <li><strong>${blind}</strong> ${blind === 1 ? "rule was" : "rules were"} not evaluated by this engine</li>
121
- ${accepted > 0 ? ` <li><strong>${accepted}</strong> ${accepted === 1 ? "element is" : "elements are"} accepted by the baseline, and not counted above</li>` : ""}
113
+ <li><strong>${totals.needsReview}</strong> ${totals.needsReview === 1 ? "rule needs" : "rules need"} manual review</li>
114
+ <li><strong>${totals.notEvaluated}</strong> ${totals.notEvaluated === 1 ? "rule was" : "rules were"} not evaluated by this engine</li>
115
+ ${totals.accepted > 0 ? ` <li><strong>${totals.accepted}</strong> ${totals.accepted === 1 ? "element is" : "elements are"} accepted by the baseline, and not counted above</li>` : ""}
122
116
  </ul>
123
117
  ${impacts ? `<ul class="impacts">\n${impacts}\n</ul>` : ""}
124
118
  <p class="note">
125
- <strong>${passes}</strong> rule results were checked and met, and <strong>${inapplicable}</strong>
119
+ <strong>${totals.passes}</strong> rule results were checked and met, and <strong>${totals.inapplicable}</strong>
126
120
  found nothing on the page to check. Those two are counted separately and never added
127
121
  together: a rule with nothing to check is not a rule that passed, and a page with no
128
122
  images proves nothing about image alternatives.
@@ -155,12 +149,12 @@ function pageSection(audit) {
155
149
  return parts.join("\n");
156
150
  }
157
151
  function violation(finding) {
158
- const impact = finding.impact && isImpactLevel(finding.impact) ? finding.impact : "unclassified";
152
+ const impact = impactLabel(finding.impact);
159
153
  const shown = finding.nodes.slice(0, MAX_NODES);
160
154
  const remaining = finding.nodes.length - shown.length;
161
155
  const nodes = shown.map((node) => ` <li>
162
156
  <code class="selector">${escapeText(node.target.join(" "))}</code>
163
- <pre><code>${escapeText(snippet(node.html))}</code></pre>
157
+ <pre><code>${escapeText(collapse(node.html, MAX_SNIPPET))}</code></pre>
164
158
  </li>`).join("\n");
165
159
  const more = remaining > 0 ? `\n <li class="more">and ${count(remaining, "more element")}</li>` : "";
166
160
  return ` <li class="finding">
@@ -175,19 +169,8 @@ ${nodes}${more}
175
169
  </ul>
176
170
  </li>`;
177
171
  }
178
- /**
179
- * What this page's result rests on.
180
- *
181
- * The four counts stay apart for the same reason they do everywhere else: only
182
- * `passed` is evidence that anything was met here.
183
- */
184
172
  function coverage(audit) {
185
- const blind = audit.incomplete.filter((finding) => finding.reason === "engine-limitation").length;
186
- const review = audit.incomplete.length - blind;
187
- const parts = [`${audit.passes.length} passed`, `${audit.inapplicable.length} not applicable`];
188
- if (review > 0) parts.push(`${review} to review`);
189
- if (blind > 0) parts.push(`${blind} not evaluated`);
190
- return `<p class="coverage">${escapeText(parts.join(" · "))}</p>`;
173
+ return `<p class="coverage">${escapeText(coverageParts(audit).join(" · "))}</p>`;
191
174
  }
192
175
  /**
193
176
  * Rules this engine reached no verdict on, listed once at the end.
@@ -196,24 +179,17 @@ function coverage(audit) {
196
179
  * the reader take the rest of the document for full coverage.
197
180
  */
198
181
  function notEvaluated(audits) {
199
- const byRule = /* @__PURE__ */ new Map();
200
- for (const audit of audits) for (const finding of audit.incomplete) {
201
- if (finding.reason !== "engine-limitation") continue;
202
- const existing = byRule.get(finding.ruleId);
203
- if (existing) existing.pages += 1;
204
- else byRule.set(finding.ruleId, {
205
- pages: 1,
206
- detail: finding.reasonDetail,
207
- finding
208
- });
209
- }
210
- if (byRule.size === 0) return "";
182
+ const blind = blindRules(audits);
183
+ if (blind.length === 0) return "";
211
184
  return `<h2>Not evaluated</h2>
212
- <p>This engine reached no verdict on these rules. They are never reported as passing.</p>
185
+ <p>This engine reached no verdict on these rules. They are never reported as passing.
186
+ Each one below says what to check by hand, because automated testing finds a minority of
187
+ accessibility barriers and this is where the rest of them are.</p>
213
188
  <ul class="not-evaluated">
214
- ${[...byRule.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ruleId, entry]) => ` <li>
215
- <code>${escapeText(ruleId)}</code> on ${count(entry.pages, "page")}${standards(entry.finding)}
216
- <span class="reason">${escapeText(entry.detail)}</span>
189
+ ${blind.map(({ ruleId, pages, finding }) => ` <li>
190
+ <code>${escapeText(ruleId)}</code> on ${count(pages, "page")}${standards(finding)}
191
+ <span class="reason">${escapeText(finding.reasonDetail)}</span>
192
+ ${manualBlock(ruleId, finding)}
217
193
  </li>`).join("\n")}
218
194
  </ul>`;
219
195
  }
@@ -237,33 +213,13 @@ function standards(finding) {
237
213
  return text ? ` — ${text}` : "";
238
214
  }
239
215
  function standardsText(finding) {
240
- const parts = [...finding.successCriteria.map((criterion) => `WCAG ${criterion}`), ...finding.enClauses.map((clause) => `EN 301 549 ${clause}`)];
241
- return escapeText(parts.join(", "));
242
- }
243
- /** Element markup, on one line and bounded, so one minified page cannot fill the report. */
244
- function snippet(html) {
245
- const collapsed = html.replace(/\s+/g, " ").trim();
246
- return collapsed.length > MAX_SNIPPET ? `${collapsed.slice(0, 199)}…` : collapsed;
216
+ return escapeText(standardsReference(finding.successCriteria, finding.enClauses));
247
217
  }
248
218
  function totalViolations(audits) {
249
219
  return audits.reduce((total, audit) => total + audit.violations.length, 0);
250
220
  }
251
221
  function byImpactThenRule(a, b) {
252
- const order = [
253
- "critical",
254
- "serious",
255
- "moderate",
256
- "minor"
257
- ];
258
- const rank = (finding) => {
259
- const index = finding.impact ? order.indexOf(finding.impact) : -1;
260
- return index === -1 ? -1 : index;
261
- };
262
- const difference = rank(a) - rank(b);
263
- return difference === 0 ? a.ruleId.localeCompare(b.ruleId) : difference;
264
- }
265
- function count(value, noun) {
266
- return `${value} ${noun}${value === 1 ? "" : "s"}`;
222
+ return impactRank(a.impact) - impactRank(b.impact) || a.ruleId.localeCompare(b.ruleId);
267
223
  }
268
224
  const STYLES = `:root { color-scheme: light dark; }
269
225
  body { margin: 0; background: #ffffff; color: #1a1a1a;
@@ -310,6 +266,34 @@ p.clean { color: #216e39; }
310
266
  p.note { font-size: 0.95rem; }
311
267
  hr { border: 0; border-top: 1px solid #d4d4d4; margin: 3rem 0 1.5rem; }
312
268
  footer { color: #4a4a4a; font-size: 0.9rem; }
269
+ .scoreboard { list-style: none; display: flex; flex-wrap: wrap; gap: 0.75rem;
270
+ padding: 0; margin: 1.25rem 0 2rem; }
271
+ .tile { flex: 1 1 8rem; padding: 0.75rem 1rem; border: 1px solid; border-radius: 6px; }
272
+ .tile .figure { display: block; font-size: 1.75rem; font-weight: 700; line-height: 1.1; }
273
+ .tile .label { font-size: 0.85rem; }
274
+ .tile.critical { background: #fdeeee; border-color: #a01b1b; color: #7a1414; }
275
+ .tile.serious { background: #fdf1e8; border-color: #a4531b; color: #7d3f14; }
276
+ .tile.moderate { background: #fdf9e3; border-color: #7a6100; color: #5c4900; }
277
+ .tile.minor { background: #eef2f8; border-color: #40556f; color: #33455a; }
278
+ .tile.unclassified { background: #f1f1f1; border-color: #565656; color: #444444; }
279
+ .tile.none { background: #eef7ee; border-color: #216e39; color: #17512a; }
280
+ .tile.unchecked { background: #f4f4f5; border-color: #565656; color: #444444; }
281
+ p.intro { color: #4a4a4a; }
282
+ p.manual { margin: 0.35rem 0; }
283
+ p.manual.browser, p.criteria { margin: 0.35rem 0; font-size: 0.9rem; color: #4a4a4a; }
284
+ ol.issues { list-style: none; padding: 0; }
285
+ li.issue { border: 1px solid #d7d7d7; border-radius: 6px; padding: 0.75rem 1rem;
286
+ margin: 0 0 1rem; }
287
+ li.issue h3 { margin: 0 0 0.25rem; font-size: 1rem; display: flex; flex-wrap: wrap;
288
+ gap: 0.5rem; align-items: baseline; }
289
+ li.issue .wcag { color: #4a4a4a; font-size: 0.85rem; font-weight: 400; }
290
+ p.help { margin: 0 0 0.75rem; }
291
+ .element { border-top: 1px solid #e6e6e6; padding-top: 0.75rem; margin-top: 0.75rem; }
292
+ .element:first-of-type { border-top: 0; padding-top: 0; margin-top: 0; }
293
+ p.reach, p.sources, p.selector, p.more, p.written-in { margin: 0.35rem 0; font-size: 0.9rem; color: #4a4a4a; }
294
+ .element details { font-size: 0.9rem; color: #4a4a4a; }
295
+ .element summary { cursor: pointer; }
296
+ ul.page-list { margin: 0.35rem 0 0; padding-left: 1.25rem; }
313
297
  @media (prefers-color-scheme: dark) {
314
298
  body { background: #121212; color: #ededed; }
315
299
  a { color: #9ec1ff; }
@@ -322,6 +306,17 @@ footer { color: #4a4a4a; font-size: 0.9rem; }
322
306
  .verdict.pass { background: #10240f; border-color: #7ee2a8; }
323
307
  .verdict.fail { background: #2b1111; border-color: #ff9d9d; }
324
308
  .verdict.broken { background: #2b2310; border-color: #ffd28a; }
309
+ .tile.critical { background: #2b1111; border-color: #ff9d9d; color: #ffc9c9; }
310
+ .tile.serious { background: #2b1d11; border-color: #ffb98a; color: #ffd7bd; }
311
+ .tile.moderate { background: #2b2610; border-color: #f0d264; color: #f5e3a4; }
312
+ .tile.minor { background: #161c25; border-color: #9db6d6; color: #c6d6ea; }
313
+ .tile.unclassified { background: #1f1f1f; border-color: #9a9a9a; color: #d0d0d0; }
314
+ .tile.none { background: #10240f; border-color: #7ee2a8; color: #b6f0cd; }
315
+ .tile.unchecked { background: #1e1e1e; border-color: #9a9a9a; color: #d0d0d0; }
316
+ p.intro, li.issue .wcag, p.reach, p.sources, p.selector, .element details,
317
+ p.manual.browser, p.criteria { color: #b6b6b6; }
318
+ li.issue { border-color: #3a3a3a; }
319
+ .element { border-top-color: #2e2e2e; }
325
320
  .badge.critical { background: #2b1111; border-color: #ff9d9d; color: #ffc9c9; }
326
321
  .badge.serious { background: #2b1c11; border-color: #ffbb8a; color: #ffd9bd; }
327
322
  .badge.moderate { background: #262110; border-color: #ffe08a; color: #ffeec2; }
@@ -333,5 +328,91 @@ footer { color: #4a4a4a; font-size: 0.9rem; }
333
328
  main { max-width: none; }
334
329
  a { color: #000000; }
335
330
  }`;
331
+ /**
332
+ * The counts, worst first, with what was never checked beside them.
333
+ *
334
+ * A row of severity totals is the first thing anybody reads, and on its own it
335
+ * is the one place this document could mislead: a client seeing "0 critical, 0
336
+ * serious" concludes the site is fine, when the honest reading of that same run
337
+ * may be that six whole rule categories could not be evaluated at all. So the
338
+ * unevaluated count sits in the same row, in the same type size, rather than in
339
+ * a section further down that nobody scrolls to.
340
+ */
341
+ function scoreboard(audits) {
342
+ const counted = /* @__PURE__ */ new Map();
343
+ for (const issue of groupIssues(audits)) {
344
+ const key = impactLabel(issue.impact);
345
+ counted.set(key, (counted.get(key) ?? 0) + issue.occurrences);
346
+ }
347
+ const blind = blindRules(audits);
348
+ const tiles = IMPACT_ORDER.filter((level) => (counted.get(level) ?? 0) > 0).map((level) => `<li class="tile ${level}"><span class="figure">${counted.get(level)}</span> <span class="label">${level}</span></li>`);
349
+ if (tiles.length === 0) tiles.push("<li class=\"tile none\"><span class=\"figure\">0</span> <span class=\"label\">violations</span></li>");
350
+ if (blind.length > 0) tiles.push(`<li class="tile unchecked"><span class="figure">${blind.length}</span> <span class="label">not evaluated</span></li>`);
351
+ return `<ul class="scoreboard">\n${tiles.join("\n")}\n</ul>`;
352
+ }
353
+ /**
354
+ * What is broken, once per element rather than once per page.
355
+ *
356
+ * The page-by-page listing further down is the same information keyed the other
357
+ * way round. It is the right shape for working through one page and the wrong
358
+ * shape for deciding what to do: on a site built from components one broken
359
+ * header is one line in one file, and a per-page listing reports it as many
360
+ * findings without ever saying they are the same defect.
361
+ */
362
+ function issues(audits, options) {
363
+ const found = groupIssues(audits);
364
+ if (found.length === 0) return "";
365
+ const elements = found.reduce((total, issue) => total + issue.elements.length, 0);
366
+ const occurrences = found.reduce((total, issue) => total + issue.occurrences, 0);
367
+ const intro = occurrences === elements ? `${count(elements, "distinct element")} to fix.` : `${count(occurrences, "violation")} across the site, from ${count(elements, "distinct element")}.`;
368
+ return `<h2 id="issues">What to fix</h2>
369
+ <p class="intro">${escapeText(intro)}</p>
370
+ <ol class="issues">
371
+ ${found.map((issue) => issueSection(issue, options)).join("\n")}
372
+ </ol>`;
373
+ }
374
+ function issueSection(issue, options) {
375
+ const impact = impactLabel(issue.impact);
376
+ const criteria = issue.successCriteria.length > 0 ? `<span class="wcag">WCAG ${escapeText(issue.successCriteria.join(" "))}</span>` : "";
377
+ return `<li class="issue">
378
+ <h3><span class="badge ${impact}">${impact}</span> <code>${escapeText(issue.ruleId)}</code> ${criteria}</h3>
379
+ <p class="help">${escapeText(issue.help)}</p>
380
+ ${issue.elements.slice(0, MAX_NODES).map((element) => issueElement(element, options)).join("\n")}
381
+ ${issue.elements.length > MAX_NODES ? `<p class="more">…and ${count(issue.elements.length - MAX_NODES, "more element")}</p>` : ""}
382
+ </li>`;
383
+ }
384
+ function issueElement(element, options) {
385
+ const sources = [...new Set(element.pages.map((page) => options.sourceFor?.(page)).filter((source) => source !== void 0))];
386
+ const list = element.pages.map((page) => `<li>${escapeText(page)}</li>`).join("");
387
+ const component = options.componentFor?.(element.html);
388
+ return `<div class="element">
389
+ <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>`}
391
+ ${element.selector === "" ? "" : `<p class="selector"><code>${escapeText(element.selector)}</code></p>`}
392
+ <p class="reach">Found on ${count(element.pages.length, "page")}.${isShared(element) ? " <strong>Identical on each — likely one shared component.</strong>" : ""}</p>
393
+ ${sources.length > 0 ? `<p class="sources">Source: ${sources.map((source) => `<code>${escapeText(source)}</code>`).join(", ")}</p>` : ""}
394
+ <details><summary>${count(element.pages.length, "page")}</summary><ul class="page-list">${list}</ul></details>
395
+ </div>`;
396
+ }
397
+ /**
398
+ * The manual check for a rule, and where to read what the criterion requires.
399
+ *
400
+ * Unconditional here, unlike the console report, which hides it behind a flag.
401
+ * This document is read once by somebody deciding what to do, not printed on
402
+ * every run by somebody who has read it before.
403
+ */
404
+ function manualBlock(ruleId, finding) {
405
+ const manual = manualCheckFor(ruleId);
406
+ const links = finding.successCriteria.map((criterion) => {
407
+ const url = understandingUrl(criterion);
408
+ return url === void 0 ? void 0 : `<a href="${escapeAttribute(url)}">WCAG ${escapeText(criterion)}</a>`;
409
+ }).filter((link) => link !== void 0);
410
+ if (manual === void 0 && links.length === 0) return "";
411
+ return [
412
+ manual === void 0 ? "" : ` <p class="manual">${escapeText(manual.check)}</p>`,
413
+ manual?.browserAnswers === true ? " <p class=\"manual browser\">Or run the audit again with <code>--browser</code>.</p>" : "",
414
+ links.length === 0 ? "" : ` <p class="criteria">${links.join(" · ")}</p>`
415
+ ].filter((line) => line !== "").join("\n");
416
+ }
336
417
  //#endregion
337
418
  export { buildHtmlReport };
@@ -22,6 +22,20 @@ function meetsThreshold(impact, threshold) {
22
22
  if (impact === null || !isImpactLevel(impact)) return true;
23
23
  return IMPACT_LEVELS.indexOf(impact) >= IMPACT_LEVELS.indexOf(threshold);
24
24
  }
25
+ /**
26
+ * Where an impact sorts, most severe first.
27
+ *
28
+ * An unclassified impact ranks with the most severe, on the same reasoning as
29
+ * `--fail-on`: not knowing how bad a barrier is is not evidence that it is mild.
30
+ */
31
+ function impactRank(impact) {
32
+ if (impact === null || !isImpactLevel(impact)) return 0;
33
+ return IMPACT_LEVELS.length - IMPACT_LEVELS.indexOf(impact);
34
+ }
35
+ /** The impact as a word, naming the gap rather than hiding it. */
36
+ function impactLabel(impact) {
37
+ return impact !== null && isImpactLevel(impact) ? impact : "unclassified";
38
+ }
25
39
  /** Violations at or above `threshold`, counted per rule per page. */
26
40
  function countAtOrAbove(audits, threshold) {
27
41
  let total = 0;
@@ -29,4 +43,4 @@ function countAtOrAbove(audits, threshold) {
29
43
  return total;
30
44
  }
31
45
  //#endregion
32
- export { isImpactLevel as i, IMPACT_LEVELS as n, 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, DEFAULT_FAIL_ON as t };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as COMPLIANCE_STATUSES, c as ISSUE_REASONS, d as defineConfig, f as parseConfig, i as ASSESSMENT_METHODS, l as STATEMENT_LOCALES, n as findConfigFile, o as COUNTRIES, r as loadConfig, s as ConfigError, t as CONFIG_FILENAMES, u as configSchema } from "./load-sCkKsvGQ.js";
2
- import { a as summariseAuditReport, i as readAuditReport, n as toHtmlBody, o as StatementError, r as toHtmlDocument, t as renderStatement } from "./render-BO0nVrrZ.js";
1
+ import { a as COMPLIANCE_STATUSES, c as ISSUE_REASONS, d as defineConfig, f as parseConfig, i as ASSESSMENT_METHODS, l as STATEMENT_LOCALES, n as findConfigFile, o as COUNTRIES, r as loadConfig, s as ConfigError, t as CONFIG_FILENAMES, u as configSchema } from "./load-UYXLqGV9.js";
2
+ import { a as summariseAuditReport, i as readAuditReport, n as toHtmlBody, o as StatementError, r as toHtmlDocument, t as renderStatement } from "./render-DI_aCnAZ.js";
3
3
  export { ASSESSMENT_METHODS, COMPLIANCE_STATUSES, CONFIG_FILENAMES, COUNTRIES, ConfigError, ISSUE_REASONS, STATEMENT_LOCALES, StatementError, configSchema, defineConfig, findConfigFile, loadConfig, parseConfig, readAuditReport, renderStatement, summariseAuditReport, toHtmlBody, toHtmlDocument };
@@ -1,5 +1,7 @@
1
- import { o as COUNTRIES, t as CONFIG_FILENAMES } from "./load-sCkKsvGQ.js";
2
- import { readFile, stat, writeFile } from "node:fs/promises";
1
+ import { o as COUNTRIES, t as CONFIG_FILENAMES } from "./load-UYXLqGV9.js";
2
+ import { t as exists } from "./fs-BmPtmFke.js";
3
+ import { i as note, o as warn, r as fail } from "./command-Dxpa00Ha.js";
4
+ import { readFile, writeFile } from "node:fs/promises";
3
5
  import path from "node:path";
4
6
  import pc from "picocolors";
5
7
  import { createInterface } from "node:readline/promises";
@@ -26,10 +28,7 @@ async function detectDefaults(cwd) {
26
28
  }
27
29
  /** Whether a config is already there, so init never overwrites one silently. */
28
30
  async function existingConfig(cwd) {
29
- for (const name of CONFIG_FILENAMES) try {
30
- await stat(path.join(cwd, name));
31
- return name;
32
- } catch {}
31
+ for (const name of CONFIG_FILENAMES) if (await exists(path.join(cwd, name))) return name;
33
32
  }
34
33
  /** Reads answers, showing what each will be if the reader just hits enter. */
35
34
  function terminalPrompt() {
@@ -51,7 +50,7 @@ async function runInitCommand(options = {}) {
51
50
  const target = path.resolve(cwd, options.output ?? "eaa.config.json");
52
51
  const already = await existingConfig(cwd);
53
52
  if (already !== void 0 && !options.force) {
54
- process.stderr.write(`${pc.yellow("warning")} ${already} already exists. Pass --force to overwrite it.\n`);
53
+ warn(`${already} already exists. Pass --force to overwrite it.`);
55
54
  return { exitCode: 1 };
56
55
  }
57
56
  const detected = await detectDefaults(cwd);
@@ -89,13 +88,17 @@ async function runInitCommand(options = {}) {
89
88
  try {
90
89
  await writeFile(target, `${JSON.stringify(config, null, 2)}\n`, "utf8");
91
90
  } catch (cause) {
92
- process.stderr.write(`${pc.red("error")} Could not write ${path.basename(target)}: ${cause instanceof Error ? cause.message : String(cause)}\n`);
91
+ const reason = cause instanceof Error ? cause.message : String(cause);
92
+ fail(`Could not write ${path.basename(target)}: ${reason}`);
93
93
  return { exitCode: 2 };
94
94
  }
95
95
  process.stderr.write(`Wrote ${path.relative(cwd, target) || path.basename(target)}\n`);
96
96
  const missing = [name === "" ? "site.name" : void 0, email === "" ? "provider.email" : void 0].filter((field) => field !== void 0);
97
- if (missing.length > 0) process.stderr.write(`${pc.yellow("warning")} ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} empty and required. Fill ${missing.length === 1 ? "it" : "them"} in before generating a statement.\n`);
98
- process.stderr.write(pc.dim("Read it before publishing anything from it: status is partially-compliant,\nwhich is the honest default before an audit has run.\n\nNext: eaa-kit audit · eaa-kit statement\n"));
97
+ if (missing.length > 0) {
98
+ const [verb, pronoun] = missing.length === 1 ? ["is", "it"] : ["are", "them"];
99
+ warn(`${missing.join(" and ")} ${verb} empty and required. Fill ${pronoun} in before generating a statement.`);
100
+ }
101
+ note("Read it before publishing anything from it: status is partially-compliant,\nwhich is the honest default before an audit has run.\n\nNext: eaa-kit audit · eaa-kit statement");
99
102
  return {
100
103
  file: target,
101
104
  exitCode: 0
@@ -1,7 +1,6 @@
1
- import { i as shapeResults, n as failedPage, r as runOptions, t as DEFAULT_TAGS } from "./result-2aZPfM8w.js";
2
- import { pathToFileURL } from "node:url";
3
- import { Script } from "node:vm";
1
+ import { i as pageUrl, n as failedPage, o as runOptions, s as shapeResults, t as DEFAULT_TAGS } from "./result-DLxd2Eip.js";
4
2
  import axe from "axe-core";
3
+ import { Script } from "node:vm";
5
4
  import { JSDOM, VirtualConsole } from "jsdom";
6
5
  //#region src/audit/runners/jsdom.ts
7
6
  /**
@@ -144,10 +143,6 @@ function blindRulesInScope(tags) {
144
143
  blindScopeCache.set(key, inScope);
145
144
  return inScope;
146
145
  }
147
- function pageUrl(page, baseUrl) {
148
- if (!baseUrl) return pathToFileURL(page.absolutePath).href;
149
- return new URL(page.relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).href;
150
- }
151
146
  async function withTimeout(promise, ms) {
152
147
  promise.catch(() => {});
153
148
  let timer;
@@ -0,0 +1,3 @@
1
+ import "./result-DLxd2Eip.js";
2
+ import { i as runJsdomAudit } from "./jsdom-BjpF-2V-.js";
3
+ export { runJsdomAudit };
@@ -0,0 +1,2 @@
1
+ import { r as serialiseJsonReport, t as buildJsonReport } from "./json-Cnv9nd6U.js";
2
+ export { buildJsonReport, serialiseJsonReport };
@@ -1,5 +1,6 @@
1
- import { i as isImpactLevel, r as countAtOrAbove } from "./impact-DvgBjupx.js";
1
+ import { i as impactLabel, o as isImpactLevel, r as countAtOrAbove } from "./impact-YdoOtFqm.js";
2
2
  import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
3
+ import { a as ruleOutcomes } from "./result-DLxd2Eip.js";
3
4
  import axe from "axe-core";
4
5
  /**
5
6
  * Build the machine-readable report.
@@ -36,23 +37,14 @@ function buildJsonReport(audits, options) {
36
37
  /** Every rule mentioned by any page, keyed by id and sorted for stable diffs. */
37
38
  function buildRuleIndex(audits) {
38
39
  const index = /* @__PURE__ */ new Map();
39
- for (const audit of audits) {
40
- const outcomes = [
41
- ...audit.violations,
42
- ...audit.accepted ?? [],
43
- ...audit.incomplete,
44
- ...audit.passes,
45
- ...audit.inapplicable
46
- ];
47
- for (const outcome of outcomes) {
48
- if (index.has(outcome.ruleId)) continue;
49
- index.set(outcome.ruleId, {
50
- help: outcome.help,
51
- helpUrl: outcome.helpUrl,
52
- successCriteria: outcome.successCriteria,
53
- en301549: outcome.enClauses
54
- });
55
- }
40
+ for (const audit of audits) for (const outcome of ruleOutcomes(audit)) {
41
+ if (index.has(outcome.ruleId)) continue;
42
+ index.set(outcome.ruleId, {
43
+ help: outcome.help,
44
+ helpUrl: outcome.helpUrl,
45
+ successCriteria: outcome.successCriteria,
46
+ en301549: outcome.enClauses
47
+ });
56
48
  }
57
49
  return Object.fromEntries([...index].sort(([a], [b]) => a.localeCompare(b)));
58
50
  }
@@ -60,6 +52,7 @@ function buildRuleIndex(audits) {
60
52
  function serialiseJsonReport(report) {
61
53
  return `${JSON.stringify(report, null, 2)}\n`;
62
54
  }
55
+ /** The run's tally, exported so the HTML report reads the same numbers. */
63
56
  function buildSummary(audits, failOn) {
64
57
  const byImpact = {
65
58
  critical: 0,
@@ -80,8 +73,7 @@ function buildSummary(audits, failOn) {
80
73
  for (const finding of audit.violations) {
81
74
  violations += 1;
82
75
  violatingElements += finding.nodes.length;
83
- const impact = finding.impact;
84
- byImpact[impact && isImpactLevel(impact) ? impact : "unclassified"] += 1;
76
+ byImpact[impactLabel(finding.impact)] += 1;
85
77
  }
86
78
  for (const finding of audit.incomplete) if (finding.reason === "engine-limitation") notEvaluated += 1;
87
79
  else needsReview += 1;
@@ -138,4 +130,4 @@ function byRuleId(a, b) {
138
130
  return a.ruleId.localeCompare(b.ruleId);
139
131
  }
140
132
  //#endregion
141
- export { buildJsonReport, serialiseJsonReport };
133
+ export { buildSummary as n, serialiseJsonReport as r, buildJsonReport as t };
@@ -1,5 +1,6 @@
1
1
  import { _ as withDefault, c as object, f as safeParse, g as url, h as union, i as isoDate, l as optional, m as transform, n as email, p as string, r as enumeration, t as array, u as pipe } from "./schema-CMZ8ItGk.js";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { r as isFile } from "./fs-BmPtmFke.js";
3
+ import { readFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { pathToFileURL } from "node:url";
5
6
  //#region src/config/define.ts
@@ -181,12 +182,5 @@ async function importModule(file) {
181
182
  if (module.default === void 0) throw new ConfigError(`${path.basename(file)} has no default export`, ["Expected: export default defineConfig({ … })"]);
182
183
  return module.default;
183
184
  }
184
- async function isFile(candidate) {
185
- try {
186
- return (await stat(candidate)).isFile();
187
- } catch {
188
- return false;
189
- }
190
- }
191
185
  //#endregion
192
186
  export { COMPLIANCE_STATUSES as a, ISSUE_REASONS as c, defineConfig as d, parseConfig as f, ASSESSMENT_METHODS as i, STATEMENT_LOCALES as l, findConfigFile as n, COUNTRIES as o, loadConfig as r, ConfigError as s, CONFIG_FILENAMES as t, configSchema as u };