eaa-kit 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +51 -4
  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-CPoZMXGM.js +779 -0
  6. package/dist/audit-CpXH2Mk8.js +2 -0
  7. package/dist/{baseline-CV_3lbER.js → baseline-22Y1NWxM.js} +1 -1
  8. package/dist/{baseline-CgBmzFTr.js → baseline-DB9CZGnV.js} +17 -27
  9. package/dist/cli/index.js +336 -142
  10. package/dist/collect-CFM8gEVv.js +134 -0
  11. package/dist/command-D8l_oYbV.js +77 -0
  12. package/dist/component-C3GL1Mnu.js +146 -0
  13. package/dist/component-DKd3EHOg.js +2 -0
  14. package/dist/coverage-B4IBKMO8.js +521 -0
  15. package/dist/{crawl-CtJbMNNb.js → crawl-BiI1Lau_.js} +13 -23
  16. package/dist/eleventy/index.d.ts +28 -0
  17. package/dist/eleventy/index.js +19 -0
  18. package/dist/frameworks-B4ClIJgE.js +314 -0
  19. package/dist/frameworks-etFg_O8K.js +2 -0
  20. package/dist/fs-BmPtmFke.js +40 -0
  21. package/dist/html-BGTO3ypW.js +543 -0
  22. package/dist/{impact-DvgBjupx.js → impact-DZt2oBCP.js} +15 -1
  23. package/dist/index.js +2 -2
  24. package/dist/{init-DRKIdpK1.js → init-DIWDE35F.js} +13 -10
  25. package/dist/jsdom-4IMzv0eE.js +3 -0
  26. package/dist/jsdom-DCpGSLfW.js +81 -0
  27. package/dist/{json-C9xS1PNC.js → json-DjEvy1nX.js} +19 -22
  28. package/dist/json-QQuFIw1W.js +2 -0
  29. package/dist/{load-sCkKsvGQ.js → load-5wRGLvub.js} +3 -9
  30. package/dist/nuxt/index.d.ts +35 -0
  31. package/dist/nuxt/index.js +23 -0
  32. package/dist/{playwright-DSRnXmcd.js → playwright-BWniOain.js} +70 -23
  33. package/dist/{pool-DixLeu8L.js → pool-BMevaLWD.js} +4 -12
  34. package/dist/{project-CufCqIE2.js → project-CzOnkLH6.js} +14 -21
  35. package/dist/project-MFrXcw1M.js +2 -0
  36. package/dist/remediation-Dtowi2EC.js +321 -0
  37. package/dist/{render-BO0nVrrZ.js → render-DrvXRCEn.js} +8 -16
  38. package/dist/{result-2aZPfM8w.js → result-DoamKFsp.js} +115 -1
  39. package/dist/routes-CmdRUuOs.js +265 -0
  40. package/dist/run-BMASMmwO.d.ts +45 -0
  41. package/dist/run-DB34BSOZ.js +53 -0
  42. package/dist/{sarif-eSCuI0eX.js → sarif-SR3_lLYd.js} +22 -36
  43. package/dist/{schema-CMZ8ItGk.js → schema-is6CGX2D.js} +4 -1
  44. package/dist/text-CKKpzkYM.js +43 -0
  45. package/dist/vite/index.d.ts +39 -0
  46. package/dist/vite/index.js +40 -0
  47. package/dist/webpack/index.d.ts +33 -0
  48. package/dist/webpack/index.js +20 -0
  49. package/package.json +36 -9
  50. package/dist/audit-B2dIKpJ5.js +0 -2
  51. package/dist/audit-DXpKkXsC.js +0 -950
  52. package/dist/escape-Dm1o_RAk.js +0 -21
  53. package/dist/html-BLEuzep6.js +0 -337
  54. package/dist/jsdom-BEu6Ra_2.js +0 -163
  55. package/dist/jsdom-C6dIyaxN.js +0 -3
  56. package/dist/routes-BxbSZKXC.js +0 -123
