@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 CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
+ "./a11y": "./src/a11y/index.ts",
10
11
  "./auth": "./src/auth/index.ts",
11
12
  "./auth/login": "./src/auth/login.ts",
12
13
  "./auth/password": "./src/auth/password.ts",
@@ -43,6 +44,7 @@
43
44
  "./observability": "./src/observability/index.ts",
44
45
  "./perf": "./src/perf/index.ts",
45
46
  "./perf/hmr-markers": "./src/perf/hmr-markers.ts",
47
+ "./perf/user-marks": "./src/perf/user-marks.ts",
46
48
  "./routes": "./src/routes/index.ts",
47
49
  "./scheduler": "./src/scheduler/index.ts",
48
50
  "./storage/s3": "./src/storage/s3/index.ts",
@@ -55,6 +57,7 @@
55
57
  "./bundler/vendor-cache-types": "./src/bundler/vendor-cache-types.ts",
56
58
  "./bundler/manifest-schema": "./src/bundler/manifest-schema.ts",
57
59
  "./bundler/analyzer": "./src/bundler/analyzer.ts",
60
+ "./bundler/budget": "./src/bundler/budget.ts",
58
61
  "./bundler/plugins": "./src/bundler/plugins/index.ts",
59
62
  "./bundler/plugins/block-generated-imports": "./src/bundler/plugins/block-generated-imports.ts",
60
63
  "./bundler/generate-static-params": "./src/bundler/generate-static-params.ts",
@@ -104,7 +107,10 @@
104
107
  "react-dom": "^19.0.0",
105
108
  "react-refresh": ">=0.18.0",
106
109
  "@tailwindcss/cli": ">=4.0.0",
107
- "webview-bun": "^2.4.0"
110
+ "webview-bun": "^2.4.0",
111
+ "axe-core": ">=4.8.0",
112
+ "jsdom": ">=24.0.0",
113
+ "happy-dom": ">=15.0.0"
108
114
  },
109
115
  "peerDependenciesMeta": {
110
116
  "@tailwindcss/cli": {
@@ -115,6 +121,15 @@
115
121
  },
116
122
  "webview-bun": {
117
123
  "optional": true
124
+ },
125
+ "axe-core": {
126
+ "optional": true
127
+ },
128
+ "jsdom": {
129
+ "optional": true
130
+ },
131
+ "happy-dom": {
132
+ "optional": true
118
133
  }
119
134
  },
