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.
- package/README.md +13 -4
- package/dist/astro/index.d.ts +0 -9
- package/dist/astro/index.js +1 -1
- package/dist/audit-CqrR9pIO.js +2 -0
- package/dist/{audit-6gbV0Zjd.js → audit-D4gju2BT.js} +507 -140
- package/dist/{baseline-Itspu3-Y.js → baseline-CV_3lbER.js} +1 -1
- package/dist/{baseline-DQTnNlc4.js → baseline-CgBmzFTr.js} +16 -16
- package/dist/cli/index.js +54 -34
- package/dist/crawl-CtJbMNNb.js +254 -0
- package/dist/index.d.ts +155 -65
- package/dist/index.js +2 -1
- package/dist/init-DRKIdpK1.js +110 -0
- package/dist/{json-1ESNIiHY.js → json-C9xS1PNC.js} +3 -1
- package/dist/load-sCkKsvGQ.js +192 -0
- package/dist/{playwright-BfWuTG_u.js → playwright-DWux49V3.js} +4 -4
- package/dist/project-CiyzKQud.js +258 -0
- package/dist/{render-K9KxDDSA.js → render-BO0nVrrZ.js} +21 -208
- package/dist/routes-BxbSZKXC.js +123 -0
- package/dist/schema-CMZ8ItGk.js +192 -0
- package/package.json +4 -5
- package/dist/audit-VtuUOuyX.js +0 -2
|
@@ -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
|
|
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 =
|
|
214
|
-
schemaVersion:
|
|
215
|
-
generatedAt:
|
|
216
|
-
summary:
|
|
217
|
-
pages:
|
|
218
|
-
needsReview:
|
|
219
|
-
notEvaluated:
|
|
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:
|
|
222
|
-
help:
|
|
223
|
-
successCriteria:
|
|
224
|
-
en301549:
|
|
34
|
+
rules: record(object({
|
|
35
|
+
help: string(),
|
|
36
|
+
successCriteria: withDefault(array(string()), () => []),
|
|
37
|
+
en301549: withDefault(array(string()), () => [])
|
|
225
38
|
})),
|
|
226
|
-
pages:
|
|
227
|
-
path:
|
|
228
|
-
violations:
|
|
229
|
-
ruleId:
|
|
230
|
-
impact:
|
|
231
|
-
}))
|
|
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 =
|
|
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 {
|
|
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 };
|