@surea11y/core 1.4.1 → 1.5.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/CHANGELOG.md +47 -7
- package/README.md +19 -3
- package/docs/API_STABILITY.md +2 -2
- package/docs/ENGINE_OPTIONS.md +14 -10
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +28 -6
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +2 -0
- package/docs/RULE_AUTHORING.md +53 -0
- package/docs/TROUBLESHOOTING.md +2 -2
- package/package.json +6 -1
- package/src/checks/automatic/aria-allowed-attr.js +27 -30
- package/src/checks/automatic/aria-allowed-role.js +14 -16
- package/src/checks/automatic/aria-braille-equivalent.js +17 -19
- package/src/checks/automatic/aria-conditional-attr.js +17 -19
- package/src/checks/automatic/aria-deprecated-role.js +62 -49
- package/src/checks/automatic/aria-hidden-body.js +2 -9
- package/src/checks/automatic/aria-hidden-focus.js +99 -18
- package/src/checks/automatic/aria-prohibited-attr.js +54 -55
- package/src/checks/automatic/aria-prohibited-children.js +26 -26
- package/src/checks/automatic/aria-required-attr.js +14 -17
- package/src/checks/automatic/aria-required-children.js +17 -20
- package/src/checks/automatic/aria-required-parent.js +17 -20
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +18 -21
- package/src/checks/automatic/aria-valid-attr.js +14 -17
- package/src/checks/automatic/autocomplete-valid.js +15 -17
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +19 -21
- package/src/checks/automatic/button-name-present.js +23 -28
- package/src/checks/automatic/combobox-name-present.js +15 -17
- package/src/checks/automatic/css-orientation-lock.js +22 -22
- package/src/checks/automatic/definition-list-children-valid.js +18 -21
- package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
- package/src/checks/automatic/dialog-name-present.js +16 -18
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +45 -37
- package/src/checks/automatic/form-control-single-label.js +38 -40
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +29 -31
- package/src/checks/automatic/iframe-name-present.js +15 -17
- package/src/checks/automatic/iframe-title-unique.js +18 -23
- package/src/checks/automatic/label-in-name.js +31 -36
- package/src/checks/automatic/link-in-text-block.js +19 -21
- package/src/checks/automatic/link-name-present.js +25 -30
- package/src/checks/automatic/list-children-valid.js +15 -17
- package/src/checks/automatic/listbox-name-present.js +15 -17
- package/src/checks/automatic/listitem-parent-valid.js +14 -17
- package/src/checks/automatic/menuitem-name-present.js +16 -18
- package/src/checks/automatic/meta-refresh-no-exceptions.js +15 -18
- package/src/checks/automatic/meta-refresh-timing-absent.js +14 -17
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +16 -18
- package/src/checks/automatic/nested-interactive-controls-absent.js +17 -19
- package/src/checks/automatic/option-name-present.js +16 -18
- package/src/checks/automatic/progressbar-name-present.js +19 -21
- package/src/checks/automatic/searchbox-name-present.js +19 -17
- package/src/checks/automatic/server-side-image-map-absent.js +15 -18
- package/src/checks/automatic/slider-name-present.js +15 -17
- package/src/checks/automatic/spinbutton-name-present.js +19 -17
- package/src/checks/automatic/summary-name-present.js +16 -18
- package/src/checks/automatic/tab-name-present.js +16 -18
- package/src/checks/automatic/table-headers-attr-valid.js +15 -17
- package/src/checks/automatic/table-th-has-data-cells.js +15 -19
- package/src/checks/automatic/target-size-minimum.js +104 -81
- package/src/checks/automatic/td-has-header.js +15 -20
- package/src/checks/automatic/textbox-name-present.js +15 -17
- package/src/checks/automatic/tooltip-name-present.js +16 -18
- package/src/checks/automatic/treeitem-name-present.js +16 -18
- package/src/checks/automatic/valid-lang.js +15 -17
- package/src/checks/manual/accesskeys-manual.js +18 -19
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
- package/src/checks/manual/bypass-blocks-present-manual.js +4 -12
- package/src/checks/manual/empty-heading-manual.js +15 -17
- package/src/checks/manual/empty-table-header-manual.js +27 -30
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
- package/src/checks/manual/heading-order-manual.js +17 -22
- package/src/checks/manual/image-redundant-alt-manual.js +14 -17
- package/src/checks/manual/label-title-only-manual.js +15 -17
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +14 -17
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +14 -17
- package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
- package/src/checks/manual/landmark-one-main-manual.js +2 -9
- package/src/checks/manual/landmark-unique-manual.js +22 -27
- package/src/checks/manual/link-name-quality-manual.js +15 -17
- package/src/checks/manual/meta-viewport-large-manual.js +14 -17
- package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
- package/src/checks/manual/page-has-heading-one-manual.js +2 -9
- package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
- package/src/checks/manual/region-manual.js +13 -6
- package/src/checks/manual/scope-attr-valid-manual.js +14 -17
- package/src/checks/manual/skip-link-manual.js +39 -47
- package/src/checks/manual/tabindex-manual.js +14 -17
- package/src/checks/manual/table-duplicate-name-manual.js +14 -17
- package/src/core.js +4203 -3604
- package/src/report.js +14 -0
- package/surea11y.browser.js +1960 -3671
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.5.0] - 2026-08-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `engine.locale` on the result records which dictionary a run actually used: `{ requested, resolved, reason }`, once per result. Locale fallback is graceful and per-string, so asking for a language the build doesn't carry has always produced fluent English with nothing in the output to say so; `reason` now distinguishes `ok`, `unknown-locale` (no dictionary for that code — a region subtag counts as its own locale) and `partial-dictionary` (dictionary used but missing keys). Purely additive, so `engine.schemaVersion` stays `"1.0.0"`. Documented in `docs/OUTPUT_SCHEMA.md` and `docs/API_STABILITY.md`.
|
|
11
|
+
- `npm run i18n:sync` rewrites every non-English locale file against `en.json`: adds keys that are new (seeded with the English text), drops keys `en.json` no longer has, and leaves existing translations untouched. `npm run i18n:check` performs the same comparison without writing and exits non-zero on drift. Adding a rule string previously meant editing four files by hand, with a missed one falling back to English silently.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- The standalone browser bundle carries English only, and every other locale ships beside it as `surea11y.i18n.<locale>.js`, loaded with a second `<script>` tag. The bundle drops from 1580 KB to 1300 KB and stops growing as languages are added — the change of shape matters more than the one-off 280 KB. Asking for a locale whose side file is not loaded returns English with `engine.locale.reason` set to `dictionary-not-loaded`, so it is visible rather than silent. `require('@surea11y/core')` and every binding are unaffected and still carry all locales. `scripts/build-browser.js` now generates its own English-only core in memory instead of reading the shipped `src/core.js`; the in-page runner keeps its table inside the function body, because the bindings serialize that function into the page, so the only way to give the bundle a smaller table is to build it with one.
|
|
15
|
+
- `npm run build` removes a `surea11y.i18n.<locale>.js` whose locale no longer exists. `files` matches those by glob, so a dropped language would otherwise have kept shipping.
|
|
16
|
+
- New `engineOptions.messages`, a `{ [locale]: { key: text } }` map checked before the built-in tables. It can override individual strings or supply a language the build does not carry, and it is how a locale side file reaches the in-page runner. Omitted keys fall back normally.
|
|
17
|
+
- Locale codes are matched case-insensitively, so `pt-br` and `PT-BR` both find `pt-BR.json`. Only an exact spelling matched before, which would have caught the first contributor to add a regional file. A code differing from its dictionary only in case reports `ok` rather than `primary-subtag` — no fallback happened.
|
|
18
|
+
- A locale code carrying a subtag now falls back to its base language before falling back to English: `de-DE` and `de-AT` both use `de.json`, matched case-insensitively, and an exact `de-DE.json` still wins if one exists. Previously any subtag resolved straight to English, so a browser-supplied `de-DE` produced English output while `de` produced German. `engine.locale.reason` reports `primary-subtag` for it. A translator now only needs `pt.json` to serve every Portuguese variant; a regional file is for when the wording genuinely differs.
|
|
19
|
+
- Locale sources are JSON (`src/i18n/*.json`) rather than CommonJS modules. A translation is now a data change with no executable code in the diff, and a contributor needs no build knowledge. The generated `src/core.js` and `surea11y.browser.js` are byte-identical across the conversion. `src/i18n/*` was already documented as internal and has never been in the published `files` allowlist, so nothing importable changed.
|
|
20
|
+
- `npm run i18n:new` writes a locale file mirroring `en.json`'s key order. Its refusal to overwrite an existing locale now points at `i18n:sync`, which updates a file without discarding translations.
|
|
21
|
+
- Locale completeness is now asserted for every locale rather than for a hand-maintained list of the ones documented as complete; `i18n:sync` makes full key parity the only valid state.
|
|
22
|
+
- `aria-deprecated-role` occurrences resolve their hint through `ariaDeprecatedRole_guidance_directory`/`_generic`/`_default`, replacing `ariaDeprecatedRole_hint_fail`/`_hint_cantTell`. Reason codes are unchanged, so existing baselines keep matching.
|
|
23
|
+
- `formControl_programmaticLabelQuality_summary_cantTell` interpolates `{{method}}` in place of `{{methodLabel}}`, which held the same value once an unreachable branch was removed.
|
|
24
|
+
- `css-orientation-lock` reports a rule whose selector cannot be read through `cssOrientationLock_summary_fail_unknownSelector` instead of substituting a placeholder selector name.
|
|
25
|
+
- The HTML report's meta bar carries the resolved locale alongside the engine tag and schema version, naming the requested locale too when the two differ. A report generated in a locale the engine does not carry previously read as an ordinary English one. A result from an older engine has no `engine.locale` and gets no chip.
|
|
26
|
+
- `target-size-minimum` now reports `cantTell` instead of `fail` when an undersized inline link's only spacing conflict is another inline link in the same run of text (e.g. pipe-separated links in a `<nav>`). The SC 2.5.8 inline exception ("the target is in a sentence or its size is otherwise constrained by the line-height of non-target text") plausibly covers such a link, but whether it applies isn't decidable from geometry — so the outcome shouldn't flip between `fail` and `pass` on the wrapping element's tag alone, which is exactly what happened before: the same links failed inside a `<nav>` yet passed inside a `<p>`, because the strict inline-text exception (`isInlineTextExceptionTarget`) requires a text-block container (`p`, `li`, `dd`, ...). The strict exception (link inside a text block → pass outright) is unchanged; the new middle tier is scoped narrowly by a shared `isInlineLinkTarget` helper (link-like, rendered inline/inline-*, with visible text) and only downgrades when both the target and its conflicting neighbor match, so an inline link crowding a `<button>` or a block-displayed nav link still fails. New `cantTell` locale strings (`targetSizeMinimum_summary_cantTell_inlineLinkRun`/`_hint_`) in all four locales; occurrences carry reason code `undersized-inline-link-run`.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- `docs/TROUBLESHOOTING.md` still described two shipped locales and warned that key parity had to be maintained by hand. There are four, and `i18n:sync` with its build check is exactly what removed that risk.
|
|
30
|
+
- The README brand tag rendered twice on npm, once per theme. It relied on `#gh-dark-mode-only` and `#gh-light-mode-only`, fragments only GitHub acts on; every other renderer drew both images. A `<picture>` with a `prefers-color-scheme` source picks one variant on GitHub and npm alike. Its paths are absolute, since the tarball ships `docs/**/*.md` but not the SVGs beside them.
|
|
31
|
+
- A `fail` resting only on elements the engine could not walk to the root of is reported as `cantTell`. Ancestor walks stop after 200 steps so a malformed tree cannot hang a scan, but past that depth the walk cannot show an element is exposed — and three rules were asserting violations on content nested inside `aria-hidden`, which is the one outcome this engine must not produce. An occurrence alongside one the walk did reach still keeps the `fail`, since a single confirmed element justifies it. Counted as `ancestorsIncludingSelf.truncated` in `perfStats`. This only reaches rules that report their element through `helpers.reportOccurrence`; the rest keep the old behaviour until they are migrated.
|
|
32
|
+
- `region` dominated the runtime of any page it fired on, taking roughly four minutes on a thousand unplaced elements and scaling cubically from there. It hand-built its occurrence objects, and an occurrence that arrives without its element makes the engine re-find one with `document.querySelector` to build `structuralPath` — a document-wide query per occurrence. It reports the element now: the same page takes under a second, with identical occurrences. A new `perfStats` counter, `structuralPath.selectorFallback`, makes the expensive path visible, `docs/RULE_AUTHORING.md` §4.3 documents it as a performance contract rather than a convenience, and `tests/structural-path-fallback.test.js` ratchets the count of rules still building occurrences by hand so it can only shrink. Every rule now reports its element: `button-name-present` on 500 unnamed buttons went from 36.9s to 0.3s, and the ancestor-walk downgrade reaches all of them rather than only the 41 that already did. Output is byte-identical across all 130 fixtures throughout — the engine fills `selector` and `html` with the same helpers the rules were calling.
|
|
33
|
+
- `contrast-minimum`, `contrast-enhanced` and `contrast-computable` never examined text inside an open shadow root. Their text collection used a `TreeWalker`, which stops at a shadow boundary, and a `querySelectorAll` for value-bearing inputs, which does not cross one either — so low-contrast text in a web component was reported as `notApplicable` rather than checked, and `contrast-computable` did not flag it as unresolvable either. Every open shadow root beneath a scan root is now walked in its own right, nested roots included. `includeShadowDom: false` still skips them, closed roots remain unreachable, and text is counted once whether it is slotted or not. Background resolution already crossed the boundary and is unchanged.
|
|
34
|
+
- `aria-roles-valid` and `aria-deprecated-role` missed a hidden shadow host. Their ancestor walk used `parentElement`, which stops at a shadow root, so a host carrying `aria-hidden` was never seen from inside its own shadow content and the rule reported anyway. The walk now follows the composed tree, stepping over the shadow root to reach the host.
|
|
35
|
+
- `aria-roles-valid` and `aria-deprecated-role` now treat an `inert` subtree as programmatically hidden, alongside `display:none`, `visibility` and `aria-hidden`. The ACT glossary those two follow predates `inert` and names only the other three, but an inert subtree is out of the accessibility tree entirely, so a role on it reaches nobody. No ACT test case for either rule uses `inert`, so consistency with ACT is unaffected. Rules that judge attribute *syntax* are deliberately unchanged — that is a static-markup property, valid or not regardless of what is visible today.
|
|
36
|
+
- The committed WCAG coverage report was stale, still describing `bypass-blocks-present` as an automatic rule under `src/checks/automatic/` after it became manual. The generator stamped a generation time into both files, so every run produced a diff and real drift had nowhere to show. The timestamp is gone (git records when the file changed), output is byte-stable across runs, and `npm run coverage:check` fails on drift the way the ARIA and language-subtag generators already do. CI runs it.
|
|
37
|
+
- A `runOnly` filter given as a comma-separated string (`includeRuleIds: 'img-alt-present'`) was silently dropped and every rule ran. The normalizer had always accepted a string; the check deciding whether `runOnly` carried any filters at all only recognised arrays, so the filter was parsed and then discarded. `ENGINE_OPTIONS.md` states these fields mirror their `engineOptions` counterparts, which have always taken a string. A bare array (`runOnly: ['img-alt-present']`) is still ignored, as documented.
|
|
38
|
+
- A partial `engineOptions.messages` entry replaced the built-in dictionary for that locale instead of layering over it, so overriding one German string silently returned every *other* string in English. `docs/I18N.md` promised the opposite ("keys you don't supply fall back normally"). A supplied dictionary now sits on top of the built-in one for the same locale, and completeness counts both layers, so a one-key override no longer reports `partial-dictionary` either.
|
|
39
|
+
- `engineOptions.locale` set to an inherited property name — `constructor`, `__proto__`, `toString` — was reported as the resolved locale, because the built-in table was tested for truthiness rather than for an own property holding a dictionary. The text stayed English, so the damage was confined to `engine.locale`, the one field whose job is to be accurate about which language came back.
|
|
40
|
+
- A malformed `engineOptions.messages` entry (`{ de: null }`, a string, an array) could crash a scan rather than being ignored.
|
|
41
|
+
- `npm run validate:automatic-rules` and `validate:manual-rules` both failed, and had for some time, because nothing ran them. Three separate bugs: the module contract asserted rules export *exactly* `id`, `meta`, `runInPage`, but 14 legitimately export `applicability` too; the free-variable scan stripped single-quoted strings before double-quoted ones, so an apostrophe inside a double-quoted hint opened a phantom string and exposed the code after it; and regex literals were not stripped at all, so `replace(/"/g, ...)` did the same thing with its quote. All three are fixed, `applicability` is validated as a function when present, and the scan still rejects a bare `id`/`meta`, `{ id }` shorthand, `require(` and `import` while accepting `.id`, `id:` as a key, the word "id" in prose, and a regex containing a quote — `tests/validate-rule.test.js` covers each case. Both validators now run in CI as `npm run validate:rules`, so they cannot rot unnoticed again. `CONTRIBUTING.md` described the wrong contract and has been corrected.
|
|
42
|
+
- `docs/RULE_TEMPLATE.js` told rule authors to name i18n keys `checks.<ruleId>.occurrence.<case>.summary`. No key in `en.json` has ever used that shape — 124 of the 125 rules use `<ruleName>_summary_<outcome>`, and the one exception uses `rules.<rule-id>.…`, not `checks.…`. A rule copied from the template would have invented keys in a namespace nothing resolves, and `i18n:sync` would then have propagated them to every locale. The template now states the real convention, including the optional `_<case>` discriminator, and points at the `en.json`/`i18n:sync` workflow. `docs/RULE_TEMPLATE.md` was already correct, which is how the `.js` copy went unnoticed.
|
|
43
|
+
- `aria-deprecated-role`'s hint was English in every locale. Its two hint strings were `"{{guidance}}"` — a bare placeholder — and the text behind it was a hardcoded English literal passed in as a parameter, so a German, Spanish or French scan returned German, Spanish or French output with one English sentence in it. No coverage report could see this: the key was present in every dictionary and `engine.locale` reported a clean resolution. The guidance now lives in the dictionaries and is translated in all four locales. `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value that carries no translatable text, so a passthrough cannot be reintroduced.
|
|
44
|
+
- `duplicate-id-aria` now reports `cantTell` instead of `fail`. A duplicated id doesn't break the reference — it resolves to the first element in tree order, so the name is still computed. Whether that's the element the author meant isn't in the markup, so the engine shouldn't assert a violation. WCAG 4.1.1, which covered duplicate ids outright, was removed in 2.2; what's left is 4.1.2, and that needs the name to be demonstrably wrong. New `cantTell` strings in all four locales; the reason code is unchanged, so existing baselines keep matching.
|
|
45
|
+
- `duplicate-id-aria` reported duplicates that sat outside the scanned scope, so a run using `contextSelector` or `excludeSelectors` flagged elements it was never asked about — noise in component tests especially. Detection stays document-wide, since id uniqueness is a document property, but occurrences are now limited to the scanned scope.
|
|
46
|
+
|
|
7
47
|
## [1.4.1] - 2026-08-13
|
|
8
48
|
|
|
9
49
|
### Added
|
|
@@ -187,12 +227,12 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
187
227
|
- Full i18n support (English complete, French partial — see `docs/I18N.md`).
|
|
188
228
|
- 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`).
|
|
189
229
|
- 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`.
|
|
190
|
-
- 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
|
|
230
|
+
- 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. Overlapping/nested regions are deduped automatically. See `docs/ENGINE_OPTIONS.md`.
|
|
191
231
|
- `includeShadowDom` now defaults to `true` (opt out with `includeShadowDom: false`).
|
|
192
|
-
- `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)
|
|
193
|
-
- `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? }`)
|
|
194
|
-
- `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including genuinely cross-origin) scanning for the "plain script injection" consumption mode (no automation driver) — a cooperative `postMessage` protocol
|
|
195
|
-
- `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion (
|
|
232
|
+
- `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). See `docs/OUTPUT_SCHEMA.md`.
|
|
233
|
+
- `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? }`). `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`.
|
|
234
|
+
- `runa11yCoreAcrossFrames` / `a11yCoreEnableFrameResponder`: cross-frame (including genuinely cross-origin) scanning for the "plain script injection" consumption mode (no automation driver) — a cooperative `postMessage` protocol, with one 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.
|
|
235
|
+
- `runOnly.tags` filtering by WCAG version: every rule/composite now carries the version-correct `wcag2*`/`wcag21*`/`wcag22*` level tag for its Success Criterion (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.
|
|
196
236
|
- `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.
|
|
197
237
|
|
|
198
238
|
### Fixed (selected)
|
|
@@ -204,9 +244,9 @@ All notable changes to this project are documented here, in [Keep a Changelog](h
|
|
|
204
244
|
- `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.
|
|
205
245
|
- 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.
|
|
206
246
|
- `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).
|
|
207
|
-
- `landmark-one-main` incorrectly also flagged "more than one main landmark" — out of its real scope (
|
|
247
|
+
- `landmark-one-main` incorrectly also flagged "more than one main landmark" — out of its real scope (this rule checks 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.
|
|
208
248
|
- `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).
|
|
209
|
-
- `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source (only `aria-label`/`aria-labelledby`)
|
|
249
|
+
- `getAccessibleLandmarkName`, duplicated across 7 landmark rule files, never checked an element's `title` attribute as a naming source (only `aria-label`/`aria-labelledby`), though `title` is a valid landmark-naming fallback. Replaced all 7 copies with one shared helper, `helpers.getLandmarkNameInfo`.
|
|
210
250
|
|
|
211
251
|
### Known limitations
|
|
212
252
|
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.
|
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# @surea11y/core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://www.npmjs.com/package/@surea11y/core#gh-light-mode-only)
|
|
3
|
+
<a href="https://www.npmjs.com/package/@surea11y/core"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-dark.svg"><img alt="surea11y core" src="https://raw.githubusercontent.com/SureA11y/core/main/docs/assets/brand-tag-light.svg"></picture></a>
|
|
5
4
|
[](https://www.npmjs.com/package/@surea11y/core)
|
|
6
5
|
[](package.json)
|
|
7
6
|
[](LICENSE)
|
|
@@ -281,6 +280,22 @@ the same `runa11yCoreInPage` function described above — calling it runs a
|
|
|
281
280
|
real scan against the page it's loaded into and returns the same result
|
|
282
281
|
shape documented in [Understanding the Results](#understanding-the-results).
|
|
283
282
|
|
|
283
|
+
The bundle carries English only, to keep the download from growing with
|
|
284
|
+
every language added. For another language, load its file after the bundle:
|
|
285
|
+
|
|
286
|
+
```html
|
|
287
|
+
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
|
|
288
|
+
<script src="node_modules/@surea11y/core/surea11y.i18n.fr.js"></script>
|
|
289
|
+
<script>
|
|
290
|
+
const result = a11ycore.runa11yCoreInPage(location.href, null, { locale: "fr" }, null);
|
|
291
|
+
</script>
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Ask for a language you haven't loaded and you get English rather than an
|
|
295
|
+
error, with `result.engine.locale` saying so. The npm package is
|
|
296
|
+
unaffected — `require("@surea11y/core")` has every locale built in. See
|
|
297
|
+
[`docs/I18N.md`](./docs/I18N.md).
|
|
298
|
+
|
|
284
299
|
`contextSelector`, `engineOptions`, and `runOnly` are the same three
|
|
285
300
|
arguments described throughout this README and `docs/ENGINE_OPTIONS.md` —
|
|
286
301
|
nothing about calling the engine changes just because it's loaded this
|
|
@@ -459,7 +474,8 @@ The repository is organised so that the accessibility engine, rule
|
|
|
459
474
|
implementations and supporting infrastructure remain clearly separated.
|
|
460
475
|
|
|
461
476
|
```text
|
|
462
|
-
surea11y.browser.js # Generated standalone browser bundle
|
|
477
|
+
surea11y.browser.js # Generated standalone browser bundle (English)
|
|
478
|
+
surea11y.i18n.<locale>.js # Generated per-locale side files for that bundle
|
|
463
479
|
|
|
464
480
|
src/
|
|
465
481
|
index.js # Public API
|
package/docs/API_STABILITY.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Removing, renaming, or changing the type/meaning of any of these is a **major** version bump:
|
|
8
8
|
|
|
9
|
-
- Top-level result: `engine.tag`, `engine.schemaVersion`, `url`, `checksResults` (an array), `rulesResults` (an array).
|
|
9
|
+
- Top-level result: `engine.tag`, `engine.schemaVersion`, `engine.locale` (the field and its `requested`/`resolved`/`reason` keys — the set of `reason` *values* is open and may gain entries in a minor), `url`, `checksResults` (an array), `rulesResults` (an array).
|
|
10
10
|
- Each `checksResults[i]` / `rulesResults[i]` entry: `ruleId`, `outcome`, `outcomeNormalized`, `severity`, `confidence`, `type`, `title`, `description`, `meta` (including `meta.normativeMappings`, `meta.deprecated`/`.deprecation` — see below), `engineOptions`, `schemaVersion`.
|
|
11
11
|
- Each occurrence (`occurrences[i]`): `selector`, `html`, `summary`, `hint`, `i18n`, `structuralPath`.
|
|
12
12
|
- The rule **catalog** (`getChecksCatalog()`/`getRulesCatalog()`, a separate surface from a scan result — see `RULE_AUTHORING.md`): `ruleId`, `title`, `description`, `tags`, `wcagSc`, `normativeMappings`, `defaultSeverity`, `defaultConfidence`, `type`, `deprecated`/`.deprecation`. Note `tags` lives here, not on a per-scan `checksResults[i].meta` — the two surfaces intentionally carry different subsets of a rule's metadata.
|
|
@@ -41,7 +41,7 @@ Declaring this map is what lets the engine's internal file layout change without
|
|
|
41
41
|
- **Minor**: adding a new stable field, adding a new rule to the catalog, or marking an existing rule `deprecated` (see below).
|
|
42
42
|
- **Major**: removing or renaming a stable field, changing a stable field's type or meaning, or removing a rule ID once its deprecation notice period has passed. Paired with an `engine.schemaVersion` bump specifically when the *shape* changes (as opposed to package-level major bumps for other reasons, e.g. dropping support for an old Node version).
|
|
43
43
|
|
|
44
|
-
`engine.schemaVersion` has been `"1.0.0"` since the engine's first release and has never needed a bump — nothing has changed a stable field's shape yet. Adding the `deprecated`/`deprecation` meta fields described below is purely additive (new optional fields, ignored safely by anything not looking for them), so it does **not** warrant a schema bump either — this is the policy's first real application.
|
|
44
|
+
`engine.schemaVersion` has been `"1.0.0"` since the engine's first release and has never needed a bump — nothing has changed a stable field's shape yet. Adding the `deprecated`/`deprecation` meta fields described below is purely additive (new optional fields, ignored safely by anything not looking for them), so it does **not** warrant a schema bump either — this is the policy's first real application. `engine.locale` is the second: a new field next to the existing ones, with no change to any field a consumer already reads.
|
|
45
45
|
|
|
46
46
|
## Release cadence
|
|
47
47
|
|
package/docs/ENGINE_OPTIONS.md
CHANGED
|
@@ -18,7 +18,7 @@ runDomRulesInPage(url, null, {}, {
|
|
|
18
18
|
});
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
> ⚠️ **`runOnly` must be this object shape, not a bare array.** `runOnly: ['img-alt-present']` (a plain array
|
|
21
|
+
> ⚠️ **`runOnly` must be this object shape, not a bare array.** `runOnly: ['img-alt-present']` (a plain array) is **silently ignored**; the engine runs every rule instead. This is the single most common integration mistake — see [`TROUBLESHOOTING.md`](./TROUBLESHOOTING.md).
|
|
22
22
|
|
|
23
23
|
| Field | Type | Meaning |
|
|
24
24
|
|---|---|---|
|
|
@@ -29,13 +29,15 @@ runDomRulesInPage(url, null, {}, {
|
|
|
29
29
|
| `excludeTags` | `string[]` | Never run rules carrying any of these tags, applied after include. |
|
|
30
30
|
| `includeMode` | `'and'` \| `'or'` | When **both** an ID include and a tag include are given: `'and'` (default) requires a rule to satisfy both; `'or'` runs a rule if it satisfies either. Irrelevant if you only use one dimension. |
|
|
31
31
|
|
|
32
|
+
Each of these accepts either an array or a comma-separated string, matching the `engineOptions` form below — `includeRuleIds: 'img-alt-present, button-name-present'` and `includeRuleIds: ['img-alt-present', 'button-name-present']` are equivalent.
|
|
33
|
+
|
|
32
34
|
Rule IDs are bare (no engine prefix), e.g. `'img-alt-present'`. For backward compatibility, matching also accepts a legacy `a11ycore-`-prefixed form of the same id (`'a11ycore-img-alt-present'`).
|
|
33
35
|
|
|
34
|
-
A **legacy
|
|
36
|
+
A **legacy tag-filter shape** is also accepted as the whole `runOnly` value: `{ type: 'tag', values: ['wcag2a', 'wcag2aa'] }` — equivalent to `{ tags: ['wcag2a', 'wcag2aa'] }`.
|
|
35
37
|
|
|
36
38
|
### Filtering by WCAG version (2.1 vs 2.2)
|
|
37
39
|
|
|
38
|
-
Every rule and composite carries exactly one WCAG-version-origin level tag
|
|
40
|
+
Every rule and composite carries exactly one WCAG-version-origin level tag: `wcag2a`/`wcag2aa`/`wcag2aaa` for a Success Criterion that's WCAG 2.0 baseline, `wcag21a`/`wcag21aa`/`wcag21aaa` for one newly introduced in WCAG 2.1 (e.g. `1.3.5` Identify Input Purpose), `wcag22a`/`wcag22aa`/`wcag22aaa` for one newly introduced in WCAG 2.2 (e.g. `2.5.8` Target Size Minimum). A rule gets **only** the tag for its SC's actual origin version — a 2.1-introduced SC is never also tagged `wcag2aa`, since it doesn't exist under a WCAG 2.0 conformance target. See `src/coverage/wcag-version-map.js` for the exact, canonical per-version SC list.
|
|
39
41
|
|
|
40
42
|
Since versions are cumulative (2.1 = 2.0 + new; 2.2 = 2.0 + 2.1 + new), select a WCAG-version conformance target by combining tag sets — the engine's OR-matching on `tags` (any one match includes the rule) does the rest:
|
|
41
43
|
|
|
@@ -71,14 +73,15 @@ runDomRulesInPage(url, null, {
|
|
|
71
73
|
|
|
72
74
|
```js
|
|
73
75
|
const engineOptions = {
|
|
74
|
-
locale: 'en', // default 'en'; falls back to
|
|
76
|
+
locale: 'en', // default 'en'; de-DE falls back to de, then to en per string
|
|
77
|
+
messages: { de: { /* key: text */ } }, // optional caller-supplied dictionaries; win over built-in ones
|
|
75
78
|
includeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees too
|
|
76
79
|
includeShadowDom: true, // default true — opt OUT with `false` to skip open shadow roots
|
|
77
80
|
fragment: false, // default false — set true when the scan target isn't a real page (see below)
|
|
78
81
|
excludeSelectors: ['#cookie-banner', '.third-party-widget'], // array or comma-separated string
|
|
79
82
|
timestamp: '2026-07-20T12:00:00Z', // optional — engine has no built-in clock, see OUTPUT_SCHEMA.md
|
|
80
83
|
perfStats: false, // default false — internal timing counters, debug-only shape
|
|
81
|
-
profileRules: false, // default false — per-rule
|
|
84
|
+
profileRules: false, // default false — per-rule timings; needs perfStats, and makes output non-deterministic
|
|
82
85
|
|
|
83
86
|
contrast: {
|
|
84
87
|
mode: 'strictConformance', // 'strictConformance' (default) | 'auditorAssist'
|
|
@@ -115,7 +118,8 @@ const engineOptions = {
|
|
|
115
118
|
|
|
116
119
|
| Option | Meaning |
|
|
117
120
|
|---|---|
|
|
118
|
-
| `locale` | Any string
|
|
121
|
+
| `locale` | Any string. A code with a subtag falls back to its base language first, so `de-DE` uses `de`; failing that, English. Individual strings then fall back the same way (chosen locale → `en` → the rule's literal English text), so a partly-translated locale never produces missing text. All of that is silent in the strings themselves, so the result reports what actually happened in `engine.locale` — check it if you need to know whether you got the language you asked for. See [`I18N.md`](./I18N.md). |
|
|
122
|
+
| `messages` | Optional `{ [locale]: { key: text } }`. Checked before the engine's own tables, so it can override individual strings or supply a language the build does not carry. Keys you omit fall back normally, so a partial override is fine. This is how the standalone browser bundle receives a locale side file, and it is the only way to get a dictionary into a page context, since the in-page runner is serialized and cannot read files. See [`I18N.md`](./I18N.md). |
|
|
119
123
|
| `includeHiddenElements` | Default `false`: helper queries exclude elements hidden by structural/CSS mechanisms such as `display:none`, `[hidden]`, closed `<details>`, and hidden rendering-only host elements (with descendants excluded too). Set `true` to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). |
|
|
120
124
|
| `includeShadowDom` | Default `true`: rules using `helpers.queryAllSmart` traverse into open shadow roots. Set `false` to scan only the light DOM. Closed shadow roots are never reachable either way (no DOM API exposes them). |
|
|
121
125
|
| `fragment` | Default `false`. A handful of rules check for the presence of a property that exists once per real page — `page-title-present`, `html-lang-attr-present`, `html-xml-lang-mismatch`, `aria-hidden-body`, `css-orientation-lock`, `meta-refresh-no-exceptions`, `meta-refresh-timing-absent`, `meta-viewport-zoom-enabled`, `meta-viewport-large`, `page-title-patterns`, `region`, `bypass-blocks-present`, `landmark-one-main`, `page-has-heading-one` — and correctly report `notApplicable` for these once `contextSelector` has scoped a run narrower than the whole document (`document.documentElement` no longer among the resolved roots), since a scoped subtree was never expected to carry its own `<title>`/`<html lang>`/etc. Set `fragment: true` for the case that scoping alone can't detect: a scan target that's the *whole* given document but was never meant to represent a real page at all (e.g. a raw component snippet parsed on its own) — this forces the same `notApplicable` gating even when unscoped. See `RULE_AUTHORING.md` §11.2 ("Whole-document checks") for the underlying rule-authoring convention, and `helpers.isWholeDocumentScope()` (`src/core/dom-helpers.js`) for the mechanism these 14 rules gate on via their `applicability(ctx)` export. |
|
|
@@ -128,7 +132,7 @@ const engineOptions = {
|
|
|
128
132
|
| `output.includeSelector` / `.includeHtml` | Only affects the small number of rules (currently 4 of 125) that rely on the engine's automatic selector/HTML fill-in rather than building their own — most rules set `selector`/`html` themselves inside `runInPage` and are unaffected by this option. Not a reliable way to strip selectors/HTML from all output. |
|
|
129
133
|
| `rules[ruleId]` | Passed through to that rule as `ctx.config`, and — for `excludeSelectors` specifically — read by the engine itself before the rule ever runs. See "Rule-scoped `excludeSelectors`" below. Any other key is passthrough only: **no shipped rule currently reads `ctx.config`** for anything besides `excludeSelectors`. |
|
|
130
134
|
| `probes` | An optional, JSON-safe evidence object your host application can supply (depth- and size-capped by the engine before rules see it, via `ctx.inputs.probes`) — for future rules that might accept externally-supplied signals (e.g. real layout measurements a static DOM scan can't compute itself). Not consumed by any current rule. |
|
|
131
|
-
| `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` additionally adds a per-rule timing breakdown. Shape is not part of the stable output contract — don't build on it. |
|
|
135
|
+
| `perfStats` / `profileRules` | Debug-only. `perfStats: true` returns internal counters on the result's `perfStats` field; `profileRules: true` **additionally** adds a per-rule timing breakdown there. `profileRules` on its own does nothing — `perfStats` is what creates the object the breakdown lives in. Shape is not part of the stable output contract — don't build on it. Note also that `profileRules` is the one option that makes output non-deterministic: counters are stable across identical runs, wall-clock timings are not. Leave it off if you diff results between runs. |
|
|
132
136
|
| `pingWaitTime` / `frameWaitTime` | Only read by `runa11yCoreAcrossFrames` (see [`INTEGRATION.md`](./INTEGRATION.md#cross-frame-scanning-including-cross-origin)) — how long to wait for a child frame to answer a ping (default `500`ms) and a full run request (default `60000`ms) before treating it as unreachable. Ignored by `runDomRulesInPage`/`runa11yCoreInPage`. |
|
|
133
137
|
|
|
134
138
|
### Rule-scoped `excludeSelectors`
|
|
@@ -220,7 +224,7 @@ runDomRulesInPage(url, null, {
|
|
|
220
224
|
|
|
221
225
|
See the option-by-option table above for anything not shown here, and the `customRules` section immediately below for the full descriptor contract.
|
|
222
226
|
|
|
223
|
-
## `customRules` — runtime-registered rules
|
|
227
|
+
## `customRules` — runtime-registered rules
|
|
224
228
|
|
|
225
229
|
Every shipped rule is baked into `src/core.js` at build time. `engineOptions.customRules` is the runtime escape hatch: an array of rule descriptors registered for that one call only — nothing is added to the static catalog (`getRulesCatalog()`/`getChecksCatalog()`), and nothing persists between calls. This is deliberate, not a limitation to work around: surea11y already takes fresh `engineOptions` per call with no mutable global config (unlike some other engines, which need a `configure()`/`reset()` step against a shared runtime), and custom rules follow that same per-call model.
|
|
226
230
|
|
|
@@ -240,7 +244,7 @@ A descriptor has the *same shape as an internal rule module's own export* — if
|
|
|
240
244
|
|
|
241
245
|
- `runInPage`/`applicability` may be a **real function** or a **function-source string** (i.e. `fn.toString()`). Pass a real function when `engineOptions` never leaves the current JS realm (plain Node/jsdom use). Pass a string when it does — e.g. a Playwright `page.evaluate(runa11yCoreInPage, { engineOptions })` call, where `engineOptions` crosses a JSON/structured-clone boundary that cannot carry a live `Function` reference but can carry a string. The engine reconstructs a string via `new Function`, the same mechanism `scripts/build-core.js` already uses to embed every built-in rule's source into the in-page runner.
|
|
242
246
|
- `meta` gets identical defaulting/validation to a build-time rule (via the same `normalizeRuleMeta` used for the other 125 rules) — omit anything you don't need; `severity` defaults to `moderate`, `confidence` to `medium`, `type` to `automatic`, etc.
|
|
243
|
-
- A custom rule whose `id` collides with a built-in one **overrides it for that scan
|
|
247
|
+
- A custom rule whose `id` collides with a built-in one **overrides it for that scan**, rather than running both. Since a same-named custom rule is just as likely to be an accidental collision as a deliberate override, every collision is surfaced two ways: a `console.warn` naming the id(s), and a top-level `overriddenBuiltinIds` array on the result (empty when there's no collision) — see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md).
|
|
244
248
|
- An invalid descriptor (missing/non-string `id`, or a `runInPage` that isn't a function and isn't a reconstructable source string) is silently skipped — the rest of the scan, including every built-in rule, still runs normally. This isn't a validation gap to fix: a custom rule is arbitrary caller-supplied code, so "fail this one entry closed, don't abort the scan" is the safer default, mirroring how a *built-in* rule that throws is contained to a `cantTell` for that rule rather than crashing the run.
|
|
245
249
|
- Results appear in `checksResults` exactly like any other rule's, including automatic `selector`/`html`/`structuralPath` fill-in for `fail`/`cantTell` occurrences that only attach `{ __node }` (see [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md)).
|
|
246
250
|
|
|
@@ -249,6 +253,6 @@ A descriptor has the *same shape as an internal rule module's own export* — if
|
|
|
249
253
|
A CSS selector (or array of selectors) scoping the scan to one or more subtrees, resolved via `document.querySelectorAll` (all matches, not just the first), falling back to `document.documentElement`/`document.body` if nothing matches. Pass `null` to scan the whole document.
|
|
250
254
|
|
|
251
255
|
- **A single string** may itself be a comma-separated selector list (ordinary CSS union semantics) — `'#a, #b'` scans both `#a` and `#b`.
|
|
252
|
-
- **An array of strings** scans the union of every selector's matches — `['#a', '.card']` behaves the same as `'#a, .card'`; the array form exists for callers building the list programmatically.
|
|
256
|
+
- **An array of strings** scans the union of every selector's matches — `['#a', '.card']` behaves the same as `'#a, .card'`; the array form exists for callers building the list programmatically.
|
|
253
257
|
- Overlapping/nested regions are deduped automatically — an element reachable from more than one matched root is only ever reported once, not once per region.
|
|
254
258
|
- This changed from single-match (`querySelector`) to all-matches (`querySelectorAll`) semantics for the plain-string form too (2026-07-22) — a selector matching several elements previously scanned only the first, silently dropping the rest. If you relied on that first-match-only behavior, pin to a selector that only ever matches one element (e.g. an `#id`).
|
package/docs/I18N.md
CHANGED
|
@@ -4,14 +4,18 @@ Every rule's title/description, and every occurrence's summary/hint, is localize
|
|
|
4
4
|
|
|
5
5
|
## Current locale coverage
|
|
6
6
|
|
|
7
|
-
| Locale | File | Keys |
|
|
7
|
+
| Locale | File | Keys | Values still in English |
|
|
8
8
|
|---|---|---|---|
|
|
9
|
-
| `en` (English) | `src/i18n/en.
|
|
10
|
-
| `fr` (French) | `src/i18n/fr.
|
|
11
|
-
| `de` (German) | `src/i18n/de.
|
|
12
|
-
| `es` (Spanish) | `src/i18n/es.
|
|
9
|
+
| `en` (English) | `src/i18n/en.json` | 633 | — (the canonical/fallback set) |
|
|
10
|
+
| `fr` (French) | `src/i18n/fr.json` | 633 | 1 |
|
|
11
|
+
| `de` (German) | `src/i18n/de.json` | 633 | 0 |
|
|
12
|
+
| `es` (Spanish) | `src/i18n/es.json` | 633 | 0 |
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Locale files are plain JSON: a flat map of key to translated string, in the same key order as `en.json`. Nothing else lives in them, so contributing a language means editing text and never touching code.
|
|
15
|
+
|
|
16
|
+
Every locale carries every key `en.json` has. Keeping it that way is the job of `npm run i18n:sync`: run it after any change to `en.json` and it rewrites every non-English locale file to match — adding keys that are new, dropping keys `en.json` no longer has, and leaving existing translations alone. A key it adds is seeded with the English text, which counts as untranslated until someone replaces it.
|
|
17
|
+
|
|
18
|
+
Forget to run it and the build fails: `tests/i18n-sync.test.js` and `tests/i18n/i18n-locale-completeness.test.js` reject a missing key, an orphaned key, or any file that `i18n:sync` would rewrite. `npm run i18n:check` reports the same thing without touching the files, and `npm run i18n:report` prints per-locale coverage.
|
|
15
19
|
|
|
16
20
|
## Selecting a locale
|
|
17
21
|
|
|
@@ -19,17 +23,87 @@ All four locales are fully translated as of this writing. That won't stay automa
|
|
|
19
23
|
runDomRulesInPage(url, null, { locale: 'fr' }, null);
|
|
20
24
|
```
|
|
21
25
|
|
|
22
|
-
Default is `'en'` if omitted. Any string is accepted
|
|
26
|
+
Default is `'en'` if omitted. Any string is accepted; an unrecognized locale is never an error.
|
|
27
|
+
|
|
28
|
+
## How a locale is chosen
|
|
29
|
+
|
|
30
|
+
Two things happen, in this order. Getting them mixed up is the usual source of confusion, so they are described separately.
|
|
31
|
+
|
|
32
|
+
### Step 1 — pick a dictionary (once per scan)
|
|
33
|
+
|
|
34
|
+
1. Use the dictionary matching your code. `de` finds `de.json`. Case doesn't matter — `pt-br`, `pt-BR` and `PT-BR` all find `pt-BR.json`.
|
|
35
|
+
2. Otherwise, drop everything after the first `-` and try that. `de-DE` finds `de.json`; so does `de-AT`.
|
|
36
|
+
3. Otherwise, use English.
|
|
37
|
+
|
|
38
|
+
So you only need a `de-DE.json` if German in Austria and Germany should actually read differently. Ship `de.json` and every German variant is covered.
|
|
39
|
+
|
|
40
|
+
### Step 2 — resolve each string (per string)
|
|
41
|
+
|
|
42
|
+
Within the chosen dictionary, every string is looked up on its own:
|
|
43
|
+
|
|
44
|
+
1. Take the key's value from the chosen dictionary.
|
|
45
|
+
2. If that key is missing, take the English one.
|
|
46
|
+
3. If English is missing it too — which shouldn't happen for a built-in key, but can for a hand-rolled one — use the literal text the rule itself carries.
|
|
47
|
+
|
|
48
|
+
The result is never a blank, an `undefined`, or a thrown error. A half-finished translation renders in your language where it exists and in English everywhere else. See `t()` in `scripts/build-core.js` for the exact implementation.
|
|
49
|
+
|
|
50
|
+
## Knowing which locale you actually got
|
|
51
|
+
|
|
52
|
+
Graceful fallback has one drawback: ask for a language the build doesn't carry and you get fluent English back, with nothing in the strings to say so. Every result therefore reports the resolution once, at the top:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
"engine": {
|
|
56
|
+
"tag": "a11ycore",
|
|
57
|
+
"schemaVersion": "1.0.0",
|
|
58
|
+
"locale": { "requested": "ja", "resolved": "en", "reason": "unknown-locale" }
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`requested` is what you asked for (after trimming; `en` if you passed nothing or a non-string), `resolved` is the dictionary that was used, and `reason` is one of:
|
|
63
|
+
|
|
64
|
+
| `reason` | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `ok` | You got exactly what you asked for, and that dictionary carries every key. |
|
|
67
|
+
| `primary-subtag` | Your code carried a subtag with no dictionary of its own, so its base language was used — you asked for `de-DE` and got `de`. Normal and expected; nothing to fix. A difference in case alone is not this: `DE` reports `ok`. |
|
|
68
|
+
| `dictionary-not-loaded` | The project ships that language, but this copy of the engine doesn't carry it and none was supplied. In practice: the standalone browser bundle without its locale side file. |
|
|
69
|
+
| `unknown-locale` | The project has no such translation at all, so English was used. `ja` and `pt-BR` both land here today. |
|
|
70
|
+
| `partial-dictionary` | The dictionary was used but is missing some keys, so those individual strings fell back to English. |
|
|
23
71
|
|
|
24
|
-
|
|
72
|
+
Treat the list as open — a later release can add a value, so match on the ones you care about and let the rest fall through a default.
|
|
25
73
|
|
|
26
|
-
|
|
74
|
+
If you need a particular language, `requested !== resolved` is the condition to check in CI. Note it is also true for the harmless `primary-subtag` case, so gate on `reason === 'unknown-locale' || reason === 'dictionary-not-loaded'` if a base-language match is good enough for you. See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md#top-level-result) for the field's place in the result and [`API_STABILITY.md`](./API_STABILITY.md) for what is guaranteed about it.
|
|
27
75
|
|
|
28
|
-
|
|
29
|
-
2. If missing, fall back to the English (`en`) dictionary.
|
|
30
|
-
3. If still missing (shouldn't happen for a built-in key, but true for anything hand-rolled), fall back to the literal string the rule itself provided as a default.
|
|
76
|
+
## Where the dictionaries live
|
|
31
77
|
|
|
32
|
-
|
|
78
|
+
Which languages are available depends on how you load the engine.
|
|
79
|
+
|
|
80
|
+
| How you load it | What you get |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `require('@surea11y/core')` | Every locale, built in. Nothing to configure. |
|
|
83
|
+
| A binding (Playwright, Cypress, …) | Every locale, built in. Nothing to configure. |
|
|
84
|
+
| `surea11y.browser.js` in a `<script>` tag | English. Load `surea11y.i18n.<locale>.js` after it for anything else. |
|
|
85
|
+
|
|
86
|
+
The bundle is split because it travels over the network to every page that uses it, and no page needs all four languages. Keeping English inline and the rest optional took about 280 KB off the download and stops it growing as languages are added. Nothing else changes: the Node package and the bindings are unaffected.
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<script src="surea11y.browser.js"></script>
|
|
90
|
+
<script src="surea11y.i18n.de.js"></script>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Ask for a language whose file you didn't load and you get English, with `engine.locale.reason` set to `dictionary-not-loaded` — different from `unknown-locale`, which means the project has no such translation at all.
|
|
94
|
+
|
|
95
|
+
### Supplying a dictionary yourself
|
|
96
|
+
|
|
97
|
+
`engineOptions.messages` accepts `{ [locale]: { key: value } }` and takes precedence over anything built in or loaded from a side file. Useful for overriding a handful of strings, or for a language you maintain privately:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
runDomRulesInPage(url, null, {
|
|
101
|
+
locale: 'de',
|
|
102
|
+
messages: { de: { img_altPresent_title: 'Eigener Text' } }
|
|
103
|
+
}, null);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Keys you don't supply fall back normally, so a partial override is fine.
|
|
33
107
|
|
|
34
108
|
## Where keys are used
|
|
35
109
|
|
|
@@ -40,11 +114,93 @@ Two independent key namespaces, both resolved the same way:
|
|
|
40
114
|
|
|
41
115
|
Both are included in the result alongside the already-resolved text, so you can re-render in a different locale from a saved result without re-scanning — see the `i18n` field's presence in `OUTPUT_SCHEMA.md`.
|
|
42
116
|
|
|
43
|
-
##
|
|
117
|
+
## Adding your language
|
|
118
|
+
|
|
119
|
+
You do not need to know how the engine works, and you will not write any JavaScript beyond editing quoted strings. A translation is one data file.
|
|
120
|
+
|
|
121
|
+
### 1. Get the repository running
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
git clone https://github.com/SureA11y/core.git
|
|
125
|
+
cd core
|
|
126
|
+
npm install
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 2. Create the file
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
npm run i18n:new pt-BR
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
That writes `src/i18n/pt-BR.json` containing every key `en.json` has, in the same order, each seeded with the English text as a placeholder. **Use the shortest code that identifies the language** — `pt`, `nl`, `pl`. A file named `pt.json` serves everyone who asks for `pt`, `pt-BR` or `pt-PT`, because a code with a subtag falls back to its base language. Name it `pt-BR.json` and only people who ask for exactly that get it; `pt` speakers elsewhere fall through to English.
|
|
136
|
+
|
|
137
|
+
Add a regional file only when the wording genuinely has to differ, and add it alongside the base language rather than instead of it.
|
|
138
|
+
|
|
139
|
+
The command refuses to overwrite a file that already exists. To pick up work on an existing locale, edit it directly.
|
|
140
|
+
|
|
141
|
+
### 3. Translate the values
|
|
142
|
+
|
|
143
|
+
Each entry is `"key": "text"`. Change only the text on the right:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
"img_altPresent_title": "<img> must have an alt attribute",
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
becomes
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
"img_altPresent_title": "<img> precisa ter um atributo alt",
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Four things to leave alone:
|
|
156
|
+
|
|
157
|
+
- **The keys.** `img_altPresent_title` is an identifier the engine looks up. Renaming one breaks the lookup.
|
|
158
|
+
- **`{{placeholder}}` tokens** — `{{ratio}}`, `{{element}}`, `{{role}}` and friends are substituted with real values at scan time. Keep them spelled exactly as in English. You may move them within the sentence if your grammar needs it.
|
|
159
|
+
- **`{{#name}}…{{/name}}` and `{{^name}}…{{/name}}` blocks** — conditional sections, shown or hidden depending on the finding. Translate the text inside them; keep the markers.
|
|
160
|
+
- **Code identifiers** — `<img>`, `aria-label`, `role="dialog"`, `alt=""`, CSS property names. These are things the reader will look for in their own source, so they stay in the original. A double quote inside a value has to stay escaped as `\"`, which is the one piece of JSON syntax you need.
|
|
161
|
+
|
|
162
|
+
Write for someone fixing the page, not for a specialist: say what is wrong and what to do about it. Where your language has established accessibility vocabulary (a national WCAG translation, a government standard), follow it rather than inventing terms.
|
|
163
|
+
|
|
164
|
+
If a string is genuinely identical in your language, leave it. It is counted as untranslated but behaves correctly.
|
|
165
|
+
|
|
166
|
+
### 4. Check your progress
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
npm run i18n:report
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Prints, per locale, how many values differ from English, plus any missing or orphaned keys. Anything still matching the English text is reported as untranslated — that is a progress signal, not an error.
|
|
173
|
+
|
|
174
|
+
You do not need every string on day one. Per-string fallback means an unfinished locale renders in your language where you have translated it and in English everywhere else, which is exactly how `fr`, `de` and `es` started. Ship what you have.
|
|
175
|
+
|
|
176
|
+
### 5. Before opening the pull request
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
npm run i18n:sync # confirms your file matches en.json key-for-key
|
|
180
|
+
npm test
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`npm run build` also emits `surea11y.i18n.<locale>.js` for the standalone browser bundle — generated, so there is nothing for you to write.
|
|
184
|
+
|
|
185
|
+
Then open a pull request touching `src/i18n/<locale>.json`, plus one row in the coverage table at the top of this file. Tell us which language and, if you use one, which national terminology standard you followed — that helps whoever reviews it later.
|
|
186
|
+
|
|
187
|
+
Once a locale is in the repository it is maintained with the rest of the engine: when a new rule adds strings, `npm run i18n:sync` seeds them in your file in English and `npm run i18n:report` shows them as outstanding.
|
|
188
|
+
|
|
189
|
+
## Maintaining the locales
|
|
190
|
+
|
|
191
|
+
When you add, rename or remove a key in `src/i18n/en.json`:
|
|
192
|
+
|
|
193
|
+
```sh
|
|
194
|
+
npm run i18n:sync
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Every other locale is rewritten to match — new keys seeded in English, removed keys dropped, existing translations untouched. The command is idempotent and prints what it changed per locale.
|
|
198
|
+
|
|
199
|
+
| Command | Does |
|
|
200
|
+
|---|---|
|
|
201
|
+
| `npm run i18n:new <locale>` | Create a new locale file from `en.json`. Refuses to overwrite. |
|
|
202
|
+
| `npm run i18n:sync` | Bring every locale file back in line with `en.json`. Add `-- <locale>` to restrict it to one. |
|
|
203
|
+
| `npm run i18n:check` | Same comparison, writes nothing, exits non-zero on drift. |
|
|
204
|
+
| `npm run i18n:report` | Per-locale translation coverage. |
|
|
44
205
|
|
|
45
|
-
|
|
46
|
-
2. Replace the placeholder values with real translations, key by key. Leave any you're unsure about as-is for now — a value identical to English is treated as untranslated, not broken (see the fallback behavior above).
|
|
47
|
-
3. Check progress any time with `npm run i18n:report` — it prints, per locale, how many keys have been translated vs. still match the English placeholder, plus any missing or orphaned keys.
|
|
48
|
-
4. You don't need every key on day one — the fallback behavior above means a partial translation degrades gracefully to English per-string, exactly like `fr`/`de`/`es` did during their own early stages. Ship what you have. A partial locale is valid and won't fail `i18n-locale-completeness.test.js` unless you also add it to `FULLY_TRANSLATED_LOCALES` in that file — only do that once `npm run i18n:report` shows 100% coverage.
|
|
49
|
-
5. Keep `{{placeholder}}` tokens (and `{{#foo}}...{{/foo}}` conditional blocks) in translated strings exactly as they appear in the English source — they're substituted/evaluated verbatim regardless of locale (e.g. `{{element}}`, `{{role}}`). Never translate HTML tag/attribute names (`<img>`, `aria-label`, `role="dialog"`, etc.) — they're code identifiers, not prose.
|
|
50
|
-
6. Run `npm run build && npm test` to confirm nothing broke, including locale-completeness.
|
|
206
|
+
`npm test` fails if a locale file has drifted, so an added key cannot reach `main` without every locale carrying it.
|
package/docs/INTEGRATION.md
CHANGED
|
@@ -107,7 +107,29 @@ Good for: a manual check against a page open in a real browser, a bookmarklet, o
|
|
|
107
107
|
|
|
108
108
|
`a11ycore.runa11yCoreInPage` is the exact same function described in Pattern 2 above — the bundle exists only to solve *loading* it without `require`/a module system, not to add a separate API surface. It carries the same self-containment property (own inlined rule catalog, no free variables), which is what makes a plain `<script>` tag sufficient.
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
### Scanning in a language other than English
|
|
111
|
+
|
|
112
|
+
The bundle carries English only. Every other locale ships beside it as its own file — load one after the bundle and that language becomes available:
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<script src="node_modules/@surea11y/core/surea11y.browser.js"></script>
|
|
116
|
+
<script src="node_modules/@surea11y/core/surea11y.i18n.de.js"></script>
|
|
117
|
+
<script>
|
|
118
|
+
const result = a11ycore.runa11yCoreInPage(location.href, null, { locale: 'de' }, null);
|
|
119
|
+
</script>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Load as many as you need; each adds one language and they don't interfere. Order matters only in that the bundle has to come first — a side file loaded on its own throws with a message saying so.
|
|
123
|
+
|
|
124
|
+
Ask for a locale whose file you haven't loaded and you get English, not an error, with `result.engine.locale.reason` set to `dictionary-not-loaded` so it's visible rather than silent. See [`I18N.md`](./I18N.md).
|
|
125
|
+
|
|
126
|
+
Why the split: the bundle is fetched over the network, and every language would otherwise be paid for by every page whether used or not. Keeping English inline and the rest optional took roughly 280 KB off the download and stops it growing as languages are added. The Node package is unaffected — `require('@surea11y/core')` still has every locale built in.
|
|
127
|
+
|
|
128
|
+
If you'd rather supply a dictionary yourself, `engineOptions.messages` takes `{ [locale]: { key: value } }` directly and wins over a loaded side file.
|
|
129
|
+
|
|
130
|
+
### What the bundle leaves out
|
|
131
|
+
|
|
132
|
+
Deliberately excluded: `runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder`. Cross-frame scanning needs the embedded frame to load the engine and opt in too (see "Cross-frame scanning" below) — not a fit for a single dropped-in script tag. Use the npm package directly if you need it.
|
|
111
133
|
|
|
112
134
|
## Scoping a scan to part of the page
|
|
113
135
|
|
|
@@ -139,11 +161,11 @@ Notes for CI specifically:
|
|
|
139
161
|
|
|
140
162
|
### Cross-frame scanning (including cross-origin)
|
|
141
163
|
|
|
142
|
-
`runa11yCoreInPage` only ever scans the single document it runs in — it has no visibility into `<iframe>` content, same-origin or not. For most uses that's fine (rules apply to the current document; a consumer running once per frame, e.g. once per content-script injection into `all_frames: true`, already covers every frame independently). But sometimes you want ONE scan's result to include what's inside embedded frames too — payment widgets, cookie-consent dialogs, third-party embeds —
|
|
164
|
+
`runa11yCoreInPage` only ever scans the single document it runs in — it has no visibility into `<iframe>` content, same-origin or not. For most uses that's fine (rules apply to the current document; a consumer running once per frame, e.g. once per content-script injection into `all_frames: true`, already covers every frame independently). But sometimes you want ONE scan's result to include what's inside embedded frames too — payment widgets, cookie-consent dialogs, third-party embeds — which is what the cross-frame functions below are for.
|
|
143
165
|
|
|
144
|
-
`runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` are a separate, additive pair of functions for exactly this — not needed at all if you're driving the browser with Puppeteer/Playwright (see "Pattern 2" above): an automation driver already reaches every frame unconditionally via CDP, which is strictly *better* than what's described here. This exists specifically for when there's **no automation driver** — a plain script/bundled widget/browser extension running inside the page itself, fully subject to the same-origin policy
|
|
166
|
+
`runa11yCoreAcrossFrames`/`a11yCoreEnableFrameResponder` are a separate, additive pair of functions for exactly this — not needed at all if you're driving the browser with Puppeteer/Playwright (see "Pattern 2" above): an automation driver already reaches every frame unconditionally via CDP, which is strictly *better* than what's described here. This exists specifically for when there's **no automation driver** — a plain script/bundled widget/browser extension running inside the page itself, fully subject to the same-origin policy.
|
|
145
167
|
|
|
146
|
-
**How it works**: a parent frame's `runa11yCoreAcrossFrames()` call pings each direct child `<iframe>`/`<frame>` via `postMessage`; if — and only if — that child has *also* called `a11yCoreEnableFrameResponder()` (its own opt-in to being scannable from above), it runs its own scan and replies with the result, which the parent includes. **A non-cooperating frame (the common case for most third-party embeds you don't control) is simply unreachable** —
|
|
168
|
+
**How it works**: a parent frame's `runa11yCoreAcrossFrames()` call pings each direct child `<iframe>`/`<frame>` via `postMessage`; if — and only if — that child has *also* called `a11yCoreEnableFrameResponder()` (its own opt-in to being scannable from above), it runs its own scan and replies with the result, which the parent includes. **A non-cooperating frame (the common case for most third-party embeds you don't control) is simply unreachable** — the same-origin policy allows no way around it from inside the page.
|
|
147
169
|
|
|
148
170
|
```js
|
|
149
171
|
// Inside the embedded/child page (e.g. a widget's own bundle), once, at load:
|
|
@@ -167,8 +189,8 @@ for (const frame of result.frames) {
|
|
|
167
189
|
|
|
168
190
|
A few things worth knowing:
|
|
169
191
|
- **Async, unlike the other two runners** — `postMessage` round-trips can't be synchronous, so this is a separate, Promise-returning pair rather than an `engineOptions` flag on `runa11yCoreInPage` (which stays synchronous, unchanged, for every existing caller).
|
|
170
|
-
- **`engineOptions.pingWaitTime`** (default `500`ms) and **`engineOptions.frameWaitTime`** (default `60000`ms) control how long a child frame gets to answer a ping and a full run request respectively
|
|
192
|
+
- **`engineOptions.pingWaitTime`** (default `500`ms) and **`engineOptions.frameWaitTime`** (default `60000`ms) control how long a child frame gets to answer a ping and a full run request respectively.
|
|
171
193
|
- **No jsdom/Node equivalent** — this is browser-only. jsdom's window/frame model doesn't meaningfully represent independent-realm cross-origin `postMessage`, and the feature has no purpose in Node anyway.
|
|
172
194
|
- **Bundler-free, like `runa11yCoreInPage`** — both functions are fully self-contained (their own private copy of the rule catalog and every helper they need), so raw-source injection (a bookmarklet, a content script with no build step) works with zero bundler needed, exactly like `runa11yCoreInPage` already does. If you *do* use a normal bundler/`require`/`import`, that works too, unchanged.
|
|
173
195
|
- **Cost of that self-containment**: `src/core.js` grew from ~1.86MB to ~3.1MB, since these two functions each needed their own complete private copy of the rule catalog and shared helpers rather than sharing the outer `RULE_IMPLS`. If this file's size ever becomes a real problem, the fix would be to drop the bundler-free requirement for just these two functions (accepting that cross-frame scanning in "plain script injection" mode needs a real bundler, unlike `runa11yCoreInPage` alone) rather than tripling the embedded catalog again for some future feature.
|
|
174
|
-
- **No origin/identity check on the sender** beyond the message's own namespaced envelope
|
|
196
|
+
- **No origin/identity check on the sender** beyond the message's own namespaced envelope. Running a read-only scan and replying with DOM-derived results isn't a privileged operation; the content involved is no more sensitive than what's already rendered on the page.
|
package/docs/LIMITATIONS.md
CHANGED
|
@@ -14,7 +14,7 @@ surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at
|
|
|
14
14
|
|
|
15
15
|
- **jsdom (Node, no real browser) has no CSS layout engine.** Rules needing real geometry — most notably `target-size-minimum` (WCAG 2.5.8, needs real `getBoundingClientRect()`) — report `notApplicable` under plain jsdom rather than guess. Run under a real browser (Puppeteer/Playwright — see [`INTEGRATION.md`](./INTEGRATION.md) Pattern 2) to get real findings from these rules.
|
|
16
16
|
- **`<dialog>` and other elements hidden by the default UA stylesheet** (no `open` attribute, `display: none` by spec), along with any other subtree hidden via `display:none`, `visibility:hidden`, `[hidden]`, or closed `<details>`, are **excluded from rule evaluation by default** — matching the visibility-aware behavior of other established engines. This is a deliberate default (`engineOptions.includeHiddenElements: false`), not an oversight: hidden content isn't reachable by assistive technology or keyboard until it's shown, so flagging a markup defect inside it by default would often be noise. Set `engineOptions.includeHiddenElements: true` to evaluate hidden/collapsed subtrees anyway — e.g. to catch a markup defect (like a broken ARIA ID reference) before a dialog ever opens. See [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#engineoptions--the-rest) for the option and exactly which hiding mechanisms it covers.
|
|
17
|
-
- **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup.
|
|
17
|
+
- **Static markup vs. live/post-hydration DOM state.** The rule logic itself is DOM-source-agnostic — it evaluates whatever DOM it's handed, whether that's jsdom-parsed static HTML (Pattern 1) or an already-loaded, already-hydrated real browser tab (Pattern 2, see [`INTEGRATION.md`](./INTEGRATION.md)). But the CLI (`npx @surea11y/cli scan <url>`) specifically fetches static HTML only, with no JS execution — see [the CLI docs](https://github.com/SureA11y/cli/blob/main/docs/CLI.md). For a JS-framework-hydrated widget whose server-rendered markup intentionally ships one state before client JS syncs it (e.g. `<input type="checkbox" aria-checked="true">` shipped before client JS sets the native `checked` property to match on hydration — an extremely common, entirely legitimate pattern), a CLI scan only sees the pre-hydration markup. A scan running inside an actual loaded browser tab sees the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with rule correctness. `aria-checked-state-mismatch` is deliberately `manual`/`cantTell`-capped for this exact reason rather than a hard `fail`. If you need live-DOM accuracy for hydration-sensitive checks, run the library directly against an already-loaded page via Pattern 2, not the static-fetch CLI.
|
|
18
18
|
|
|
19
19
|
## Deliberately not attempted — judgment calls, not automatable safely
|
|
20
20
|
|