120
135
  "dependencies": {
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Tests for the Phase 18.χ accessibility audit runner.
3
+ *
4
+ * Philosophy: we never actually install axe-core/jsdom in the test
5
+ * environment — that would both contradict the "optional peerDep"
6
+ * contract and slow every `bun test` run by a full second. Instead
7
+ * each test injects `axeLoader` + `domLoader` fakes and asserts on the
8
+ * report shape. A single "no deps installed" case exercises the real
9
+ * resolution path by leaving both loaders undefined.
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
13
+ import fs from "fs/promises";
14
+ import path from "path";
15
+ import os from "os";
16
+ import { runAudit, formatAuditReport } from "../run-audit";
17
+ import type { AuditImpact } from "../types";
18
+
19
+ async function mkTmp(): Promise<string> {
20
+ return fs.mkdtemp(path.join(os.tmpdir(), "mandu-a11y-"));
21
+ }
22
+
23
+ async function writeHtml(dir: string, name: string, html: string): Promise<string> {
24
+ const full = path.join(dir, name);
25
+ await fs.mkdir(path.dirname(full), { recursive: true });
26
+ await fs.writeFile(full, html, "utf-8");
27
+ return full;
28
+ }
29
+
30
+ /**
31
+ * Fake axe-core handle. Returns the violations that the test preloads
32
+ * into `queued` for the next `.run()` call. Enables multi-file
33
+ * scenarios where each file yields a different result.
34
+ */
35
+ function createFakeAxe(queued: Array<unknown>) {
36
+ let i = 0;
37
+ return {
38
+ default: {
39
+ async run() {
40
+ const next = queued[i] ?? { violations: [] };
41
+ i += 1;
42
+ return next;
43
+ },
44
+ },
45
+ };
46
+ }
47
+
48
+ /** A DOM provider stub that returns a no-op window. axe never touches
49
+ * it because the fake axe above ignores its `context` argument. */
50
+ const fakeDom = {
51
+ kind: "jsdom" as const,
52
+ async fromHtml() {
53
+ return {
54
+ window: { document: {} },
55
+ dispose() { /* no-op */ },
56
+ };
57
+ },
58
+ };
59
+
60
+ describe("runAudit — dependency resolution", () => {
61
+ it("returns axe-missing when axeLoader rejects (graceful degradation)", async () => {
62
+ const report = await runAudit([], {
63
+ axeLoader: async () => { throw new Error("Cannot find module 'axe-core'"); },
64
+ });
65
+ expect(report.outcome).toBe("axe-missing");
66
+ expect(report.filesScanned).toBe(0);
67
+ expect(report.violations).toEqual([]);
68
+ expect(report.note).toMatch(/axe-core not installed/);
69
+ expect(report.durationMs).toBe(0);
70
+ });
71
+
72
+ it("returns axe-missing when no DOM provider is available", async () => {
73
+ const report = await runAudit([], {
74
+ axeLoader: async () => createFakeAxe([]),
75
+ domLoader: async () => null,
76
+ });
77
+ expect(report.outcome).toBe("axe-missing");
78
+ expect(report.note).toMatch(/DOM provider/);
79
+ });
80
+
81
+ it("treats loader returning non-axe-shape as missing", async () => {
82
+ const report = await runAudit([], {
83
+ axeLoader: async () => ({ notAxe: true }),
84
+ });
85
+ expect(report.outcome).toBe("axe-missing");
86
+ });
87
+ });
88
+
89
+ describe("runAudit — real audit flow", () => {
90
+ let tmp: string;
91
+ beforeEach(async () => { tmp = await mkTmp(); });
92
+ afterEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); });
93
+
94
+ it("reports outcome=ok when no violations fire", async () => {
95
+ const file = await writeHtml(tmp, "clean.html", "<html><body><h1>Hi</h1></body></html>");
96
+ const report = await runAudit([file], {
97
+ axeLoader: async () => createFakeAxe([{ violations: [] }]),
98
+ domLoader: async () => fakeDom,
99
+ });
100
+ expect(report.outcome).toBe("ok");
101
+ expect(report.filesScanned).toBe(1);
102
+ expect(report.violations).toEqual([]);
103
+ });
104
+
105
+ it("aggregates violations across multiple files with per-file attribution", async () => {
106
+ const a = await writeHtml(tmp, "a.html", "<html></html>");
107
+ const b = await writeHtml(tmp, "b.html", "<html></html>");
108
+ const violationA = {
109
+ id: "image-alt",
110
+ impact: "serious" as AuditImpact,
111
+ help: "Images must have alternate text",
112
+ helpUrl: "https://axe.dev/image-alt",
113
+ nodes: [{ target: ["html > body > img"], failureSummary: "missing alt" }],
114
+ };
115
+ const violationB = {
116
+ id: "color-contrast",
117
+ impact: "critical" as AuditImpact,
118
+ help: "Elements must have sufficient contrast",
119
+ nodes: [{ target: "html > body > p", failureSummary: "1.2:1 ratio" }],
120
+ };
121
+ const report = await runAudit([a, b], {
122
+ axeLoader: async () =>
123
+ createFakeAxe([
124
+ { violations: [violationA] },
125
+ { violations: [violationB] },
126
+ ]),
127
+ domLoader: async () => fakeDom,
128
+ });
129
+ expect(report.outcome).toBe("violations");
130
+ expect(report.filesScanned).toBe(2);
131
+ expect(report.violations.length).toBe(2);
132
+ expect(report.violations[0].file).toBe(path.resolve(a));
133
+ expect(report.violations[1].file).toBe(path.resolve(b));
134
+ expect(report.impactCounts.serious).toBe(1);
135
+ expect(report.impactCounts.critical).toBe(1);
136
+ expect(report.impactCounts.minor).toBe(0);
137
+ });
138
+
139
+ it("drops violations below minImpact threshold", async () => {
140
+ const file = await writeHtml(tmp, "page.html", "<html></html>");
141
+ const report = await runAudit([file], {
142
+ minImpact: "serious",
143
+ axeLoader: async () =>
144
+ createFakeAxe([
145
+ {
146
+ violations: [
147
+ { id: "minor-rule", impact: "minor", help: "x", nodes: [] },
148
+ { id: "moderate-rule", impact: "moderate", help: "y", nodes: [] },
149
+ { id: "serious-rule", impact: "serious", help: "z", nodes: [{ target: "x" }] },
150
+ { id: "critical-rule", impact: "critical", help: "w", nodes: [{ target: "y" }] },
151
+ ],
152
+ },
153
+ ]),
154
+ domLoader: async () => fakeDom,
155
+ });
156
+ expect(report.violations.map((v) => v.rule)).toEqual([
157
+ "serious-rule",
158
+ "critical-rule",
159
+ ]);
160
+ expect(report.impactCounts.minor).toBe(0);
161
+ expect(report.impactCounts.moderate).toBe(0);
162
+ });
163
+
164
+ it("attaches fixHint for recognised rule ids", async () => {
165
+ const file = await writeHtml(tmp, "form.html", "<html></html>");
166
+ const report = await runAudit([file], {
167
+ axeLoader: async () =>
168
+ createFakeAxe([
169
+ {
170
+ violations: [
171
+ { id: "label", impact: "serious", help: "Labels", nodes: [{ target: "input" }] },
172
+ { id: "unknown-rule-xyz", impact: "critical", help: "?", nodes: [{ target: "div" }] },
173
+ ],
174
+ },
175
+ ]),
176
+ domLoader: async () => fakeDom,
177
+ });
178
+ const labelV = report.violations.find((v) => v.rule === "label")!;
179
+ const unknownV = report.violations.find((v) => v.rule === "unknown-rule-xyz")!;
180
+ expect(labelV.fixHint).toMatch(/label/i);
181
+ expect(unknownV.fixHint).toBeUndefined();
182
+ });
183
+
184
+ it("caps node list at 10 per violation and truncates long HTML snippets", async () => {
185
+ const file = await writeHtml(tmp, "big.html", "<html></html>");
186
+ const manyNodes = Array.from({ length: 25 }, (_, i) => ({
187
+ target: [`#node${i}`],
188
+ failureSummary: "x",
189
+ html: "<div>" + "a".repeat(500) + "</div>",
190
+ }));
191
+ const report = await runAudit([file], {
192
+ axeLoader: async () =>
193
+ createFakeAxe([
194
+ { violations: [{ id: "landmark-one-main", impact: "moderate", help: "m", nodes: manyNodes }] },
195
+ ]),
196
+ domLoader: async () => fakeDom,
197
+ });
198
+ const v = report.violations[0];
199
+ expect(v.nodes.length).toBe(10);
200
+ for (const n of v.nodes) {
201
+ expect(n.html!.length).toBeLessThanOrEqual(300);
202
+ expect(n.html!.endsWith("...")).toBe(true);
203
+ }
204
+ });
205
+
206
+ it("skips unreadable files without failing the whole run", async () => {
207
+ const good = await writeHtml(tmp, "good.html", "<html></html>");
208
+ const missing = path.join(tmp, "does-not-exist.html");
209
+ const report = await runAudit([missing, good], {
210
+ axeLoader: async () =>
211
+ createFakeAxe([
212
+ { violations: [] },
213
+ ]),
214
+ domLoader: async () => fakeDom,
215
+ });
216
+ expect(report.filesScanned).toBe(1);
217
+ expect(report.outcome).toBe("ok");
218
+ });
219
+
220
+ it("honours maxFiles cap", async () => {
221
+ const files: string[] = [];
222
+ for (let i = 0; i < 5; i++) {
223
+ files.push(await writeHtml(tmp, `p${i}.html`, "<html></html>"));
224
+ }
225
+ const report = await runAudit(files, {
226
+ maxFiles: 2,
227
+ axeLoader: async () =>
228
+ createFakeAxe([
229
+ { violations: [] },
230
+ { violations: [] },
231
+ { violations: [] },
232
+ { violations: [] },
233
+ { violations: [] },
234
+ ]),
235
+ domLoader: async () => fakeDom,
236
+ });
237
+ expect(report.filesScanned).toBe(2);
238
+ });
239
+
240
+ it("produces a JSON-serialisable report (stable shape contract)", async () => {
241
+ const file = await writeHtml(tmp, "x.html", "<html></html>");
242
+ const report = await runAudit([file], {
243
+ axeLoader: async () =>
244
+ createFakeAxe([
245
+ {
246
+ violations: [
247
+ {
248
+ id: "image-alt",
249
+ impact: "serious",
250
+ help: "Images",
251
+ helpUrl: "https://x",
252
+ nodes: [{ target: ["img"], failureSummary: "s" }],
253
+ },
254
+ ],
255
+ },
256
+ ]),
257
+ domLoader: async () => fakeDom,
258
+ });
259
+ const roundtrip = JSON.parse(JSON.stringify(report));
260
+ expect(roundtrip).toMatchObject({
261
+ outcome: "violations",
262
+ filesScanned: 1,
263
+ minImpact: "minor",
264
+ });
265
+ expect(roundtrip.violations[0]).toMatchObject({
266
+ rule: "image-alt",
267
+ impact: "serious",
268
+ help: "Images",
269
+ fixHint: expect.any(String),
270
+ });
271
+ expect(Array.isArray(roundtrip.violations[0].nodes)).toBe(true);
272
+ });
273
+ });
274
+
275
+ describe("formatAuditReport", () => {
276
+ it("renders an actionable message when deps are missing", () => {
277
+ const text = formatAuditReport({
278
+ outcome: "axe-missing",
279
+ filesScanned: 0,
280
+ violations: [],
281
+ impactCounts: { minor: 0, moderate: 0, serious: 0, critical: 0 },
282
+ minImpact: "minor",
283
+ durationMs: 0,
284
+ note: "axe-core not installed — skipping audit",
285
+ });
286
+ expect(text).toMatch(/bun add -d axe-core jsdom/);
287
+ });
288
+
289
+ it("prints a PASS summary when there are zero violations", () => {
290
+ const text = formatAuditReport({
291
+ outcome: "ok",
292
+ filesScanned: 4,
293
+ violations: [],
294
+ impactCounts: { minor: 0, moderate: 0, serious: 0, critical: 0 },
295
+ minImpact: "minor",
296
+ durationMs: 12,
297
+ });
298
+ expect(text).toMatch(/PASS/);
299
+ expect(text).toMatch(/Files scanned: 4/);
300
+ });
301
+
302
+ it("groups violations by rule and surfaces fix hints + doc links", () => {
303
+ const text = formatAuditReport({
304
+ outcome: "violations",
305
+ filesScanned: 2,
306
+ violations: [
307
+ {
308
+ file: "a.html",
309
+ rule: "image-alt",
310
+ impact: "serious",
311
+ help: "Images must have alt",
312
+ helpUrl: "https://example/image-alt",
313
+ nodes: [{ target: "img", failureSummary: "x" }],
314
+ fixHint: "Add alt attribute",
315
+ },
316
+ {
317
+ file: "b.html",
318
+ rule: "image-alt",
319
+ impact: "serious",
320
+ help: "Images must have alt",
321
+ nodes: [{ target: "img", failureSummary: "x" }],
322
+ },
323
+ ],
324
+ impactCounts: { minor: 0, moderate: 0, serious: 2, critical: 0 },
325
+ minImpact: "minor",
326
+ durationMs: 45,
327
+ });
328
+ expect(text).toMatch(/\[SERIOUS\] image-alt/);
329
+ expect(text).toMatch(/Fix: Add alt attribute/);
330
+ expect(text).toMatch(/Docs: https:\/\/example\/image-alt/);
331
+ expect(text).toMatch(/2 file\(s\)/);
332
+ });
333
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Curated fix hints for the most common axe-core rules we see in the
3
+ * wild during framework dogfooding. Each hint is a single actionable
4
+ * sentence — long enough to be useful, short enough to fit in a CI
5
+ * table cell. When a rule is not in this map the runner simply omits
6
+ * `fixHint` and callers fall back to axe's `helpUrl` link.
7
+ *
8
+ * Rule ids mirror axe-core's canonical list:
9
+ * https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md
10
+ */
11
+ export const AXE_RULE_FIX_HINTS: Record<string, string> = {
12
+ "color-contrast":
13
+ "Increase foreground/background contrast to meet WCAG AA 4.5:1 for normal text (3:1 for large).",
14
+ "image-alt":
15
+ "Add an `alt` attribute to every <img>. Decorative images should use `alt=\"\"`.",
16
+ "label":
17
+ "Associate every form control with a <label for=\"id\"> or wrap it in <label>.",
18
+ "link-name":
19
+ "Ensure <a> elements contain accessible text (visible, aria-label, or aria-labelledby).",
20
+ "button-name":
21
+ "Give every <button> an accessible name: visible text, aria-label, or aria-labelledby.",
22
+ "document-title":
23
+ "Render a non-empty <title> inside <head>. Mandu's `metadata.title` exports auto-populate this.",
24
+ "html-has-lang":
25
+ "Set `lang` on <html>. In Mandu, configure via `metadata.lang` or the root layout.",
26
+ "html-lang-valid":
27
+ "Use a BCP-47 language code (`en`, `ko`, `en-US`) — case-sensitive region subtag matters.",
28
+ "landmark-one-main":
29
+ "Wrap primary content in exactly one <main> landmark per page.",
30
+ "region":
31
+ "Place all content inside a landmark (<header>, <main>, <nav>, <footer>, or role=\"region\").",
32
+ "duplicate-id":
33
+ "Every `id` must be unique in the DOM. Check island + SSR output for collisions.",
34
+ "duplicate-id-active":
35
+ "Focusable elements must have unique ids — screen readers cannot resolve duplicates.",
36
+ "duplicate-id-aria":
37
+ "ids referenced by aria-* attributes must be unique (one target per reference).",
38
+ "meta-viewport":
39
+ "Do not disable user scaling: avoid `user-scalable=no` or `maximum-scale<2` in <meta name=viewport>.",
40
+ "aria-valid-attr":
41
+ "Remove unknown aria-* attributes. Check for typos (`aria-labeledby` → `aria-labelledby`).",
42
+ "aria-valid-attr-value":
43
+ "aria-* attribute values must match the allowed set for that attribute.",
44
+ "aria-required-attr":
45
+ "The ARIA role you used requires additional attributes (e.g. role=\"slider\" needs aria-valuenow).",
46
+ "aria-roles":
47
+ "Use a valid ARIA role. Custom roles (`role=\"card\"`) are ignored by screen readers.",
48
+ "list":
49
+ "<ul>/<ol> must contain only <li> children (plus script/template). Wrap other content inside <li>.",
50
+ "listitem":
51
+ "<li> must have a parent <ul>, <ol>, or <menu>.",
52
+ "heading-order":
53
+ "Headings must increase by one level at a time — don't skip from <h2> to <h4>.",
54
+ "empty-heading":
55
+ "Remove empty heading tags or add text content — they confuse screen-reader navigation.",
56
+ "tabindex":
57
+ "Avoid tabindex values greater than 0 — they break the natural focus order.",
58
+ "frame-title":
59
+ "Give every <iframe> a `title` attribute describing its contents.",
60
+ "object-alt":
61
+ "Provide fallback text for <object> via its text content or `aria-label`.",
62
+ "video-caption":
63
+ "Every <video> must have at least one <track kind=\"captions\">.",
64
+ "bypass":
65
+ "Include a skip-link or a landmark so keyboard users can bypass repeated blocks.",
66
+ } as const;
67
+
68
+ /**
69
+ * Return the fix hint for a rule id, or `undefined` when we don't have
70
+ * one. Kept as a function (rather than direct map access) so the
71
+ * lookup layer can evolve (e.g. add localization) without rippling
72
+ * through call sites.
73
+ */
74
+ export function getFixHint(ruleId: string): string | undefined {
75
+ return AXE_RULE_FIX_HINTS[ruleId];
76
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @mandujs/core/a11y — public surface.
3
+ *
4
+ * Accessibility audit runner (Phase 18.χ). Consumers typically reach
5
+ * for `runAudit` + `formatAuditReport`; the type exports are there so
6
+ * CI tooling can build typed gates on top of the report shape.
7
+ */
8
+
9
+ export { runAudit, formatAuditReport } from "./run-audit";
10
+ export { AXE_RULE_FIX_HINTS, getFixHint } from "./fix-hints";
11
+ export { AUDIT_IMPACT_ORDER, impactAtLeast } from "./types";
12
+ export type {
13
+ AuditImpact,
14
+ AuditNode,
15
+ AuditViolation,
16
+ AuditReport,
17
+ RunAuditOptions,
18
+ } from "./types";