eaa-kit 0.2.1 → 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 +80 -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-DSRnXmcd.js → playwright-DYFsGUNd.js} +70 -23
  28. package/dist/{pool-DixLeu8L.js → pool-BWkWZiJW.js} +4 -12
  29. package/dist/{project-CufCqIE2.js → project-DW08TseF.js} +8 -21
  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-B2dIKpJ5.js +0 -2
  41. package/dist/audit-DXpKkXsC.js +0 -950
  42. package/dist/escape-Dm1o_RAk.js +0 -21
  43. package/dist/jsdom-C6dIyaxN.js +0 -3
@@ -0,0 +1,239 @@
1
+ import { a as impactRank } from "./impact-YdoOtFqm.js";
2
+ import { t as elementFingerprint } from "./fingerprint-DRoneAjj.js";
3
+ import { r as findingElements } from "./result-DLxd2Eip.js";
4
+ /** Whether this element looks like one component rendered on many pages. */
5
+ function isShared(element) {
6
+ return element.pages.length >= 3;
7
+ }
8
+ /**
9
+ * Fold a run's violations into one entry per rule, and one per element within it.
10
+ *
11
+ * Accepted violations are not included: a baseline moves them out of what fails
12
+ * the build, and this is a view of what fails.
13
+ */
14
+ function groupIssues(audits) {
15
+ const byRule = /* @__PURE__ */ new Map();
16
+ for (const audit of audits) for (const finding of audit.violations) {
17
+ let issue = byRule.get(finding.ruleId);
18
+ if (issue === void 0) {
19
+ issue = {
20
+ ruleId: finding.ruleId,
21
+ help: finding.help,
22
+ impact: finding.impact ?? null,
23
+ successCriteria: finding.successCriteria,
24
+ enClauses: finding.enClauses,
25
+ helpUrl: finding.helpUrl,
26
+ elements: [],
27
+ pages: [],
28
+ occurrences: 0
29
+ };
30
+ byRule.set(finding.ruleId, issue);
31
+ }
32
+ if (!issue.pages.includes(audit.relativePath)) issue.pages.push(audit.relativePath);
33
+ for (const node of findingElements(finding)) {
34
+ issue.occurrences += 1;
35
+ const fingerprint = elementFingerprint(finding.ruleId, node.selector, node.html);
36
+ const existing = issue.elements.find((element) => element.fingerprint === fingerprint);
37
+ if (existing) {
38
+ if (!existing.pages.includes(audit.relativePath)) existing.pages.push(audit.relativePath);
39
+ continue;
40
+ }
41
+ issue.elements.push({
42
+ fingerprint,
43
+ selector: node.selector,
44
+ html: node.html,
45
+ pages: [audit.relativePath]
46
+ });
47
+ }
48
+ }
49
+ const issues = [...byRule.values()];
50
+ for (const issue of issues) {
51
+ issue.pages.sort();
52
+ for (const element of issue.elements) element.pages.sort();
53
+ issue.elements.sort(byReachThenSelector);
54
+ }
55
+ issues.sort(bySeverityThenReach);
56
+ return issues;
57
+ }
58
+ /**
59
+ * Unevaluated rules folded across the site, sorted by rule id.
60
+ *
61
+ * Both reports list these once at the end rather than under every page: on a
62
+ * large site the same handful recurs on each one, and a wall of "not evaluated"
63
+ * would bury the findings that are real.
64
+ */
65
+ function blindRules(audits) {
66
+ const byRule = /* @__PURE__ */ new Map();
67
+ for (const audit of audits) for (const finding of audit.incomplete) {
68
+ if (finding.reason !== "engine-limitation") continue;
69
+ const entry = byRule.get(finding.ruleId);
70
+ if (entry) entry.pages += 1;
71
+ else byRule.set(finding.ruleId, {
72
+ ruleId: finding.ruleId,
73
+ pages: 1,
74
+ finding
75
+ });
76
+ }
77
+ return [...byRule.values()].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
78
+ }
79
+ /**
80
+ * What one page's result actually rests on, as phrases both reports print.
81
+ *
82
+ * The counts stay separate on purpose. Only `passed` is evidence that a
83
+ * criterion was met here; `not applicable` means the rule found nothing to
84
+ * check, and adding the two together would turn an empty page into a
85
+ * near-perfect score.
86
+ */
87
+ function coverageParts(audit) {
88
+ const blind = audit.incomplete.filter((finding) => finding.reason === "engine-limitation").length;
89
+ const review = audit.incomplete.length - blind;
90
+ const parts = [`${audit.passes.length} passed`, `${audit.inapplicable.length} not applicable`];
91
+ if (review > 0) parts.push(`${review} to review`);
92
+ if (blind > 0) parts.push(`${blind} not evaluated`);
93
+ return parts;
94
+ }
95
+ /** Widest reach first, then by selector so two runs agree. */
96
+ function byReachThenSelector(a, b) {
97
+ return b.pages.length - a.pages.length || a.selector.localeCompare(b.selector);
98
+ }
99
+ /** Worst first, then widest reach, then by rule id. */
100
+ function bySeverityThenReach(a, b) {
101
+ return impactRank(a.impact) - impactRank(b.impact) || b.pages.length - a.pages.length || a.ruleId.localeCompare(b.ruleId);
102
+ }
103
+ //#endregion
104
+ //#region src/audit/manual.ts
105
+ /**
106
+ * What a person has to check, and where to read about it.
107
+ *
108
+ * Automated testing finds a minority of accessibility barriers. This tool says
109
+ * so everywhere, and saying so is not much use on its own: a reader told that
110
+ * six rules "could not be evaluated" is left knowing there is a gap and not
111
+ * what to do about it.
112
+ *
113
+ * So each rule the browserless engine cannot decide carries the check somebody
114
+ * would do by hand, and each success criterion carries a link to what it
115
+ * actually requires. The point is to turn the honest disclaimer into the part
116
+ * of the report with the most work in it.
117
+ */
118
+ /**
119
+ * WCAG 2.2 Understanding pages, by success criterion.
120
+ *
121
+ * The Understanding document rather than the criterion text: the text says what
122
+ * conformance is, and the Understanding page says what it is for and how to
123
+ * meet it, which is what somebody reading a report needs.
124
+ *
125
+ * Level A and AA only, since that is what this tool audits against.
126
+ */
127
+ const UNDERSTANDING = {
128
+ "1.1.1": "non-text-content",
129
+ "1.2.1": "audio-only-and-video-only-prerecorded",
130
+ "1.2.2": "captions-prerecorded",
131
+ "1.2.3": "audio-description-or-media-alternative-prerecorded",
132
+ "1.2.4": "captions-live",
133
+ "1.2.5": "audio-description-prerecorded",
134
+ "1.3.1": "info-and-relationships",
135
+ "1.3.2": "meaningful-sequence",
136
+ "1.3.3": "sensory-characteristics",
137
+ "1.3.4": "orientation",
138
+ "1.3.5": "identify-input-purpose",
139
+ "1.4.1": "use-of-color",
140
+ "1.4.2": "audio-control",
141
+ "1.4.3": "contrast-minimum",
142
+ "1.4.4": "resize-text",
143
+ "1.4.5": "images-of-text",
144
+ "1.4.10": "reflow",
145
+ "1.4.11": "non-text-contrast",
146
+ "1.4.12": "text-spacing",
147
+ "1.4.13": "content-on-hover-or-focus",
148
+ "2.1.1": "keyboard",
149
+ "2.1.2": "no-keyboard-trap",
150
+ "2.1.4": "character-key-shortcuts",
151
+ "2.2.1": "timing-adjustable",
152
+ "2.2.2": "pause-stop-hide",
153
+ "2.3.1": "three-flashes-or-below-threshold",
154
+ "2.4.1": "bypass-blocks",
155
+ "2.4.2": "page-titled",
156
+ "2.4.3": "focus-order",
157
+ "2.4.4": "link-purpose-in-context",
158
+ "2.4.5": "multiple-ways",
159
+ "2.4.6": "headings-and-labels",
160
+ "2.4.7": "focus-visible",
161
+ "2.4.11": "focus-not-obscured-minimum",
162
+ "2.5.1": "pointer-gestures",
163
+ "2.5.2": "pointer-cancellation",
164
+ "2.5.3": "label-in-name",
165
+ "2.5.4": "motion-actuation",
166
+ "2.5.7": "dragging-movements",
167
+ "2.5.8": "target-size-minimum",
168
+ "3.1.1": "language-of-page",
169
+ "3.1.2": "language-of-parts",
170
+ "3.2.1": "on-focus",
171
+ "3.2.2": "on-input",
172
+ "3.2.3": "consistent-navigation",
173
+ "3.2.4": "consistent-identification",
174
+ "3.2.6": "consistent-help",
175
+ "3.3.1": "error-identification",
176
+ "3.3.2": "labels-or-instructions",
177
+ "3.3.3": "error-suggestion",
178
+ "3.3.4": "error-prevention-legal-financial-data",
179
+ "3.3.7": "redundant-entry",
180
+ "3.3.8": "accessible-authentication-minimum",
181
+ "4.1.2": "name-role-value",
182
+ "4.1.3": "status-messages"
183
+ };
184
+ /** Where to read what a success criterion requires, or undefined if unknown. */
185
+ function understandingUrl(criterion) {
186
+ const slug = UNDERSTANDING[criterion];
187
+ return slug === void 0 ? void 0 : `https://www.w3.org/WAI/WCAG22/Understanding/${slug}.html`;
188
+ }
189
+ /**
190
+ * The check a person does for a rule this engine could not decide.
191
+ *
192
+ * Written as an action rather than a restatement of the criterion. "Ensure
193
+ * sufficient contrast" tells somebody nothing they did not already know; the
194
+ * useful sentence names what to open and what to look at.
195
+ */
196
+ const MANUAL_CHECKS = {
197
+ "color-contrast": {
198
+ check: "Open the page and check text against its background with a contrast checker. Body text needs 4.5:1, and large or bold text 3:1. Check the states too — hover, focus, visited, disabled and placeholder text are the ones usually missed.",
199
+ browserAnswers: true
200
+ },
201
+ "color-contrast-enhanced": {
202
+ check: "Only needed if you are claiming AAA. Body text needs 7:1 and large text 4.5:1.",
203
+ browserAnswers: true
204
+ },
205
+ "target-size": {
206
+ check: "Measure the clickable area of buttons, icon links and form controls: 24×24 CSS pixels at minimum, unless they are inline in a sentence or have that much clear space around them. Icon-only controls in a header or a media player are where this usually fails.",
207
+ browserAnswers: true
208
+ },
209
+ "scrollable-region-focusable": {
210
+ check: "Find anything that scrolls inside the page — a code block, a wide table, a long list — and try reaching it with the Tab key alone. If it cannot take focus, a keyboard user cannot scroll it.",
211
+ browserAnswers: true
212
+ },
213
+ "link-in-text-block": {
214
+ check: "Look at links inside paragraphs. If the only thing distinguishing them from the surrounding text is colour, they need an underline or a 3:1 contrast difference against that text as well.",
215
+ browserAnswers: true
216
+ },
217
+ "no-autoplay-audio": {
218
+ check: "Load the page and listen. Anything that plays for more than three seconds on its own needs a pause or stop control, or a volume control independent of the system volume.",
219
+ browserAnswers: false
220
+ },
221
+ "avoid-inline-spacing": {
222
+ check: "Override line height to 1.5×, paragraph spacing to 2×, letter spacing to 0.12× and word spacing to 0.16× the font size, then check nothing is clipped or overlapping. Inline styles that set spacing with !important are what break this.",
223
+ browserAnswers: true
224
+ },
225
+ "p-as-heading": {
226
+ check: "Look for paragraphs styled to look like headings. A screen reader reads them as body text, so they are invisible to anyone navigating by heading.",
227
+ browserAnswers: false
228
+ },
229
+ "css-orientation-lock": {
230
+ check: "Rotate a phone or tablet. The content should work in both orientations unless one is essential, which is rare outside games and instruments.",
231
+ browserAnswers: false
232
+ }
233
+ };
234
+ /** The check for a rule, if there is one written for it. */
235
+ function manualCheckFor(ruleId) {
236
+ return MANUAL_CHECKS[ruleId];
237
+ }
238
+ //#endregion
239
+ export { groupIssues as a, coverageParts as i, understandingUrl as n, isShared as o, blindRules as r, manualCheckFor as t };
@@ -1,4 +1,5 @@
1
- import { i as shapeResults, n as failedPage, r as runOptions, t as DEFAULT_TAGS } from "./result-2aZPfM8w.js";
1
+ import { r as isFile } from "./fs-BmPtmFke.js";
2
+ import { i as pageUrl, n as failedPage, o as runOptions, s as shapeResults, t as DEFAULT_TAGS } from "./result-DLxd2Eip.js";
2
3
  import { createRequire } from "node:module";
