eaa-kit 0.1.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,774 @@
1
+ import { n as IMPACT_LEVELS } from "./impact-DvgBjupx.js";
2
+ import { n as escapeText, t as escapeAttribute } from "./escape-Dm1o_RAk.js";
3
+ import { t as TOOL_VERSION } from "./version-B3v4rNoG.js";
4
+ import { z } from "zod";
5
+ import { readFile, readdir, stat } from "node:fs/promises";
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
195
+ //#region src/statement/error.ts
196
+ /**
197
+ * Lives in its own module so that both the renderer and the audit-report reader
198
+ * can throw it without importing each other.
199
+ *
200
+ * Everything the statement command can fail on lands here: a missing template,
201
+ * an audit report it cannot read. The CLI turns it into exit code 2, since a
202
+ * statement that could not be produced is never a statement with a problem in
203
+ * it — nothing is emitted at all.
204
+ */
205
+ var StatementError = class extends Error {
206
+ name = "StatementError";
207
+ };
208
+ /**
209
+ * Only the fields the statement reads. Everything else in the report — node
210
+ * markup, selectors, passes, inapplicable — is audit detail with no place in a
211
+ * legal document, and unknown keys are dropped rather than being carried along.
212
+ */
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()
220
+ }),
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([])
225
+ })),
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([])
232
+ }))
233
+ });
234
+ /**
235
+ * Turn a parsed `eaa-kit audit --format json` document into statement input.
236
+ *
237
+ * Only violations become barriers. A rule needing manual review has not been
238
+ * found inaccessible, and a rule the engine could not evaluate has not been
239
+ * found anything at all; listing either as non-accessible content would be a
240
+ * claim the audit never made. Both are carried as counts instead, so the
241
+ * statement can say how much the automated run left open.
242
+ */
243
+ function summariseAuditReport(value, source = "audit report") {
244
+ const result = reportSchema.safeParse(value);
245
+ 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
+ const report = result.data;
247
+ if (report.schemaVersion !== 1) throw new StatementError(`${source} has schemaVersion ${report.schemaVersion}; this version of eaa-kit reads 1`);
248
+ const byRule = /* @__PURE__ */ new Map();
249
+ const rules = new Map(Object.entries(report.rules));
250
+ for (const page of report.pages) for (const violation of page.violations) {
251
+ const rule = rules.get(violation.ruleId);
252
+ if (!rule) throw new StatementError(`${source} references rule ${violation.ruleId}, which is not in its rule index`);
253
+ const existing = byRule.get(violation.ruleId);
254
+ if (existing) {
255
+ if (!existing.pages.includes(page.path)) existing.pages.push(page.path);
256
+ continue;
257
+ }
258
+ byRule.set(violation.ruleId, {
259
+ ruleId: violation.ruleId,
260
+ help: rule.help,
261
+ impact: toImpact(violation.impact),
262
+ successCriteria: rule.successCriteria,
263
+ en301549: rule.en301549,
264
+ pages: [page.path]
265
+ });
266
+ }
267
+ const findings = [...byRule.values()];
268
+ for (const finding of findings) finding.pages.sort();
269
+ findings.sort(bySeverityThenRule);
270
+ return {
271
+ findings,
272
+ pages: report.summary.pages,
273
+ needsReview: report.summary.needsReview,
274
+ notEvaluated: report.summary.notEvaluated,
275
+ generatedAt: report.generatedAt
276
+ };
277
+ }
278
+ /** Read and summarise a report written by `eaa-kit audit --format json`. */
279
+ async function readAuditReport(file, cwd = process.cwd()) {
280
+ const target = path.resolve(cwd, file);
281
+ let raw;
282
+ try {
283
+ raw = await readFile(target, "utf8");
284
+ } catch {
285
+ throw new StatementError(`Could not read the audit report at ${file}`);
286
+ }
287
+ let value;
288
+ try {
289
+ value = JSON.parse(raw);
290
+ } catch (cause) {
291
+ throw new StatementError(`${path.basename(target)} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
292
+ }
293
+ return summariseAuditReport(value, path.basename(target));
294
+ }
295
+ function toImpact(value) {
296
+ return IMPACT_LEVELS.includes(value ?? "") ? value : null;
297
+ }
298
+ /**
299
+ * Most severe first, then by rule id so two runs of the same build order the
300
+ * list identically. An unclassified impact sorts with the most severe, on the
301
+ * same reasoning as `--fail-on`: a missing impact is a gap in what we know, not
302
+ * evidence that the barrier is harmless.
303
+ */
304
+ function bySeverityThenRule(a, b) {
305
+ const rank = (finding) => finding.impact === null ? IMPACT_LEVELS.length : IMPACT_LEVELS.indexOf(finding.impact);
306
+ const difference = rank(b) - rank(a);
307
+ return difference === 0 ? a.ruleId.localeCompare(b.ruleId) : difference;
308
+ }
309
+ //#endregion
310
+ //#region src/statement/html.ts
311
+ /**
312
+ * A standalone, self-contained HTML page.
313
+ *
314
+ * Self-contained because the common destination is a CMS or a static host where
315
+ * a second file would have to be wired up by hand. The markup inside `<main>`
316
+ * carries no classes and no inline styles, so lifting it into an existing page
317
+ * template and dropping this one's `<style>` block is a copy and paste.
318
+ */
319
+ function toHtmlDocument(markdown, options) {
320
+ const body = toHtmlBody(markdown);
321
+ const title = firstHeading(markdown) ?? options.fallbackTitle;
322
+ return `<!doctype html>
323
+ <html lang="${escapeAttribute(options.lang)}">
324
+ <head>
325
+ <meta charset="utf-8">
326
+ <meta name="viewport" content="width=device-width, initial-scale=1">
327
+ <meta name="generator" content="eaa-kit ${escapeAttribute(TOOL_VERSION)}">
328
+ <title>${escapeText(title)}</title>
329
+ <style>
330
+ ${STYLES}
331
+ </style>
332
+ </head>
333
+ <body>
334
+ <main>
335
+ ${body}
336
+ </main>
337
+ </body>
338
+ </html>
339
+ `;
340
+ }
341
+ /** The document body: the block elements alone, for embedding in a page. */
342
+ function toHtmlBody(markdown) {
343
+ const html = [];
344
+ const lines = markdown.split("\n");
345
+ let index = 0;
346
+ while (index < lines.length) {
347
+ const line = lines[index] ?? "";
348
+ if (line.trim() === "") {
349
+ index += 1;
350
+ continue;
351
+ }
352
+ const heading = /^(#{1,3})\s+(.*)$/.exec(line);
353
+ if (heading?.[1] && heading[2] !== void 0) {
354
+ const level = heading[1].length;
355
+ html.push(`<h${level}>${inline(heading[2].trim())}</h${level}>`);
356
+ index += 1;
357
+ continue;
358
+ }
359
+ if (/^-{3,}$/.test(line.trim())) {
360
+ html.push("<hr>");
361
+ index += 1;
362
+ continue;
363
+ }
364
+ if (isListItem(line)) {
365
+ const list = takeList(lines, index);
366
+ html.push(list.html);
367
+ index = list.next;
368
+ continue;
369
+ }
370
+ const paragraph = takeParagraph(lines, index);
371
+ html.push(`<p>${inline(paragraph.text)}</p>`);
372
+ index = paragraph.next;
373
+ }
374
+ return html.join("\n");
375
+ }
376
+ const STYLES = `:root { color-scheme: light dark; }
377
+ body { margin: 0; background: #ffffff; color: #1a1a1a;
378
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
379
+ main { max-width: 44rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
380
+ h1 { font-size: 1.9rem; line-height: 1.25; margin: 0 0 1.5rem; }
381
+ h2 { font-size: 1.3rem; line-height: 1.3; margin: 2.5rem 0 0.75rem; }
382
+ p { margin: 0 0 1rem; }
383
+ ul { margin: 0 0 1rem; padding-left: 1.25rem; }
384
+ li + li { margin-top: 0.75rem; }
385
+ a { color: #0b4fa8; }
386
+ a:focus-visible { outline: 3px solid currentColor; outline-offset: 2px; }
387
+ hr { border: 0; border-top: 1px solid #767676; margin: 2.5rem 0; }
388
+ @media (prefers-color-scheme: dark) {
389
+ body { background: #121212; color: #ededed; }
390
+ a { color: #9ec1ff; }
391
+ }`;
392
+ function isListItem(line) {
393
+ return /^[-*]\s+/.test(line);
394
+ }
395
+ /** A continuation line: indented, and part of the item above it. */
396
+ function isContinuation(line) {
397
+ return /^\s+\S/.test(line) && !isListItem(line.trim());
398
+ }
399
+ /**
400
+ * One `<ul>`, with each item's continuation lines kept inside the item it
401
+ * belongs to. The templates put an issue's standards reference, reason and
402
+ * remedy date on those lines, and promoting them to items of their own would
403
+ * read as four separate barriers instead of one described in detail.
404
+ */
405
+ function takeList(lines, from) {
406
+ const items = [];
407
+ let index = from;
408
+ while (index < lines.length) {
409
+ const line = lines[index] ?? "";
410
+ if (isListItem(line)) {
411
+ items.push([line.replace(/^[-*]\s+/, "").trim()]);
412
+ index += 1;
413
+ continue;
414
+ }
415
+ const current = items.at(-1);
416
+ if (current && isContinuation(line)) {
417
+ current.push(line.trim());
418
+ index += 1;
419
+ continue;
420
+ }
421
+ break;
422
+ }
423
+ return {
424
+ html: `<ul>\n${items.map((item) => ` <li>${item.map(inline).join("<br>\n ")}</li>`).join("\n")}\n</ul>`,
425
+ next: index
426
+ };
427
+ }
428
+ /** Soft-wrapped lines are one paragraph, as in markdown. */
429
+ function takeParagraph(lines, from) {
430
+ const collected = [];
431
+ let index = from;
432
+ while (index < lines.length) {
433
+ const line = lines[index] ?? "";
434
+ if (line.trim() === "" || isListItem(line) || /^#{1,3}\s/.test(line) || /^-{3,}$/.test(line)) break;
435
+ collected.push(line.trim());
436
+ index += 1;
437
+ }
438
+ return {
439
+ text: collected.join(" "),
440
+ next: index
441
+ };
442
+ }
443
+ function firstHeading(markdown) {
444
+ for (const line of markdown.split("\n")) {
445
+ const match = /^#\s+(.*)$/.exec(line);
446
+ if (match?.[1]) return match[1].trim();
447
+ }
448
+ }
449
+ /** URLs and email addresses, which the templates write bare. */
450
+ const LINKABLE = /(https?:\/\/[^\s<>"']+|[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+)/g;
451
+ function inline(text) {
452
+ let output = "";
453
+ let cursor = 0;
454
+ for (const match of text.matchAll(LINKABLE)) {
455
+ const raw = match[0];
456
+ const start = match.index;
457
+ output += escapeText(text.slice(cursor, start));
458
+ const trailing = trailingPunctuation(raw);
459
+ const target = raw.slice(0, raw.length - trailing.length);
460
+ const href = target.includes("@") ? `mailto:${target}` : target;
461
+ output += `<a href="${escapeAttribute(href)}">${escapeText(target)}</a>${escapeText(trailing)}`;
462
+ cursor = start + raw.length;
463
+ }
464
+ return output + escapeText(text.slice(cursor));
465
+ }
466
+ function trailingPunctuation(candidate) {
467
+ let end = candidate.length;
468
+ while (end > 0) {
469
+ const character = candidate[end - 1] ?? "";
470
+ if (".,;:!?".includes(character)) {
471
+ end -= 1;
472
+ continue;
473
+ }
474
+ if (character === ")" && !candidate.slice(0, end).includes("(")) {
475
+ end -= 1;
476
+ continue;
477
+ }
478
+ break;
479
+ }
480
+ return candidate.slice(end);
481
+ }
482
+ //#endregion
483
+ //#region src/statement/template.ts
484
+ var TemplateError = class extends Error {
485
+ name = "TemplateError";
486
+ };
487
+ function renderTemplate(template, scope) {
488
+ return renderScope(stripStandaloneTags(template), [scope]);
489
+ }
490
+ /**
491
+ * A block tag alone on its line loses that line entirely, the way mustache
492
+ * treats standalone tags. Without this, every conditional leaves a blank line
493
+ * behind and a list item ends up separated from its own detail lines.
494
+ */
495
+ function stripStandaloneTags(template) {
496
+ return template.replace(/^[ \t]*(\{\{[#/][^}]*\}\})[ \t]*\r?\n/gm, "$1");
497
+ }
498
+ function renderScope(template, scopes) {
499
+ let output = "";
500
+ let index = 0;
501
+ while (index < template.length) {
502
+ const open = template.indexOf("{{", index);
503
+ if (open === -1) {
504
+ output += template.slice(index);
505
+ break;
506
+ }
507
+ output += template.slice(index, open);
508
+ const close = template.indexOf("}}", open);
509
+ if (close === -1) throw new TemplateError(`Unclosed tag at position ${open}`);
510
+ const tag = template.slice(open + 2, close).trim();
511
+ if (tag.startsWith("#")) {
512
+ const [kind, path] = splitBlockTag(tag);
513
+ const body = findBlockBody(template, kind, close + 2);
514
+ output += renderBlock(kind, path, body.content, scopes);
515
+ index = body.end;
516
+ continue;
517
+ }
518
+ if (tag.startsWith("/")) throw new TemplateError(`Unexpected closing tag {{${tag}}}`);
519
+ output += stringify(lookup(tag, scopes));
520
+ index = close + 2;
521
+ }
522
+ return output;
523
+ }
524
+ function splitBlockTag(tag) {
525
+ const match = /^#(if|each)\s+(\S+)$/.exec(tag);
526
+ if (!match?.[1] || !match[2]) throw new TemplateError(`Malformed block tag {{${tag}}}`);
527
+ return [match[1], match[2]];
528
+ }
529
+ /** Finds the matching close tag, counting nested blocks of the same kind. */
530
+ function findBlockBody(template, kind, from) {
531
+ const openTag = new RegExp(`\\{\\{#${kind}\\s`, "g");
532
+ const closeTag = new RegExp(`\\{\\{/${kind}\\}\\}`, "g");
533
+ let depth = 1;
534
+ let cursor = from;
535
+ while (depth > 0) {
536
+ openTag.lastIndex = cursor;
537
+ closeTag.lastIndex = cursor;
538
+ const next = closeTag.exec(template);
539
+ if (!next) throw new TemplateError(`Missing {{/${kind}}}`);
540
+ const nested = openTag.exec(template);
541
+ if (nested && nested.index < next.index) {
542
+ depth += 1;
543
+ cursor = nested.index + nested[0].length;
544
+ continue;
545
+ }
546
+ depth -= 1;
547
+ if (depth === 0) return {
548
+ content: template.slice(from, next.index),
549
+ end: next.index + next[0].length
550
+ };
551
+ cursor = next.index + next[0].length;
552
+ }
553
+ throw new TemplateError(`Missing {{/${kind}}}`);
554
+ }
555
+ function renderBlock(kind, path, body, scopes) {
556
+ const value = lookup(path, scopes);
557
+ if (kind === "if") return isTruthy(value) ? renderScope(body, scopes) : "";
558
+ if (!Array.isArray(value)) return "";
559
+ return value.map((item) => renderScope(body, [item, ...scopes])).join("");
560
+ }
561
+ function isTruthy(value) {
562
+ if (Array.isArray(value)) return value.length > 0;
563
+ return Boolean(value);
564
+ }
565
+ function lookup(path, scopes) {
566
+ if (path === ".") return scopes[0];
567
+ const segments = path.split(".");
568
+ for (const scope of scopes) {
569
+ const value = resolve(scope, segments);
570
+ if (value !== void 0) return value;
571
+ }
572
+ }
573
+ function resolve(scope, segments) {
574
+ let current = scope;
575
+ for (const segment of segments) {
576
+ if (current === null || typeof current !== "object") return void 0;
577
+ current = current[segment];
578
+ if (current === void 0) return void 0;
579
+ }
580
+ return current;
581
+ }
582
+ function stringify(value) {
583
+ if (value === void 0 || value === null) return "";
584
+ if (typeof value === "string") return value;
585
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
586
+ if (Array.isArray(value)) return value.map(stringify).join(", ");
587
+ return String(value);
588
+ }
589
+ //#endregion
590
+ //#region src/statement/render.ts
591
+ /** How many affected pages a barrier lists before it starts counting instead. */
592
+ const MAX_LISTED_PAGES = 5;
593
+ /**
594
+ * Render an accessibility statement from a validated config.
595
+ *
596
+ * All prose lives in the templates. This function only decides which template
597
+ * to load and prepares the values it interpolates, including the booleans the
598
+ * template branches on, so that no German sentence is assembled in TypeScript.
599
+ */
600
+ async function renderStatement(config, options = {}) {
601
+ const country = options.country ?? config.enforcement.country;
602
+ const locale = options.locale ?? defaultLocale(config);
603
+ const template = `${country.toLowerCase()}.${locale}`;
604
+ const markdown = tidy(renderTemplate(await loadTemplate(template), buildScope(config, locale, options.audit)));
605
+ return {
606
+ markdown,
607
+ html: toHtmlDocument(markdown, {
608
+ lang: locale,
609
+ fallbackTitle: config.site.name
610
+ }),
611
+ locale,
612
+ country,
613
+ template
614
+ };
615
+ }
616
+ /** A German-language site gets a German statement unless told otherwise. */
617
+ function defaultLocale(config) {
618
+ return config.site.locale.toLowerCase().startsWith("de") ? "de" : "en";
619
+ }
620
+ /**
621
+ * Values the templates interpolate.
622
+ *
623
+ * Enum-shaped fields become booleans here rather than being compared inside the
624
+ * template, which keeps the template language trivial and puts the mapping
625
+ * somewhere that can be typechecked.
626
+ */
627
+ function buildScope(config, locale, audit) {
628
+ const issues = [...config.compliance.knownIssues.map((issue) => toIssueScope(issue, locale)), ...(audit?.findings ?? []).map((finding) => toFindingScope(finding, config.compliance.auditReason))];
629
+ return {
630
+ site: { ...config.site },
631
+ provider: { ...config.provider },
632
+ compliance: {
633
+ standard: config.compliance.standard,
634
+ knownIssues: issues,
635
+ assessedOnFormatted: formatDate(config.compliance.assessedOn, locale),
636
+ isCompliant: config.compliance.status === "compliant",
637
+ isPartiallyCompliant: config.compliance.status === "partially-compliant",
638
+ isNonCompliant: config.compliance.status === "non-compliant",
639
+ isSelfAssessment: config.compliance.assessmentMethod === "self-assessment",
640
+ isExternalAudit: config.compliance.assessmentMethod === "external-audit"
641
+ },
642
+ audit: audit ? toAuditScope(audit, locale) : void 0,
643
+ hasAudit: audit !== void 0,
644
+ hasKnownIssues: issues.length > 0,
645
+ hasNoKnownIssues: issues.length === 0
646
+ };
647
+ }
648
+ /**
649
+ * What the automated run itself contributes to the "preparation" section.
650
+ *
651
+ * The counts are here because leaving them out would let a reader take the
652
+ * barrier list for the whole picture. A rule the engine could not evaluate was
653
+ * not checked, and saying so is the same commitment the audit report makes.
654
+ */
655
+ function toAuditScope(audit, locale) {
656
+ return {
657
+ pages: audit.pages,
658
+ isSinglePage: audit.pages === 1,
659
+ isMultiPage: audit.pages > 1,
660
+ needsReview: audit.needsReview,
661
+ needsReviewIsSingle: audit.needsReview === 1,
662
+ needsReviewIsPlural: audit.needsReview > 1,
663
+ notEvaluated: audit.notEvaluated,
664
+ notEvaluatedIsSingle: audit.notEvaluated === 1,
665
+ notEvaluatedIsPlural: audit.notEvaluated > 1,
666
+ checkedOnFormatted: formatDate(audit.generatedAt.slice(0, 10), locale)
667
+ };
668
+ }
669
+ function toIssueScope(issue, locale) {
670
+ return {
671
+ ...reasonScope(issue.reason),
672
+ description: issue.description,
673
+ standards: standardsReference(issue.successCriteria, issue.en301549),
674
+ remedyByFormatted: issue.remedyBy ? formatDate(issue.remedyBy, locale) : "",
675
+ isFromAudit: false,
676
+ ruleId: "",
677
+ pageList: "",
678
+ morePages: 0,
679
+ hasMorePages: false
680
+ };
681
+ }
682
+ /**
683
+ * An audit finding as a barrier.
684
+ *
685
+ * `description` is axe-core's help text, which is English however the statement
686
+ * is written, so the templates mark it as the tool's words rather than the
687
+ * provider's and tell the reader to replace it. Generating German legal prose
688
+ * from an English rule description is not something to do behind someone's
689
+ * back, and a statement is published under their name, not ours.
690
+ */
691
+ function toFindingScope(finding, reason) {
692
+ const listed = finding.pages.slice(0, MAX_LISTED_PAGES);
693
+ const remaining = finding.pages.length - listed.length;
694
+ return {
695
+ ...reasonScope(reason),
696
+ description: finding.help,
697
+ standards: standardsReference(finding.successCriteria, finding.en301549),
698
+ remedyByFormatted: "",
699
+ isFromAudit: true,
700
+ ruleId: finding.ruleId,
701
+ pageList: listed.join(", "),
702
+ morePages: remaining,
703
+ hasMorePages: remaining > 0
704
+ };
705
+ }
706
+ /** Enum to booleans, so no template has to compare strings. */
707
+ function reasonScope(reason) {
708
+ return {
709
+ isDisproportionateBurden: reason === "disproportionate-burden",
710
+ isOutOfScope: reason === "out-of-scope",
711
+ isFixPlanned: reason === "fix-planned"
712
+ };
713
+ }
714
+ function standardsReference(successCriteria, en301549) {
715
+ return [...successCriteria.map((criterion) => `WCAG ${criterion}`), ...en301549.map((clause) => `EN 301 549 ${clause}`)].join(", ");
716
+ }
717
+ /**
718
+ * 2026-08-20 becomes 20. August 2026 or 20 August 2026.
719
+ *
720
+ * Every date reaching this has been through a schema that checks it, so the
721
+ * fallback should be unreachable. It is here because the alternative to
722
+ * returning the string unchanged is Intl throwing a RangeError from inside a
723
+ * document generator, and a statement that comes out with an odd-looking date
724
+ * is recoverable in a way that a stack trace is not.
725
+ */
726
+ function formatDate(iso, locale) {
727
+ const date = /* @__PURE__ */ new Date(`${iso}T00:00:00Z`);
728
+ if (Number.isNaN(date.getTime())) return iso;
729
+ return new Intl.DateTimeFormat(locale === "de" ? "de-AT" : "en-GB", {
730
+ day: "numeric",
731
+ month: "long",
732
+ year: "numeric",
733
+ timeZone: "UTC"
734
+ }).format(date);
735
+ }
736
+ /**
737
+ * Block tags sit on their own lines in the templates, which leaves blank lines
738
+ * behind once they are removed. Collapse runs of them so the markdown does not
739
+ * come out full of gaps.
740
+ */
741
+ function tidy(markdown) {
742
+ return `${markdown.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim()}\n`;
743
+ }
744
+ let templateDirectory;
745
+ async function loadTemplate(name) {
746
+ templateDirectory ??= await findTemplateDirectory();
747
+ const directory = templateDirectory;
748
+ const file = path.join(directory, `${name}.md`);
749
+ try {
750
+ return await readFile(file, "utf8");
751
+ } catch {
752
+ throw new StatementError(`No statement template for ${name}. Available: ${(await readdir(directory)).filter((entry) => entry.endsWith(".md")).map((entry) => entry.replace(/\.md$/, "")).sort().join(", ")}`);
753
+ }
754
+ }
755
+ /**
756
+ * Templates ship as files rather than being inlined, so they have to be found
757
+ * at runtime. The layout differs between running from source and running the
758
+ * bundle, where every module collapses into dist/cli/index.js, so the
759
+ * candidates are tried in order rather than assuming one.
760
+ */
761
+ async function findTemplateDirectory() {
762
+ const here = fileURLToPath(new URL(".", import.meta.url));
763
+ const candidates = [
764
+ path.join(here, "templates"),
765
+ path.join(here, "..", "statement", "templates"),
766
+ path.join(here, "statement", "templates")
767
+ ];
768
+ for (const candidate of candidates) try {
769
+ if ((await stat(candidate)).isDirectory()) return candidate;
770
+ } catch {}
771
+ throw new StatementError(`Could not locate the statement templates. Looked in: ${candidates.join(", ")}`);
772
+ }
773
+ //#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 };