@surea11y/core 1.4.0 → 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.
Files changed (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
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 | Coverage vs. English |
7
+ | Locale | File | Keys | Values still in English |
8
8
  |---|---|---|---|
9
- | `en` (English) | `src/i18n/en.js` | 614 | 100% (the canonical/fallback set) |
10
- | `fr` (French) | `src/i18n/fr.js` | 614 | 100% |
11
- | `de` (German) | `src/i18n/de.js` | 614 | 100% |
12
- | `es` (Spanish) | `src/i18n/es.js` | 614 | 100% |
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
- All four locales are fully translated as of this writing. That won't stay automatically true — every time a new rule (or a new i18n key) is added to `en.js`, every other locale needs the same key added, or it silently falls back to English for that string (see the fallback behavior below). `tests/i18n/i18n-locale-completeness.test.js` catches this: it fails the build if a locale listed in `FULLY_TRANSLATED_LOCALES` (currently `fr`, `de`, `es`) is missing any key present in `en.js`, and fails for *any* locale that has an orphaned key not in `en.js` (a sign of a typo or a stale key left behind after a rule was removed). Run `npm run i18n:report` any time to see per-locale coverage.
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 — an unrecognized locale (e.g. `'ja'`, not yet built) behaves exactly like a locale that's 0% translated: every string falls through to English (see below), not an error.
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
- ## Fallback behavior (per-string, not per-locale)
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
- Resolution happens independently for *every individual string*, not once for the whole scan:
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
- 1. Look up the key in the requested locale's dictionary.
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
- This means requesting `locale: 'fr'` today gives you a real mix — translated strings where `fr.js` has the key, English strings where it doesn't — never a blank, `undefined`, or thrown error. See `t()` in `scripts/build-core.js` if you need the exact implementation.
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
- ## Contributing a translation
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
- 1. Scaffold the file: `npm run i18n:new <locale>` (e.g. `npm run i18n:new de`) creates `src/i18n/<locale>.js` with all of `en.js`'s keys already present, each seeded with the English text as a placeholder. It refuses to overwrite an existing locale file unless you pass `--force`.
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.
@@ -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
- Deliberately excluded from this bundle: `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.
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 — the same real scenario other engines' own cross-frame messaging protocols exist for.
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, exactly like other engines' equivalent mechanisms are.
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** — this mirrors the same real limitation other engines have for non-cooperating frames; it is not a gap `surea11y` closes that they don't have either.
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, matching the defaults other engines use for the equivalent options.
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 — matching the same permissiveness other engines take here. 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.
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.
@@ -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. Other engines running inside an actual loaded browser tab see the post-hydration state instead, so the two can disagree on exactly this class of element for reasons that have nothing to do with either engine's 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.
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
 
@@ -15,7 +15,11 @@ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `ru
15
15
 
16
16
  ```ts
17
17
  {
18
- engine: { tag: string, schemaVersion: string },
18
+ engine: {
19
+ tag: string,
20
+ schemaVersion: string,
21
+ locale: { requested: string, resolved: string, reason: string }
22
+ },
19
23
  url: string | null,
20
24
  title: string | null,
21
25
  timestamp: string | null,
@@ -31,6 +35,8 @@ This is the exact shape of the object returned by `runDomRulesInPage(...)` / `ru
31
35
  |---|---|
32
36
  | `engine.tag` | The engine's own identity tag, currently `"a11ycore"`. Every rule (built-in or custom) carries it in `meta.tags` — rule `ruleId`s themselves are bare (no prefix). |
33
37
  | `engine.schemaVersion` | The result-schema version (`"1.0.0"`). Bump-worthy if this document's shape ever changes incompatibly — pin to it if you're parsing output programmatically. See [`API_STABILITY.md`](./API_STABILITY.md) for the full stable/unstable field list and version-bump policy. |
38
+ | `engine.locale` | Which dictionary the run actually used. `requested` is your `engineOptions.locale` after trimming (`"en"` if you passed nothing or a non-string); `resolved` is the locale whose dictionary was used; `reason` explains the pairing. Because locale fallback is graceful and per-string, asking for a language the build does not carry produces English text rather than an error — this field is how you find that out without reading the strings. Reported once per result: a run uses one dictionary throughout. |
39
+ | `engine.locale.reason` | `"ok"` — you got the dictionary you asked for, and it carries every key. `"primary-subtag"` — your code had a subtag with no dictionary of its own, so its base language was used: `"de-DE"` resolves to `"de"`. `"dictionary-not-loaded"` — the project ships that language, but this build does not carry it and none was supplied (the standalone browser bundle, without its locale side file). `"unknown-locale"` — the project has no such translation at all. `"partial-dictionary"` — the dictionary was used but is missing keys English has, so those strings fell back to English. Treat the set as open; later releases can add to it. |
34
40
  | `url` | The `pageUrl` argument you passed in, or `document.location.href` if you passed `null`/omitted it, or `null` if neither is available. |
35
41
  | `title` | `document.title` at scan time, or `null`. |
36
42
  | `timestamp` | **Not auto-generated.** Only set if you pass `engineOptions.timestamp` as a non-empty string — the engine has no built-in clock (deterministic-by-design). If you want a scan timestamp in the result, supply it yourself. |
@@ -97,7 +103,7 @@ Notes:
97
103
 
98
104
  - **`outcome` vs `outcomeNormalized`**: identical except `notApplicable` becomes `"inapplicable"` in `outcomeNormalized`. Both are provided so you can match either your own vocabulary or the engine's internal one.
99
105
  - **`type: "manual"` rules can never report `outcome: "fail"`.** If a manual rule's own logic would have said `fail`, the engine coerces it to `cantTell` and appends an explanatory note to `error` — this is enforced centrally (`policy.coerceManualFailToCantTell`, on by default under the `a11y` policy contract; see [`POLICY.md`](./POLICY.md)), not something each rule has to remember. `fail` is reserved for deterministic, high-confidence, `type: "automatic"` findings only.
100
- - **`meta.normativeMappings`** is how a check result ties back to a WCAG Success Criterion — `[]` for rules with no formal WCAG mapping (other engines call these "Best Practices"; this engine calls them advisory `type: "manual"` rules). See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up.
106
+ - **`meta.normativeMappings`** is how a check result ties back to a WCAG Success Criterion — `[]` for rules with no formal WCAG mapping (this engine calls them advisory `type: "manual"` rules). See [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up.
101
107
  - **`error`**: only present if the rule implementation threw an uncaught exception, or if the manual-fail coercion above fired. A thrown rule always surfaces as `outcome: "cantTell"` with `occurrences: []` and `error` set to the exception message — the engine never lets one broken rule crash the whole scan.
102
108
  - **`engineOptions`** on each result is the *resolved* options object (after locale/contrast defaults were applied), not literally what you passed in — useful for confirming what a given rule actually saw, especially the resolved `locale` and `contrast.mode`/`contrast.rootCanvasFallback`.
103
109
 
@@ -185,7 +191,11 @@ const result = runDomRulesInPage(
185
191
 
186
192
  ```json
187
193
  {
188
- "engine": { "tag": "a11ycore", "schemaVersion": "1.0.0" },
194
+ "engine": {
195
+ "tag": "a11ycore",
196
+ "schemaVersion": "1.0.0",
197
+ "locale": { "requested": "en", "resolved": "en", "reason": "ok" }
198
+ },
189
199
  "url": "https://example.test/",
190
200
  "title": "Example",
191
201
  "timestamp": null,
package/docs/REPORT.md CHANGED
@@ -15,6 +15,8 @@ Open `report.html` directly from disk. Works alongside any other output mode —
15
15
  - **WCAG rollup**: grouped by conformance level (A / AA / AAA), sourced directly from the engine's own `rulesResults[]` composite rollups (`docs/WCAG_CONFORMANCE.md`) — one row per Success Criterion, its outcome, a pass/fail/needs-review/n/a breakdown, and which atomic rules contributed. This is real engine data, not an invented grouping — the same rollup you'd get from the raw JSON's `rulesResults`.
16
16
  - **Full technical data** (collapsed by default): a scorecard (tiles per outcome) and a searchable, filterable (by outcome), paginated table of every individual occurrence across the whole scan.
17
17
 
18
+ The meta bar under the header carries the rule count, occurrence count, engine tag, schema version, and the locale the scan resolved to. Locale fallback is per-string and invisible in the text itself, so a report requested in a language the engine does not carry reads as an ordinary English one — the chip names the requested locale alongside the resolved one when the two differ. See [`I18N.md`](./I18N.md).
19
+
18
20
  ## Library usage
19
21
 
20
22
  ```js
@@ -132,6 +132,10 @@ The build/runtime resolves i18n by:
132
132
  2) falling back to `en` if missing,
133
133
  3) falling back to the literal `title`/`description` strings if still missing.
134
134
 
135
+ Add the key and its English text to `src/i18n/en.json`, then run
136
+ `npm run i18n:sync` so every other locale picks it up. `npm test` fails if you
137
+ forget. See [`I18N.md`](./I18N.md).
138
+
135
139
  #### `meta.tags`
136
140
  Tags are used for grouping/filtering. Typical tag families in this ruleset include:
137
141
  - WCAG tagging: `wcag2a`, `wcag111`
@@ -163,6 +167,32 @@ const meta = {
163
167
 
164
168
  ---
165
169
 
170
+ ## 4.3 Reporting an occurrence
171
+
172
+ Build occurrences with `helpers.reportOccurrence(element, { summary, hint, i18n, data })`
173
+ rather than assembling the object by hand:
174
+
175
+ ```js
176
+ occurrences.push(helpers.reportOccurrence(el, { summary: '…', hint: '…' }));
177
+ ```
178
+
179
+ It attaches the element for the engine to finalize, which is how `selector`,
180
+ `html` and `structuralPath` get filled in centrally instead of in each of the
181
+ 124 rules.
182
+
183
+ **This is a performance contract, not just a convenience.** Every occurrence
184
+ gets a `structuralPath`. Given the element, the engine computes it directly.
185
+ Given only a hand-built occurrence, it re-finds the element with
186
+ `document.querySelector(selector)` — one DOM query per occurrence. That is
187
+ fine for a rule reporting a single document-level finding, and quadratic for
188
+ one reporting many: `region` hand-built its occurrences and took four minutes
189
+ on a thousand-element page, against under a second afterwards.
190
+
191
+ `perfStats.counters['structuralPath.selectorFallback']` counts how often the
192
+ engine had to re-find an element, so a slow rule can be spotted without
193
+ guessing. `tests/structural-path-fallback.test.js` fails if that count starts
194
+ growing with page size.
195
+
166
196
  ## 5) i18n in occurrences (repo reality)
167
197
 
168
198
  Occurrences also support i18n via keys + params.
@@ -189,6 +219,29 @@ At normalization time, the engine:
189
219
  Translation strings use `{{paramName}}` placeholders.
190
220
  `params` is shallow-copied and passed into interpolation.
191
221
 
222
+ **A param carries a value, never prose.** Element names, roles, attribute names,
223
+ selectors, ids, counts and ratios are values: they read the same in every
224
+ language, because the author will search their own source for them. An English
225
+ word or sentence is not, and passing one means it stays English in every locale
226
+ — with nothing to reveal it, since the key is present everywhere and coverage
227
+ reports look complete.
228
+
229
+ When a message varies by case, give each case its own key rather than
230
+ interpolating the differing text:
231
+
232
+ ```js
233
+ // wrong: the sentence lives in the rule, so no locale can reach it
234
+ i18n: { hintKey: 'myRule_hint_fail', params: { advice: 'Replace it with role="list".' } }
235
+
236
+ // right: one key per case, each translatable on its own
237
+ i18n: { hintKey: 'myRule_hint_fail_directory', params: { role } }
238
+ ```
239
+
240
+ `tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value with
241
+ no translatable text of its own, which catches the `"{{advice}}"` shape above.
242
+ It cannot catch a param carrying prose into an otherwise-normal sentence, so
243
+ that one is on you.
244
+
192
245
  ---
193
246
 
194
247
  ## 6) Helpers contract used by rules (ctx.helpers)
@@ -2,11 +2,11 @@
2
2
 
3
3
  Generated from the compiled engine's own catalog (`getChecksCatalog()`/`getRulesCatalog()`) — run `node scripts/generate-rule-catalog.js` after `npm run build` to regenerate this file whenever rules change. Do not hand-edit.
4
4
 
5
- **125 rules total: 77 automatic (WCAG-normative, can return `fail`), 48 manual (advisory/judgment-required, capped at `cantTell`). 101 carry at least one formal WCAG Success Criterion mapping.**
5
+ **125 rules total: 76 automatic (WCAG-normative, can return `fail`), 49 manual (advisory/judgment-required, capped at `cantTell`). 101 carry at least one formal WCAG Success Criterion mapping.**
6
6
 
7
7
  See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) for what `type`/`confidence`/`severity` mean on a scan result, and [`WCAG_CONFORMANCE.md`](./WCAG_CONFORMANCE.md) for how these roll up to an SC-level conformance claim. For WCAG-facet-level coverage-gap tracking (which parts of an SC are and aren't automatable yet), see `coverage/coverage-report.md` instead — that one is organized by facet, this one by rule.
8
8
 
9
- ## Automatic rules (77) — can return `fail`
9
+ ## Automatic rules (76) — can return `fail`
10
10
 
11
11
  | Rule ID | Title | WCAG SC | Level | Confidence | Default severity |
12
12
  |---|---|---|---|---|---|
@@ -15,7 +15,7 @@ See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) for what `type`/`confidence`/`sever
15
15
  | `aria-allowed-role` | Explicit role must be permitted for its host element | 4.1.2 | A | high | moderate |
16
16
  | `aria-braille-equivalent` | aria-braillelabel/aria-brailleroledescription must have a non-braille equivalent | 4.1.2 | A | high | serious |
17
17
  | `aria-conditional-attr` | aria-errormessage requires aria-invalid to be set to a non-false value | 4.1.2 | A | high | serious |
18
- | `aria-deprecated-role` | role attribute must not use a deprecated or author-prohibited ARIA role | 4.1.2 | A | high | moderate |
18
+ | `aria-deprecated-role` | role attribute should not use a deprecated or author-discouraged ARIA role | 4.1.2 | A | high | moderate |
19
19
  | `aria-hidden-body` | The document &lt;body&gt; must not be aria-hidden | 1.3.1, 4.1.2 | A | high | critical |
20
20
  | `aria-hidden-focus` | ARIA hidden elements must not be focusable | 2.4.7, 4.1.2 | AA | high | serious |
21
21
  | `aria-prohibited-attr` | ARIA naming attributes must not be used on roles that prohibit them | 4.1.2 | A | high | moderate |
@@ -28,10 +28,9 @@ See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) for what `type`/`confidence`/`sever
28
28
  | `aria-valid-attr` | aria-* attributes must be real, defined ARIA attributes | 4.1.2 | A | high | serious |
29
29
  | `aria-valid-attr-value` | aria-* attribute values must match their declared type | 4.1.2 | A | high | serious |
30
30
  | `autocomplete-valid` | autocomplete attribute must be a valid autofill value | 1.3.5 | AA | high | moderate |
31
- | `avoid-inline-spacing` | Inline style must not force text spacing with !important | 1.4.12 | AA | high | moderate |
31
+ | `avoid-inline-spacing` | Inline style must not force text spacing below the WCAG metric | 1.4.12 | AA | high | moderate |
32
32
  | `binary-control-name-present` | Binary controls have an accessible name | 4.1.2 | A | high | serious |
33
33
  | `button-name-present` | Buttons have an accessible name | 4.1.2 | A | high | serious |
34
- | `bypass-blocks-present` | Page must provide a way to bypass repeated blocks | 2.4.1 | A | medium | serious |
35
34
  | `canvas-text-alternative-present` | &lt;canvas&gt; must provide a text alternative | 1.1.1 | A | high | serious |
36
35
  | `combobox-name-present` | Comboboxes have an accessible name | 4.1.2 | A | high | serious |
37
36
  | `contrast-computable` | Color contrast is computable for rendered text | 1.4.3, 1.4.6 | AAA | high | serious |
@@ -88,7 +87,7 @@ See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) for what `type`/`confidence`/`sever
88
87
  | `valid-lang` | Element lang attribute must be syntactically valid | 3.1.2 | AA | high | moderate |
89
88
  | `video-poster-text-alternative-present` | &lt;video&gt; poster must have a text alternative | 1.1.1 | A | medium | serious |
90
89
 
91
- ## Manual rules (48) — advisory, capped at `cantTell`
90
+ ## Manual rules (49) — advisory, capped at `cantTell`
92
91
 
93
92
  | Rule ID | Title | WCAG SC | Level | Confidence | Default severity |
94
93
  |---|---|---|---|---|---|
@@ -97,6 +96,7 @@ See [`OUTPUT_SCHEMA.md`](./OUTPUT_SCHEMA.md) for what `type`/`confidence`/`sever
97
96
  | `area-alt-quality` | &lt;area&gt; alt text must be appropriate (manual review) | 1.1.1 | A | medium | minor |
98
97
  | `aria-checked-state-mismatch` | Native checkbox/radio aria-checked should match its actual state | 4.1.2 | A | medium | moderate |
99
98
  | `aria-text` | role="text" elements should have no focusable descendants | — | — | medium | minor |
99
+ | `bypass-blocks-present` | Page must provide a way to bypass repeated blocks | 2.4.1 | A | medium | moderate |
100
100
  | `canvas-text-alternative-quality` | &lt;canvas&gt; text alternative must be appropriate (manual review) | 1.1.1 | A | medium | minor |
101
101
  | `css-hidden-focus` | Focusable elements must not be visually hidden | 2.4.7 | AA | low | serious |
102
102
  | `embed-text-alternative-quality` | &lt;embed&gt; text alternative must be appropriate (manual review) | 1.1.1 | A | medium | minor |
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## "I passed `runOnly: ['some-rule-id']` but every rule still ran"
4
4
 
5
- `runOnly` must be an object, not a bare array — `runOnly: ['img-alt-present']` is silently ignored (the engine falls through to "run everything"), because that shape has none of the fields the engine actually checks (`includeRuleIds`, `tags`, etc.). This is easy to get wrong if you're coming from another engine that does accept a bare array.
5
+ `runOnly` must be an object, not a bare array — `runOnly: ['img-alt-present']` is silently ignored (the engine falls through to "run everything"), because that shape has none of the fields the engine actually checks (`includeRuleIds`, `tags`, etc.). A bare array is easy to reach for, so this is worth checking first.
6
6
 
7
7
  Fix:
8
8
 
@@ -41,7 +41,7 @@ Treat it as "needs a human to look" — it's neither pass nor fail by design. Mo
41
41
 
42
42
  ## "What happens if a locale is only partially translated?"
43
43
 
44
- Missing keys fall back to English per-string (never a blank or broken result), so a partial locale degrades gracefully rather than failing outright — see [`I18N.md`](./I18N.md) for the mechanism and current coverage. Both shipped locales (`en`, `fr`) are at full parity as of this writing, but that's not guaranteed to stay true automatically: adding a new rule adds a new key to `en.js`, and unless the same key is added to `fr.js` (or any other locale file you maintain), that string falls back to English until it is.
44
+ Missing keys fall back to English per-string (never a blank or broken result), so a partial locale degrades gracefully rather than failing outright — see [`I18N.md`](./I18N.md) for the mechanism and current coverage. Key parity is enforced rather than hoped for: `npm run i18n:sync` carries any new or renamed `en.json` key into every other locale file, and the build fails if one is out of step. A key it adds holds the English text until someone translates it, so a locale can be behind on wording without ever being behind on keys.
45
45
 
46
46
  ## "`runDomRulesInPage` vs `runa11yCoreInPage` — which one do I want?"
47
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Deterministic WCAG 2.2 accessibility engine that tells you what it can't tell you. Zero dependencies.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -61,6 +61,7 @@
61
61
  "src/checks/**/*.js",
62
62
  "bin/surea11y-core.js",
63
63
  "surea11y.browser.js",
64
+ "surea11y.i18n.*.js",
64
65
  "docs/**/*.md",
65
66
  "README.md",
66
67
  "LICENSE",
@@ -87,19 +88,25 @@
87
88
  "test:typography-helpers": "node tests/contrast-typography.test.js",
88
89
  "coverage": "node scripts/generate-wcag-coverage.js --rulesDir src/checks",
89
90
  "coverage:strict": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --strictFacets",
91
+ "coverage:check": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --check",
90
92
  "fixtures:index": "node scripts/generate-fixture-index.js",
91
93
  "docs:rule-catalog": "npm run build && node scripts/generate-rule-catalog.js",
92
94
  "validate:automatic-rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic",
93
95
  "validate:manual-rules": "npm run build && node scripts/validate-all-rules.js src/checks/manual",
96
+ "validate:rules": "npm run validate:automatic-rules && npm run validate:manual-rules",
94
97
  "i18n:new": "node scripts/i18n-scaffold.js",
98
+ "i18n:sync": "node scripts/i18n-sync.js",
99
+ "i18n:check": "node scripts/i18n-sync.js --check",
95
100
  "i18n:report": "node scripts/i18n-report.js"
96
101
  },
97
102
  "devDependencies": {
98
103
  "@eslint/js": "^10.0.1",
104
+ "aria-query": "^5.3.2",
99
105
  "eslint": "^10.8.0",
100
106
  "eslint-config-prettier": "^10.1.8",
101
107
  "globals": "^17.8.0",
102
108
  "jsdom": "^29.1.1",
109
+ "language-subtag-registry": "^0.4.2",
103
110
  "playwright": "^1.62.1",
104
111
  "prettier": "^3.9.6"
105
112
  }