@@ -0,0 +1,543 @@
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
+ import { t as TOOL_VERSION } from "./version-B3v4rNoG.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-Dtowi2EC.js";
5
+ import { r as componentPath } from "./component-C3GL1Mnu.js";
6
+ import { i as understandingUrl, r as manualCheckFor, t as buildCoverage } from "./coverage-B4IBKMO8.js";
7
+ import { n as buildSummary } from "./json-DjEvy1nX.js";
8
+ import axe from "axe-core";
9
+ //#region src/audit/report/html.ts
10
+ /**
11
+ * A standalone HTML audit report.
12
+ *
13
+ * The console report is for the person who ran the command; JSON and SARIF are
14
+ * for other programs. This one is for somebody who was not at the terminal —
15
+ * the client whose site it is, or the colleague who has to fix it — so it is a
16
+ * single file that can be attached to an email and opened, with no server, no
17
+ * assets and no scripts.
18
+ *
19
+ * It says exactly what the console report says, in the same order and with the
20
+ * same refusals: the four result categories stay apart, rules this engine could
21
+ * not evaluate are named rather than quietly dropped, and nothing here adds up
22
+ * to a compliance claim. A report with no findings means no findings were
23
+ * found, which is not the same as a site being accessible, and the document
24
+ * says so in its own footer rather than leaving the reader to infer it.
25
+ */
26
+ /** Severity levels, worst first, as both the scoreboard and the summary list them. */
27
+ const IMPACT_ORDER = [
28
+ "critical",
29
+ "serious",
30
+ "moderate",
31
+ "minor",
32
+ "unclassified"
33
+ ];
34
+ /** Elements listed per rule before the rest are summarised. */
35
+ const MAX_NODES = 5;
36
+ /** Longest element markup shown before it is truncated. */
37
+ const MAX_SNIPPET = 200;
38
+ function buildHtmlReport(audits, options) {
39
+ const engine = audits[0]?.engine ?? "jsdom";
40
+ const failing = countAtOrAbove(audits, options.failOn);
41
+ const generatedAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
42
+ const title = `Accessibility audit · ${options.directory}`;
43
+ return `<!doctype html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="utf-8">
47
+ <meta name="viewport" content="width=device-width, initial-scale=1">
48
+ <meta name="generator" content="eaa-kit ${escapeAttribute(TOOL_VERSION)}">
49
+ <title>${escapeText(title)}</title>
50
+ <style>
51
+ ${STYLES}
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <main>
56
+ <h1>Accessibility audit</h1>
57
+ ${verdict(audits, failing, options)}
58
+ ${scoreboard(audits)}
59
+ ${issues(audits, options)}
60
+ ${runDetails(audits, engine, generatedAt, options)}
61
+ ${notMeasured(options)}
62
+ ${summary(audits, failing, options)}
63
+ ${pages(audits)}
64
+ ${notEvaluated(audits)}
65
+ ${coverageSection(audits)}
66
+ ${footer()}
67
+ </main>
68
+ </body>
69
+ </html>
70
+ `;
71
+ }
72
+ /**
73
+ * The result, in words, before any of the detail.
74
+ *
75
+ * Not colour alone: the badge carries the word as well, because a reader who
76
+ * cannot distinguish the two shades still has to be able to read the outcome —
77
+ * which would be an embarrassing thing for this document in particular to get
78
+ * wrong.
79
+ */
80
+ function verdict(audits, failing, options) {
81
+ const unaudited = audits.filter((audit) => audit.error).length;
82
+ if (unaudited > 0) return banner("broken", "Could not finish", `${count(unaudited, "page")} could not be audited, so this run reached no verdict.`);
83
+ if (failing > 0) return banner("fail", "Violations found", `${count(failing, "violation")} at or above ${escapeText(options.failOn)}.`);
84
+ const below = totalViolations(audits) - failing;
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.`);
88
+ return banner("pass", "No violations found", "Automated testing found nothing to report.");
89
+ }
90
+ function banner(kind, heading, detail) {
91
+ return `<p class="verdict ${escapeAttribute(kind)}"><strong>${escapeText(heading)}</strong> ${detail}</p>`;
92
+ }
93
+ function runDetails(audits, engine, generatedAt, options) {
94
+ const rows = [
95
+ ["Directory", options.directory],
96
+ ["Pages", String(audits.length)],
97
+ ["Engine", engine === "browser" ? "Chromium" : "jsdom (browserless)"],
98
+ ["Threshold", `${options.failOn} and above fails the run`],
99
+ ["Generated", generatedAt],
100
+ ["eaa-kit", `${TOOL_VERSION} · axe-core ${axe.version}`]
101
+ ];
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)]);
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>`;
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
+ }
133
+ /**
134
+ * The counts, taken from the JSON report's own tally rather than recomputed.
135
+ *
136
+ * Two reports of one run disagreeing about how many violations there were is
137
+ * the kind of bug nobody notices until a client does, so there is one place
138
+ * that counts.
139
+ */
140
+ function summary(audits, failing, options) {
141
+ const totals = buildSummary(audits, options.failOn);
142
+ 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");
143
+ return `<h2>Summary</h2>
144
+ <ul class="counts">
145
+ <li><strong>${count(totals.violations, "violation")}</strong> on ${totals.pagesWithViolations} of ${count(totals.pages, "page")}, across ${count(totals.violatingElements, "element")}</li>
146
+ <li><strong>${failing}</strong> at or above ${escapeText(options.failOn)}</li>
147
+ <li><strong>${totals.needsReview}</strong> ${totals.needsReview === 1 ? "rule needs" : "rules need"} manual review</li>
148
+ <li><strong>${totals.notEvaluated}</strong> ${totals.notEvaluated === 1 ? "rule was" : "rules were"} not evaluated by this engine</li>
149
+ ${totals.accepted > 0 ? ` <li><strong>${totals.accepted}</strong> ${totals.accepted === 1 ? "element is" : "elements are"} accepted by the baseline, and not counted above</li>` : ""}
150
+ </ul>
151
+ ${impacts ? `<ul class="impacts">\n${impacts}\n</ul>` : ""}
152
+ <p class="note">
153
+ <strong>${totals.passes}</strong> rule results were checked and met, and <strong>${totals.inapplicable}</strong>
154
+ found nothing on the page to check. Those two are counted separately and never added
155
+ together: a rule with nothing to check is not a rule that passed, and a page with no
156
+ images proves nothing about image alternatives.
157
+ </p>`;
158
+ }
159
+ function pages(audits) {
160
+ return `<h2>Pages</h2>\n${audits.map(pageSection).join("\n")}`;
161
+ }
162
+ function pageSection(audit) {
163
+ const heading = `<h3 class="page">${escapeText(audit.relativePath)}</h3>`;
164
+ if (audit.error) return `${heading}
165
+ <p class="verdict broken"><strong>Not audited</strong> ${escapeText(audit.error)}</p>`;
166
+ const parts = [heading];
167
+ if (audit.violations.length === 0) parts.push((audit.accepted ?? []).length === 0 ? "<p class=\"clean\">No violations.</p>" : "<p class=\"coverage\">No new violations.</p>");
168
+ else {
169
+ const findings = [...audit.violations].sort(byImpactThenRule).map(violation).join("\n");
170
+ parts.push(`<ol class="findings">\n${findings}\n</ol>`);
171
+ }
172
+ const review = audit.incomplete.filter((finding) => finding.reason === "needs-review");
173
+ if (review.length > 0) {
174
+ const items = review.map((finding) => ` <li><code>${escapeText(finding.ruleId)}</code> needs manual review${standards(finding)}</li>`).join("\n");
175
+ parts.push(`<p class="review-heading">A human has to decide these:</p>\n<ul>\n${items}\n</ul>`);
176
+ }
177
+ const accepted = audit.accepted ?? [];
178
+ if (accepted.length > 0) {
179
+ const items = [...accepted].sort(byImpactThenRule).map((finding) => ` <li><code>${escapeText(finding.ruleId)}</code> — ${escapeText(finding.help)} (${count(finding.nodes.length, "element")})</li>`).join("\n");
180
+ parts.push(`<p class="accepted-heading">Accepted by the baseline. Still violations, and not counted above:</p>\n<ul class="accepted">\n${items}\n</ul>`);
181
+ }
182
+ parts.push(coverage(audit));
183
+ return parts.join("\n");
184
+ }
185
+ function violation(finding) {
186
+ const impact = impactLabel(finding.impact);
187
+ const shown = finding.nodes.slice(0, MAX_NODES);
188
+ const remaining = finding.nodes.length - shown.length;
189
+ const nodes = shown.map((node) => ` <li>
190
+ <code class="selector">${escapeText(node.target.join(" "))}</code>
191
+ <pre><code>${escapeText(collapse(node.html, MAX_SNIPPET))}</code></pre>
192
+ </li>`).join("\n");
193
+ const more = remaining > 0 ? `\n <li class="more">and ${count(remaining, "more element")}</li>` : "";
194
+ return ` <li class="finding">
195
+ <p class="rule">
196
+ <span class="badge ${escapeAttribute(impact)}">${escapeText(impact)}</span>
197
+ <code>${escapeText(finding.ruleId)}</code>
198
+ <a href="${escapeAttribute(finding.helpUrl)}">${escapeText(finding.help)}</a>
199
+ </p>
200
+ <p class="standards">${standardsText(finding) || "No mapped success criterion"}</p>
201
+ <ul class="nodes">
202
+ ${nodes}${more}
203
+ </ul>
204
+ </li>`;
205
+ }
206
+ function coverage(audit) {
207
+ return `<p class="coverage">${escapeText(coverageParts(audit).join(" · "))}</p>`;
208
+ }
209
+ /**
210
+ * Rules this engine reached no verdict on, listed once at the end.
211
+ *
212
+ * They are never reported as passing, and leaving them out entirely would let
213
+ * the reader take the rest of the document for full coverage.
214
+ */
215
+ function notEvaluated(audits) {
216
+ const blind = blindRules(audits);
217
+ if (blind.length === 0) return "";
218
+ return `<h2>Not evaluated</h2>
219
+ <p>This engine reached no verdict on these rules. They are never reported as passing.
220
+ Each one below says what to check by hand, because automated testing finds a minority of
221
+ accessibility barriers and this is where the rest of them are.</p>
222
+ <ul class="not-evaluated">
223
+ ${blind.map(({ ruleId, pages, finding }) => ` <li>
224
+ <code>${escapeText(ruleId)}</code> on ${count(pages, "page")}${standards(finding)}
225
+ <span class="reason">${escapeText(finding.reasonDetail)}</span>
226
+ ${manualBlock(ruleId, finding)}
227
+ </li>`).join("\n")}
228
+ </ul>`;
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
+ }
284
+ function footer() {
285
+ return `<hr>
286
+ <footer>
287
+ <p>
288
+ Generated with <a href="https://github.com/likeBloodMoon/eaa-kit">eaa-kit</a>. Automated
289
+ testing finds a minority of accessibility barriers: it cannot judge whether alternative
290
+ text is accurate, whether a page makes sense in reading order, or whether a form can
291
+ actually be completed with a screen reader.
292
+ </p>
293
+ <p>
294
+ <strong>A report with no findings is not a compliance statement.</strong> It means
295
+ nothing was found by this engine, which is not the same as a site being accessible.
296
+ </p>
297
+ </footer>`;
298
+ }
299
+ function standards(finding) {
300
+ const text = standardsText(finding);
301
+ return text ? ` — ${text}` : "";
302
+ }
303
+ function standardsText(finding) {
304
+ return escapeText(standardsReference(finding.successCriteria, finding.enClauses));
305
+ }
306
+ function totalViolations(audits) {
307
+ return audits.reduce((total, audit) => total + audit.violations.length, 0);
308
+ }
309
+ function byImpactThenRule(a, b) {
310
+ return impactRank(a.impact) - impactRank(b.impact) || a.ruleId.localeCompare(b.ruleId);
311
+ }
312
+ const STYLES = `:root { color-scheme: light dark; }
313
+ body { margin: 0; background: #ffffff; color: #1a1a1a;
314
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
315
+ main { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
316
+ h1 { font-size: 1.9rem; line-height: 1.25; margin: 0 0 1rem; }
317
+ h2 { font-size: 1.35rem; margin: 2.5rem 0 0.75rem; padding-top: 1.5rem;
318
+ border-top: 1px solid #d4d4d4; }
319
+ h3.page { font-size: 1.05rem; margin: 2rem 0 0.5rem; font-family: ui-monospace, monospace; }
320
+ p { margin: 0 0 1rem; }
321
+ a { color: #0b4fa8; }
322
+ a:focus-visible { outline: 3px solid currentColor; outline-offset: 2px; }
323
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
324
+ pre { margin: 0.4rem 0 0; padding: 0.6rem 0.75rem; overflow-x: auto;
325
+ background: #f4f4f5; border-radius: 4px; }
326
+ pre code { font-size: 0.85em; }
327
+ ul, ol { margin: 0 0 1rem; padding-left: 1.25rem; }
328
+ li + li { margin-top: 0.5rem; }
329
+ .verdict { padding: 0.75rem 1rem; border-radius: 4px; border-left: 4px solid; }
330
+ .verdict.pass { background: #eef7ee; border-color: #216e39; }
331
+ .verdict.fail { background: #fdeeee; border-color: #a01b1b; }
332
+ .verdict.broken { background: #fdf4e3; border-color: #8a5a00; }
333
+ .verdict.partial { background: #fdf4e3; border-color: #8a5a00; }
334
+ .badge { display: inline-block; padding: 0 0.45rem; border-radius: 3px; font-size: 0.8rem;
335
+ font-weight: 600; border: 1px solid; }
336
+ .badge.critical { background: #fdeeee; border-color: #a01b1b; color: #7a1414; }
337
+ .badge.serious { background: #fdf1e8; border-color: #a4531b; color: #7d3f14; }
338
+ .badge.moderate { background: #fdf9e3; border-color: #7a6100; color: #5c4900; }
339
+ .badge.minor { background: #eef2f8; border-color: #40556f; color: #33455a; }
340
+ .badge.unclassified { background: #f1f1f1; border-color: #565656; color: #444444; }
341
+ dl.run { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1.5rem; margin: 0; }
342
+ dl.run > div { display: contents; }
343
+ dl.run dt { font-weight: 600; }
344
+ dl.run dd { margin: 0; }
345
+ ul.counts, ul.impacts { list-style: none; padding-left: 0; }
346
+ ul.impacts li { display: inline-block; margin-right: 1rem; }
347
+ ol.findings { list-style: none; padding-left: 0; }
348
+ li.finding { margin: 0 0 1.5rem; padding-left: 0.9rem; border-left: 3px solid #d4d4d4; }
349
+ p.rule { margin-bottom: 0.25rem; }
350
+ p.standards, p.coverage, .reason, li.more, p.accepted-heading, ul.accepted { color: #4a4a4a; font-size: 0.9rem; }
351
+ p.coverage { margin-top: 0.5rem; }
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; }
364
+ code.selector { color: #4a4a4a; }
365
+ p.clean { color: #216e39; }
366
+ p.note { font-size: 0.95rem; }
367
+ hr { border: 0; border-top: 1px solid #d4d4d4; margin: 3rem 0 1.5rem; }
368
+ footer { color: #4a4a4a; font-size: 0.9rem; }
369
+ .scoreboard { list-style: none; display: flex; flex-wrap: wrap; gap: 0.75rem;
370
+ padding: 0; margin: 1.25rem 0 2rem; }
371
+ .tile { flex: 1 1 8rem; padding: 0.75rem 1rem; border: 1px solid; border-radius: 6px; }
372
+ .tile .figure { display: block; font-size: 1.75rem; font-weight: 700; line-height: 1.1; }
373
+ .tile .label { font-size: 0.85rem; }
374
+ .tile.critical { background: #fdeeee; border-color: #a01b1b; color: #7a1414; }
375
+ .tile.serious { background: #fdf1e8; border-color: #a4531b; color: #7d3f14; }
376
+ .tile.moderate { background: #fdf9e3; border-color: #7a6100; color: #5c4900; }
377
+ .tile.minor { background: #eef2f8; border-color: #40556f; color: #33455a; }
378
+ .tile.unclassified { background: #f1f1f1; border-color: #565656; color: #444444; }
379
+ .tile.none { background: #eef7ee; border-color: #216e39; color: #17512a; }
380
+ .tile.unchecked { background: #f4f4f5; border-color: #565656; color: #444444; }
381
+ p.intro { color: #4a4a4a; }
382
+ p.manual { margin: 0.35rem 0; }
383
+ p.manual.browser, p.criteria { margin: 0.35rem 0; font-size: 0.9rem; color: #4a4a4a; }
384
+ ol.issues { list-style: none; padding: 0; }
385
+ li.issue { border: 1px solid #d7d7d7; border-radius: 6px; padding: 0.75rem 1rem;
386
+ margin: 0 0 1rem; }
387
+ li.issue h3 { margin: 0 0 0.25rem; font-size: 1rem; display: flex; flex-wrap: wrap;
388
+ gap: 0.5rem; align-items: baseline; }
389
+ li.issue .wcag { color: #4a4a4a; font-size: 0.85rem; font-weight: 400; }
390
+ p.help { margin: 0 0 0.75rem; }
391
+ .element { border-top: 1px solid #e6e6e6; padding-top: 0.75rem; margin-top: 0.75rem; }
392
+ .element:first-of-type { border-top: 0; padding-top: 0; margin-top: 0; }
393
+ p.reach, p.sources, p.selector, p.more, p.written-in { margin: 0.35rem 0; font-size: 0.9rem; color: #4a4a4a; }
394
+ .element details { font-size: 0.9rem; color: #4a4a4a; }
395
+ .element summary { cursor: pointer; }
396
+ ul.page-list { margin: 0.35rem 0 0; padding-left: 1.25rem; }
397
+ @media (prefers-color-scheme: dark) {
398
+ body { background: #121212; color: #ededed; }
399
+ a { color: #9ec1ff; }
400
+ h2, hr { border-color: #3a3a3a; }
401
+ pre { background: #1e1e1e; }
402
+ li.finding { border-color: #3a3a3a; }
403
+ p.standards, p.coverage, .reason, li.more, code.selector, footer,
404
+ p.accepted-heading, ul.accepted { color: #b6b6b6; }
405
+ p.clean { color: #7ee2a8; }
406
+ .verdict.pass { background: #10240f; border-color: #7ee2a8; }
407
+ .verdict.fail { background: #2b1111; border-color: #ff9d9d; }
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; }
414
+ .tile.critical { background: #2b1111; border-color: #ff9d9d; color: #ffc9c9; }
415
+ .tile.serious { background: #2b1d11; border-color: #ffb98a; color: #ffd7bd; }
416
+ .tile.moderate { background: #2b2610; border-color: #f0d264; color: #f5e3a4; }
417
+ .tile.minor { background: #161c25; border-color: #9db6d6; color: #c6d6ea; }
418
+ .tile.unclassified { background: #1f1f1f; border-color: #9a9a9a; color: #d0d0d0; }
419
+ .tile.none { background: #10240f; border-color: #7ee2a8; color: #b6f0cd; }
420
+ .tile.unchecked { background: #1e1e1e; border-color: #9a9a9a; color: #d0d0d0; }
421
+ p.intro, li.issue .wcag, p.reach, p.sources, p.selector, .element details,
422
+ p.manual.browser, p.criteria { color: #b6b6b6; }
423
+ li.issue { border-color: #3a3a3a; }
424
+ .element { border-top-color: #2e2e2e; }
425
+ .badge.critical { background: #2b1111; border-color: #ff9d9d; color: #ffc9c9; }
426
+ .badge.serious { background: #2b1c11; border-color: #ffbb8a; color: #ffd9bd; }
427
+ .badge.moderate { background: #262110; border-color: #ffe08a; color: #ffeec2; }
428
+ .badge.minor { background: #16202b; border-color: #a8c6e8; color: #cfdff0; }
429
+ .badge.unclassified { background: #1f1f1f; border-color: #b6b6b6; color: #d8d8d8; }
430
+ }
431
+ @media print {
432
+ body { background: #ffffff; color: #000000; }
433
+ main { max-width: none; }
434
+ a { color: #000000; }
435
+ }`;
436
+ /**
437
+ * The counts, worst first, with what was never checked beside them.
438
+ *
439
+ * A row of severity totals is the first thing anybody reads, and on its own it
440
+ * is the one place this document could mislead: a client seeing "0 critical, 0
441
+ * serious" concludes the site is fine, when the honest reading of that same run
442
+ * may be that six whole rule categories could not be evaluated at all. So the
443
+ * unevaluated count sits in the same row, in the same type size, rather than in
444
+ * a section further down that nobody scrolls to.
445
+ */
446
+ function scoreboard(audits) {
447
+ const counted = /* @__PURE__ */ new Map();
448
+ for (const issue of groupIssues(audits)) {
449
+ const key = impactLabel(issue.impact);
450
+ counted.set(key, (counted.get(key) ?? 0) + issue.occurrences);
451
+ }
452
+ const blind = blindRules(audits);
453
+ 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>`);
454
+ if (tiles.length === 0) tiles.push("<li class=\"tile none\"><span class=\"figure\">0</span> <span class=\"label\">violations</span></li>");
455
+ if (blind.length > 0) tiles.push(`<li class="tile unchecked"><span class="figure">${blind.length}</span> <span class="label">not evaluated</span></li>`);
456
+ return `<ul class="scoreboard">\n${tiles.join("\n")}\n</ul>`;
457
+ }
458
+ /**
459
+ * What is broken, once per element rather than once per page.
460
+ *
461
+ * The page-by-page listing further down is the same information keyed the other
462
+ * way round. It is the right shape for working through one page and the wrong
463
+ * shape for deciding what to do: on a site built from components one broken
464
+ * header is one line in one file, and a per-page listing reports it as many
465
+ * findings without ever saying they are the same defect.
466
+ */
467
+ function issues(audits, options) {
468
+ const found = groupIssues(audits);
469
+ if (found.length === 0) return "";
470
+ const elements = found.reduce((total, issue) => total + issue.elements.length, 0);
471
+ const occurrences = found.reduce((total, issue) => total + issue.occurrences, 0);
472
+ const intro = occurrences === elements ? `${count(elements, "distinct element")} to fix.` : `${count(occurrences, "violation")} across the site, from ${count(elements, "distinct element")}.`;
473
+ return `<h2 id="issues">What to fix</h2>
474
+ <p class="intro">${escapeText(intro)}</p>
475
+ <ol class="issues">
476
+ ${found.map((issue) => issueSection(issue, options)).join("\n")}
477
+ </ol>`;
478
+ }
479
+ function issueSection(issue, options) {
480
+ const impact = impactLabel(issue.impact);
481
+ const criteria = issue.successCriteria.length > 0 ? `<span class="wcag">WCAG ${escapeText(issue.successCriteria.join(" "))}</span>` : "";
482
+ return `<li class="issue">
483
+ <h3><span class="badge ${impact}">${impact}</span> <code>${escapeText(issue.ruleId)}</code> ${criteria}</h3>
484
+ <p class="help">${escapeText(issue.help)}</p>
485
+ ${remediationBlock(issue.ruleId, issue.elements[0]?.html, options)}
486
+ ${issue.elements.slice(0, MAX_NODES).map((element) => issueElement(element, options)).join("\n")}
487
+ ${issue.elements.length > MAX_NODES ? `<p class="more">…and ${count(issue.elements.length - MAX_NODES, "more element")}</p>` : ""}
488
+ </li>`;
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
+ }
509
+ function issueElement(element, options) {
510
+ const sources = [...new Set(element.pages.map((page) => options.sourceFor?.(page)).filter((source) => source !== void 0))];
511
+ const list = element.pages.map((page) => `<li>${escapeText(page)}</li>`).join("");
512
+ const component = options.componentFor?.(element.html);
513
+ return `<div class="element">
514
+ <pre><code>${escapeText(collapse(element.html, MAX_SNIPPET))}</code></pre>
515
+ ${component === void 0 ? "" : `<p class="written-in">Written in <code>${escapeText(componentPath(component))}</code></p>`}
516
+ ${element.selector === "" ? "" : `<p class="selector"><code>${escapeText(element.selector)}</code></p>`}
517
+ <p class="reach">Found on ${count(element.pages.length, "page")}.${isShared(element) ? " <strong>Identical on each — likely one shared component.</strong>" : ""}</p>
518
+ ${sources.length > 0 ? `<p class="sources">Source: ${sources.map((source) => `<code>${escapeText(source)}</code>`).join(", ")}</p>` : ""}
519
+ <details><summary>${count(element.pages.length, "page")}</summary><ul class="page-list">${list}</ul></details>
520
+ </div>`;
521
+ }
522
+ /**
523
+ * The manual check for a rule, and where to read what the criterion requires.
524
+ *
525
+ * Unconditional here, unlike the console report, which hides it behind a flag.
526
+ * This document is read once by somebody deciding what to do, not printed on
527
+ * every run by somebody who has read it before.
528
+ */
529
+ function manualBlock(ruleId, finding) {
530
+ const manual = manualCheckFor(ruleId);
531
+ const links = finding.successCriteria.map((criterion) => {
532
+ const url = understandingUrl(criterion);
533
+ return url === void 0 ? void 0 : `<a href="${escapeAttribute(url)}">WCAG ${escapeText(criterion)}</a>`;
534
+ }).filter((link) => link !== void 0);
535
+ if (manual === void 0 && links.length === 0) return "";
536
+ return [
537
+ manual === void 0 ? "" : ` <p class="manual">${escapeText(manual.check)}</p>`,
538
+ manual?.browserAnswers === true ? " <p class=\"manual browser\">Or run the audit again with <code>--browser</code>.</p>" : "",
539
+ links.length === 0 ? "" : ` <p class="criteria">${links.join(" · ")}</p>`
540
+ ].filter((line) => line !== "").join("\n");
541
+ }
542
+ //#endregion
543
+ 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, meetsThreshold as s, 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-5wRGLvub.js";
2
+ import { a as summariseAuditReport, i as readAuditReport, n as toHtmlBody, o as StatementError, r as toHtmlDocument, t as renderStatement } from "./render-DrvXRCEn.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-5wRGLvub.js";
2
+ import { t as exists } from "./fs-BmPtmFke.js";
3
+ import { i as note, o as warn, r as fail } from "./command-D8l_oYbV.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
@@ -0,0 +1,3 @@
1
+ import "./result-DoamKFsp.js";
2
+ import { n as runJsdomAudit } from "./jsdom-DCpGSLfW.js";
3
+ export { runJsdomAudit };