@surea11y/core 1.0.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.
Files changed (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Changed
8
+ - Trimmed the published npm package: rule-authoring scaffolding (`docs/RULE_TEMPLATE.*`, `docs/RULE_TEST_TEMPLATE.md`, `docs/RULE_TEST_AUTHORING.md`, `docs/TEST_OUTCOME_STABILITY.md`) and the not-yet-documented `src/explain` module no longer ship in the tarball — both stay in the git repo for contributors.
9
+ - README rewritten for clarity: corrected the install command and `require()` examples to the actual package name (`@surea11y/core`), and updated the rule count to 125.
10
+
11
+ ## [1.1.1] - 2026-07-24
12
+
13
+ ### Added
14
+ - 125 rules (77 automatic/`fail`-capable, 48 manual/advisory) — see `docs/RULE_CATALOG.md` for the full list.
15
+ - Full i18n support (English complete, French partial — see `docs/I18N.md`).
16
+ - A generated public rule catalog (`docs/RULE_CATALOG.md`, via `npm run docs:rule-catalog`) and WCAG facet-coverage report (`coverage/coverage-report.md`, via `npm run coverage`).
17
+ - This documentation set: `README.md`, `docs/OUTPUT_SCHEMA.md`, `docs/ENGINE_OPTIONS.md`, `docs/WCAG_CONFORMANCE.md`, `docs/POLICY.md`, `docs/I18N.md`, `docs/INTEGRATION.md`, `docs/LIMITATIONS.md`, `docs/TROUBLESHOOTING.md`, `LICENSE`.
18
+ - Multi-region `contextSelector` support: pass an array of selectors (or one comma-separated selector string) to scan multiple, possibly disjoint regions in a single run — equivalent to the multi-region include capability found in other engines. Overlapping/nested regions are deduped automatically. See `docs/ENGINE_OPTIONS.md`.
19
+ - `includeShadowDom` now defaults to `true` (opt out with `includeShadowDom: false`).
20
+ - `structuralPath` on every `fail`/`cantTell` occurrence: a sibling-index path from `documentElement` down to the flagged element, a more robust element-identity mechanism than `selector` alone (survives some DOM changes a selector wouldn't) — equivalent to the ancestry/xpath-addressing feature found in other engines. See `docs/OUTPUT_SCHEMA.md`.
21
+ - `engineOptions.customRules`: register additional rules at runtime, scan-scoped (not added to the static catalog), matching the shape of an internal rule module (`{ id, meta, runInPage, applicability?, data? }`) — equivalent to the rule/check registration pattern used by other engines. `runInPage`/`applicability` accept a real function or a function-source string, the latter needed for cross-realm callers (e.g. Playwright) whose `engineOptions` argument can't carry a live function across a serialization boundary. See `docs/ENGINE_OPTIONS.md`.
22
+ - `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including genuinely cross-origin) scanning for the "plain script injection" consumption mode (no automation driver) — a cooperative `postMessage` protocol similar in spirit to the cross-frame messaging mechanisms used by other engines, including the same real limitation (a non-cooperating child frame is unreachable). Bundler-free, like `runa11yCoreInPage`. See `docs/INTEGRATION.md`'s "Cross-frame scanning" section and `docs/OUTPUT_SCHEMA.md`'s "Cross-frame result" section.
23
+ - `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion (matching the tagging convention used by other engines — a 2.1/2.2-introduced SC is tagged only with its true origin version, never also the pre-existing baseline tag), so a caller can select a WCAG 2.0/2.1/2.2 conformance target by combining tag sets. See `docs/ENGINE_OPTIONS.md`'s "Filtering by WCAG version" section and `src/coverage/wcag-version-map.js` for the canonical per-version SC list.
24
+ - `docs/BINDING_AUTHORS_GUIDE.md`: a reference for building a *new* framework binding (Puppeteer, Cypress, ...) on top of this engine — what's already engine-level vs. what every binding has to build itself, checked against what the `surea11y-playwright` sibling project actually needed.
25
+
26
+ ### Fixed (selected)
27
+ - A shared `buildSelector` helper bug where an ancestor element that was the *last* of several same-tag siblings got no `:nth-of-type()` disambiguation, producing selectors that matched multiple elements instead of the one they were built for.
28
+ - Several `ALLOWED_ROLES_BY_ELEMENT` entries (`<label>`, `<table>`/`<td>`/`<th>`/`<tr>`, `input[type=checkbox][role=button]`) that were missing or too restrictive, found via real-world-page testing and verified against the W3C ARIA-in-HTML spec.
29
+ - `aria-hidden-focus` false-flagging the common `tabindex="-1"`-behind-`aria-hidden` pattern (checked raw focusability instead of tabbability).
30
+ - A label-naming bug (`hasLabelAssociation` ignoring a `<label>`'s own `aria-label`), duplicated across 7 rule files, all fixed identically.
31
+ - A systemic "name from content" false-positive affecting 19 rule files (an `<img alt>` or `aria-label`-named descendant inside a link/button wasn't recognized as providing the accessible name).
32
+ - `contextSelector` resolving via `document.querySelector` (first match only) instead of `querySelectorAll` — a selector matching several elements silently scanned only the first, dropping the rest with no indication.
33
+ - Three rules (`form-control-programmatic-label-present`, `target-size-minimum`, `label-in-name`) that queried `ctx.root` directly instead of through the shared `queryAllSmart`/`queryAll` helpers, found while implementing multi-region `contextSelector` support — silently broke (found nothing) the moment `ctx.root` became an array.
34
+ - `aria-required-parent`/`aria-required-children`'s ancestor/descendant searches and `getContentNameInfo`'s "name from content" walk not following shadow-DOM `<slot>` assignment; a duplicated `resolveAriaLabelledbyText` pattern across 16 rules and `getLabelText` across 7 not checking an `aria-labelledby`/`<label>` target's `title` attribute as a final accname fallback (e.g. an `<iframe title="...">` target, whose content is always empty).
35
+ - `landmark-one-main` incorrectly also flagged "more than one main landmark" — out of its real scope (the equivalent check in other engines is presence-only; duplicates are `landmark-no-duplicate-main`'s job, already implemented correctly) and missing the accessibility-tree visibility filter its sibling rule already has.
36
+ - `aria-required-children`, `aria-prohibited-children`, `aria-required-parent`, and `aria-required-attr` flagged containers/elements that were not currently exposed to the accessibility tree at all (`hidden`, a closed `<dialog>`, etc.) — e.g. a closed flyout `role="menu"` populated on open, or a custom `role="checkbox"` whose `aria-checked` is set on hydration. All four now skip elements that fail `isAccTreeEligible`; `aria-required-children`/`aria-required-attr` also honor `aria-busy="true"` as an explicit author signal of transient incompleteness (WAI-ARIA's own escape hatch for required owned elements, extended by analogy to required attributes).
37
+ - `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source (only `aria-label`/`aria-labelledby`) — confirmed against another engine's real scan that `title` is a valid landmark-naming fallback. Replaced all 7 copies with one shared helper, `helpers.getLandmarkNameInfo`.
38
+
39
+ ### Known limitations
40
+ See `docs/LIMITATIONS.md` — structural (keyboard-trap detection, reflow-at-zoom), environment-dependent (jsdom vs. real-browser geometry), and deliberately-not-automated (text-quality judgment calls) limitations, stated explicitly rather than left to be discovered.
41
+
42
+ ---
43
+
44
+ ## How to add an entry
45
+
46
+ When you ship a change worth calling out to consumers (not every commit):
47
+ 1. Add a bullet under `[Unreleased]`, in the right subsection (`Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, `Security`) — create the subsection if it doesn't exist yet for this cycle.
48
+ 2. Write it from the consumer's perspective ("what changed for someone using this package"), not the implementation's.
49
+ 3. When you tag a release, rename `[Unreleased]` to `## [x.y.z] - YYYY-MM-DD` and start a fresh empty `[Unreleased]` above it.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jorge Rumoroso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # surea11y
2
+
3
+ **surea11y runs automated accessibility tests against a real DOM** — 125 rules, standards-traceable, safe by default. Point it at a static HTML file or a URL, or run it inside a real rendered page — via Puppeteer, Playwright, Selenium, Cypress, or any other browser-automation driver — and it tells you exactly what fails a WCAG Success Criterion, where, and why — no browser extension, no dashboard sign-up, just a function call or a CLI command.
4
+
5
+ What makes it trustworthy enough to gate a build on:
6
+
7
+ - **No false alarms by design.** `fail` is reserved for deterministic, high-confidence, normative violations — never a heuristic guess. Where certainty isn't possible, the result says so (`cantTell`) instead of forcing a guess into a `pass`/`fail`.
8
+ - **Deterministic, every time.** Same input, same result — no built-in clock, no randomness, nothing that can make a CI run flaky.
9
+ - **Every rule is atomic and traceable.** One normative decision per rule, mapped to a WCAG Success Criterion where one applies, with a machine-readable rule ID and a human-readable hint for fixing it.
10
+ - **Zero runtime dependencies in the library itself** (see [`SECURITY.md`](./SECURITY.md)) — the CLI is the only part that pulls in `jsdom`, and only if you use it.
11
+ - **Runs anywhere your DOM does — jsdom or a real browser, each with real tradeoffs.** See "Static HTML vs. a live browser" below before picking one.
12
+ - **Fully localized output** (English, French, more on the way) without sacrificing stable, locale-independent rule IDs and machine-readable keys.
13
+ - **Extensible.** Register custom rules at scan time, filter by rule ID/tag/WCAG version, or scan several regions of a page in a single pass.
14
+
15
+ ## Static HTML vs. a live browser
16
+
17
+ This is the decision that determines what content the engine can actually see — pick wrong and you'll get clean results on a page that isn't really accessible.
18
+
19
+ - **CLI (`scan ./file.html` / `scan <url>`) and jsdom (`runDomRulesInPage`) — no JavaScript execution, no real CSS layout engine.** These parse markup as text/fetch it as-is; a URL scan sees exactly what the server sent, not what the page looks like after client JS runs. Fine for static or server-rendered HTML. Blind to anything a client-side framework adds after load — SPA content, hydration-driven attribute changes, modals/dropdowns that only exist post-interaction. Layout-dependent rules (`target-size-minimum`, most notably) report `notApplicable` rather than guess, since jsdom has no real `getBoundingClientRect()`.
20
+ - **A real browser (`runa11yCoreInPage`, run inside a live page — Puppeteer/Playwright's `page.evaluate`, Selenium's `execute_script`, Cypress's in-browser test code, a browser extension, or any other driver that can run code in the page) — sees the fully rendered, post-JS, post-hydration DOM and real computed layout.** Required for client-rendered apps, and for accurate results on layout-dependent rules.
21
+
22
+ Full detail on both: [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md) (everything each mode structurally can't see) and [`docs/INTEGRATION.md`](./docs/INTEGRATION.md) (the two patterns, in depth, with working code).
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ npm install @surea11y/core
28
+ ```
29
+
30
+ ## Quickstart
31
+
32
+ ### CLI — fastest way to just try it
33
+
34
+ ```sh
35
+ npx @surea11y/core scan ./index.html
36
+ npx @surea11y/core scan https://example.com/
37
+ ```
38
+
39
+ Static HTML only (no page JavaScript execution) — see [`docs/CLI.md`](./docs/CLI.md) for every flag, exit codes, and when you need the library instead (client-rendered content, real-browser geometry).
40
+
41
+ ### Library — for your own scripts, test suites, or browser-automation code
42
+
43
+ Two runner functions, depending on where you run it (see [`docs/INTEGRATION.md`](./docs/INTEGRATION.md) for the full explanation of the difference and more patterns):
44
+
45
+ ```js
46
+ // Node + jsdom (no real browser needed)
47
+ const { JSDOM } = require('jsdom');
48
+ const { runDomRulesInPage } = require('@surea11y/core');
49
+
50
+ const dom = new JSDOM('<img src="logo.png">', { url: 'https://example.com/', pretendToBeVisual: true });
51
+ global.window = dom.window;
52
+ global.document = dom.window.document;
53
+
54
+ const result = runDomRulesInPage('https://example.com/', null, {}, null);
55
+ console.log(result.checksResults.filter((r) => r.outcome === 'fail'));
56
+ // -> [{ ruleId: 'img-alt-present', outcome: 'fail', occurrences: [...] }]
57
+ ```
58
+
59
+ ```js
60
+ // Puppeteer / Playwright, against a real rendered page
61
+ const { runa11yCoreInPage } = require('@surea11y/core');
62
+
63
+ const result = await page.evaluate(runa11yCoreInPage, 'https://example.com/', null, {}, null);
64
+ ```
65
+
66
+ `runa11yCoreInPage` is fully self-contained (see [`docs/INTEGRATION.md`](./docs/INTEGRATION.md)), so it isn't Puppeteer/Playwright-specific — the same function works with Selenium's `execute_script`, runs natively in Cypress's in-browser test code, or in a browser extension content script. Puppeteer/Playwright are just the two shown here.
67
+
68
+ ## What you get back
69
+
70
+ ```json
71
+ {
72
+ "engine": { "tag": "a11ycore", "schemaVersion": "1.0.0" },
73
+ "url": "https://example.com/",
74
+ "checksResults": [
75
+ {
76
+ "ruleId": "img-alt-present",
77
+ "outcome": "fail",
78
+ "severity": "serious",
79
+ "confidence": "high",
80
+ "occurrences": [
81
+ { "selector": "html > body > img", "html": "<img src=\"logo.png\">", "summary": "Missing alt attribute on <img>.", "hint": "Add an alt attribute (use alt=\"\" only for decorative images)." }
82
+ ]
83
+ }
84
+ ],
85
+ "rulesResults": []
86
+ }
87
+ ```
88
+
89
+ Full field-by-field reference (what every field means, what `cantTell` vs `notApplicable` means, how composite WCAG-SC rollups work): [`docs/OUTPUT_SCHEMA.md`](./docs/OUTPUT_SCHEMA.md).
90
+
91
+ ## Documentation
92
+
93
+ | Doc | What's in it |
94
+ |---|---|
95
+ | [`docs/OUTPUT_SCHEMA.md`](./docs/OUTPUT_SCHEMA.md) | The full result shape — every field, every outcome value, worked examples. Start here. |
96
+ | [`docs/CLI.md`](./docs/CLI.md) | The `surea11y scan` command — flags, exit codes, what it can and can't scan. |
97
+ | [`docs/ENGINE_OPTIONS.md`](./docs/ENGINE_OPTIONS.md) | Every `engineOptions`/`runOnly` field: selecting rules, locale, shadow DOM, contrast mode, policy, and the common `runOnly` gotcha. |
98
+ | [`docs/INTEGRATION.md`](./docs/INTEGRATION.md) | Node/jsdom vs. real-browser (Puppeteer, Playwright, Selenium, Cypress, or any driver) usage, CI gating, browser-extension context. |
99
+ | [`docs/BINDING_AUTHORS_GUIDE.md`](./docs/BINDING_AUTHORS_GUIDE.md) | Building a *new* framework binding (Puppeteer, Cypress, ...)? What the engine gives you for free vs. what every binding has to build itself, checked against what `surea11y-playwright` actually needed. |
100
+ | [`docs/RULE_CATALOG.md`](./docs/RULE_CATALOG.md) | All 125 rules — id, WCAG SC, level, confidence, severity. Generated; run `npm run docs:rule-catalog` to refresh. |
101
+ | [`docs/WCAG_CONFORMANCE.md`](./docs/WCAG_CONFORMANCE.md) | How rule outcomes roll up to an SC-level / A-AA-AAA conformance picture, and what that picture does and doesn't claim. |
102
+ | [`docs/POLICY.md`](./docs/POLICY.md) | The `a11y`/`generic` policy contracts — what they control and how to customize. |
103
+ | [`docs/I18N.md`](./docs/I18N.md) | Current locale coverage and how to contribute a translation. |
104
+ | [`docs/LIMITATIONS.md`](./docs/LIMITATIONS.md) | What this engine cannot do, and why — stated upfront. |
105
+ | [`docs/TROUBLESHOOTING.md`](./docs/TROUBLESHOOTING.md) | Common gotchas, FAQ. |
106
+ | [`docs/RULE_AUTHORING.md`](./docs/RULE_AUTHORING.md) | How to write a new rule — the exact module contract, a critical footgun to avoid, fixture requirements. |
107
+ | [`docs/RULE_TAXONOMY.md`](./docs/RULE_TAXONOMY.md) | How rules are categorized. |
108
+ | [`CONTRIBUTING.md`](./CONTRIBUTING.md) | How to add/change a rule, commit conventions, required checks before a PR. |
109
+ | [`SECURITY.md`](./SECURITY.md) | Scope, threat model, how to report a vulnerability. |
110
+ | [`CHANGELOG.md`](./CHANGELOG.md) | What changed, release to release. |
111
+
112
+ ## Folder layout
113
+
114
+ ```
115
+ bin/
116
+ core.js # CLI entry point (`npx @surea11y/core scan ...`) — see docs/CLI.md
117
+ src/
118
+ index.js # public entry point (re-exports src/core.js)
119
+ core.js # GENERATED — do not edit directly, see "Build" below
120
+ checks/
121
+ automatic/ # type: 'automatic' rules — can return `fail`
122
+ manual/ # type: 'manual' rules — advisory, capped at `cantTell`
123
+ core/ # shared runtime: dom-runner, dom-helpers, aria-helpers, contrast-helpers
124
+ policy/ # policy contracts (a11y / generic)
125
+ i18n/ # locale dictionaries (en.js, fr.js)
126
+ coverage/ # WCAG facet definitions
127
+ catalogs/ # composite (WCAG-SC rollup) rule definitions
128
+ scripts/
129
+ build-core.js # bundles src/checks/**, src/core/**, src/i18n/** into src/core.js
130
+ docs/ # everything in the table above
131
+ tests/
132
+ fixtures/ # one *-all-scenarios.html scenario page per rule
133
+ engine-checks/ # per-rule unit + fixture-coverage tests
134
+ ```
135
+
136
+ ## Build & test
137
+
138
+ ```sh
139
+ npm run build # regenerate src/core.js from source
140
+ npm test # build + run the full test suite
141
+ ```
142
+
143
+ ## License
144
+
145
+ [MIT](./LICENSE)
package/bin/core.js ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * surea11y CLI — a thin wrapper around the library for ad hoc/CI use.
6
+ *
7
+ * This is the one part of the package that depends on jsdom (see
8
+ * package.json's "dependencies" vs the library itself, which has none --
9
+ * `require('surea11y')` never loads jsdom; only running this CLI does).
10
+ * Static-HTML only: it does not execute page JavaScript, so client-rendered
11
+ * content won't be scanned. For that, use a real browser via Puppeteer/
12
+ * Playwright — see docs/INTEGRATION.md Pattern 2.
13
+ *
14
+ * Usage:
15
+ * surea11y scan <file-or-url> [options]
16
+ *
17
+ * See docs/CLI.md for the full option reference.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const pkg = require('../package.json');
24
+
25
+ // Piping output to `head`/`less`/etc. closes stdout early — without this,
26
+ // the next write throws an unhandled EPIPE and crashes with a raw stack
27
+ // trace instead of just stopping quietly, like well-behaved CLI tools do.
28
+ process.stdout.on('error', (err) => {
29
+ if (err && err.code === 'EPIPE') process.exit(0);
30
+ throw err;
31
+ });
32
+
33
+ function printHelp() {
34
+ process.stdout.write(`surea11y v${pkg.version}
35
+
36
+ Usage:
37
+ surea11y scan <file-or-url> [options]
38
+
39
+ Options:
40
+ --json Print the raw result object as JSON instead of a summary
41
+ --locale <locale> Locale for output text (default: en)
42
+ --rules <ids> Comma-separated rule IDs to run (only these)
43
+ --exclude-rules <ids> Comma-separated rule IDs to exclude
44
+ --tags <tags> Comma-separated tags to run (e.g. wcag2a,wcag2aa)
45
+ --context <selector> CSS selector to scope the scan to a subtree
46
+ -h, --help Show this help
47
+ -v, --version Show the installed version
48
+
49
+ Exit codes:
50
+ 0 scan completed, no "fail" outcomes
51
+ 1 scan completed, at least one "fail" outcome
52
+ 2 usage error or the scan itself could not run (bad path/URL, network failure, etc.)
53
+
54
+ Examples:
55
+ surea11y scan ./index.html
56
+ surea11y scan https://example.com/ --tags wcag2a,wcag2aa
57
+ surea11y scan ./index.html --json > result.json
58
+ `);
59
+ }
60
+
61
+ function parseArgs(argv) {
62
+ const out = { _: [], json: false };
63
+ for (let i = 0; i < argv.length; i++) {
64
+ const a = argv[i];
65
+ switch (a) {
66
+ case '--json':
67
+ out.json = true;
68
+ break;
69
+ case '--locale':
70
+ out.locale = argv[++i];
71
+ break;
72
+ case '--rules':
73
+ out.rules = argv[++i];
74
+ break;
75
+ case '--exclude-rules':
76
+ out.excludeRules = argv[++i];
77
+ break;
78
+ case '--tags':
79
+ out.tags = argv[++i];
80
+ break;
81
+ case '--context':
82
+ out.context = argv[++i];
83
+ break;
84
+ case '-h':
85
+ case '--help':
86
+ out.help = true;
87
+ break;
88
+ case '-v':
89
+ case '--version':
90
+ out.version = true;
91
+ break;
92
+ default:
93
+ out._.push(a);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function isUrl(s) {
100
+ return /^https?:\/\//i.test(s);
101
+ }
102
+
103
+ function formatError(err) {
104
+ const base = err && err.message ? err.message : String(err);
105
+ const cause = err && err.cause && err.cause.message ? err.cause.message : (err && err.cause ? String(err.cause) : '');
106
+ return cause ? `${base}: ${cause}` : base;
107
+ }
108
+
109
+ async function loadHtml(target) {
110
+ if (isUrl(target)) {
111
+ const res = await fetch(target);
112
+ if (!res.ok) {
113
+ throw new Error(`Fetching ${target} failed: HTTP ${res.status} ${res.statusText}`);
114
+ }
115
+ return { html: await res.text(), url: target };
116
+ }
117
+
118
+ const resolved = path.resolve(process.cwd(), target);
119
+ if (!fs.existsSync(resolved)) {
120
+ throw new Error(`No such file: ${resolved}`);
121
+ }
122
+ return { html: fs.readFileSync(resolved, 'utf8'), url: `file://${resolved}` };
123
+ }
124
+
125
+ function buildEngineOptions(args) {
126
+ const engineOptions = {};
127
+ if (args.locale) engineOptions.locale = args.locale;
128
+ if (args.rules || args.excludeRules) {
129
+ engineOptions.rules = {};
130
+ if (args.rules) engineOptions.rules.include = args.rules;
131
+ if (args.excludeRules) engineOptions.rules.exclude = args.excludeRules;
132
+ }
133
+ if (args.tags) engineOptions.tags = { include: args.tags };
134
+ return engineOptions;
135
+ }
136
+
137
+ function printSummary(result) {
138
+ const byOutcome = { pass: 0, fail: 0, cantTell: 0, notApplicable: 0 };
139
+ for (const r of result.checksResults) {
140
+ if (Object.prototype.hasOwnProperty.call(byOutcome, r.outcome)) byOutcome[r.outcome] += 1;
141
+ }
142
+
143
+ process.stdout.write(`\nsurea11y scan: ${result.url || '(no url)'}\n`);
144
+ process.stdout.write(` pass: ${byOutcome.pass} fail: ${byOutcome.fail} cantTell: ${byOutcome.cantTell} notApplicable: ${byOutcome.notApplicable}\n\n`);
145
+
146
+ const fails = result.checksResults.filter((r) => r.outcome === 'fail');
147
+ if (fails.length) {
148
+ process.stdout.write(`FAIL (${fails.length} rule(s)):\n`);
149
+ for (const r of fails) {
150
+ process.stdout.write(`\n ${r.ruleId} (${r.severity}, ${r.occurrences.length} occurrence(s))\n`);
151
+ for (const occ of r.occurrences.slice(0, 5)) {
152
+ process.stdout.write(` - ${occ.selector || '(no selector)'}\n ${occ.summary}\n`);
153
+ if (occ.hint) process.stdout.write(` hint: ${occ.hint}\n`);
154
+ }
155
+ if (r.occurrences.length > 5) {
156
+ process.stdout.write(` ... and ${r.occurrences.length - 5} more\n`);
157
+ }
158
+ }
159
+ process.stdout.write('\n');
160
+ }
161
+
162
+ const cantTells = result.checksResults.filter((r) => r.outcome === 'cantTell');
163
+ if (cantTells.length) {
164
+ process.stdout.write(`cantTell — needs human review (${cantTells.length} rule(s)): ${cantTells.map((r) => r.ruleId).join(', ')}\n\n`);
165
+ }
166
+ }
167
+
168
+ async function runScan(args) {
169
+ const target = args._[0];
170
+ if (!target) {
171
+ process.stderr.write('Error: scan requires a file path or URL. See --help.\n');
172
+ process.exitCode = 2;
173
+ return;
174
+ }
175
+
176
+ let html, url;
177
+ try {
178
+ ({ html, url } = await loadHtml(target));
179
+ } catch (err) {
180
+ process.stderr.write(`Error: ${formatError(err)}\n`);
181
+ process.exitCode = 2;
182
+ return;
183
+ }
184
+
185
+ let JSDOM;
186
+ try {
187
+ ({ JSDOM } = require('jsdom'));
188
+ } catch {
189
+ process.stderr.write('Error: the surea11y CLI requires jsdom. Run `npm install jsdom` (it should already be a dependency of this package — this likely means a broken install).\n');
190
+ process.exitCode = 2;
191
+ return;
192
+ }
193
+
194
+ const { runDomRulesInPage } = require('../src/index.js');
195
+
196
+ const dom = new JSDOM(html, { url, pretendToBeVisual: true });
197
+ global.window = dom.window;
198
+ global.document = dom.window.document;
199
+
200
+ let result;
201
+ try {
202
+ result = runDomRulesInPage(url, args.context || null, buildEngineOptions(args), null);
203
+ } finally {
204
+ dom.window.close();
205
+ }
206
+
207
+ if (args.json) {
208
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
209
+ } else {
210
+ printSummary(result);
211
+ }
212
+
213
+ const hasFail = result.checksResults.some((r) => r.outcome === 'fail');
214
+ process.exitCode = hasFail ? 1 : 0;
215
+ }
216
+
217
+ async function main() {
218
+ const args = parseArgs(process.argv.slice(2));
219
+
220
+ if (args.version) {
221
+ process.stdout.write(`${pkg.version}\n`);
222
+ return;
223
+ }
224
+ if (args.help || args._.length === 0) {
225
+ printHelp();
226
+ process.exitCode = args._.length === 0 && !args.help ? 2 : 0;
227
+ return;
228
+ }
229
+
230
+ const [command, ...rest] = args._;
231
+ if (command !== 'scan') {
232
+ process.stderr.write(`Error: unknown command "${command}". Only "scan" is supported. See --help.\n`);
233
+ process.exitCode = 2;
234
+ return;
235
+ }
236
+
237
+ args._ = rest;
238
+ await runScan(args);
239
+ }
240
+
241
+ main().catch((err) => {
242
+ process.stderr.write(`Error: ${formatError(err)}\n`);
243
+ process.exitCode = 2;
244
+ });
@@ -0,0 +1,41 @@
1
+ # Binding authors' guide
2
+
3
+ Written for whoever builds the *next* framework binding on top of `surea11y` (Puppeteer, Cypress, Selenium, WebdriverIO, whatever comes after) — not for someone consuming a binding, and not for someone calling the engine directly. If that's you, see [`INTEGRATION.md`](./INTEGRATION.md) instead.
4
+
5
+ The first real binding, `surea11y-playwright` (a sibling project, not part of this repo), has already worked through most of the design questions a new binding hits. This doc exists so the next one doesn't have to re-derive them — it's a checklist and a map of "what the engine already gives you for free" vs. "what every binding has to build itself," backed by what that binding actually did, not theory.
6
+
7
+ ## Core-engine vs. binding-layer: know which side you're on
8
+
9
+ Some engine-parity features (relative to other established engines) are **engine-level** — call the engine, get the behavior, no binding code required. Others are **binding-layer** — the engine deliberately doesn't own them, because they only make sense once you have a real automation driver (a `Page`/`Browser`/`ElementHandle`-shaped object) in front of you. Building a new binding without knowing which is which leads to either reimplementing something the engine already does, or missing something because "surely the engine handles that."
10
+
11
+ **Already engine-level, works the moment you call `runa11yCoreInPage`/`runDomRulesInPage` — no binding code needed:**
12
+ - All rule execution, WCAG SC mapping, composite rollups.
13
+ - `runOnly`/`engineOptions.rules`/`.tags`/`.tests` rule selection — including [WCAG-version filtering](./ENGINE_OPTIONS.md#filtering-by-wcag-version-21-vs-22) (`wcag21a`/`wcag22aa`-style tags): if your binding has any kind of `.withTags()`/`.options()` passthrough that forwards `runOnly`/`engineOptions` generically, WCAG-version filtering already works through it with zero extra code — just document the tag vocabulary for your users, the way `surea11y-playwright`'s README does.
14
+ - `structuralPath` on every `fail`/`cantTell` occurrence — if your binding passes occurrences through unreshaped (don't strip fields you don't recognize), this reaches your consumers automatically.
15
+ - `engineOptions.customRules` — runtime rule registration. Works through a generic `engineOptions` passthrough too, **but** see the caveat below: if your binding crosses a serialization boundary (see next section), your consumers must pass `runInPage`/`applicability` as `fn.toString()` source, not a live function. Worth a dedicated `.withCustomRules([...])` convenience method for ergonomics, but not required for the feature to work.
16
+ - Cross-frame scanning **if** your driver reaches every frame itself already (Puppeteer/Cypress/Selenium all can, via CDP or equivalent) — you don't need `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` at all. Just call the engine once per frame your driver already gives you and merge the results yourself (see `surea11y-playwright`'s `.frames(true)`, which does exactly this — no engine change was needed for it). The `postMessage`-based cross-frame functions exist specifically for the *no-automation-driver* case (a plain injected script) and are the wrong tool for a driver-based binding.
17
+
18
+ **Binding-layer — your binding has to build these itself, the engine won't:**
19
+ - **Element references.** The engine returns `selector`/`structuralPath` strings, never a live handle — it has no concept of your driver's element-reference type. Resolve `occurrences[i].selector` back to a real handle yourself (Playwright's approach: `page.evaluateHandle` instead of `page.evaluate`, then `elementHandle.$(selector)` per occurrence — see `.elementRef(true)` in `surea11y-playwright`).
20
+ - **Result verbosity/reporter filtering.** The engine deliberately always returns every rule's outcome, including `pass`/`notApplicable` — "not a violations-only list" is a stated engine design choice (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)), not an oversight to work around. If your consumers want a trimmed view for CI-scale output, that's a post-filter your binding adds (`surea11y-playwright`'s `.reportOnly(['fail','cantTell'])` is a simple array-filter over the full result — no engine change).
21
+ - **Formatted failure output for your framework's own assertion/reporting style** (e.g. Playwright/Jest-style multi-line failure messages). The engine's raw result is framework-agnostic on purpose; shaping it into "what shows up in a failed test's stack trace" is squarely binding territory.
22
+
23
+ ## The serialization-boundary caveat
24
+
25
+ If your binding drives a *separate JS realm* (a browser page/tab is a different realm than your Node test process — this is Playwright/Puppeteer/Selenium's situation, not Cypress's, since Cypress test code already runs in-browser), anything you hand to `page.evaluate()`-equivalent gets structurally cloned/JSON-serialized. **Live functions do not survive that boundary.** This bit `surea11y-playwright` in two places:
26
+ 1. `runa11yCoreInPage` itself is designed around this — it's fully self-contained (`.toString()`-serializable, no closure over outer scope) specifically so it can be reconstructed from source inside the page realm.
27
+ 2. `engineOptions.customRules[].runInPage`/`.applicability` accept a function-source string for exactly this reason — a binding crossing this boundary must tell its consumers to pass `fn.toString()`, not `fn`. Document this prominently; it's an easy trap (a function looks like it should just work as an argument until it silently fails to serialize).
28
+
29
+ If your binding runs in the *same* realm as the page (a browser extension content script, or Cypress-style in-browser test code), this whole section doesn't apply to you — pass live functions freely.
30
+
31
+ ## Things to check before shipping a new binding
32
+
33
+ A short list, derived from what the audit pass on `surea11y-playwright` actually found missing on a first pass (per its own `ROADMAP.md`) — worth checking explicitly rather than assuming your binding's generic passthrough covers them:
34
+ - [ ] Combinations of your own filtering methods behave sanely together (e.g. include+exclude on the same ID, tag-include + tag-exclude on the same tag) — these interact through the engine's `includeMode`/exclude-always-wins semantics ([`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)), test them explicitly rather than assuming.
35
+ - [ ] `structuralPath` and `customRules` still work correctly when combined with whatever binding-layer features you build (verbosity filtering, element refs, per-frame scanning) — a filter applied after the fact should never silently drop fields a consumer expects on a surviving occurrence.
36
+ - [ ] If you support cross-frame scanning via your own driver, confirm each frame's result gets the same normalization (selector/structuralPath/severity) as a single-document scan — don't let a "per-frame" code path silently skip the shared result-shaping logic.
37
+ - [ ] TypeScript types (if you ship any) stay in sync with actual engine output — `structuralPath: number[] | null` and any new fields ([`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) is the source of truth) are easy to leave stale after an engine update.
38
+
39
+ ## A known engine-side tradeoff worth knowing about
40
+
41
+ `src/core.js` is not small (~3.1MB as of 2026-07-22) because the bundler-free, no-driver-context functions (`runa11yCoreInPage`, `runa11yCoreAcrossFrames`, `a11yCoreEnableFrameResponder`) each carry their own complete self-contained copy of the rule catalog. If your binding only ever uses `require('@surea11y/core')` in Node and injects `runa11yCoreInPage.toString()` into the page (the same pattern `surea11y-playwright` uses), your actual browser-injected payload is unaffected by this — only your Node-side `require()` footprint grows. Worth knowing if your binding's own package size matters to your consumers.
package/docs/CLI.md ADDED
@@ -0,0 +1,49 @@
1
+ # CLI
2
+
3
+ `surea11y` ships a small CLI (`bin/core.js`) for ad hoc scans and CI, on top of the library API described in [`INTEGRATION.md`](./INTEGRATION.md).
4
+
5
+ ```sh
6
+ npx @surea11y/core scan ./index.html
7
+ npx @surea11y/core scan https://example.com/
8
+ ```
9
+
10
+ ## What it can and can't scan
11
+
12
+ The CLI reads **static HTML only** — a local file, or the raw response of an HTTP(S) GET request — and never executes page JavaScript. That means client-rendered content (anything your framework injects after page load) won't be captured, and geometry-dependent rules like `target-size-minimum` will report `notApplicable` (no real CSS layout — see [`LIMITATIONS.md`](./LIMITATIONS.md)). If you need either of those, drive a real browser yourself and use `runa11yCoreInPage` directly — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2. The CLI is the fast path for static/server-rendered pages and CI; the library is what you reach for beyond that.
13
+
14
+ ## Options
15
+
16
+ | Flag | Meaning |
17
+ |---|---|
18
+ | `--json` | Print the raw result object (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)) instead of a human-readable summary. |
19
+ | `--locale <locale>` | Output text locale (default `en`) — see [`I18N.md`](./I18N.md). |
20
+ | `--rules <ids>` | Comma-separated rule IDs — only run these. |
21
+ | `--exclude-rules <ids>` | Comma-separated rule IDs — never run these. |
22
+ | `--tags <tags>` | Comma-separated tags — e.g. `--tags wcag2a,wcag2aa` to target a conformance level (see [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md)). |
23
+ | `--context <selector>` | Scope the scan to one CSS-selected subtree. |
24
+ | `-h`, `--help` | Show usage. |
25
+ | `-v`, `--version` | Show the installed version. |
26
+
27
+ These map directly onto `engineOptions`/`runOnly` (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md)) — `--rules`/`--exclude-rules` become `engineOptions.rules.include`/`.exclude`, `--tags` becomes `engineOptions.tags.include`.
28
+
29
+ ## Exit codes
30
+
31
+ | Code | Meaning |
32
+ |---|---|
33
+ | `0` | Scan completed, no `fail` outcomes. |
34
+ | `1` | Scan completed, at least one `fail` outcome — the CI-gating case. |
35
+ | `2` | Usage error, or the scan itself couldn't run (bad path/URL, network failure, missing `jsdom`). |
36
+
37
+ `cantTell` outcomes never affect the exit code — they're printed as a "needs human review" summary, consistent with the manual/`cantTell` mental model in [`TROUBLESHOOTING.md`](./TROUBLESHOOTING.md#should-i-treat-canttell-as-a-failure). If you need `cantTell`-aware gating, use `--json` and inspect `checksResults` yourself, or call the library directly (see [`INTEGRATION.md`](./INTEGRATION.md#ci-gating-a-build-on-the-result)).
38
+
39
+ ## In CI
40
+
41
+ ```sh
42
+ npx @surea11y/core scan ./dist/index.html || exit 1
43
+ ```
44
+
45
+ Or, since the exit code already reflects pass/fail, just let the command's own exit code propagate — most CI systems fail the step automatically on a non-zero exit.
46
+
47
+ ## A note on dependencies
48
+
49
+ The CLI is the one part of this package that depends on `jsdom` — `require('@surea11y/core')` as a library never loads it (see [`SECURITY.md`](../SECURITY.md)). If you only ever use the library API directly against your own DOM (jsdom, a real browser, whatever you already have), you're not paying for `jsdom` a second time.