@burdenoff/fe-libs 2026.823.2 → 2026.825.2

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 (39) hide show
  1. package/dist/shared/hooks/index.d.ts +4 -0
  2. package/dist/shared/hooks/index.d.ts.map +1 -1
  3. package/dist/shared/hooks/usePushDeviceRegistration.d.ts +10 -0
  4. package/dist/shared/hooks/usePushDeviceRegistration.d.ts.map +1 -0
  5. package/dist/shared/hooks/usePushDeviceRegistration.js +54 -0
  6. package/dist/shared/native/push-registration.d.ts +124 -0
  7. package/dist/shared/native/push-registration.d.ts.map +1 -0
  8. package/dist/shared/native/push-registration.js +120 -0
  9. package/dist/shared/native/types.d.ts +11 -1
  10. package/dist/shared/native/types.d.ts.map +1 -1
  11. package/dist/shared-hooks.js +10 -8
  12. package/dist/shared.js +23 -21
  13. package/dist/shell-native/index.d.ts +3 -2
  14. package/dist/shell-native/index.d.ts.map +1 -1
  15. package/dist/shell-native/push.d.ts +10 -0
  16. package/dist/shell-native/push.d.ts.map +1 -1
  17. package/dist/shell-native/push.js +27 -22
  18. package/dist/shell-native/service-worker.d.ts +9 -1
  19. package/dist/shell-native/service-worker.d.ts.map +1 -1
  20. package/dist/shell-native/service-worker.js +102 -1
  21. package/dist/shell-native/types.d.ts +21 -9
  22. package/dist/shell-native/types.d.ts.map +1 -1
  23. package/dist/shell-native/web-manifest.d.ts +21 -0
  24. package/dist/shell-native/web-manifest.d.ts.map +1 -1
  25. package/dist/shell-native/web-manifest.js +4 -1
  26. package/dist/shell-native/web-push.d.ts +74 -0
  27. package/dist/shell-native/web-push.d.ts.map +1 -0
  28. package/dist/shell-native/web-push.js +106 -0
  29. package/dist/shell-native.js +10 -9
  30. package/package.json +6 -3
  31. package/scripts/a11y-audit/README.md +172 -0
  32. package/scripts/a11y-audit/allowlist.ts +178 -0
  33. package/scripts/a11y-audit/audit.ts +306 -0
  34. package/scripts/a11y-audit/cli.ts +161 -0
  35. package/scripts/a11y-audit/config.ts +319 -0
  36. package/scripts/a11y-audit/index.ts +19 -0
  37. package/scripts/a11y-audit/report.ts +144 -0
  38. package/scripts/a11y-audit/server.ts +237 -0
  39. package/scripts/a11y-audit/types.ts +234 -0
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * a11y-audit — the fleet's WCAG 2.1 AA gate.
4
+ *
5
+ * Runs a real axe-core pass against a built app shell, microfrontend harness or
6
+ * website and fails the build on `serious` and `critical` violations. Known
7
+ * exceptions live in an explicit allowlist where every entry must carry a
8
+ * written reason; there is no way to switch a rule off wholesale.
9
+ *
10
+ * This replaces the per-repo Playwright specs that asserted `critical` only via
11
+ * `expect.soft`, which could never fail a build and were never wired into CI.
12
+ *
13
+ * Usage (from a consuming repo root, after `bun run build`):
14
+ * bun run node_modules/@burdenoff/fe-libs/scripts/a11y-audit/cli.ts
15
+ * or, since fe-libs declares this as a package `bin`:
16
+ * a11y-audit [configPath] [--base-url=URL] [--json=PATH] [--route=/path ...] [--quiet]
17
+ *
18
+ * `configPath` defaults to `./a11y-audit.config.json`.
19
+ *
20
+ * Exit codes:
21
+ * 0 no blocking violations
22
+ * 1 blocking violations (or a stale allowlist when failOnStaleAllowlist is set)
23
+ * 2 the audit could not run — bad config, app never came up, route failed to load
24
+ */
25
+
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { dirname, resolve } from 'node:path';
28
+ import { runAudit } from './audit';
29
+ import { ConfigError, resolveConfig } from './config';
30
+ import { renderConsole, renderMarkdown } from './report';
31
+ import { findUnreachableRouteScopes } from './allowlist';
32
+ import { startApp } from './server';
33
+
34
+ interface CliArgs {
35
+ configPath: string;
36
+ baseUrl?: string;
37
+ jsonPath?: string;
38
+ routes: string[];
39
+ quiet: boolean;
40
+ }
41
+
42
+ export function parseArgs(argv: string[]): CliArgs {
43
+ const args: CliArgs = { configPath: 'a11y-audit.config.json', routes: [], quiet: false };
44
+ let positionalSeen = false;
45
+ for (const arg of argv) {
46
+ if (arg.startsWith('--base-url=')) args.baseUrl = arg.slice('--base-url='.length);
47
+ else if (arg.startsWith('--json=')) args.jsonPath = arg.slice('--json='.length);
48
+ else if (arg.startsWith('--route=')) args.routes.push(arg.slice('--route='.length));
49
+ else if (arg === '--quiet') args.quiet = true;
50
+ else if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`);
51
+ else if (!positionalSeen) {
52
+ args.configPath = arg;
53
+ positionalSeen = true;
54
+ } else throw new Error(`Unexpected argument: ${arg}`);
55
+ }
56
+ return args;
57
+ }
58
+
59
+ async function main(): Promise<number> {
60
+ let args: CliArgs;
61
+ try {
62
+ args = parseArgs(process.argv.slice(2));
63
+ } catch (error) {
64
+ console.error(`[a11y-audit] ${(error as Error).message}`);
65
+ return 2;
66
+ }
67
+
68
+ const configPath = resolve(process.cwd(), args.configPath);
69
+ if (!existsSync(configPath)) {
70
+ console.error(`[a11y-audit] Config not found: ${configPath}`);
71
+ console.error(
72
+ 'Create an a11y-audit.config.json at your repo root. See ' +
73
+ '@burdenoff/fe-libs/scripts/a11y-audit/README.md for the config shape.'
74
+ );
75
+ return 2;
76
+ }
77
+
78
+ let config;
79
+ try {
80
+ config = resolveConfig(JSON.parse(readFileSync(configPath, 'utf8')));
81
+ } catch (error) {
82
+ if (error instanceof ConfigError) console.error(`[a11y-audit] ${error.message}`);
83
+ else console.error(`[a11y-audit] Failed to read ${configPath}: ${(error as Error).message}`);
84
+ return 2;
85
+ }
86
+
87
+ if (args.routes.length > 0) {
88
+ const filtered = config.routes.filter((route) => args.routes.includes(route.path));
89
+ if (filtered.length === 0) {
90
+ console.error(`[a11y-audit] --route filter matched none of the configured routes.`);
91
+ return 2;
92
+ }
93
+ config.routes = filtered;
94
+ }
95
+
96
+ const unreachable = findUnreachableRouteScopes(config.allowlist, config.routes);
97
+ if (unreachable.length > 0 && args.routes.length === 0) {
98
+ for (const item of unreachable) {
99
+ console.error(
100
+ `[a11y-audit] allowlist[${item.index}] is scoped to route "${item.route}", which is not ` +
101
+ 'in the audited route list — fix the typo or the scope is silently doing nothing'
102
+ );
103
+ }
104
+ return 2;
105
+ }
106
+
107
+ let app;
108
+ try {
109
+ app = await startApp(config, args.baseUrl);
110
+ } catch (error) {
111
+ console.error(`[a11y-audit] Could not reach the app under test: ${(error as Error).message}`);
112
+ return 2;
113
+ }
114
+
115
+ try {
116
+ const report = await runAudit(config, app);
117
+
118
+ const jsonPath = resolve(process.cwd(), args.jsonPath ?? config.reportPath);
119
+ mkdirSync(dirname(jsonPath), { recursive: true });
120
+ writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
121
+
122
+ if (!args.quiet) console.log(renderConsole(report));
123
+ console.log(`[a11y-audit] JSON report written to ${jsonPath}`);
124
+
125
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
126
+ if (summaryPath) {
127
+ writeFileSync(summaryPath, renderMarkdown(report), { flag: 'a' });
128
+ }
129
+
130
+ if (report.totals.blocking > 0) {
131
+ console.error(
132
+ `[a11y-audit] FAILED: ${report.totals.blocking} blocking violation node(s) at ` +
133
+ `${config.failOn.join('/')} impact. Fix them, or add a narrowly-scoped allowlist entry ` +
134
+ 'with a written reason and an expiry.'
135
+ );
136
+ return 1;
137
+ }
138
+ if (config.failOnStaleAllowlist && report.staleAllowlistEntries.length > 0) {
139
+ console.error(
140
+ `[a11y-audit] FAILED: ${report.staleAllowlistEntries.length} allowlist entr(ies) matched ` +
141
+ 'nothing and failOnStaleAllowlist is set — delete them.'
142
+ );
143
+ return 1;
144
+ }
145
+ console.log('[a11y-audit] PASSED: no blocking accessibility violations.');
146
+ return 0;
147
+ } catch (error) {
148
+ console.error(`[a11y-audit] ${(error as Error).message}`);
149
+ return 2;
150
+ } finally {
151
+ await app.close();
152
+ }
153
+ }
154
+
155
+ main().then(
156
+ (code) => process.exit(code),
157
+ (error) => {
158
+ console.error(`[a11y-audit] Unexpected failure: ${(error as Error).stack ?? error}`);
159
+ process.exit(2);
160
+ }
161
+ );
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Config parsing + validation for the accessibility gate.
3
+ *
4
+ * Everything here is pure so the rules that decide what may be suppressed are
5
+ * unit-testable without a browser. A config that does not validate is a hard
6
+ * error (exit 2) — a gate that silently degrades to "audited nothing" is the
7
+ * failure mode this whole check exists to remove.
8
+ */
9
+
10
+ import { IMPACT_LEVELS } from './types';
11
+ import type {
12
+ A11yAuditConfig,
13
+ AllowlistEntry,
14
+ ImpactLevel,
15
+ ResolvedConfig,
16
+ RouteSpec,
17
+ ViewportSpec,
18
+ } from './types';
19
+
20
+ export const DEFAULT_STANDARD = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
21
+ export const DEFAULT_FAIL_ON: ImpactLevel[] = ['critical', 'serious'];
22
+ export const DEFAULT_VIEWPORTS: ViewportSpec[] = [{ name: 'desktop', width: 1280, height: 800 }];
23
+ export const MIN_REASON_LENGTH = 20;
24
+
25
+ /** Reason strings that are technically non-empty but say nothing. */
26
+ const PLACEHOLDER_REASONS = [
27
+ 'todo',
28
+ 'tbd',
29
+ 'fixme',
30
+ 'wip',
31
+ 'n/a',
32
+ 'na',
33
+ 'none',
34
+ 'temporary',
35
+ 'temp',
36
+ 'later',
37
+ 'known issue',
38
+ 'known issues',
39
+ 'legacy',
40
+ 'see ticket',
41
+ ];
42
+
43
+ export class ConfigError extends Error {
44
+ readonly issues: string[];
45
+
46
+ constructor(issues: string[]) {
47
+ super(`Invalid a11y-audit config:\n - ${issues.join('\n - ')}`);
48
+ this.name = 'ConfigError';
49
+ this.issues = issues;
50
+ }
51
+ }
52
+
53
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
54
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
55
+ }
56
+
57
+ function isNonEmptyString(value: unknown): value is string {
58
+ return typeof value === 'string' && value.trim().length > 0;
59
+ }
60
+
61
+ /** `true` for a bare wildcard, in any of the shapes people reach for first. */
62
+ export function isWildcard(value: string): boolean {
63
+ const trimmed = value.trim();
64
+ return trimmed === '*' || trimmed === '**' || trimmed === '/*' || trimmed === '.*';
65
+ }
66
+
67
+ const PLACEHOLDER_PREFIX =
68
+ /^(todo|tbd|fixme|wip|n\/a|na|none|temp|temporary|later|known issues?|legacy|see ticket)\b[\s:,.\-–—]*/;
69
+
70
+ /**
71
+ * True for a reason that is technically prose but says nothing — either the
72
+ * bare placeholder, or a placeholder token with no substance behind it
73
+ * ("TODO: fix later"). Checked before the length floor so the error message
74
+ * names the real problem.
75
+ */
76
+ export function isPlaceholderReason(reason: string): boolean {
77
+ const normalized = reason
78
+ .trim()
79
+ .toLowerCase()
80
+ .replace(/[.!\-–—:;,]+$/g, '')
81
+ .trim();
82
+ if (PLACEHOLDER_REASONS.includes(normalized)) return true;
83
+ const stripped = normalized.replace(PLACEHOLDER_PREFIX, '').trim();
84
+ return stripped !== normalized && stripped.length < MIN_REASON_LENGTH;
85
+ }
86
+
87
+ /** Valid ISO calendar date, and a real one (2026-02-31 is rejected). */
88
+ export function isIsoDate(value: string): boolean {
89
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
90
+ const parsed = new Date(`${value}T00:00:00Z`);
91
+ if (Number.isNaN(parsed.getTime())) return false;
92
+ return parsed.toISOString().slice(0, 10) === value;
93
+ }
94
+
95
+ function validateAllowlistEntry(entry: unknown, index: number, issues: string[]): void {
96
+ const where = `allowlist[${index}]`;
97
+ if (!isPlainObject(entry)) {
98
+ issues.push(`${where} must be an object`);
99
+ return;
100
+ }
101
+
102
+ if (!isNonEmptyString(entry.rule)) {
103
+ issues.push(`${where}.rule is required and must be a non-empty axe rule id`);
104
+ } else if (isWildcard(entry.rule)) {
105
+ issues.push(
106
+ `${where}.rule "${entry.rule}" is a wildcard — allowlist entries must name one axe rule; ` +
107
+ 'blanket suppressions are not supported'
108
+ );
109
+ }
110
+
111
+ if (!isNonEmptyString(entry.reason)) {
112
+ issues.push(
113
+ `${where}.reason is required — every allowlisted violation must say why it is tolerated`
114
+ );
115
+ } else if (isPlaceholderReason(entry.reason)) {
116
+ issues.push(
117
+ `${where}.reason "${entry.reason.trim()}" is a placeholder, not a reason — describe the ` +
118
+ 'actual constraint and who owns the fix'
119
+ );
120
+ } else if (entry.reason.trim().length < MIN_REASON_LENGTH) {
121
+ issues.push(
122
+ `${where}.reason must be at least ${MIN_REASON_LENGTH} characters explaining why this is ` +
123
+ `tolerated (got ${entry.reason.trim().length})`
124
+ );
125
+ }
126
+
127
+ for (const key of ['routes', 'selectors'] as const) {
128
+ const value = entry[key];
129
+ if (value === undefined) continue;
130
+ if (!Array.isArray(value) || value.length === 0) {
131
+ issues.push(`${where}.${key} must be a non-empty array when present`);
132
+ continue;
133
+ }
134
+ value.forEach((item, i) => {
135
+ if (!isNonEmptyString(item)) {
136
+ issues.push(`${where}.${key}[${i}] must be a non-empty string`);
137
+ } else if (isWildcard(item)) {
138
+ issues.push(
139
+ `${where}.${key}[${i}] "${item}" is a wildcard — omit ${key} entirely if the entry is ` +
140
+ 'meant to apply everywhere, so the report shows it as unscoped'
141
+ );
142
+ }
143
+ });
144
+ }
145
+
146
+ if (entry.expires !== undefined) {
147
+ if (!isNonEmptyString(entry.expires) || !isIsoDate(entry.expires)) {
148
+ issues.push(`${where}.expires must be an ISO date, e.g. "2026-12-31"`);
149
+ }
150
+ }
151
+
152
+ if (entry.ticket !== undefined && !isNonEmptyString(entry.ticket)) {
153
+ issues.push(`${where}.ticket must be a non-empty string when present`);
154
+ }
155
+ }
156
+
157
+ function validateRoute(route: unknown, index: number, issues: string[]): void {
158
+ const where = `routes[${index}]`;
159
+ if (!isPlainObject(route)) {
160
+ issues.push(`${where} must be an object`);
161
+ return;
162
+ }
163
+ if (!isNonEmptyString(route.path)) {
164
+ issues.push(`${where}.path is required`);
165
+ } else if (!route.path.startsWith('/')) {
166
+ issues.push(`${where}.path "${route.path}" must start with "/"`);
167
+ }
168
+ if (route.name !== undefined && !isNonEmptyString(route.name)) {
169
+ issues.push(`${where}.name must be a non-empty string when present`);
170
+ }
171
+ if (route.waitForSelector !== undefined && !isNonEmptyString(route.waitForSelector)) {
172
+ issues.push(`${where}.waitForSelector must be a non-empty string when present`);
173
+ }
174
+ if (
175
+ route.settleMs !== undefined &&
176
+ (typeof route.settleMs !== 'number' || !Number.isFinite(route.settleMs) || route.settleMs < 0)
177
+ ) {
178
+ issues.push(`${where}.settleMs must be a non-negative number when present`);
179
+ }
180
+ if (
181
+ route.minTextLength !== undefined &&
182
+ (typeof route.minTextLength !== 'number' ||
183
+ !Number.isFinite(route.minTextLength) ||
184
+ route.minTextLength < 0)
185
+ ) {
186
+ issues.push(`${where}.minTextLength must be a non-negative number when present`);
187
+ }
188
+ }
189
+
190
+ function validateServe(serve: unknown, issues: string[]): void {
191
+ if (!isPlainObject(serve)) {
192
+ issues.push('serve is required and must be an object');
193
+ return;
194
+ }
195
+ const modes = (['staticDir', 'command', 'baseUrl'] as const).filter((k) =>
196
+ isNonEmptyString(serve[k])
197
+ );
198
+ if (modes.length === 0) {
199
+ issues.push('serve must set exactly one of staticDir, command or baseUrl');
200
+ } else if (modes.length > 1) {
201
+ issues.push(`serve sets ${modes.join(' and ')} — pick exactly one`);
202
+ }
203
+ if (isNonEmptyString(serve.command) && typeof serve.port !== 'number') {
204
+ issues.push('serve.port is required when serve.command is used');
205
+ }
206
+ if (
207
+ serve.readyTimeoutMs !== undefined &&
208
+ (typeof serve.readyTimeoutMs !== 'number' || serve.readyTimeoutMs <= 0)
209
+ ) {
210
+ issues.push('serve.readyTimeoutMs must be a positive number when present');
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Validate a raw parsed config and fill in defaults.
216
+ *
217
+ * @throws ConfigError listing every problem found, not just the first.
218
+ */
219
+ export function resolveConfig(raw: unknown): ResolvedConfig {
220
+ const issues: string[] = [];
221
+
222
+ if (!isPlainObject(raw)) {
223
+ throw new ConfigError(['config must be a JSON object']);
224
+ }
225
+
226
+ if (!isNonEmptyString(raw.name)) {
227
+ issues.push('name is required (use the repo name, e.g. "vibecontrols-app")');
228
+ }
229
+
230
+ validateServe(raw.serve, issues);
231
+
232
+ if (!Array.isArray(raw.routes) || raw.routes.length === 0) {
233
+ issues.push('routes must be a non-empty array — an audit with no routes proves nothing');
234
+ } else {
235
+ raw.routes.forEach((route, i) => validateRoute(route, i, issues));
236
+ }
237
+
238
+ if (raw.viewports !== undefined) {
239
+ if (!Array.isArray(raw.viewports) || raw.viewports.length === 0) {
240
+ issues.push('viewports must be a non-empty array when present');
241
+ } else {
242
+ raw.viewports.forEach((vp, i) => {
243
+ if (
244
+ !isPlainObject(vp) ||
245
+ !isNonEmptyString(vp.name) ||
246
+ typeof vp.width !== 'number' ||
247
+ typeof vp.height !== 'number'
248
+ ) {
249
+ issues.push(`viewports[${i}] must be { name, width, height }`);
250
+ }
251
+ });
252
+ }
253
+ }
254
+
255
+ if (raw.standard !== undefined) {
256
+ if (!Array.isArray(raw.standard) || raw.standard.length === 0) {
257
+ issues.push('standard must be a non-empty array of axe tags when present');
258
+ } else if (!raw.standard.every(isNonEmptyString)) {
259
+ issues.push('standard entries must be non-empty axe tag strings');
260
+ }
261
+ }
262
+
263
+ if (raw.failOn !== undefined) {
264
+ if (!Array.isArray(raw.failOn) || raw.failOn.length === 0) {
265
+ issues.push('failOn must be a non-empty array when present');
266
+ } else {
267
+ for (const level of raw.failOn) {
268
+ if (typeof level !== 'string' || !IMPACT_LEVELS.includes(level as ImpactLevel)) {
269
+ issues.push(`failOn contains "${String(level)}" — allowed: ${IMPACT_LEVELS.join(', ')}`);
270
+ }
271
+ }
272
+ if (Array.isArray(raw.failOn) && !raw.failOn.includes('critical')) {
273
+ issues.push('failOn must include "critical" — the fleet floor is critical + serious');
274
+ }
275
+ if (Array.isArray(raw.failOn) && !raw.failOn.includes('serious')) {
276
+ issues.push('failOn must include "serious" — the fleet floor is critical + serious');
277
+ }
278
+ }
279
+ }
280
+
281
+ if (raw.allowlist !== undefined) {
282
+ if (!Array.isArray(raw.allowlist)) {
283
+ issues.push('allowlist must be an array when present');
284
+ } else {
285
+ raw.allowlist.forEach((entry, i) => validateAllowlistEntry(entry, i, issues));
286
+ }
287
+ }
288
+
289
+ if (issues.length > 0) throw new ConfigError(issues);
290
+
291
+ const config = raw as unknown as A11yAuditConfig;
292
+ const settleMs = typeof config.settleMs === 'number' ? config.settleMs : 1500;
293
+
294
+ return {
295
+ name: config.name,
296
+ serve: {
297
+ ...config.serve,
298
+ spa: config.serve.spa !== false,
299
+ readyTimeoutMs: config.serve.readyTimeoutMs ?? 60_000,
300
+ },
301
+ routes: config.routes.map((route: RouteSpec) => ({
302
+ ...route,
303
+ name: route.name ?? route.path,
304
+ allowRedirect: route.allowRedirect ?? false,
305
+ })),
306
+ viewports: config.viewports ?? DEFAULT_VIEWPORTS,
307
+ standard: config.standard ?? DEFAULT_STANDARD,
308
+ failOn: config.failOn ?? DEFAULT_FAIL_ON,
309
+ allowlist: (config.allowlist ?? []) as AllowlistEntry[],
310
+ reportPath: config.reportPath ?? 'a11y-report.json',
311
+ failOnStaleAllowlist: config.failOnStaleAllowlist ?? false,
312
+ navigationTimeoutMs: config.navigationTimeoutMs ?? 45_000,
313
+ settleMs,
314
+ blockExternalRequests: config.blockExternalRequests !== false,
315
+ domQuietMs: config.domQuietMs ?? 1500,
316
+ minTextLength: config.minTextLength ?? 100,
317
+ routeAttempts: config.routeAttempts ?? 3,
318
+ };
319
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Programmatic entry point for the accessibility gate, for the rare consumer
3
+ * that needs to embed the audit rather than shell out to the CLI.
4
+ */
5
+
6
+ export { resolveConfig, ConfigError, DEFAULT_FAIL_ON, DEFAULT_STANDARD } from './config';
7
+ export {
8
+ entryCovers,
9
+ findUnreachableRouteScopes,
10
+ flattenViolations,
11
+ isExpired,
12
+ partitionNodes,
13
+ routeMatches,
14
+ selectorMatches,
15
+ } from './allowlist';
16
+ export { renderConsole, renderMarkdown } from './report';
17
+ export { runAudit } from './audit';
18
+ export { startApp } from './server';
19
+ export type * from './types';
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Report rendering. Pure string building so the output format is testable and
3
+ * the CLI stays a thin shell.
4
+ */
5
+
6
+ import type { AuditReport, FlatNode } from './types';
7
+
8
+ function ruleSummary(
9
+ nodes: FlatNode[]
10
+ ): Array<{ rule: string; impact: string; count: number; helpUrl: string; help: string }> {
11
+ const byRule = new Map<
12
+ string,
13
+ { rule: string; impact: string; count: number; helpUrl: string; help: string }
14
+ >();
15
+ for (const node of nodes) {
16
+ const existing = byRule.get(node.rule);
17
+ if (existing) existing.count += 1;
18
+ else
19
+ byRule.set(node.rule, {
20
+ rule: node.rule,
21
+ impact: node.impact,
22
+ count: 1,
23
+ helpUrl: node.helpUrl,
24
+ help: node.help,
25
+ });
26
+ }
27
+ return [...byRule.values()].sort((a, b) => b.count - a.count || a.rule.localeCompare(b.rule));
28
+ }
29
+
30
+ /** Human-readable console report. */
31
+ export function renderConsole(report: AuditReport): string {
32
+ const lines: string[] = [];
33
+ lines.push('');
34
+ lines.push(` a11y-audit — ${report.name}`);
35
+ lines.push(
36
+ ` axe-core ${report.axeVersion} · tags: ${report.standard.join(', ')} · failing on: ${report.failOn.join(', ')}`
37
+ );
38
+ lines.push(
39
+ ` base URL: ${report.baseUrl}` +
40
+ (report.blockExternalRequests ? ' (external requests blocked)' : '')
41
+ );
42
+ lines.push('');
43
+
44
+ for (const route of report.routes) {
45
+ const status = route.blocking.length === 0 ? 'PASS' : 'FAIL';
46
+ const redirect = route.finalUrl.endsWith(route.route) ? '' : ` -> ${route.finalUrl}`;
47
+ lines.push(
48
+ ` [${status}] ${route.routeName} @ ${route.viewport}${redirect}` +
49
+ `\n ${route.blocking.length} blocking · ${route.allowed.length} allowlisted · ` +
50
+ `${route.advisory.length} advisory · ${route.passCount} checks passed · ` +
51
+ `${route.incompleteCount} needs review · ${route.renderedTextLength} chars rendered` +
52
+ `${route.domSettled ? '' : ' (DOM never went quiet)'}`
53
+ );
54
+ for (const entry of ruleSummary(route.blocking)) {
55
+ lines.push(` x ${entry.rule} (${entry.impact}) x${entry.count} — ${entry.help}`);
56
+ for (const node of route.blocking.filter((n) => n.rule === entry.rule).slice(0, 5)) {
57
+ lines.push(` ${node.target.join(' , ')}`);
58
+ }
59
+ const hidden = route.blocking.filter((n) => n.rule === entry.rule).length - 5;
60
+ if (hidden > 0) lines.push(` ... and ${hidden} more node(s)`);
61
+ lines.push(` ${entry.helpUrl}`);
62
+ }
63
+ for (const node of route.allowed) {
64
+ lines.push(
65
+ ` ~ ${node.rule} (${node.impact}) allowlisted [#${node.allowlistIndex}` +
66
+ `${node.allowlistTicket ? ` ${node.allowlistTicket}` : ''}]: ${node.allowlistReason}`
67
+ );
68
+ }
69
+ }
70
+
71
+ lines.push('');
72
+ lines.push(
73
+ ` TOTAL: ${report.totals.blocking} blocking · ${report.totals.allowed} allowlisted · ` +
74
+ `${report.totals.advisory} advisory across ${report.totals.routesAudited} route/viewport runs`
75
+ );
76
+
77
+ if (report.expiredAllowlistEntries.length > 0) {
78
+ lines.push('');
79
+ lines.push(' EXPIRED allowlist entries (they no longer suppress anything):');
80
+ for (const entry of report.expiredAllowlistEntries) {
81
+ lines.push(` - allowlist[${entry.index}] ${entry.rule} expired ${entry.expires}`);
82
+ }
83
+ }
84
+
85
+ if (report.staleAllowlistEntries.length > 0) {
86
+ lines.push('');
87
+ lines.push(' STALE allowlist entries (matched nothing this run — delete them):');
88
+ for (const entry of report.staleAllowlistEntries) {
89
+ lines.push(` - allowlist[${entry.index}] ${entry.rule}: ${entry.reason}`);
90
+ }
91
+ }
92
+
93
+ lines.push('');
94
+ return lines.join('\n');
95
+ }
96
+
97
+ /** GitHub step-summary flavoured markdown. */
98
+ export function renderMarkdown(report: AuditReport): string {
99
+ const lines: string[] = [];
100
+ const verdict = report.totals.blocking === 0 ? 'PASS' : 'FAIL';
101
+ lines.push(`## Accessibility (WCAG 2.1 AA) — ${report.name}: ${verdict}`);
102
+ lines.push('');
103
+ lines.push(
104
+ `axe-core \`${report.axeVersion}\`, tags \`${report.standard.join(', ')}\`, failing on ` +
105
+ `\`${report.failOn.join(', ')}\`.`
106
+ );
107
+ lines.push('');
108
+ lines.push('| Route | Viewport | Blocking | Allowlisted | Advisory |');
109
+ lines.push('| --- | --- | ---: | ---: | ---: |');
110
+ for (const route of report.routes) {
111
+ lines.push(
112
+ `| \`${route.routeName}\` | ${route.viewport} | ${route.blocking.length} | ` +
113
+ `${route.allowed.length} | ${route.advisory.length} |`
114
+ );
115
+ }
116
+
117
+ const allBlocking = report.routes.flatMap((r) => r.blocking);
118
+ if (allBlocking.length > 0) {
119
+ lines.push('');
120
+ lines.push('### Blocking violations');
121
+ lines.push('');
122
+ lines.push('| Rule | Impact | Nodes | Help |');
123
+ lines.push('| --- | --- | ---: | --- |');
124
+ for (const entry of ruleSummary(allBlocking)) {
125
+ lines.push(
126
+ `| \`${entry.rule}\` | ${entry.impact} | ${entry.count} | [${entry.help}](${entry.helpUrl}) |`
127
+ );
128
+ }
129
+ }
130
+
131
+ if (report.staleAllowlistEntries.length > 0 || report.expiredAllowlistEntries.length > 0) {
132
+ lines.push('');
133
+ lines.push('### Allowlist hygiene');
134
+ for (const entry of report.expiredAllowlistEntries) {
135
+ lines.push(`- expired: \`allowlist[${entry.index}]\` \`${entry.rule}\` (${entry.expires})`);
136
+ }
137
+ for (const entry of report.staleAllowlistEntries) {
138
+ lines.push(`- stale: \`allowlist[${entry.index}]\` \`${entry.rule}\` matched nothing`);
139
+ }
140
+ }
141
+
142
+ lines.push('');
143
+ return lines.join('\n');
144
+ }