@mandujs/core 0.53.2 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -380
- package/src/diagnose/checks.ts +832 -720
- package/src/diagnose/index.ts +17 -16
- package/src/diagnose/run.ts +93 -91
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +24 -31
- package/src/runtime/streaming-ssr.ts +32 -37
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
package/src/a11y/run-audit.ts
CHANGED
|
@@ -1,394 +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: () => Promise<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
|
-
async 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
|
-
async dispose() {
|
|
174
|
-
if (typeof window.close === "function") {
|
|
175
|
-
try { await 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 | Promise<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
|
-
await 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
|
-
}
|
|
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: () => Promise<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
|
+
async 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
|
+
async dispose() {
|
|
174
|
+
if (typeof window.close === "function") {
|
|
175
|
+
try { await 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 | Promise<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
|
+
await 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
|
+
}
|