3
4
  import { stat } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -74,7 +75,7 @@ async function handle(root, requestUrl, response) {
74
75
  }
75
76
  try {
76
77
  const target = (await stat(file)).isDirectory() ? path.join(file, "index.html") : file;
77
- if (!await isReadableFile(target)) {
78
+ if (!await isFile(target)) {
78
79
  response.writeHead(404).end();
79
80
  return;
80
81
  }
@@ -88,13 +89,6 @@ async function handle(root, requestUrl, response) {
88
89
  response.writeHead(404).end();
89
90
  }
90
91
  }
91
- async function isReadableFile(candidate) {
92
- try {
93
- return (await stat(candidate)).isFile();
94
- } catch {
95
- return false;
96
- }
97
- }
98
92
  /** Resolves a request path inside the root, or nothing if it escapes it. */
99
93
  function resolveFile(root, requestUrl) {
100
94
  const { pathname } = new URL(requestUrl, "http://127.0.0.1");
@@ -151,7 +145,7 @@ async function runBrowserAudit(directory, pages, options = {}) {
151
145
  browser = await chromium.launch({ headless: true });
152
146
  } catch (cause) {
153
147
  await server?.close();
154
- throw cause;
148
+ throw launchFailure(cause);
155
149
  }
156
150
  try {
157
151
  const context = await browser.newContext({
@@ -176,7 +170,7 @@ async function auditOne(context, origin, page, options) {
176
170
  const identity = {
177
171
  relativePath: page.relativePath,
178
172
  absolutePath: page.absolutePath,
179
- url: reportedUrl(page, options.baseUrl),
173
+ url: pageUrl(page, options.baseUrl),
180
174
  engine: "browser"
181
175
  };
182
176
  const tab = await context.newPage();
@@ -218,9 +212,27 @@ async function auditOne(context, origin, page, options) {
218
212
  function servedUrl(origin, relativePath) {
219
213
  return `${origin}/${relativePath.split("/").map(encodeURIComponent).join("/")}`;
220
214
  }
221
- function reportedUrl(page, baseUrl) {
222
- if (!baseUrl) return pathToFileURL(page.absolutePath).href;
223
- return new URL(page.relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).href;
215
+ /**
216
+ * A launch that failed because the browser was never downloaded, told as setup.
217
+ *
218
+ * The third setup failure, and the only one that used to arrive as a crash.
219
+ * `npm i -D playwright` does not fetch Chromium, so somebody who followed the
220
+ * install line exactly still lands here — and what they saw was Playwright's
221
+ * message boxed in ASCII, wrapped in a stack trace through this package's
222
+ * bundled internals. That reads as eaa-kit falling over, not as a step left to
223
+ * run, and the two want very different things from the reader.
224
+ *
225
+ * Anything else is passed through untouched: a launch can fail for reasons
226
+ * that really are bugs, and dressing those up as setup advice would send
227
+ * somebody off installing a browser they already have.
228
+ */
229
+ function launchFailure(cause) {
230
+ if (!(cause instanceof Error)) return cause;
231
+ const missing = /Executable doesn't exist at (.+)/.exec(cause.message);
232
+ if (missing === null) return cause;
233
+ return new BrowserUnavailableError(`Playwright is installed, but the Chromium it drives is not.
234
+ Download it with: npx playwright install chromium
235
+ It was looked for at: ${missing[1]?.trim() ?? "an unreported path"}`);
224
236
  }
225
237
  /**
226
238
  * Playwright is an optional peer dependency: the browserless path must never
@@ -229,18 +241,53 @@ function reportedUrl(page, baseUrl) {
229
241
  * problems with different fixes.
230
242
  */
231
243
  async function loadChromium(cwd = process.cwd()) {
232
- let module;
244
+ const tried = [];
245
+ const specifiers = ["playwright", "@playwright/test"];
246
+ const roots = [cwd, void 0];
247
+ for (const root of roots) for (const specifier of specifiers) {
248
+ const loaded = await tryLoad(specifier, root);
249
+ if (loaded === void 0) continue;
250
+ tried.push(`${specifier}${root === void 0 ? " (from eaa-kit)" : ""}`);
251
+ const chromium = launcherIn(loaded);
252
+ if (chromium !== void 0) return chromium;
253
+ }
254
+ if (tried.length === 0) throw new BrowserUnavailableError("Browser mode needs Playwright, which is an optional peer dependency.\n Install it with: npm i -D playwright\n Then the browser: npx playwright install chromium");
255
+ throw new BrowserUnavailableError(`Found ${tried.join(" and ")}, but no chromium launcher on it.\n This usually means an incomplete or mismatched install. Try:
256
+ npm i -D playwright@latest
257
+ npx playwright install chromium --force`);
258
+ }
259
+ /** Import a specifier, resolved from `root` when given. Undefined if absent. */
260
+ async function tryLoad(specifier, root) {
233
261
  try {
234
- const resolved = createRequire(pathToFileURL(path.join(cwd, "package.json")).href).resolve("playwright");
235
- module = await import(pathToFileURL(resolved).href);
236
- } catch {}
237
- if (module === void 0) try {
238
- module = await import("playwright");
262
+ if (root === void 0) return await import(specifier);
263
+ const require = createRequire(pathToFileURL(path.join(root, "package.json")).href);
264
+ return await import(pathToFileURL(require.resolve(specifier)).href);
239
265
  } catch {
240
- throw new BrowserUnavailableError("Browser mode needs Playwright, which is an optional peer dependency.\n Install it with: npm i -D playwright\n Then the browser: npx playwright install chromium");
266
+ return;
267
+ }
268
+ }
269
+ /**
270
+ * The chromium launcher, whichever shape the module came back in.
271
+ *
272
+ * Playwright is CommonJS. Whether `await import()` of it exposes named exports
273
+ * depends on Node's static analysis of the file succeeding, which is not
274
+ * guaranteed and differs between versions; when it does not, everything is on
275
+ * `default` instead. Reading only the named export produced "installed but
276
+ * exports no chromium launcher" against a perfectly good install.
277
+ *
278
+ * Exported for its own tests. Driving this through a real import under vitest
279
+ * proves nothing: vitest hands CommonJS back through an interop proxy that
280
+ * answers `.chromium` whether or not Node hoisted it, so both shapes look
281
+ * identical from in there and the test passes with this function broken. The
282
+ * shapes are checked here directly, and the real interop is exercised against
283
+ * real Node by scripts/test-packaged.mjs.
284
+ */
285
+ function launcherIn(module) {
286
+ const candidates = [module, module?.default];
287
+ for (const candidate of candidates) {
288
+ const chromium = candidate?.chromium;
289
+ if (chromium !== void 0 && typeof chromium.launch === "function") return chromium;
241
290
  }
242
- if (!module.chromium) throw new BrowserUnavailableError("Playwright is installed but exports no chromium launcher");
243
- return module.chromium;
244
291
  }
245
292
  //#endregion
246
293
  export { BrowserUnavailableError, runBrowserAudit };
@@ -1,6 +1,5 @@
1
- import { n as failedPage } from "./result-2aZPfM8w.js";
2
- import { stat } from "node:fs/promises";
3
- import { pathToFileURL } from "node:url";
1
+ import { r as isFile } from "./fs-BmPtmFke.js";
2
+ import { i as pageUrl, n as failedPage } from "./result-DLxd2Eip.js";
4
3
  import { Worker } from "node:worker_threads";
5
4
  import { availableParallelism } from "node:os";
6
5
  //#region src/audit/runners/pool.ts
@@ -97,7 +96,7 @@ async function runPooledAudit(pages, options = {}) {
97
96
  return runWorkers(pages, runnerOptions, Math.min(workers, pages.length), entry);
98
97
  }
99
98
  async function auditHere(pages, options) {
100
- const { runJsdomAudit } = await import("./jsdom-C6dIyaxN.js");
99
+ const { runJsdomAudit } = await import("./jsdom-X4KYfTp8.js");
101
100
  return runJsdomAudit(pages, options);
102
101
  }
103
102
  async function runWorkers(pages, options, count, entry) {
@@ -159,11 +158,6 @@ function identity(page, options) {
159
158
  durationMs: 0
160
159
  };
161
160
  }
162
- /** Kept in step with the sequential runner's own URL derivation. */
163
- function pageUrl(page, baseUrl) {
164
- if (!baseUrl) return pathToFileURL(page.absolutePath).href;
165
- return new URL(page.relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).href;
166
- }
167
161
  /**
168
162
  * Locate the worker entry point.
169
163
  *
@@ -180,9 +174,7 @@ async function findWorkerEntry() {
180
174
  new URL("../audit/runners/worker.js", import.meta.url),
181
175
  new URL("./worker.js", import.meta.url)
182
176
  ];
183
- for (const candidate of candidates) try {
184
- if ((await stat(candidate)).isFile()) return candidate;
185
- } catch {}
177
+ for (const candidate of candidates) if (await isFile(candidate)) return candidate;
186
178
  }
187
179
  //#endregion
188
180
  export { plannedWorkers, runPooledAudit };
@@ -1,4 +1,6 @@
1
- import { readFile, stat } from "node:fs/promises";
1
+ import { n as isDirectory, t as exists } from "./fs-BmPtmFke.js";
2
+ import { r as candidateOutputs } from "./frameworks-BYa3tULg.js";
3
+ import { readFile } from "node:fs/promises";
2
4
  import path from "node:path";
3
5
  import { glob } from "tinyglobby";
4
6
  import { spawn } from "node:child_process";
@@ -17,15 +19,6 @@ import { spawn } from "node:child_process";
17
19
  * project's server if the build produces nothing browsable. Naming a directory
18
20
  * or passing --url skips all of it.
19
21
  */
20
- /** Build output directories, in the order worth trying them. */
21
- const BUILD_DIRECTORIES = [
22
- "dist",
23
- "out",
24
- "build",
25
- "_site",
26
- ".output/public",
27
- "public"
28
- ];
29
22
  /** Ports the common dev and preview servers use, tried if nothing is announced. */
30
23
  const KNOWN_PORTS = [
31
24
  3e3,
@@ -55,10 +48,7 @@ async function detectPackageManager(cwd) {
55
48
  ["pnpm-lock.yaml", "pnpm"],
56
49
  ["yarn.lock", "yarn"],
57
50
  ["bun.lockb", "bun"]
58
- ]) try {
59
- await stat(path.join(cwd, file));
60
- return manager;
61
- } catch {}
51
+ ]) if (await exists(path.join(cwd, file))) return manager;
62
52
  return "npm";
63
53
  }
64
54
  /**
@@ -69,13 +59,10 @@ async function detectPackageManager(cwd) {
69
59
  * assets. What makes a directory the build output is that there is HTML in it.
70
60
  */
71
61
  async function findBuildOutput(cwd) {
72
- for (const candidate of BUILD_DIRECTORIES) {
62
+ const candidates = await candidateOutputs(cwd, await readPackageJson(cwd));
63
+ for (const candidate of candidates) {
73
64
  const directory = path.join(cwd, candidate);
74
- try {
75
- if (!(await stat(directory)).isDirectory()) continue;
76
- } catch {
77
- continue;
78
- }
65
+ if (!await isDirectory(directory)) continue;
79
66
  if ((await glob(["**/*.html", "**/*.htm"], {
80
67
  cwd: directory,
81
68
  ignore: ["**/node_modules/**"],
@@ -278,4 +265,4 @@ async function autoDetectSource(cwd, options = {}) {
278
265
  };
279
266
  }
280
267
  //#endregion
281
- export { autoDetectSource };
268
+ export { autoDetectSource, readPackageJson };
@@ -1,8 +1,9 @@
1
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";
2
- import { n as IMPACT_LEVELS } from "./impact-DvgBjupx.js";
3
- import { n as escapeText, t as escapeAttribute } from "./escape-Dm1o_RAk.js";
2
+ 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";
4
5
  import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
5
- import { readFile, readdir, stat } from "node:fs/promises";
6
+ import { readFile, readdir } from "node:fs/promises";
6
7
  import path from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  //#region src/statement/error.ts
@@ -110,14 +111,10 @@ function toImpact(value) {
110
111
  }
111
112
  /**
112
113
  * Most severe first, then by rule id so two runs of the same build order the
113
- * list identically. An unclassified impact sorts with the most severe, on the
114
- * same reasoning as `--fail-on`: a missing impact is a gap in what we know, not
115
- * evidence that the barrier is harmless.
114
+ * list identically.
116
115
  */
117
116
  function bySeverityThenRule(a, b) {
118
- const rank = (finding) => finding.impact === null ? IMPACT_LEVELS.length : IMPACT_LEVELS.indexOf(finding.impact);
119
- const difference = rank(b) - rank(a);
120
- return difference === 0 ? a.ruleId.localeCompare(b.ruleId) : difference;
117
+ return impactRank(a.impact) - impactRank(b.impact) || a.ruleId.localeCompare(b.ruleId);
121
118
  }
122
119
  //#endregion
123
120
  //#region src/statement/html.ts
@@ -524,9 +521,6 @@ function reasonScope(reason) {
524
521
  isFixPlanned: reason === "fix-planned"
525
522
  };
526
523
  }
527
- function standardsReference(successCriteria, en301549) {
528
- return [...successCriteria.map((criterion) => `WCAG ${criterion}`), ...en301549.map((clause) => `EN 301 549 ${clause}`)].join(", ");
529
- }
530
524
  /**
531
525
  * 2026-08-20 becomes 20. August 2026 or 20 August 2026.
532
526
  *
@@ -578,9 +572,7 @@ async function findTemplateDirectory() {
578
572
  path.join(here, "..", "statement", "templates"),
579
573
  path.join(here, "statement", "templates")
580
574
  ];
581
- for (const candidate of candidates) try {
582
- if ((await stat(candidate)).isDirectory()) return candidate;
583
- } catch {}
575
+ for (const candidate of candidates) if (await isDirectory(candidate)) return candidate;
584
576
  throw new StatementError(`Could not locate the statement templates. Looked in: ${candidates.join(", ")}`);
585
577
  }
586
578
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { pathToFileURL } from "node:url";
1
2
  import axe from "axe-core";
2
3
  //#region src/audit/result.ts
3
4
  /** WCAG 2.2 AA and everything it builds on. Best-practice rules stay off. */
@@ -8,6 +9,42 @@ const DEFAULT_TAGS = [
8
9
  "wcag21aa",
9
10
  "wcag22aa"
10
11
  ];
12
+ /**
13
+ * The URL a page is audited under, shared by every runner so their reports name
14
+ * the same page. Without a base URL that is the file it came off disk as.
15
+ */
16
+ function pageUrl(page, baseUrl) {
17
+ if (!baseUrl) return pathToFileURL(page.absolutePath).href;
18
+ return new URL(page.relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).href;
19
+ }
20
+ /**
21
+ * The elements a finding points at, as selector and markup.
22
+ *
23
+ * A rule can fail with no node attached — a document-level rule. It still has
24
+ * an identity, so it gets one empty element: the baseline writer, the baseline
25
+ * matcher, the issues view and SARIF all have to agree on what that identity
26
+ * is, and they only do because they all come through here.
27
+ */
28
+ function findingElements(finding) {
29
+ if (finding.nodes.length === 0) return [{
30
+ selector: "",
31
+ html: ""
32
+ }];
33
+ return finding.nodes.map((node) => ({
34
+ selector: node.target.join(" "),
35
+ html: node.html
36
+ }));
37
+ }
38
+ /** Every rule this page reached any verdict on, in one list. */
39
+ function ruleOutcomes(audit) {
40
+ return [
41
+ ...audit.violations,
42
+ ...audit.accepted ?? [],
43
+ ...audit.incomplete,
44
+ ...audit.passes,
45
+ ...audit.inapplicable
46
+ ];
47
+ }
11
48
  function runOptions(tags) {
12
49
  return {
13
50
  runOnly: {
@@ -165,4 +202,4 @@ function enClauses(tags) {
165
202
  return [...clauses].sort();
166
203
  }
167
204
  //#endregion
168
- export { shapeResults as i, failedPage as n, runOptions as r, DEFAULT_TAGS as t };
205
+ export { ruleOutcomes as a, pageUrl as i, failedPage as n, runOptions as o, findingElements as r, shapeResults as s, DEFAULT_TAGS as t };
@@ -1,4 +1,4 @@
1
- import { stat } from "node:fs/promises";
1
+ import { i as toPosix, n as isDirectory } from "./fs-BmPtmFke.js";
2
2
  import path from "node:path";
3
3
  import { glob } from "tinyglobby";
4
4
  //#region src/audit/routes.ts
@@ -49,7 +49,7 @@ const CONVENTIONS = [
49
49
  * without knowing the data behind it.
50
50
  */
51
51
  function routePathFor(relativeFile, framework) {
52
- let route = relativeFile.split(path.sep).join("/");
52
+ let route = toPosix(relativeFile);
53
53
  if (framework === "next-app" || framework === "sveltekit") route = route.includes("/") ? route.replace(/\/[^/]+$/, "") : "";
54
54
  else {
55
55
  route = route.replace(/\.[^./]+$/, "");
@@ -90,11 +90,7 @@ function emittedPathsFor(route) {
90
90
  async function buildRouteMap(cwd) {
91
91
  for (const convention of CONVENTIONS) {
92
92
  const directory = path.join(cwd, convention.dir);
93
- try {
94
- if (!(await stat(directory)).isDirectory()) continue;
95
- } catch {
96
- continue;
97
- }
93
+ if (!await isDirectory(directory)) continue;
98
94
  const files = await glob([convention.pattern], {
99
95
  cwd: directory,
100
96
  ignore: ["**/node_modules/**"],
@@ -105,7 +101,7 @@ async function buildRouteMap(cwd) {
105
101
  for (const file of files) {
106
102
  const route = routePathFor(file, convention.framework);
107
103
  if (route === void 0) continue;
108
- const source = `${convention.dir}/${file.split(path.sep).join("/")}`;
104
+ const source = `${convention.dir}/${toPosix(file)}`;
109
105
  for (const emitted of emittedPathsFor(route)) if (!sources.has(emitted)) sources.set(emitted, source);
110
106
  }
111
107
  if (sources.size > 0) return {
@@ -0,0 +1,45 @@
1
+ import { t as ImpactLevel } from "./impact-EEB9ZXmC.js";
2
+ //#region src/cli/audit.d.ts
3
+ declare const OUTPUT_FORMATS: readonly ['console', 'json', 'sarif', 'html'];
4
+ type OutputFormat = (typeof OUTPUT_FORMATS)[number];
5
+ //#endregion
6
+ //#region src/integration/run.d.ts
7
+ /**
8
+ * What a build-time integration does once the build has written its files.
9
+ *
10
+ * Astro, Vite and Next all reach the same point by different routes: a
11
+ * directory exists, and the build should stop if what is in it fails the
12
+ * threshold. Only the hook name and the logger differ, so only those live in
13
+ * the integrations.
14
+ */
15
+ interface IntegrationOptions {
16
+ /** Lowest impact that fails the build. Defaults to 'serious'. */
17
+ failOn?: ImpactLevel;
18
+ include?: string[];
19
+ exclude?: string[];
20
+ baseUrl?: string;
21
+ /** Audit in real Chromium. Needs the playwright peer. */
22
+ browser?: boolean;
23
+ concurrency?: number;
24
+ baseline?: string;
25
+ format?: OutputFormat;
26
+ /** Write the report here instead of the build log. */
27
+ output?: string;
28
+ /**
29
+ * Report without failing the build. For the week it takes to adopt this on a
30
+ * site that already exists — a baseline is the honest way to go green after
31
+ * that.
32
+ */
33
+ failBuild?: boolean;
34
+ /** Skip entirely. For turning it off per environment without unwiring it. */
35
+ enabled?: boolean;
36
+ }
37
+ /**
38
+ * Thrown to fail the build. Its own class so a consumer can tell an audit
39
+ * failure from the build tool falling over.
40
+ */
41
+ declare class BuildAuditError extends Error {
42
+ readonly name = "BuildAuditError";
43
+ }
44
+ //#endregion
45
+ export { IntegrationOptions as n, BuildAuditError as t };