@mandujs/core 0.33.0 → 0.34.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/package.json +17 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -0
- package/src/a11y/fix-hints.ts +76 -0
- package/src/a11y/index.ts +18 -0
- package/src/a11y/run-audit.ts +394 -0
- package/src/a11y/types.ts +125 -0
- package/src/bundler/analyzer.ts +119 -4
- package/src/bundler/budget.ts +404 -0
- package/src/bundler/prerender.ts +432 -20
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +88 -0
- package/src/config/validate.ts +72 -0
- package/src/diagnose/__tests__/checks.test.ts +4 -2
- package/src/diagnose/checks.ts +121 -0
- package/src/diagnose/index.ts +1 -0
- package/src/diagnose/run.ts +7 -3
- package/src/perf/__tests__/user-marks.test.ts +354 -0
- package/src/perf/index.ts +29 -0
- package/src/perf/user-marks.ts +553 -0
- package/src/runtime/server.ts +257 -37
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/a11y — accessibility audit runner (Phase 18.χ).
|
|
3
|
+
*
|
|
4
|
+
* # Design contract
|
|
5
|
+
*
|
|
6
|
+
* 1. **Zero runtime cost when unused.** axe-core (~1 MB) and jsdom are
|
|
7
|
+
* declared as optional peer dependencies in `@mandujs/core` —
|
|
8
|
+
* neither is pulled into user bundles. `runAudit` uses dynamic
|
|
9
|
+
* `import()` for both, so the axe-core bytes never reach Node's
|
|
10
|
+
* module graph unless the caller actually asked for an audit.
|
|
11
|
+
*
|
|
12
|
+
* 2. **Graceful degradation.** When axe-core is absent the runner
|
|
13
|
+
* returns `outcome: "axe-missing"` with an actionable `note` rather
|
|
14
|
+
* than throwing. CLI callers translate that into a single-line
|
|
15
|
+
* informational message and exit 0 (quality is opt-in).
|
|
16
|
+
*
|
|
17
|
+
* 3. **DOM provider preference.** JSDOM is the canonical host because
|
|
18
|
+
* axe-core was written against it. When jsdom is not installed we
|
|
19
|
+
* try HappyDOM (Mandu already uses `@happy-dom/global-registrator`
|
|
20
|
+
* as a dev-dep) which covers the 80% path. If neither provider is
|
|
21
|
+
* available we return `axe-missing` with a note that names which
|
|
22
|
+
* piece is missing — jsdom, HappyDOM, or both.
|
|
23
|
+
*
|
|
24
|
+
* 4. **Bounded work.** `maxFiles` caps the input list (default 500) so
|
|
25
|
+
* a misconfigured project that prerenders 10k routes can't hang CI.
|
|
26
|
+
* Each file is audited sequentially; axe-core is CPU-bound and
|
|
27
|
+
* parallelising across Bun's event loop offers zero wall-clock win.
|
|
28
|
+
*
|
|
29
|
+
* 5. **Testable.** `axeLoader` / `domLoader` options short-circuit the
|
|
30
|
+
* module resolution so `run-audit.test.ts` can inject deterministic
|
|
31
|
+
* fakes without mutating Bun's module cache.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import fs from "fs/promises";
|
|
35
|
+
import path from "path";
|
|
36
|
+
import { AUDIT_IMPACT_ORDER, impactAtLeast } from "./types";
|
|
37
|
+
import type {
|
|
38
|
+
AuditImpact,
|
|
39
|
+
AuditNode,
|
|
40
|
+
AuditReport,
|
|
41
|
+
AuditViolation,
|
|
42
|
+
RunAuditOptions,
|
|
43
|
+
} from "./types";
|
|
44
|
+
import { getFixHint } from "./fix-hints";
|
|
45
|
+
|
|
46
|
+
/** Minimal structural typing for the axe-core handle we actually use. */
|
|
47
|
+
interface AxeLike {
|
|
48
|
+
run(context: unknown, options?: unknown): Promise<AxeRunResult>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** axe-core's result shape (only the fields we consume). */
|
|
52
|
+
interface AxeRunResult {
|
|
53
|
+
violations: Array<{
|
|
54
|
+
id: string;
|
|
55
|
+
impact: AuditImpact | null;
|
|
56
|
+
help: string;
|
|
57
|
+
helpUrl?: string;
|
|
58
|
+
nodes: Array<{
|
|
59
|
+
target?: string[] | string;
|
|
60
|
+
failureSummary?: string;
|
|
61
|
+
html?: string;
|
|
62
|
+
}>;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Minimal structural typing for the DOM provider handle. */
|
|
67
|
+
interface DomProvider {
|
|
68
|
+
kind: "jsdom" | "happy-dom";
|
|
69
|
+
fromHtml(html: string, url: string): Promise<{ window: unknown; dispose: () => void }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_MAX_FILES = 500;
|
|
73
|
+
const DEFAULT_MIN_IMPACT: AuditImpact = "minor";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Zero every entry in an impact-count record. Returned by value so
|
|
77
|
+
* callers never mutate a shared singleton.
|
|
78
|
+
*/
|
|
79
|
+
function emptyImpactCounts(): Record<AuditImpact, number> {
|
|
80
|
+
return { minor: 0, moderate: 0, serious: 0, critical: 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the axe-core module. Tries the caller-supplied loader first
|
|
85
|
+
* (test-only path), then falls back to dynamic `import("axe-core")`.
|
|
86
|
+
* Returns `null` when the package is not installed — NEVER throws.
|
|
87
|
+
*/
|
|
88
|
+
async function resolveAxe(options: RunAuditOptions): Promise<AxeLike | null> {
|
|
89
|
+
const tryLoad = async (loader: () => Promise<unknown>): Promise<AxeLike | null> => {
|
|
90
|
+
try {
|
|
91
|
+
const mod = await loader();
|
|
92
|
+
if (!mod) return null;
|
|
93
|
+
// axe-core exports `.default` under ESM/CJS interop. Accept either.
|
|
94
|
+
const candidate = (mod as { default?: unknown }).default ?? mod;
|
|
95
|
+
if (candidate && typeof (candidate as AxeLike).run === "function") {
|
|
96
|
+
return candidate as AxeLike;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (options.axeLoader) return tryLoad(options.axeLoader);
|
|
105
|
+
// @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
|
|
106
|
+
return tryLoad(() => import("axe-core"));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a DOM provider. Prefers jsdom; falls back to HappyDOM via
|
|
111
|
+
* `happy-dom`'s `Window` export (the same class Mandu's test harness
|
|
112
|
+
* already depends on).
|
|
113
|
+
*/
|
|
114
|
+
async function resolveDomProvider(options: RunAuditOptions): Promise<DomProvider | null> {
|
|
115
|
+
// Caller override — used exclusively by tests that inject a fake
|
|
116
|
+
// with a `.kind` field.
|
|
117
|
+
if (options.domLoader) {
|
|
118
|
+
try {
|
|
119
|
+
const mod = await options.domLoader();
|
|
120
|
+
if (mod && typeof mod === "object" && "kind" in mod && "fromHtml" in mod) {
|
|
121
|
+
return mod as DomProvider;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Preferred path — jsdom.
|
|
130
|
+
try {
|
|
131
|
+
// @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
|
|
132
|
+
const jsdom = await import("jsdom");
|
|
133
|
+
const JSDOMCtor = (jsdom as { JSDOM?: new (html: string, opts?: unknown) => unknown }).JSDOM;
|
|
134
|
+
if (JSDOMCtor) {
|
|
135
|
+
return {
|
|
136
|
+
kind: "jsdom",
|
|
137
|
+
async fromHtml(html: string, url: string) {
|
|
138
|
+
const instance = new JSDOMCtor(html, { url });
|
|
139
|
+
const window = (instance as { window: unknown }).window;
|
|
140
|
+
return {
|
|
141
|
+
window,
|
|
142
|
+
dispose() {
|
|
143
|
+
const w = window as { close?: () => void };
|
|
144
|
+
if (typeof w.close === "function") {
|
|
145
|
+
try { w.close(); } catch { /* no-op */ }
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// jsdom not installed — fall through to HappyDOM.
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Fallback path — HappyDOM.
|
|
157
|
+
try {
|
|
158
|
+
// @ts-ignore -- optional peer dependency, may not be resolvable at typecheck time
|
|
159
|
+
const happy = await import("happy-dom");
|
|
160
|
+
const WindowCtor = (happy as { Window?: new (opts?: { url?: string; innerWidth?: number }) => unknown }).Window;
|
|
161
|
+
if (WindowCtor) {
|
|
162
|
+
return {
|
|
163
|
+
kind: "happy-dom",
|
|
164
|
+
async fromHtml(html: string, url: string) {
|
|
165
|
+
const window = new WindowCtor({ url, innerWidth: 1024 }) as {
|
|
166
|
+
document: { write: (html: string) => void; close: () => void };
|
|
167
|
+
close?: () => Promise<void>;
|
|
168
|
+
};
|
|
169
|
+
window.document.write(html);
|
|
170
|
+
window.document.close();
|
|
171
|
+
return {
|
|
172
|
+
window,
|
|
173
|
+
dispose() {
|
|
174
|
+
if (typeof window.close === "function") {
|
|
175
|
+
try { window.close(); } catch { /* no-op */ }
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// HappyDOM not installed — both providers exhausted.
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Flatten axe-core's node shape into our slim `AuditNode`. axe emits
|
|
191
|
+
* `target` as either a string or `string[]` depending on iframe
|
|
192
|
+
* context; we normalize to a single selector chain joined by `>`.
|
|
193
|
+
*/
|
|
194
|
+
function normalizeNodes(
|
|
195
|
+
raw: AxeRunResult["violations"][number]["nodes"]
|
|
196
|
+
): AuditNode[] {
|
|
197
|
+
const nodes: AuditNode[] = [];
|
|
198
|
+
for (const n of raw.slice(0, 10)) {
|
|
199
|
+
let target: string;
|
|
200
|
+
if (Array.isArray(n.target)) {
|
|
201
|
+
target = n.target.filter((s) => typeof s === "string").join(" > ");
|
|
202
|
+
} else if (typeof n.target === "string") {
|
|
203
|
+
target = n.target;
|
|
204
|
+
} else {
|
|
205
|
+
target = "(unknown)";
|
|
206
|
+
}
|
|
207
|
+
const html = typeof n.html === "string" && n.html.length > 300
|
|
208
|
+
? n.html.slice(0, 297) + "..."
|
|
209
|
+
: n.html;
|
|
210
|
+
nodes.push({
|
|
211
|
+
target,
|
|
212
|
+
failureSummary: n.failureSummary ?? "",
|
|
213
|
+
...(html ? { html } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return nodes;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Audit a single HTML file. Returns the violations discovered (already
|
|
221
|
+
* filtered by `minImpact`) or `null` when the file could not be read —
|
|
222
|
+
* caller decides whether to warn or abort.
|
|
223
|
+
*/
|
|
224
|
+
async function auditFile(
|
|
225
|
+
absFile: string,
|
|
226
|
+
axe: AxeLike,
|
|
227
|
+
dom: DomProvider,
|
|
228
|
+
minImpact: AuditImpact
|
|
229
|
+
): Promise<AuditViolation[] | null> {
|
|
230
|
+
let html: string;
|
|
231
|
+
try {
|
|
232
|
+
html = await fs.readFile(absFile, "utf-8");
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const pageUrl = "file://" + absFile.replace(/\\/g, "/");
|
|
238
|
+
let handle: { window: unknown; dispose: () => void };
|
|
239
|
+
try {
|
|
240
|
+
handle = await dom.fromHtml(html, pageUrl);
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
// axe-core accepts a `document` as context. Both jsdom and HappyDOM
|
|
247
|
+
// expose `.window.document`.
|
|
248
|
+
const w = handle.window as { document?: unknown };
|
|
249
|
+
const context = w.document ?? handle.window;
|
|
250
|
+
const result = await axe.run(context);
|
|
251
|
+
const out: AuditViolation[] = [];
|
|
252
|
+
for (const v of result.violations) {
|
|
253
|
+
if (!impactAtLeast(v.impact ?? null, minImpact)) continue;
|
|
254
|
+
out.push({
|
|
255
|
+
file: absFile,
|
|
256
|
+
rule: v.id,
|
|
257
|
+
impact: v.impact ?? null,
|
|
258
|
+
help: v.help,
|
|
259
|
+
helpUrl: v.helpUrl,
|
|
260
|
+
nodes: normalizeNodes(v.nodes),
|
|
261
|
+
...(getFixHint(v.id) ? { fixHint: getFixHint(v.id)! } : {}),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
} finally {
|
|
268
|
+
handle.dispose();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Public entry. Run axe-core against every HTML file in `htmlFiles`
|
|
274
|
+
* and aggregate the results. See `./types.ts` for the full report
|
|
275
|
+
* shape; this function never throws — every failure mode is surfaced
|
|
276
|
+
* via `outcome` + `note`.
|
|
277
|
+
*/
|
|
278
|
+
export async function runAudit(
|
|
279
|
+
htmlFiles: string[],
|
|
280
|
+
options: RunAuditOptions = {}
|
|
281
|
+
): Promise<AuditReport> {
|
|
282
|
+
const started = performance.now();
|
|
283
|
+
const minImpact = options.minImpact ?? DEFAULT_MIN_IMPACT;
|
|
284
|
+
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
285
|
+
const bounded = htmlFiles.slice(0, maxFiles);
|
|
286
|
+
|
|
287
|
+
const axe = await resolveAxe(options);
|
|
288
|
+
if (!axe) {
|
|
289
|
+
return {
|
|
290
|
+
outcome: "axe-missing",
|
|
291
|
+
filesScanned: 0,
|
|
292
|
+
violations: [],
|
|
293
|
+
impactCounts: emptyImpactCounts(),
|
|
294
|
+
minImpact,
|
|
295
|
+
note: "axe-core not installed — skipping audit (bun add -d axe-core jsdom)",
|
|
296
|
+
durationMs: 0,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const dom = await resolveDomProvider(options);
|
|
301
|
+
if (!dom) {
|
|
302
|
+
return {
|
|
303
|
+
outcome: "axe-missing",
|
|
304
|
+
filesScanned: 0,
|
|
305
|
+
violations: [],
|
|
306
|
+
impactCounts: emptyImpactCounts(),
|
|
307
|
+
minImpact,
|
|
308
|
+
note: "No DOM provider available — install jsdom (recommended) or happy-dom",
|
|
309
|
+
durationMs: 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const allViolations: AuditViolation[] = [];
|
|
314
|
+
const impactCounts = emptyImpactCounts();
|
|
315
|
+
let scanned = 0;
|
|
316
|
+
|
|
317
|
+
for (const file of bounded) {
|
|
318
|
+
const abs = path.resolve(file);
|
|
319
|
+
const perFile = await auditFile(abs, axe, dom, minImpact);
|
|
320
|
+
if (perFile === null) continue; // unreadable — counts as not scanned
|
|
321
|
+
scanned += 1;
|
|
322
|
+
for (const v of perFile) {
|
|
323
|
+
allViolations.push(v);
|
|
324
|
+
if (v.impact) impactCounts[v.impact] += 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
outcome: allViolations.length > 0 ? "violations" : "ok",
|
|
330
|
+
filesScanned: scanned,
|
|
331
|
+
violations: allViolations,
|
|
332
|
+
impactCounts,
|
|
333
|
+
minImpact,
|
|
334
|
+
durationMs: Math.round(performance.now() - started),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Pretty-print an audit report as a multi-line ASCII table suitable
|
|
340
|
+
* for CLI output. Separate from `runAudit` so JSON consumers stay
|
|
341
|
+
* unaffected by formatting concerns.
|
|
342
|
+
*/
|
|
343
|
+
export function formatAuditReport(report: AuditReport): string {
|
|
344
|
+
const lines: string[] = [];
|
|
345
|
+
lines.push("Accessibility audit (axe-core)");
|
|
346
|
+
lines.push("=".repeat(50));
|
|
347
|
+
|
|
348
|
+
if (report.outcome === "axe-missing") {
|
|
349
|
+
lines.push(report.note ?? "axe-core not installed — skipping audit");
|
|
350
|
+
lines.push("");
|
|
351
|
+
lines.push(" Install the optional peers to enable:");
|
|
352
|
+
lines.push(" bun add -d axe-core jsdom");
|
|
353
|
+
return lines.join("\n");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
lines.push(
|
|
357
|
+
` Files scanned: ${report.filesScanned} · ` +
|
|
358
|
+
`Violations: ${report.violations.length} · ` +
|
|
359
|
+
`Duration: ${report.durationMs}ms`
|
|
360
|
+
);
|
|
361
|
+
lines.push(
|
|
362
|
+
` By impact: ` +
|
|
363
|
+
AUDIT_IMPACT_ORDER
|
|
364
|
+
.map((i) => `${i}=${report.impactCounts[i]}`)
|
|
365
|
+
.join(" ")
|
|
366
|
+
);
|
|
367
|
+
lines.push(` Min impact: ${report.minImpact}`);
|
|
368
|
+
lines.push("");
|
|
369
|
+
|
|
370
|
+
if (report.outcome === "ok") {
|
|
371
|
+
lines.push(" No violations at or above minImpact. PASS.");
|
|
372
|
+
return lines.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Group by rule id so the table is navigable.
|
|
376
|
+
const byRule = new Map<string, AuditViolation[]>();
|
|
377
|
+
for (const v of report.violations) {
|
|
378
|
+
if (!byRule.has(v.rule)) byRule.set(v.rule, []);
|
|
379
|
+
byRule.get(v.rule)!.push(v);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const [rule, violations] of byRule) {
|
|
383
|
+
const first = violations[0];
|
|
384
|
+
const impact = first.impact ?? "unknown";
|
|
385
|
+
lines.push(` [${impact.toUpperCase()}] ${rule} — ${first.help}`);
|
|
386
|
+
if (first.fixHint) lines.push(` Fix: ${first.fixHint}`);
|
|
387
|
+
const totalNodes = violations.reduce((n, v) => n + v.nodes.length, 0);
|
|
388
|
+
lines.push(` ${violations.length} file(s), ${totalNodes} node(s)`);
|
|
389
|
+
if (first.helpUrl) lines.push(` Docs: ${first.helpUrl}`);
|
|
390
|
+
lines.push("");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return lines.join("\n");
|
|
394
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/a11y — audit result types.
|
|
3
|
+
*
|
|
4
|
+
* Phase 18.χ introduces a framework-level accessibility guardrail that
|
|
5
|
+
* runs axe-core against prerendered HTML and aggregates WCAG violations
|
|
6
|
+
* into a structured report. axe-core and jsdom are **optional peer
|
|
7
|
+
* dependencies** — we never install them for the user. When absent, the
|
|
8
|
+
* runner degrades gracefully to an `"axe-missing"` outcome instead of
|
|
9
|
+
* throwing or bundling ~1 MB of rules into every consumer.
|
|
10
|
+
*
|
|
11
|
+
* Severity mapping matches axe-core's `impact` scale verbatim so CI
|
|
12
|
+
* gates using `--audit-fail-on=<impact>` speak the same vocabulary as
|
|
13
|
+
* the axe documentation:
|
|
14
|
+
*
|
|
15
|
+
* - `minor` — nice-to-fix cosmetic a11y issue
|
|
16
|
+
* - `moderate` — noticeable UX degradation
|
|
17
|
+
* - `serious` — significant barrier for users with disabilities
|
|
18
|
+
* - `critical` — blocks assistive-tech users outright (default gate)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** axe-core impact scale (lowest → highest). */
|
|
22
|
+
export type AuditImpact = "minor" | "moderate" | "serious" | "critical";
|
|
23
|
+
|
|
24
|
+
/** Canonical ordering used by severity comparisons. */
|
|
25
|
+
export const AUDIT_IMPACT_ORDER: readonly AuditImpact[] = [
|
|
26
|
+
"minor",
|
|
27
|
+
"moderate",
|
|
28
|
+
"serious",
|
|
29
|
+
"critical",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return `true` when `candidate` is at least as severe as `threshold`.
|
|
34
|
+
* Used by both the runner (severity filter) and the CLI gate
|
|
35
|
+
* (`--audit-fail-on`).
|
|
36
|
+
*/
|
|
37
|
+
export function impactAtLeast(candidate: AuditImpact | null | undefined, threshold: AuditImpact): boolean {
|
|
38
|
+
if (!candidate) return false;
|
|
39
|
+
const ci = AUDIT_IMPACT_ORDER.indexOf(candidate);
|
|
40
|
+
const ti = AUDIT_IMPACT_ORDER.indexOf(threshold);
|
|
41
|
+
return ci >= 0 && ti >= 0 && ci >= ti;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Per-node failure detail. Keeps payloads small — we intentionally
|
|
45
|
+
* discard axe's full `any`/`all`/`none` check trees and keep only the
|
|
46
|
+
* fields humans act on (selector + failure summary). */
|
|
47
|
+
export interface AuditNode {
|
|
48
|
+
/** CSS selector chain axe-core emits (e.g. `html > body > div#root`). */
|
|
49
|
+
target: string;
|
|
50
|
+
/** axe's `failureSummary` — already-localized multi-line description. */
|
|
51
|
+
failureSummary: string;
|
|
52
|
+
/** Raw HTML snippet for the offending node (truncated to 300 chars). */
|
|
53
|
+
html?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One WCAG violation emitted by axe-core, scoped to a single HTML file. */
|
|
57
|
+
export interface AuditViolation {
|
|
58
|
+
/** Source HTML file (absolute path). */
|
|
59
|
+
file: string;
|
|
60
|
+
/** axe-core rule id, e.g. `color-contrast`, `label`, `image-alt`. */
|
|
61
|
+
rule: string;
|
|
62
|
+
/** Severity — may be `null` when axe fails to classify (rare). */
|
|
63
|
+
impact: AuditImpact | null;
|
|
64
|
+
/** One-line human-readable rule summary (`node.help`). */
|
|
65
|
+
help: string;
|
|
66
|
+
/** URL to axe-core's documentation for this rule. */
|
|
67
|
+
helpUrl?: string;
|
|
68
|
+
/** Offending DOM nodes. Capped at 10 to keep reports consumable. */
|
|
69
|
+
nodes: AuditNode[];
|
|
70
|
+
/** Phase 18.χ hint — short actionable fix recipe when we recognize the rule. */
|
|
71
|
+
fixHint?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Audit outcome. The three arms are mutually exclusive:
|
|
76
|
+
*
|
|
77
|
+
* - `ok` — runner executed, zero violations ≥ minImpact.
|
|
78
|
+
* - `violations` — runner executed, at least one violation fired.
|
|
79
|
+
* - `axe-missing` — optional dep not installed; runner was a no-op.
|
|
80
|
+
*
|
|
81
|
+
* The `filesScanned` counter is always present so callers can print a
|
|
82
|
+
* meaningful summary ("audited 12 files, 0 violations") regardless of
|
|
83
|
+
* outcome.
|
|
84
|
+
*/
|
|
85
|
+
export interface AuditReport {
|
|
86
|
+
outcome: "ok" | "violations" | "axe-missing";
|
|
87
|
+
/** Number of HTML files actually fed to axe-core (0 when dep missing). */
|
|
88
|
+
filesScanned: number;
|
|
89
|
+
/** Aggregated violations across every file. Empty when `outcome !== "violations"`. */
|
|
90
|
+
violations: AuditViolation[];
|
|
91
|
+
/** Count of violations at each impact level. */
|
|
92
|
+
impactCounts: Record<AuditImpact, number>;
|
|
93
|
+
/** Effective severity filter applied during this run. */
|
|
94
|
+
minImpact: AuditImpact;
|
|
95
|
+
/** Optional human-readable note (e.g. why the runner skipped). */
|
|
96
|
+
note?: string;
|
|
97
|
+
/** Elapsed wallclock ms. Zero when runner was a no-op. */
|
|
98
|
+
durationMs: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Options accepted by `runAudit`. */
|
|
102
|
+
export interface RunAuditOptions {
|
|
103
|
+
/**
|
|
104
|
+
* Minimum severity to include in the report. Violations below this
|
|
105
|
+
* threshold are dropped at aggregation time. Default `"minor"`.
|
|
106
|
+
*/
|
|
107
|
+
minImpact?: AuditImpact;
|
|
108
|
+
/**
|
|
109
|
+
* Cap on files to audit. Prevents catastrophic CI runs on projects
|
|
110
|
+
* that accidentally prerender thousands of routes. Default `500`.
|
|
111
|
+
*/
|
|
112
|
+
maxFiles?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Override for axe-core module resolution. When undefined, the runner
|
|
115
|
+
* uses dynamic `import("axe-core")`. Tests inject a fixture instead
|
|
116
|
+
* of shimming the module resolver.
|
|
117
|
+
*/
|
|
118
|
+
axeLoader?: () => Promise<unknown>;
|
|
119
|
+
/**
|
|
120
|
+
* Override for jsdom module resolution. Same contract as `axeLoader`;
|
|
121
|
+
* returning `null` forces the runner to fall back to HappyDOM which is
|
|
122
|
+
* already a transitive test-time dep for Mandu.
|
|
123
|
+
*/
|
|
124
|
+
domLoader?: () => Promise<unknown>;
|
|
125
|
+
}
|
package/src/bundler/analyzer.ts
CHANGED
|
@@ -79,6 +79,7 @@ import path from "path";
|
|
|
79
79
|
import zlib from "zlib";
|
|
80
80
|
|
|
81
81
|
import type { BundleManifest } from "./types";
|
|
82
|
+
import type { BudgetReport } from "./budget";
|
|
82
83
|
|
|
83
84
|
// ============================================================================
|
|
84
85
|
// Types
|
|
@@ -439,21 +440,26 @@ export async function analyzeBundle(
|
|
|
439
440
|
*
|
|
440
441
|
* Returns the absolute paths of both files so the CLI can print them.
|
|
441
442
|
* Callers that want JSON only can skip the HTML step via `{ htmlPath: null }`.
|
|
443
|
+
*
|
|
444
|
+
* Phase 18.φ — `opts.budget` is an optional pre-computed budget report
|
|
445
|
+
* that, when present, renders a budget-bar section in the HTML output
|
|
446
|
+
* and is serialised alongside `report.json` as `report.budget`.
|
|
442
447
|
*/
|
|
443
448
|
export async function writeAnalyzeReport(
|
|
444
449
|
rootDir: string,
|
|
445
450
|
report: AnalyzeReport,
|
|
446
|
-
opts: { html?: boolean } = {}
|
|
451
|
+
opts: { html?: boolean; budget?: BudgetReport | null } = {}
|
|
447
452
|
): Promise<{ jsonPath: string; htmlPath: string | null }> {
|
|
448
453
|
const outDir = path.join(rootDir, ".mandu", "analyze");
|
|
449
454
|
await fs.mkdir(outDir, { recursive: true });
|
|
450
455
|
const jsonPath = path.join(outDir, "report.json");
|
|
451
|
-
|
|
456
|
+
const jsonPayload = opts.budget ? { ...report, budget: opts.budget } : report;
|
|
457
|
+
await fs.writeFile(jsonPath, JSON.stringify(jsonPayload, null, 2), "utf8");
|
|
452
458
|
|
|
453
459
|
let htmlPath: string | null = null;
|
|
454
460
|
if (opts.html !== false) {
|
|
455
461
|
htmlPath = path.join(outDir, "report.html");
|
|
456
|
-
await fs.writeFile(htmlPath, renderAnalyzeHtml(report), "utf8");
|
|
462
|
+
await fs.writeFile(htmlPath, renderAnalyzeHtml(report, opts.budget ?? null), "utf8");
|
|
457
463
|
}
|
|
458
464
|
return { jsonPath, htmlPath };
|
|
459
465
|
}
|
|
@@ -475,7 +481,10 @@ export async function writeAnalyzeReport(
|
|
|
475
481
|
* toggles visibility. This keeps the report working even with JS
|
|
476
482
|
* disabled (you lose drill-down, but the island treemap still renders).
|
|
477
483
|
*/
|
|
478
|
-
export function renderAnalyzeHtml(
|
|
484
|
+
export function renderAnalyzeHtml(
|
|
485
|
+
report: AnalyzeReport,
|
|
486
|
+
budget: BudgetReport | null = null
|
|
487
|
+
): string {
|
|
479
488
|
const { islands, shared, summary } = report;
|
|
480
489
|
|
|
481
490
|
// ── Island-level treemap ─────────────────────────────────────────────────
|
|
@@ -585,6 +594,18 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
|
|
|
585
594
|
<div class="card"><div class="label">Dedupe savings</div><div class="value">${fmtBytes(summary.dedupeSavings)}</div></div>
|
|
586
595
|
`;
|
|
587
596
|
|
|
597
|
+
// ── Phase 18.φ — Budget bar section ──────────────────────────────────────
|
|
598
|
+
//
|
|
599
|
+
// Renders one horizontal bar per island when a budget was evaluated,
|
|
600
|
+
// coloured by `BudgetStatus`: green (within), yellow (within 10% of
|
|
601
|
+
// limit), red (exceeded). The bar width is proportional to
|
|
602
|
+
// `island.gz / gzLimit` (or `raw / rawLimit` if gzLimit is null).
|
|
603
|
+
// When every axis is unconstrained the bar is hidden with a muted "—"
|
|
604
|
+
// placeholder. Matches the "red/yellow/green" spec in Phase 18.φ.
|
|
605
|
+
const budgetSection = budget
|
|
606
|
+
? renderBudgetSection(budget)
|
|
607
|
+
: "";
|
|
608
|
+
|
|
588
609
|
return `<!doctype html>
|
|
589
610
|
<html lang="en">
|
|
590
611
|
<head>
|
|
@@ -621,6 +642,25 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
|
|
|
621
642
|
button.close { background: #1f2937; color: #e5e7eb; border: 1px solid #374151; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-family: inherit; font-size: 11px; }
|
|
622
643
|
button.close:hover { background: #374151; }
|
|
623
644
|
.drill-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
645
|
+
/* Phase 18.φ — budget bars */
|
|
646
|
+
.budget-row { display: grid; grid-template-columns: 180px 1fr 160px; gap: 10px; align-items: center; padding: 4px 0; border-bottom: 1px solid #1f2937; }
|
|
647
|
+
.budget-row:last-child { border-bottom: none; }
|
|
648
|
+
.budget-name { font-size: 12px; color: #e5e7eb; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
649
|
+
.budget-meta { font-size: 11px; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums; }
|
|
650
|
+
.budget-bar-track { position: relative; height: 10px; background: #0f172a; border: 1px solid #1f2937; border-radius: 2px; overflow: hidden; }
|
|
651
|
+
.budget-bar-fill { height: 100%; border-radius: 2px; }
|
|
652
|
+
.budget-bar-fill.within { background: linear-gradient(90deg, #16a34a, #22c55e); }
|
|
653
|
+
.budget-bar-fill.within10 { background: linear-gradient(90deg, #ca8a04, #eab308); }
|
|
654
|
+
.budget-bar-fill.exceeded { background: linear-gradient(90deg, #b91c1c, #ef4444); }
|
|
655
|
+
.budget-bar-fill.unbounded { background: repeating-linear-gradient(45deg, #1f2937, #1f2937 4px, #0f172a 4px, #0f172a 8px); }
|
|
656
|
+
.budget-legend { display: inline-flex; gap: 14px; font-size: 11px; color: #94a3b8; margin-bottom: 10px; }
|
|
657
|
+
.budget-legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
|
|
658
|
+
.budget-legend .lg-within::before { background: #22c55e; }
|
|
659
|
+
.budget-legend .lg-within10::before { background: #eab308; }
|
|
660
|
+
.budget-legend .lg-exceeded::before { background: #ef4444; }
|
|
661
|
+
.budget-mode { display: inline-block; font-size: 10px; text-transform: uppercase; padding: 2px 6px; border-radius: 2px; border: 1px solid #374151; color: #cbd5e1; margin-left: 8px; letter-spacing: 0.04em; }
|
|
662
|
+
.budget-mode.error { background: #7f1d1d; border-color: #991b1b; color: #fecaca; }
|
|
663
|
+
.budget-mode.warning { background: #713f12; border-color: #854d0e; color: #fde68a; }
|
|
624
664
|
</style>
|
|
625
665
|
</head>
|
|
626
666
|
<body>
|
|
@@ -632,6 +672,8 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
|
|
|
632
672
|
<h2>Summary</h2>
|
|
633
673
|
<div class="cards">${summaryCards}</div>
|
|
634
674
|
|
|
675
|
+
${budgetSection}
|
|
676
|
+
|
|
635
677
|
<h2>Islands (click to drill in)</h2>
|
|
636
678
|
<svg class="treemap" viewBox="0 0 ${VIEW_W} ${VIEW_H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Island bundle treemap">
|
|
637
679
|
${islandSvg || `<text x="20" y="30" fill="#64748b">No islands to display.</text>`}
|
|
@@ -703,6 +745,79 @@ export function renderAnalyzeHtml(report: AnalyzeReport): string {
|
|
|
703
745
|
// Helpers — formatting + squarify
|
|
704
746
|
// ============================================================================
|
|
705
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Phase 18.φ — render the budget-bar block. Colour-codes each island
|
|
750
|
+
* by {@link BudgetReport.BudgetStatus} and the project-wide total (when
|
|
751
|
+
* present). Islands without any applicable limit render a diagonal-
|
|
752
|
+
* hatched "unbounded" bar so the user sees the row but understands
|
|
753
|
+
* nothing is enforced. Self-contained: no JS, no external assets.
|
|
754
|
+
*/
|
|
755
|
+
function renderBudgetSection(budget: BudgetReport): string {
|
|
756
|
+
const modeClass = budget.mode === "error" ? "error" : "warning";
|
|
757
|
+
const rows = budget.islands
|
|
758
|
+
.map((i) => renderBudgetRow(i.name, i.raw, i.gz, i.rawLimit, i.gzLimit, i.status))
|
|
759
|
+
.join("");
|
|
760
|
+
const totalRow = budget.total
|
|
761
|
+
? renderBudgetRow(
|
|
762
|
+
"<project total>",
|
|
763
|
+
budget.total.raw,
|
|
764
|
+
budget.total.gz,
|
|
765
|
+
budget.total.rawLimit,
|
|
766
|
+
budget.total.gzLimit,
|
|
767
|
+
budget.total.status
|
|
768
|
+
)
|
|
769
|
+
: "";
|
|
770
|
+
const exceedHeadline = budget.hasExceeded
|
|
771
|
+
? ` · <span style="color:#fca5a5">${budget.exceededCount} over limit</span>`
|
|
772
|
+
: "";
|
|
773
|
+
return `
|
|
774
|
+
<h2>Bundle budget <span class="budget-mode ${modeClass}">${escText(budget.mode)}</span></h2>
|
|
775
|
+
<div class="budget-legend">
|
|
776
|
+
<span class="lg-within">within</span>
|
|
777
|
+
<span class="lg-within10">approaching (≥90%)</span>
|
|
778
|
+
<span class="lg-exceeded">exceeded</span>
|
|
779
|
+
</div>
|
|
780
|
+
<p class="muted" style="margin:0 0 10px">
|
|
781
|
+
${budget.withinCount}/${budget.islandCount} islands within limits${exceedHeadline}
|
|
782
|
+
</p>
|
|
783
|
+
<div class="card" style="padding:12px 14px">
|
|
784
|
+
${rows}
|
|
785
|
+
${totalRow}
|
|
786
|
+
</div>`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function renderBudgetRow(
|
|
790
|
+
name: string,
|
|
791
|
+
raw: number,
|
|
792
|
+
gz: number,
|
|
793
|
+
rawLimit: number | null,
|
|
794
|
+
gzLimit: number | null,
|
|
795
|
+
status: "within" | "within10" | "exceeded"
|
|
796
|
+
): string {
|
|
797
|
+
// Prefer gz-axis progress bar when a gz limit exists (the 90%-of-the-
|
|
798
|
+
// time-useful axis); fall back to raw when only raw is constrained.
|
|
799
|
+
let pct = 0;
|
|
800
|
+
let barClass: string = status;
|
|
801
|
+
let meta: string;
|
|
802
|
+
if (gzLimit !== null) {
|
|
803
|
+
pct = Math.min(100, Math.max(0, (gz / Math.max(gzLimit, 1)) * 100));
|
|
804
|
+
meta = `${fmtBytes(gz)} / ${fmtBytes(gzLimit)} gz`;
|
|
805
|
+
} else if (rawLimit !== null) {
|
|
806
|
+
pct = Math.min(100, Math.max(0, (raw / Math.max(rawLimit, 1)) * 100));
|
|
807
|
+
meta = `${fmtBytes(raw)} / ${fmtBytes(rawLimit)} raw`;
|
|
808
|
+
} else {
|
|
809
|
+
barClass = "unbounded";
|
|
810
|
+
pct = 100;
|
|
811
|
+
meta = `${fmtBytes(gz)} gz · no limit`;
|
|
812
|
+
}
|
|
813
|
+
return `
|
|
814
|
+
<div class="budget-row">
|
|
815
|
+
<div class="budget-name" title="${escAttr(name)}">${escText(name)}</div>
|
|
816
|
+
<div class="budget-bar-track"><div class="budget-bar-fill ${barClass}" style="width:${pct.toFixed(1)}%"></div></div>
|
|
817
|
+
<div class="budget-meta">${meta}</div>
|
|
818
|
+
</div>`;
|
|
819
|
+
}
|
|
820
|
+
|
|
706
821
|
/** Human-readable byte formatter. Matches the style used by `printBundleStats`. */
|
|
707
822
|
export function fmtBytes(n: number): string {
|
|
708
823
|
if (!Number.isFinite(n) || n <= 0) return "0 B";
|