eaa-kit 0.1.0 → 0.2.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.
@@ -0,0 +1,258 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { glob } from "tinyglobby";
4
+ import { spawn } from "node:child_process";
5
+ //#region src/audit/project.ts
6
+ /**
7
+ * Working out what to audit when nobody said.
8
+ *
9
+ * `eaa-kit audit ./dist` assumes the reader knows which directory their builder
10
+ * fills, that it has been built, and that it produces HTML at all — three
11
+ * assumptions that are wrong often enough to be the main thing standing between
12
+ * installing this tool and getting a report out of it. A Next.js project fails
13
+ * all three at once.
14
+ *
15
+ * So with no directory and no --url, the tool works it out: an existing build
16
+ * output if there is one, the project's own build if there is not, and the
17
+ * project's server if the build produces nothing browsable. Naming a directory
18
+ * or passing --url skips all of it.
19
+ */
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
+ /** Ports the common dev and preview servers use, tried if nothing is announced. */
30
+ const KNOWN_PORTS = [
31
+ 3e3,
32
+ 4321,
33
+ 5173,
34
+ 8080,
35
+ 4173,
36
+ 3001
37
+ ];
38
+ /** How long to wait for a started server to answer. */
39
+ const SERVER_START_TIMEOUT_MS = 9e4;
40
+ async function readPackageJson(cwd) {
41
+ try {
42
+ return JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8"));
43
+ } catch {
44
+ return;
45
+ }
46
+ }
47
+ /**
48
+ * The package manager this project uses, from its lockfile.
49
+ *
50
+ * Running the wrong one either fails or, worse, silently installs a second
51
+ * dependency tree, so the lockfile decides rather than a guess.
52
+ */
53
+ async function detectPackageManager(cwd) {
54
+ for (const [file, manager] of [
55
+ ["pnpm-lock.yaml", "pnpm"],
56
+ ["yarn.lock", "yarn"],
57
+ ["bun.lockb", "bun"]
58
+ ]) try {
59
+ await stat(path.join(cwd, file));
60
+ return manager;
61
+ } catch {}
62
+ return "npm";
63
+ }
64
+ /**
65
+ * The first build directory that exists and actually holds HTML.
66
+ *
67
+ * Existence alone is not enough: `.next/` exists after any Next.js build and
68
+ * holds no browsable page, and `public/` exists in most projects and holds
69
+ * assets. What makes a directory the build output is that there is HTML in it.
70
+ */
71
+ async function findBuildOutput(cwd) {
72
+ for (const candidate of BUILD_DIRECTORIES) {
73
+ const directory = path.join(cwd, candidate);
74
+ try {
75
+ if (!(await stat(directory)).isDirectory()) continue;
76
+ } catch {
77
+ continue;
78
+ }
79
+ if ((await glob(["**/*.html", "**/*.htm"], {
80
+ cwd: directory,
81
+ ignore: ["**/node_modules/**"],
82
+ onlyFiles: true,
83
+ dot: false
84
+ })).length > 0) return directory;
85
+ }
86
+ }
87
+ /**
88
+ * Run one of the project's own scripts and wait for it.
89
+ *
90
+ * stdio is inherited so the build's output goes where the user expects it. This
91
+ * runs project code, which is what a build-time tool does — the Astro
92
+ * integration already audits from inside one — but it is announced first and
93
+ * `--no-build` turns it off.
94
+ */
95
+ async function runScript(cwd, script) {
96
+ const manager = await detectPackageManager(cwd);
97
+ const command = `${manager} run ${script}`;
98
+ return new Promise((resolve) => {
99
+ const child = spawn(manager, ["run", script], {
100
+ cwd,
101
+ stdio: [
102
+ "ignore",
103
+ "inherit",
104
+ "inherit"
105
+ ],
106
+ shell: process.platform === "win32"
107
+ });
108
+ child.on("error", () => resolve({
109
+ ok: false,
110
+ command
111
+ }));
112
+ child.on("close", (code) => resolve({
113
+ ok: code === 0,
114
+ command
115
+ }));
116
+ });
117
+ }
118
+ /** Whether something is answering HTTP there yet. */
119
+ async function answers(origin) {
120
+ try {
121
+ const controller = new AbortController();
122
+ const timer = setTimeout(() => controller.abort(), 2e3);
123
+ try {
124
+ await fetch(origin, { signal: controller.signal });
125
+ return true;
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+ /**
134
+ * Start the project's server and wait until it answers.
135
+ *
136
+ * The origin is taken from whatever the server prints, since a project may be
137
+ * on any port, and only falls back to probing the common ones. Returns undefined
138
+ * if nothing came up before the timeout, having already stopped the process.
139
+ */
140
+ async function startServer(cwd, script) {
141
+ const manager = await detectPackageManager(cwd);
142
+ const child = spawn(manager, ["run", script], {
143
+ cwd,
144
+ stdio: [
145
+ "ignore",
146
+ "pipe",
147
+ "pipe"
148
+ ],
149
+ shell: process.platform === "win32",
150
+ detached: process.platform !== "win32"
151
+ });
152
+ let announced;
153
+ const watch = (chunk) => {
154
+ const match = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):(\d{2,5})/.exec(String(chunk));
155
+ if (match && announced === void 0) announced = `http://localhost:${match[1]}`;
156
+ };
157
+ child.stdout?.on("data", watch);
158
+ child.stderr?.on("data", watch);
159
+ let stopped = false;
160
+ const stop = async () => {
161
+ if (stopped) return;
162
+ stopped = true;
163
+ const signal = (name) => {
164
+ try {
165
+ if (child.pid === void 0) return;
166
+ if (process.platform === "win32") child.kill(name);
167
+ else process.kill(-child.pid, name);
168
+ } catch {}
169
+ };
170
+ if (child.exitCode === null && child.signalCode === null) {
171
+ signal("SIGTERM");
172
+ await new Promise((resolve) => {
173
+ const timer = setTimeout(() => {
174
+ signal("SIGKILL");
175
+ resolve();
176
+ }, 5e3);
177
+ child.once("close", () => {
178
+ clearTimeout(timer);
179
+ resolve();
180
+ });
181
+ });
182
+ }
183
+ child.stdout?.destroy();
184
+ child.stderr?.destroy();
185
+ child.unref();
186
+ };
187
+ const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
188
+ while (Date.now() < deadline) {
189
+ if (child.exitCode !== null) return void 0;
190
+ for (const origin of announced === void 0 ? KNOWN_PORTS.map((port) => `http://localhost:${port}`) : [announced]) if (await answers(origin)) return {
191
+ origin,
192
+ stop
193
+ };
194
+ await new Promise((resolve) => setTimeout(resolve, 500));
195
+ }
196
+ await stop();
197
+ }
198
+ /**
199
+ * Work out what to audit in this project, building or serving it if need be.
200
+ *
201
+ * Returns undefined when there is nothing it can do, leaving the caller to
202
+ * explain — it has better context for the message than this does.
203
+ */
204
+ async function autoDetectSource(cwd, options = {}) {
205
+ const steps = [];
206
+ const step = (message) => {
207
+ steps.push(message);
208
+ options.onStep?.(message);
209
+ };
210
+ const existing = await findBuildOutput(cwd);
211
+ if (existing !== void 0) {
212
+ step(`Found a build in ${path.relative(cwd, existing) || "."}/`);
213
+ return {
214
+ directory: existing,
215
+ steps
216
+ };
217
+ }
218
+ const pkg = await readPackageJson(cwd);
219
+ if (pkg === void 0) return void 0;
220
+ const scripts = pkg.scripts ?? {};
221
+ if (options.noBuild) return void 0;
222
+ if (scripts["build"] !== void 0) {
223
+ step("No build found; running the project build first");
224
+ const built = await runScript(cwd, "build");
225
+ if (!built.ok) {
226
+ step(`${built.command} failed`);
227
+ return { steps };
228
+ }
229
+ const produced = await findBuildOutput(cwd);
230
+ if (produced !== void 0) {
231
+ step(`Built ${path.relative(cwd, produced) || "."}/`);
232
+ return {
233
+ directory: produced,
234
+ steps
235
+ };
236
+ }
237
+ }
238
+ const serveScript = [
239
+ "start",
240
+ "preview",
241
+ "serve"
242
+ ].find((name) => scripts[name] !== void 0);
243
+ if (serveScript === void 0) return { steps };
244
+ step(`This site renders on a server; starting it with ${serveScript}`);
245
+ const server = await startServer(cwd, serveScript);
246
+ if (server === void 0) {
247
+ step(`Could not start the site with ${serveScript}`);
248
+ return { steps };
249
+ }
250
+ step(`Auditing ${server.origin}`);
251
+ return {
252
+ url: server.origin,
253
+ cleanup: server.stop,
254
+ steps
255
+ };
256
+ }
257
+ //#endregion
258
+ export { autoDetectSource };
@@ -1,197 +1,10 @@
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
2
  import { n as IMPACT_LEVELS } from "./impact-DvgBjupx.js";
2
3
  import { n as escapeText, t as escapeAttribute } from "./escape-Dm1o_RAk.js";
3
4
  import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
4
- import { z } from "zod";
5
5
  import { readFile, readdir, stat } from "node:fs/promises";
6
6
  import path from "node:path";
7
- import { fileURLToPath, pathToFileURL } from "node:url";
8
- //#region src/config/define.ts
9
- /** Countries with their own supervisory body and statute text. */
10
- const COUNTRIES = [
11
- "AT",
12
- "DE",
13
- "CH"
14
- ];
15
- /** Languages a statement can be rendered in. */
16
- const STATEMENT_LOCALES = ["de", "en"];
17
- /**
18
- * Wording follows the EU model statement: fully, partially, or not conformant
19
- * with the standard. "partially-compliant" is the honest answer for most sites
20
- * and the one that carries obligations to list what is missing.
21
- */
22
- const COMPLIANCE_STATUSES = [
23
- "compliant",
24
- "partially-compliant",
25
- "non-compliant"
26
- ];
27
- const ASSESSMENT_METHODS = ["self-assessment", "external-audit"];
28
- /**
29
- * Why a known barrier still exists. The first two are the grounds the EU regime
30
- * recognises for leaving something inaccessible; the third is a plain promise to
31
- * fix it, which is what most small sites actually mean.
32
- */
33
- const ISSUE_REASONS = [
34
- "disproportionate-burden",
35
- "out-of-scope",
36
- "fix-planned"
37
- ];
38
- const knownIssueObject = z.object({
39
- /** What is not accessible, in the statement's language. */
40
- description: z.string().min(1),
41
- /** WCAG success criteria, e.g. ['1.4.3']. */
42
- successCriteria: z.array(z.string()).default([]),
43
- /** EN 301 549 clauses, e.g. ['9.1.4.3']. */
44
- en301549: z.array(z.string()).default([]),
45
- reason: z.enum(ISSUE_REASONS).optional(),
46
- /** ISO date by which the barrier is expected to be removed. */
47
- remedyBy: z.iso.date().optional()
48
- });
49
- /**
50
- * A bare string is accepted as shorthand for `{ description }`. It is piped
51
- * through the object schema so both branches produce the same output type,
52
- * rather than a union that callers have to narrow before reading `remedyBy`.
53
- */
54
- const knownIssueSchema = z.union([z.string().min(1).transform((description) => ({ description })).pipe(knownIssueObject), knownIssueObject]);
55
- const configSchema = z.object({
56
- site: z.object({
57
- name: z.string().min(1),
58
- url: z.url(),
59
- /** BCP 47 tag of the site itself, e.g. 'de-AT'. */
60
- locale: z.string().min(2)
61
- }),
62
- provider: z.object({
63
- /** The legal entity answerable for the service. */
64
- legalName: z.string().min(1),
65
- /**
66
- * The feedback address. Required: the EAA obliges providers to offer a way
67
- * to report accessibility barriers, and a statement without one is not
68
- * usable for its purpose.
69
- */
70
- email: z.email(),
71
- phone: z.string().min(1).optional(),
72
- address: z.string().min(1).optional(),
73
- /**
74
- * A contact or feedback form, offered alongside the address rather than
75
- * instead of it: the EAA requires a way to report barriers, and a form is
76
- * the one channel a visitor who cannot use email may still be able to use.
77
- */
78
- feedbackUrl: z.url().optional()
79
- }),
80
- compliance: z.object({
81
- status: z.enum(COMPLIANCE_STATUSES),
82
- standard: z.string().min(1).default("EN 301 549 V3.2.1 (WCAG 2.2 AA)"),
83
- knownIssues: z.array(knownIssueSchema).default([]),
84
- /** When the assessment was carried out. */
85
- assessedOn: z.iso.date(),
86
- assessmentMethod: z.enum(ASSESSMENT_METHODS).default("self-assessment"),
87
- /**
88
- * Reason attached to barriers taken from an audit report, which carries no
89
- * reason of its own. 'fix-planned' is the honest default for a barrier an
90
- * automated run just found; the other two are claims only a human can make.
91
- */
92
- auditReason: z.enum(ISSUE_REASONS).default("fix-planned")
93
- }),
94
- enforcement: z.object({
95
- /** Drives which supervisory body and statute the template names. */
96
- country: z.enum(COUNTRIES) })
97
- });
98
- /**
99
- * Identity function that gives `eaa.config.ts` its types. Deliberately does not
100
- * validate: a config file is loaded and checked in one place, so that an error
101
- * points at the file rather than at wherever the module happened to be
102
- * imported.
103
- */
104
- function defineConfig(config) {
105
- return config;
106
- }
107
- var ConfigError = class extends Error {
108
- issues;
109
- name = "ConfigError";
110
- constructor(message, issues = []) {
111
- super(message);
112
- this.issues = issues;
113
- }
114
- };
115
- /** Validate an already-loaded config object. */
116
- function parseConfig(value, source = "config") {
117
- const result = configSchema.safeParse(value);
118
- if (result.success) return result.data;
119
- const issues = result.error.issues.map((issue) => {
120
- const path = issue.path.join(".");
121
- return path ? `${path}: ${issue.message}` : issue.message;
122
- });
123
- throw new ConfigError(`${source} is not valid`, issues);
124
- }
125
- //#endregion
126
- //#region src/config/load.ts
127
- /** Checked in this order, first match wins. */
128
- const CONFIG_FILENAMES = [
129
- "eaa.config.ts",
130
- "eaa.config.mts",
131
- "eaa.config.js",
132
- "eaa.config.mjs",
133
- "eaa.config.json"
134
- ];
135
- /**
136
- * Find and load `eaa.config.{ts,mts,js,mjs,json}`.
137
- *
138
- * TypeScript configs are imported directly: Node strips types natively from
139
- * 22.18 onwards, which is below this package's floor, so no bundler or loader
140
- * dependency is needed. The failure mode that remains is a project with no
141
- * package.json at all, where Node cannot tell ESM from CommonJS; the error says
142
- * so rather than surfacing "Unexpected token 'export'".
143
- */
144
- async function loadConfig(options = {}) {
145
- const cwd = path.resolve(options.cwd ?? process.cwd());
146
- const file = options.path ? path.resolve(cwd, options.path) : await findConfigFile(cwd);
147
- if (!file) throw new ConfigError(`No config file found in ${cwd} or its parent directories`, CONFIG_FILENAMES.map((name) => `looked for ${name}`));
148
- if (!await isFile(file)) throw new ConfigError(`Config file not found: ${file}`);
149
- return {
150
- config: parseConfig(file.endsWith(".json") ? await importJson(file) : await importModule(file), path.basename(file)),
151
- path: file
152
- };
153
- }
154
- /** Walks up from `cwd`, so the CLI works from a subdirectory of the project. */
155
- async function findConfigFile(cwd) {
156
- let directory = path.resolve(cwd);
157
- while (true) {
158
- for (const name of CONFIG_FILENAMES) {
159
- const candidate = path.join(directory, name);
160
- if (await isFile(candidate)) return candidate;
161
- }
162
- const parent = path.dirname(directory);
163
- if (parent === directory) return void 0;
164
- directory = parent;
165
- }
166
- }
167
- async function importJson(file) {
168
- const raw = await readFile(file, "utf8");
169
- try {
170
- return JSON.parse(raw);
171
- } catch (cause) {
172
- throw new ConfigError(`${path.basename(file)} is not valid JSON`, [cause instanceof Error ? cause.message : String(cause)]);
173
- }
174
- }
175
- async function importModule(file) {
176
- let module;
177
- try {
178
- module = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
179
- } catch (cause) {
180
- const message = cause instanceof Error ? cause.message : String(cause);
181
- const hint = message.includes("Unexpected token") ? "If the project has no package.json, Node cannot tell ESM from CommonJS. Add one with \"type\": \"module\", or use eaa.config.json." : message;
182
- throw new ConfigError(`Could not load ${path.basename(file)}`, [hint]);
183
- }
184
- if (module.default === void 0) throw new ConfigError(`${path.basename(file)} has no default export`, ["Expected: export default defineConfig({ … })"]);
185
- return module.default;
186
- }
187
- async function isFile(candidate) {
188
- try {
189
- return (await stat(candidate)).isFile();
190
- } catch {
191
- return false;
192
- }
193
- }
194
- //#endregion
7
+ import { fileURLToPath } from "node:url";
195
8
  //#region src/statement/error.ts
196
9
  /**
197
10
  * Lives in its own module so that both the renderer and the audit-report reader
@@ -210,25 +23,25 @@ var StatementError = class extends Error {
210
23
  * markup, selectors, passes, inapplicable — is audit detail with no place in a
211
24
  * legal document, and unknown keys are dropped rather than being carried along.
212
25
  */
213
- const reportSchema = z.object({
214
- schemaVersion: z.number(),
215
- generatedAt: z.iso.datetime(),
216
- summary: z.object({
217
- pages: z.number(),
218
- needsReview: z.number(),
219
- notEvaluated: z.number()
26
+ const reportSchema = object({
27
+ schemaVersion: number(),
28
+ generatedAt: isoDateTime(),
29
+ summary: object({
30
+ pages: number(),
31
+ needsReview: number(),
32
+ notEvaluated: number()
220
33
  }),
221
- rules: z.record(z.string(), z.object({
222
- help: z.string(),
223
- successCriteria: z.array(z.string()).default([]),
224
- en301549: z.array(z.string()).default([])
34
+ rules: record(object({
35
+ help: string(),
36
+ successCriteria: withDefault(array(string()), () => []),
37
+ en301549: withDefault(array(string()), () => [])
225
38
  })),
226
- pages: z.array(z.object({
227
- path: z.string(),
228
- violations: z.array(z.object({
229
- ruleId: z.string(),
230
- impact: z.string().nullable().default(null)
231
- })).default([])
39
+ pages: array(object({
40
+ path: string(),
41
+ violations: withDefault(array(object({
42
+ ruleId: string(),
43
+ impact: withDefault(nullable(string()), () => null)
44
+ })), () => [])
232
45
  }))
233
46
  });
234
47
  /**
@@ -241,7 +54,7 @@ const reportSchema = z.object({
241
54
  * statement can say how much the automated run left open.
242
55
  */
243
56
  function summariseAuditReport(value, source = "audit report") {
244
- const result = reportSchema.safeParse(value);
57
+ const result = safeParse(reportSchema, value);
245
58
  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("; ")})`);
246
59
  const report = result.data;
247
60
  if (report.schemaVersion !== 1) throw new StatementError(`${source} has schemaVersion ${report.schemaVersion}; this version of eaa-kit reads 1`);
@@ -771,4 +584,4 @@ async function findTemplateDirectory() {
771
584
  throw new StatementError(`Could not locate the statement templates. Looked in: ${candidates.join(", ")}`);
772
585
  }
773
586
  //#endregion
774
- export { defineConfig as _, summariseAuditReport as a, findConfigFile as c, COMPLIANCE_STATUSES as d, COUNTRIES as f, configSchema as g, STATEMENT_LOCALES as h, readAuditReport as i, loadConfig as l, ISSUE_REASONS as m, toHtmlBody as n, StatementError as o, ConfigError as p, toHtmlDocument as r, CONFIG_FILENAMES as s, renderStatement as t, ASSESSMENT_METHODS as u, parseConfig as v };
587
+ export { summariseAuditReport as a, readAuditReport as i, toHtmlBody as n, StatementError as o, toHtmlDocument as r, renderStatement as t };
@@ -0,0 +1,123 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { glob } from "tinyglobby";
4
+ //#region src/audit/routes.ts
5
+ /** Route file conventions, most specific first. */
6
+ const CONVENTIONS = [
7
+ {
8
+ framework: "next-app",
9
+ dir: "app",
10
+ pattern: "**/page.{tsx,ts,jsx,js,mdx}"
11
+ },
12
+ {
13
+ framework: "next-app",
14
+ dir: "src/app",
15
+ pattern: "**/page.{tsx,ts,jsx,js,mdx}"
16
+ },
17
+ {
18
+ framework: "next-pages",
19
+ dir: "pages",
20
+ pattern: "**/*.{tsx,ts,jsx,js,mdx}"
21
+ },
22
+ {
23
+ framework: "next-pages",
24
+ dir: "src/pages",
25
+ pattern: "**/*.{tsx,ts,jsx,js,mdx}"
26
+ },
27
+ {
28
+ framework: "astro",
29
+ dir: "src/pages",
30
+ pattern: "**/*.{astro,md,mdx}"
31
+ },
32
+ {
33
+ framework: "nuxt",
34
+ dir: "pages",
35
+ pattern: "**/*.vue"
36
+ },
37
+ {
38
+ framework: "sveltekit",
39
+ dir: "src/routes",
40
+ pattern: "**/+page.{svelte,ts,js}"
41
+ }
42
+ ];
43
+ /**
44
+ * The URL path a route file serves.
45
+ *
46
+ * Returns undefined for anything that is not a page: a Next.js route group
47
+ * `(marketing)` contributes no segment, a private folder `_lib` contributes no
48
+ * route at all, and a dynamic segment `[slug]` cannot be resolved to one path
49
+ * without knowing the data behind it.
50
+ */
51
+ function routePathFor(relativeFile, framework) {
52
+ let route = relativeFile.split(path.sep).join("/");
53
+ if (framework === "next-app" || framework === "sveltekit") route = route.includes("/") ? route.replace(/\/[^/]+$/, "") : "";
54
+ else {
55
+ route = route.replace(/\.[^./]+$/, "");
56
+ route = route.replace(/\/index$/, "");
57
+ if (route === "index") route = "";
58
+ }
59
+ const segments = route.split("/").filter((segment) => segment !== "");
60
+ const kept = [];
61
+ for (const segment of segments) {
62
+ if (/^[[(]|^_|^@/.test(segment)) {
63
+ if (/^\(.*\)$/.test(segment)) continue;
64
+ return;
65
+ }
66
+ kept.push(segment);
67
+ }
68
+ return kept.join("/");
69
+ }
70
+ /** Every page path a build could have emitted for one route. */
71
+ function emittedPathsFor(route) {
72
+ if (route === "") return [
73
+ "/",
74
+ "index.html",
75
+ "index.htm"
76
+ ];
77
+ return [
78
+ route,
79
+ `${route}/`,
80
+ `${route}.html`,
81
+ `${route}/index.html`
82
+ ];
83
+ }
84
+ /**
85
+ * Build a page-to-source map by reading the project's route files.
86
+ *
87
+ * Returns undefined when the project uses no convention this understands, which
88
+ * is not a failure — most builds are not framework projects.
89
+ */
90
+ async function buildRouteMap(cwd) {
91
+ for (const convention of CONVENTIONS) {
92
+ const directory = path.join(cwd, convention.dir);
93
+ try {
94
+ if (!(await stat(directory)).isDirectory()) continue;
95
+ } catch {
96
+ continue;
97
+ }
98
+ const files = await glob([convention.pattern], {
99
+ cwd: directory,
100
+ ignore: ["**/node_modules/**"],
101
+ onlyFiles: true
102
+ });
103
+ if (files.length === 0) continue;
104
+ const sources = /* @__PURE__ */ new Map();
105
+ for (const file of files) {
106
+ const route = routePathFor(file, convention.framework);
107
+ if (route === void 0) continue;
108
+ const source = `${convention.dir}/${file.split(path.sep).join("/")}`;
109
+ for (const emitted of emittedPathsFor(route)) if (!sources.has(emitted)) sources.set(emitted, source);
110
+ }
111
+ if (sources.size > 0) return {
112
+ framework: convention.framework,
113
+ sources
114
+ };
115
+ }
116
+ }
117
+ /** The source file for an audited page, if the map knows one. */
118
+ function sourceFor(map, pagePath) {
119
+ if (map === void 0) return void 0;
120
+ return map.sources.get(pagePath) ?? map.sources.get(pagePath.replace(/^\//, ""));
121
+ }
122
+ //#endregion
123
+ export { buildRouteMap, sourceFor };