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,5 +1,5 @@
1
1
  import { r as isFile } from "./fs-BmPtmFke.js";
2
- import { i as pageUrl, n as failedPage } from "./result-DLxd2Eip.js";
2
+ import { a as failedPage, s as pageUrl } from "./result-BWcYXeRs.js";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { availableParallelism } from "node:os";
5
5
  //#region src/audit/runners/pool.ts
@@ -52,6 +52,16 @@ const WORK_PER_WORKER_MS = 1600;
52
52
  /** Threading at all means at least two, or there is nothing to overlap with. */
53
53
  const MIN_WORKERS = 2;
54
54
  /**
55
+ * Grace above the runner's own per-page timeout before a worker is killed.
56
+ *
57
+ * The runner races axe-core against a timer, which works whenever the work
58
+ * yields to the event loop: the page is reported with an error and the thread
59
+ * lives on to take the next one. That is the better outcome, so it is given
60
+ * room to happen first. This is the backstop for when it cannot — see
61
+ * `watchdog` below.
62
+ */
63
+ const HARD_TIMEOUT_GRACE_MS = 5e3;
64
+ /**
55
65
  * Ceiling on workers regardless of core count. Past this, the run is bounded by
56
66
  * memory bandwidth and by each worker's own start-up rather than by cores, and
57
67
  * every extra thread holds another jsdom document tree.
@@ -96,10 +106,33 @@ async function runPooledAudit(pages, options = {}) {
96
106
  return runWorkers(pages, runnerOptions, Math.min(workers, pages.length), entry);
97
107
  }
98
108
  async function auditHere(pages, options) {
99
- const { runJsdomAudit } = await import("./jsdom-X4KYfTp8.js");
109
+ const { runJsdomAudit } = await import("./jsdom-22Bkt65v.js");
100
110
  return runJsdomAudit(pages, options);
101
111
  }
112
+ /**
113
+ * The only place a per-page ceiling can actually be enforced.
114
+ *
115
+ * The runner's own timeout is a `Promise.race`, and a race cannot interrupt
116
+ * synchronous work: jsdom's parse and axe-core's walk of the tree both hold the
117
+ * thread, so the timer that is meant to stop them never gets to run. Measured
118
+ * on a 120,000-element document with a two-second ceiling, the audit was still
119
+ * going more than ten minutes later — and because the pool waits on its
120
+ * workers, the whole run went with it. A CI job hung until the platform killed
121
+ * it, which is the failure the ceiling exists to prevent.
122
+ *
123
+ * `worker.terminate()` is the answer, because it stops the thread whatever it
124
+ * is doing. So the supervisor keeps its own deadline per page and kills the
125
+ * thread that overruns it, records that page as unaudited, and lets the rest of
126
+ * the run carry on. The page is reported as a failure rather than as a clean
127
+ * page, which the CLI already turns into exit 2.
128
+ *
129
+ * Two runs still have no hard ceiling, because both refuse the threads that
130
+ * would carry it: `--concurrency 1`, and a machine with too few cores to spare
131
+ * one. Both are documented rather than papered over, and the size cap in
132
+ * `collectPages` is what keeps them bounded in practice.
133
+ */
102
134
  async function runWorkers(pages, options, count, entry) {
135
+ const deadlineMs = (options.timeoutMs ?? 3e4) + HARD_TIMEOUT_GRACE_MS;
103
136
  const audits = Array.from({ length: pages.length });
104
137
  let next = 0;
105
138
  await Promise.all(Array.from({ length: count }, () => {
@@ -113,7 +146,13 @@ async function runWorkers(pages, options, count, entry) {
113
146
  }
114
147
  let inFlight;
115
148
  let completed = 0;
149
+ let watchdog;
150
+ const disarm = () => {
151
+ if (watchdog !== void 0) clearTimeout(watchdog);
152
+ watchdog = void 0;
153
+ };
116
154
  const finish = () => {
155
+ disarm();
117
156
  worker.terminate();
118
157
  resolve();
119
158
  };
@@ -127,8 +166,18 @@ async function runWorkers(pages, options, count, entry) {
127
166
  next += 1;
128
167
  inFlight = index;
129
168
  worker.postMessage(pages[index]);
169
+ watchdog = setTimeout(() => {
170
+ watchdog = void 0;
171
+ if (inFlight !== void 0) {
172
+ audits[inFlight] = failedPage(identity(pages[inFlight], options), `the audit worker was stopped after ${deadlineMs}ms on this page`);
173
+ inFlight = void 0;
174
+ }
175
+ finish();
176
+ }, deadlineMs);
177
+ watchdog.unref?.();
130
178
  };
131
179
  worker.on("message", (audit) => {
180
+ disarm();
132
181
  if (inFlight !== void 0) {
133
182
  audits[inFlight] = audit;
134
183
  completed += 1;
@@ -136,6 +185,7 @@ async function runWorkers(pages, options, count, entry) {
136
185
  feed();
137
186
  });
138
187
  worker.on("error", (cause) => {
188
+ disarm();
139
189
  if (inFlight !== void 0 && completed > 0) audits[inFlight] = failedPage(identity(pages[inFlight], options), `audit worker failed: ${cause.message}`);
140
190
  finish();
141
191
  });
@@ -1,5 +1,5 @@
1
1
  import { n as isDirectory, t as exists } from "./fs-BmPtmFke.js";
2
- import { r as candidateOutputs } from "./frameworks-BYa3tULg.js";
2
+ import { r as candidateOutputs } from "./frameworks-B4ClIJgE.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { glob } from "tinyglobby";
@@ -226,6 +226,12 @@ async function autoDetectSource(cwd, options = {}) {
226
226
  };
227
227
  }
228
228
  const pkg = await readPackageJson(cwd);
229
+ const { detectFramework } = await import("./frameworks-etFg_O8K.js");
230
+ const detected = await detectFramework(cwd, pkg);
231
+ if (detected !== void 0 && detected.framework.outputs.length === 0) {
232
+ step(`${detected.framework.name} renders on a server and writes no HTML to disk`);
233
+ return { steps };
234
+ }
229
235
  if (pkg === void 0) return void 0;
230
236
  const scripts = pkg.scripts ?? {};
231
237
  if (options.noBuild) return void 0;
@@ -265,4 +271,4 @@ async function autoDetectSource(cwd, options = {}) {
265
271
  };
266
272
  }
267
273
  //#endregion
268
- export { autoDetectSource, readPackageJson };
274
+ export { runScript as a, readPackageJson as i, detectPackageManager as n, startServer as o, findBuildOutput as r, autoDetectSource as t };
@@ -0,0 +1,2 @@
1
+ import { i as readPackageJson, t as autoDetectSource } from "./project-CzOnkLH6.js";
2
+ export { autoDetectSource, readPackageJson };
@@ -0,0 +1,321 @@
1
+ import { a as impactRank } from "./impact-DZt2oBCP.js";
2
+ import { t as elementFingerprint } from "./fingerprint-BjYV_0F7.js";
3
+ import { o as findingElements } from "./result-BWcYXeRs.js";
4
+ //#region src/audit/completeness.ts
5
+ /**
6
+ * Fold what the collector knew together with what the engine managed.
7
+ *
8
+ * The audits carry the second half: a page with an `error` was collected and
9
+ * then not audited, and is as unmeasured as one that was never fetched.
10
+ */
11
+ function runCompleteness(audits, collection) {
12
+ const errored = audits.filter((audit) => audit.error).length;
13
+ return {
14
+ ...collection,
15
+ audited: audits.length - errored,
16
+ errored,
17
+ complete: collection.unreachable.length === 0 && !collection.truncated && errored === 0
18
+ };
19
+ }
20
+ /**
21
+ * What was missed, as phrases both reports print.
22
+ *
23
+ * Separate clauses rather than one total, for the same reason the coverage
24
+ * parts are: a page that could not be fetched and a page that could not be
25
+ * parsed are different problems with different fixes, and summing them would
26
+ * name neither.
27
+ */
28
+ function missedParts(completeness) {
29
+ const parts = [];
30
+ if (completeness.unreachable.length > 0) parts.push(`${completeness.unreachable.length} could not be reached`);
31
+ if (completeness.errored > 0) parts.push(`${completeness.errored} could not be audited`);
32
+ if (completeness.truncated) parts.push("the run stopped at its page limit");
33
+ return parts;
34
+ }
35
+ /** How the pages were found, in words, for the run details both reports show. */
36
+ function discoveryLabel(discovery) {
37
+ switch (discovery) {
38
+ case "directory": return "files in the build directory";
39
+ case "sitemap": return "sitemap.xml and links";
40
+ case "links": return "links from the entry page";
41
+ }
42
+ }
43
+ /** Whether this element looks like one component rendered on many pages. */
44
+ function isShared(element) {
45
+ return element.pages.length >= 3;
46
+ }
47
+ /**
48
+ * Fold a run's violations into one entry per rule, and one per element within it.
49
+ *
50
+ * Accepted violations are not included: a baseline moves them out of what fails
51
+ * the build, and this is a view of what fails.
52
+ */
53
+ function groupIssues(audits) {
54
+ const byRule = /* @__PURE__ */ new Map();
55
+ for (const audit of audits) for (const finding of audit.violations) {
56
+ let issue = byRule.get(finding.ruleId);
57
+ if (issue === void 0) {
58
+ issue = {
59
+ ruleId: finding.ruleId,
60
+ help: finding.help,
61
+ impact: finding.impact ?? null,
62
+ successCriteria: finding.successCriteria,
63
+ enClauses: finding.enClauses,
64
+ helpUrl: finding.helpUrl,
65
+ elements: [],
66
+ pages: [],
67
+ occurrences: 0
68
+ };
69
+ byRule.set(finding.ruleId, issue);
70
+ }
71
+ if (!issue.pages.includes(audit.relativePath)) issue.pages.push(audit.relativePath);
72
+ for (const node of findingElements(finding)) {
73
+ issue.occurrences += 1;
74
+ const fingerprint = elementFingerprint(finding.ruleId, node.selector, node.html);
75
+ const existing = issue.elements.find((element) => element.fingerprint === fingerprint);
76
+ if (existing) {
77
+ if (!existing.pages.includes(audit.relativePath)) existing.pages.push(audit.relativePath);
78
+ continue;
79
+ }
80
+ issue.elements.push({
81
+ fingerprint,
82
+ selector: node.selector,
83
+ html: node.html,
84
+ pages: [audit.relativePath]
85
+ });
86
+ }
87
+ }
88
+ const issues = [...byRule.values()];
89
+ for (const issue of issues) {
90
+ issue.pages.sort();
91
+ for (const element of issue.elements) element.pages.sort();
92
+ issue.elements.sort(byReachThenSelector);
93
+ }
94
+ issues.sort(bySeverityThenReach);
95
+ return issues;
96
+ }
97
+ /**
98
+ * Unevaluated rules folded across the site, sorted by rule id.
99
+ *
100
+ * Both reports list these once at the end rather than under every page: on a
101
+ * large site the same handful recurs on each one, and a wall of "not evaluated"
102
+ * would bury the findings that are real.
103
+ */
104
+ function blindRules(audits) {
105
+ const byRule = /* @__PURE__ */ new Map();
106
+ for (const audit of audits) for (const finding of audit.incomplete) {
107
+ if (finding.reason !== "engine-limitation") continue;
108
+ const entry = byRule.get(finding.ruleId);
109
+ if (entry) entry.pages += 1;
110
+ else byRule.set(finding.ruleId, {
111
+ ruleId: finding.ruleId,
112
+ pages: 1,
113
+ finding
114
+ });
115
+ }
116
+ return [...byRule.values()].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
117
+ }
118
+ /**
119
+ * What one page's result actually rests on, as phrases both reports print.
120
+ *
121
+ * The counts stay separate on purpose. Only `passed` is evidence that a
122
+ * criterion was met here; `not applicable` means the rule found nothing to
123
+ * check, and adding the two together would turn an empty page into a
124
+ * near-perfect score.
125
+ */
126
+ function coverageParts(audit) {
127
+ const blind = audit.incomplete.filter((finding) => finding.reason === "engine-limitation").length;
128
+ const review = audit.incomplete.length - blind;
129
+ const parts = [`${audit.passes.length} passed`, `${audit.inapplicable.length} not applicable`];
130
+ if (review > 0) parts.push(`${review} to review`);
131
+ if (blind > 0) parts.push(`${blind} not evaluated`);
132
+ return parts;
133
+ }
134
+ /** Widest reach first, then by selector so two runs agree. */
135
+ function byReachThenSelector(a, b) {
136
+ return b.pages.length - a.pages.length || a.selector.localeCompare(b.selector);
137
+ }
138
+ /** Worst first, then widest reach, then by rule id. */
139
+ function bySeverityThenReach(a, b) {
140
+ return impactRank(a.impact) - impactRank(b.impact) || b.pages.length - a.pages.length || a.ruleId.localeCompare(b.ruleId);
141
+ }
142
+ //#endregion
143
+ //#region src/audit/remediation.ts
144
+ /**
145
+ * Swap or add an attribute on the opening tag of the failing element.
146
+ *
147
+ * String surgery rather than a parser: the input is one element as axe-core
148
+ * captured it, the output is shown to a person rather than written to disk, and
149
+ * pulling in a parser to produce a suggestion would cost more than it is worth.
150
+ */
151
+ function withAttribute(html, attribute, value) {
152
+ const openingTag = /^<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/.exec(html.trim());
153
+ if (openingTag === null) return void 0;
154
+ const [, tag = "", raw = ""] = openingTag;
155
+ const selfClosing = /\/\s*$/.test(raw) ? " /" : "";
156
+ const attributes = raw.replace(/\s*\/\s*$/, "");
157
+ const existing = new RegExp(`\\s${attribute}\\s*=\\s*("[^"]*"|'[^']*')`, "i");
158
+ const replacement = `${attribute}="${value}"`;
159
+ return `<${tag}${existing.test(attributes) ? attributes.replace(existing, ` ${replacement}`) : `${attributes} ${replacement}`}${selfClosing}>`;
160
+ }
161
+ /** Put text between the tags of an element that has none. */
162
+ function withContent(html, content) {
163
+ const opening = /^<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/.exec(html.trim());
164
+ if (opening === null) return void 0;
165
+ const [matched, tag = ""] = opening;
166
+ return `${matched}${content}</${tag}>`;
167
+ }
168
+ /**
169
+ * The generic fix for a rule, whatever built the site.
170
+ *
171
+ * Scoped to the rules that actually fire on real sites. A table covering every
172
+ * axe-core rule would be mostly entries nobody reads, and each one is a claim
173
+ * this project has to keep true.
174
+ */
175
+ const GENERIC = {
176
+ "image-alt": {
177
+ why: "A screen reader announces this image by its filename, or skips it entirely.",
178
+ fix: "Add alt text describing what the image conveys. If it is decorative and repeats adjacent text, use alt=\"\" so it is skipped deliberately rather than by accident.",
179
+ example: (html) => withAttribute(html, "alt", "What this image shows")
180
+ },
181
+ "link-name": {
182
+ why: "A screen reader announces this as \"link\" with nothing after it, so where it goes is unknowable without following it.",
183
+ fix: "Give the link text. Where the design calls for an icon alone, keep the visible icon and add a visually hidden label or an aria-label.",
184
+ example: (html) => withContent(html, "Where this link goes")
185
+ },
186
+ "button-name": {
187
+ why: "A screen reader announces this as \"button\" with nothing after it, so what it does is unknowable without pressing it.",
188
+ fix: "Give the button text, or an aria-label where it shows only an icon.",
189
+ example: (html) => withContent(html, "What this button does")
190
+ },
191
+ "html-has-lang": {
192
+ why: "A screen reader reads the page in whatever language it defaults to, so German content is read with English pronunciation rules and is close to unintelligible.",
193
+ fix: "Set the lang attribute on <html> to the language the page is written in.",
194
+ example: (html) => withAttribute(html, "lang", "de")
195
+ },
196
+ "html-lang-valid": {
197
+ why: "An unrecognised language tag leaves a screen reader guessing, with the same result as no tag at all.",
198
+ fix: "Use a valid BCP 47 tag: de, de-AT, en-GB.",
199
+ example: (html) => withAttribute(html, "lang", "de-AT")
200
+ },
201
+ "document-title": {
202
+ why: "The title is the first thing a screen reader announces and what a tab and a bookmark show. Without one, every page of the site is indistinguishable from every other.",
203
+ fix: "Add a <title> to the document head, naming this page before the site."
204
+ },
205
+ label: {
206
+ why: "A screen reader announces this field with no name, so what to type in it is unknowable.",
207
+ fix: "Give the field a <label for=\"…\">, or an aria-label where the design has no visible label. A placeholder is not a label: it disappears as soon as somebody types."
208
+ },
209
+ "form-field-multiple-labels": {
210
+ why: "Screen readers disagree about which label to announce, so what somebody hears depends on their software.",
211
+ fix: "Leave one label on the field and fold the rest into it, or into aria-describedby."
212
+ },
213
+ "aria-allowed-attr": {
214
+ why: "An ARIA attribute its role does not permit is ignored or, worse, changes how the element is announced in a way nobody intended.",
215
+ fix: "Remove the attribute, or change the role to one that allows it."
216
+ },
217
+ "aria-required-attr": {
218
+ why: "The role promises state that is not there, so a screen reader announces a control without saying whether it is checked, expanded or selected.",
219
+ fix: "Add the attributes the role requires, and keep them in step with the state as it changes."
220
+ },
221
+ "aria-valid-attr-value": {
222
+ why: "An aria-labelledby or aria-describedby pointing at an id that is not on the page leaves the element with no name at all.",
223
+ fix: "Point it at an element that exists, or drop the attribute and label the element directly."
224
+ },
225
+ "aria-hidden-focus": {
226
+ why: "The element is hidden from screen readers and still reachable by keyboard, so somebody tabbing through the page lands on something their software cannot describe.",
227
+ fix: "Remove aria-hidden, or take the element out of the tab order with tabindex=\"-1\" and by disabling the control."
228
+ },
229
+ "heading-order": {
230
+ why: "Headings are how screen reader users navigate a page. A level skipped reads as a missing section.",
231
+ fix: "Step heading levels one at a time. Where the jump was for visual size, keep the level and set the size in CSS."
232
+ },
233
+ "empty-heading": {
234
+ why: "It appears in the heading list a screen reader user navigates by, with nothing to read.",
235
+ fix: "Give the heading text, or remove it and style the surrounding element instead."
236
+ },
237
+ "landmark-one-main": {
238
+ why: "Without a main landmark there is no \"skip to content\": a screen reader user hears the whole navigation again on every page.",
239
+ fix: "Wrap the page content in <main>, once per page."
240
+ },
241
+ region: {
242
+ why: "Content outside a landmark cannot be reached by landmark navigation, so it is only found by reading the page from the top.",
243
+ fix: "Put the content inside <header>, <nav>, <main> or <footer>."
244
+ },
245
+ list: {
246
+ why: "A screen reader announces \"list, N items\" and lets somebody skip it. Anything else between the <li>s breaks that count.",
247
+ fix: "Make every direct child of <ul> or <ol> an <li>, and move anything else inside one."
248
+ },
249
+ listitem: {
250
+ why: "An <li> outside a list is announced as ordinary text, so the grouping the layout implies is not there for anybody who cannot see it.",
251
+ fix: "Put the item inside a <ul> or <ol>."
252
+ },
253
+ "duplicate-id-aria": {
254
+ why: "ARIA references resolve to the first match, so one of these elements is silently labelled by the wrong thing.",
255
+ fix: "Make the ids unique. Where they come from a component rendered more than once, derive the id from a prop or a generated suffix."
256
+ },
257
+ "color-contrast": {
258
+ why: "Text this close to its background is unreadable for many people with low vision, and for anybody in bright sunlight.",
259
+ fix: "Raise the contrast to 4.5:1 for body text, or 3:1 for large or bold text. Check hover, focus, visited, disabled and placeholder states too — those are the ones usually missed."
260
+ },
261
+ "link-in-text-block": {
262
+ why: "A link distinguished from its paragraph by colour alone is invisible to somebody who cannot distinguish those colours.",
263
+ fix: "Underline links inside paragraphs, or give them a 3:1 contrast difference against the surrounding text as well as against the background."
264
+ },
265
+ "target-size": {
266
+ why: "A target this small is hard to hit for anybody with a tremor, and for everybody on a phone.",
267
+ fix: "Make the clickable area at least 24×24 CSS pixels, with padding rather than a bigger icon."
268
+ },
269
+ "frame-title": {
270
+ why: "A screen reader announces an untitled frame as \"frame\", so what is in it is unknowable without entering it.",
271
+ fix: "Add a title attribute saying what the frame contains.",
272
+ example: (html) => withAttribute(html, "title", "What this frame contains")
273
+ },
274
+ "meta-viewport": {
275
+ why: "Blocking zoom stops anybody who needs larger text from reading the page at all on a phone.",
276
+ fix: "Remove user-scalable=no and any maximum-scale below 5 from the viewport meta tag.",
277
+ example: (html) => withAttribute(html, "content", "width=device-width, initial-scale=1")
278
+ }
279
+ };
280
+ /**
281
+ * Fixes that genuinely differ by framework.
282
+ *
283
+ * Deliberately short. Everything absent here is covered by the generic entry,
284
+ * because for most rules the correction is identical whatever produced the
285
+ * markup, and a per-framework table full of restatements would be a maintenance
286
+ * cost with no reader.
287
+ */
288
+ const BY_FRAMEWORK = {
289
+ next: {
290
+ "image-alt": { fix: "next/image requires alt, so an empty one here means it was set to '' or a plain <img> was used. Give it a real alt, or alt=\"\" only where the image repeats adjacent text." },
291
+ "html-has-lang": { fix: "Set lang on the <html> element in app/layout.tsx (App Router) or pages/_document.tsx (Pages Router). Setting it in a page component will not reach the document." }
292
+ },
293
+ nuxt: {
294
+ "html-has-lang": { fix: "Set app.head.htmlAttrs.lang in nuxt.config.ts, or call useHead({ htmlAttrs: { lang: 'de' } }) in app.vue." },
295
+ "link-name": { fix: "Give the <NuxtLink> content, or an aria-label where it renders an icon alone." }
296
+ },
297
+ astro: {
298
+ "html-has-lang": { fix: "Set lang on the <html> element in your layout under src/layouts, not in the individual page." },
299
+ "image-alt": { fix: "Astro's <Image /> requires alt. Give it one describing what the image conveys, or alt=\"\" where it repeats adjacent text." }
300
+ },
301
+ sveltekit: { "html-has-lang": { fix: "Set lang on the <html> element in src/app.html, which is the template every route is rendered into." } },
302
+ remix: { "html-has-lang": { fix: "Set lang on the <html> element in the root route (app/root.tsx), which renders the document shell." } }
303
+ };
304
+ /**
305
+ * What to do about a rule, in this project's idiom where that differs.
306
+ *
307
+ * `framework` is the registry id from `detectFramework`. An unknown one, or
308
+ * none, falls through to the generic advice rather than to nothing: the fix for
309
+ * most rules does not depend on what built the page.
310
+ */
311
+ function remediationFor(ruleId, framework) {
312
+ const generic = GENERIC[ruleId];
313
+ const specific = framework === void 0 ? void 0 : BY_FRAMEWORK[framework]?.[ruleId];
314
+ if (generic === void 0) return void 0;
315
+ return specific === void 0 ? generic : {
316
+ ...generic,
317
+ ...specific
318
+ };
319
+ }
320
+ //#endregion
321
+ export { isShared as a, runCompleteness as c, groupIssues as i, blindRules as n, discoveryLabel as o, coverageParts as r, missedParts as s, remediationFor as t };
@@ -1,7 +1,8 @@
1
- import { _ as withDefault, a as isoDateTime, c as object, d as record, f as safeParse, o as nullable, p as string, s as number, t as array } from "./schema-CMZ8ItGk.js";
1
+ import { a as impactRank, n as IMPACT_LEVELS } from "./impact-DZt2oBCP.js";
2
+ import { c as nullable, h as string, l as number, m as safeParse, p as record, s as isoDateTime, t as array, u as object, y as withDefault } from "./schema-DJSF4K05.js";
3
+ import { u as STATEMENT_LOCALES } from "./load-yAR4wzez.js";
2
4
  import { n as isDirectory } from "./fs-BmPtmFke.js";
3
- import { a as impactRank, n as IMPACT_LEVELS } from "./impact-YdoOtFqm.js";
4
- import { a as standardsReference, i as escapeText, r as escapeAttribute } from "./text-BFmNtMsV.js";
5
+ import { i as escapeText, o as standardsReference, r as escapeAttribute } from "./text-CKKpzkYM.js";
5
6
  import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
6
7
  import { readFile, readdir } from "node:fs/promises";
7
8
  import path from "node:path";
@@ -58,7 +59,7 @@ function summariseAuditReport(value, source = "audit report") {
58
59
  const result = safeParse(reportSchema, value);
59
60
  if (!result.success) throw new StatementError(`${source} is not an eaa-kit JSON report (${result.error.issues.map((issue) => `${issue.path.join(".") || "document"}: ${issue.message}`).slice(0, 5).join("; ")})`);
60
61
  const report = result.data;
61
- if (report.schemaVersion !== 1) throw new StatementError(`${source} has schemaVersion ${report.schemaVersion}; this version of eaa-kit reads 1`);
62
+ if (report.schemaVersion !== 2) throw new StatementError(`${source} has schemaVersion ${report.schemaVersion}; this version of eaa-kit reads 2`);
62
63
  const byRule = /* @__PURE__ */ new Map();
63
64
  const rules = new Map(Object.entries(report.rules));
64
65
  for (const page of report.pages) for (const violation of page.violations) {
@@ -411,7 +412,7 @@ async function renderStatement(config, options = {}) {
411
412
  const country = options.country ?? config.enforcement.country;
412
413
  const locale = options.locale ?? defaultLocale(config);
413
414
  const template = `${country.toLowerCase()}.${locale}`;
414
- const markdown = tidy(renderTemplate(await loadTemplate(template), buildScope(config, locale, options.audit)));
415
+ const markdown = tidy(renderTemplate(await loadTemplate(country, locale), buildScope(config, locale, options.audit)));
415
416
  return {
416
417
  markdown,
417
418
  html: toHtmlDocument(markdown, {
@@ -423,9 +424,16 @@ async function renderStatement(config, options = {}) {
423
424
  template
424
425
  };
425
426
  }
426
- /** A German-language site gets a German statement unless told otherwise. */
427
+ /**
428
+ * A site gets its statement in its own language where there is one for it.
429
+ *
430
+ * From `site.locale`, which is a BCP 47 tag: `de-AT` and `de` both mean the
431
+ * German document. English is the fallback because every country has an English
432
+ * template, being the language a statement is most often also published in.
433
+ */
427
434
  function defaultLocale(config) {
428
- return config.site.locale.toLowerCase().startsWith("de") ? "de" : "en";
435
+ const language = config.site.locale.toLowerCase().split("-")[0];
436
+ return STATEMENT_LOCALES.find((candidate) => candidate === language) ?? "en";
429
437
  }
430
438
  /**
431
439
  * Values the templates interpolate.
@@ -522,6 +530,22 @@ function reasonScope(reason) {
522
530
  };
523
531
  }
524
532
  /**
533
+ * Where each statement language formats its dates.
534
+ *
535
+ * A region is named for every one, because a bare language tag leaves the
536
+ * format to whatever ICU picks: `de` is de-DE, and this tool's German documents
537
+ * have always been dated the Austrian way. `en-GB` for the same reason —
538
+ * 20 August 2026, not August 20, 2026, in a European legal document.
539
+ */
540
+ const DATE_LOCALES = {
541
+ de: "de-AT",
542
+ en: "en-GB",
543
+ es: "es-ES",
544
+ fr: "fr-FR",
545
+ it: "it-IT",
546
+ nl: "nl-NL"
547
+ };
548
+ /**
525
549
  * 2026-08-20 becomes 20. August 2026 or 20 August 2026.
526
550
  *
527
551
  * Every date reaching this has been through a schema that checks it, so the
@@ -533,7 +557,7 @@ function reasonScope(reason) {
533
557
  function formatDate(iso, locale) {
534
558
  const date = /* @__PURE__ */ new Date(`${iso}T00:00:00Z`);
535
559
  if (Number.isNaN(date.getTime())) return iso;
536
- return new Intl.DateTimeFormat(locale === "de" ? "de-AT" : "en-GB", {
560
+ return new Intl.DateTimeFormat(DATE_LOCALES[locale], {
537
561
  day: "numeric",
538
562
  month: "long",
539
563
  year: "numeric",
@@ -549,14 +573,28 @@ function tidy(markdown) {
549
573
  return `${markdown.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim()}\n`;
550
574
  }
551
575
  let templateDirectory;
552
- async function loadTemplate(name) {
576
+ /**
577
+ * The document for a country in a language, if there is one.
578
+ *
579
+ * The matrix is deliberately sparse: a country's statement is written under its
580
+ * own law and published in the language the law is administered in, plus
581
+ * English. Asking for a combination nobody wrote is an error naming the
582
+ * languages that country does have — not a fall back to another language, which
583
+ * would hand somebody a document in a language their readers may not have and
584
+ * do it quietly.
585
+ */
586
+ async function loadTemplate(country, locale) {
553
587
  templateDirectory ??= await findTemplateDirectory();
554
588
  const directory = templateDirectory;
589
+ const name = `${country.toLowerCase()}.${locale}`;
555
590
  const file = path.join(directory, `${name}.md`);
556
591
  try {
557
592
  return await readFile(file, "utf8");
558
593
  } catch {
559
- throw new StatementError(`No statement template for ${name}. Available: ${(await readdir(directory)).filter((entry) => entry.endsWith(".md")).map((entry) => entry.replace(/\.md$/, "")).sort().join(", ")}`);
594
+ const templates = (await readdir(directory)).filter((entry) => entry.endsWith(".md")).map((entry) => entry.replace(/\.md$/, "")).sort();
595
+ const prefix = `${country.toLowerCase()}.`;
596
+ const forCountry = templates.filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length));
597
+ throw new StatementError(forCountry.length > 0 ? `No ${country} statement in ${locale}. ${country} has: ${forCountry.join(", ")}` : `No statement template for ${name}. Available: ${templates.join(", ")}`);
560
598
  }
561
599
  }
562
600
  /**