@surea11y/core 1.4.1 → 1.6.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 +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +18 -21
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
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` | 666 | — (the canonical/fallback set) |
|
|
10
|
+
| `fr` (French) | `src/i18n/fr.json` | 666 | 1 |
|
|
11
|
+
| `de` (German) | `src/i18n/de.json` | 666 | 0 |
|
|
12
|
+
| `es` (Spanish) | `src/i18n/es.json` | 666 | 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
|
-
- **Cost of that self-containment**: `src/core.js` grew from ~1.86MB to ~3.1MB
|
|
174
|
-
- **No origin/identity check on the sender** beyond the message's own namespaced envelope
|
|
195
|
+
- **Cost of that self-containment**: `src/core.js` grew from ~1.86MB to ~3.1MB when these two functions landed, since each needed its own complete private copy of the rule catalog and shared helpers rather than sharing the outer `RULE_IMPLS`; it is ~4.3MB now, and grows with every rule added, three times over. 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.
|
|
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
|
@@ -6,7 +6,7 @@ Stated plainly and upfront, not left for you to discover. Every item here is a d
|
|
|
6
6
|
|
|
7
7
|
surea11y is a **static DOM scan**: it reads the DOM tree and computed styles at one instant, with no ability to simulate user interaction, wait for async state changes, or measure real layout at arbitrary viewport sizes. These aren't missing rules — no rule implementation, however clever, can close them without a fundamentally different architecture (real browser automation driving actual keyboard/pointer events over time):
|
|
8
8
|
|
|
9
|
-
- **Keyboard-trap detection** (WCAG 2.1.2) — requires
|
|
9
|
+
- **Keyboard-trap detection** (WCAG 2.1.2) — requires driving focus around the page and watching where it lands, which no single read of the DOM can do. A technique that would work has been scoped out (see the keyboard-trap section of [`ACT_RULE_MAPPING.md`](./ACT_RULE_MAPPING.md#keyboard-trap-detection-scoping-notes)), but it would be this engine's first check to mutate the page it is inspecting, so it is not built and would not be part of a normal scan if it were. Nothing in static markup stands in for it in the meantime.
|
|
10
10
|
- **Reflow / clipping at zoom** (WCAG 1.4.10) — requires real layout measurement (`clientWidth`/`scrollWidth`) at a simulated 320px-equivalent viewport. Unlike some CSS-declaration-based heuristics elsewhere in this engine, there is no static markup proxy for "does content get clipped at 400% zoom" at all.
|
|
11
11
|
- **Dynamic/post-interaction state** — anything that only exists after a click, hover, or async data load (a modal's contents, a dropdown's options, form-validation error messages) is invisible to a scan of the page's *current* DOM. If your framework renders it eagerly (even off-screen/`hidden`), it's scannable; if it only exists after interaction, it isn't, unless you drive that interaction yourself before scanning (e.g. click the button, *then* scan).
|
|
12
12
|
|
|
@@ -14,17 +14,19 @@ 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
|
-
- **
|
|
17
|
+
- **jsdom's computed `text-shadow` is unreliable on a second read of the same element.** Confirmed in jsdom 29.1.1: reading a computed `text-shadow` value a second time on the same element — through any accessor, from any freshly-requested `CSSStyleDeclaration` for that element, regardless of caching — silently returns a different, wrong "no shadow" value instead of the real declared one. The first read is always correct. This engine works around it internally by reading each element's `text-shadow` exactly once per run and caching the result (see `__textShadowInfoEl` in `src/core/contrast-helpers.js`), so a single scan is unaffected. It only surfaces if you read `getComputedStyle(el).textShadow` yourself, more than once, against the same jsdom-parsed element — a real browser has no such bug.
|
|
18
|
+
- **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. That's why `aria-checked-state-mismatch` is capped at `manual`/`cantTell` 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
19
|
|
|
19
|
-
##
|
|
20
|
+
## Not attempted: judgment calls that aren't automatable safely
|
|
20
21
|
|
|
21
22
|
These have no comparably safe heuristic at this engine's confidence bar (`fail` must stay reserved for deterministic, high-confidence violations, full stop). Building them anyway would either catch almost nothing (too narrow to be useful) or risk real false positives (too broad to trust):
|
|
22
23
|
|
|
23
|
-
- **"Is this heading/label text meaningful?"** — real headings and labels are enormously varied and legitimately short ("FAQ," "Name," "Overview" are all fine)
|
|
24
|
+
- **"Is this heading/label text meaningful?"** — real headings and labels are enormously varied and legitimately short ("FAQ," "Name," "Overview" are all fine), so nothing decides from markup whether a heading describes the section under it or a label describes the field beside it. What *is* decidable is that some strings cannot describe anything: `heading-quality` and `form-control-label-quality` flag leftover placeholders, numbered template slots, filenames and URLs against curated exact-match lists, the same precision-over-recall trade-off `link-name-quality` makes. Both are `manual` rules capped at `cantTell` — they raise a candidate for review, they never assert the text is wrong.
|
|
24
25
|
- **"Does this error message describe the problem?"** — what triggers a validation error and its content are almost always JS/validation-library-driven, invisible to a static scan in the first place; not just a heuristic-design problem.
|
|
25
26
|
- **Fine-grained time-based-media sub-checks** (WCAG 1.2.x has ~8 distinct ACT-rule-level cases beyond what's built) — audio/video content itself is fundamentally unverifiable from static markup; the two broadest, safest cases are covered (`media-alternative-transcript-evidence`, `video-caption`), the narrower ones are not, by design.
|
|
26
27
|
- **Images-of-text content analysis** (WCAG 1.4.5/1.4.9) — would need OCR-equivalent image understanding; out of scope for a static-markup engine.
|
|
27
28
|
- **Motion-actuation controls** (WCAG 2.5.4) — niche, low real-world incidence; not prioritized, not structurally impossible.
|
|
29
|
+
- **`img-alt-decorative`'s two ACT e88epe edge cases** — an `<img>` whose current network request state isn't "completely available" (still loading, or broken), and a `<canvas>` that is fully transparent (nothing actually drawn on it), are both exempt from ACT's own applicability. Neither is decidable from a static DOM scan (no image decoding, no canvas pixel readback), so both are left in scope rather than exempted — a rare false positive a human reviewer dismisses at a glance, safer than silently under-reporting a real excluded/undecorative element.
|
|
28
30
|
|
|
29
31
|
## What this means in practice
|
|
30
32
|
|
package/docs/OUTPUT_SCHEMA.md
CHANGED
|
@@ -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: {
|
|
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 (
|
|
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": {
|
|
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
|
|
@@ -30,4 +32,4 @@ require('fs').writeFileSync('report.html', html);
|
|
|
30
32
|
|
|
31
33
|
## Scope
|
|
32
34
|
|
|
33
|
-
This is a single-scan report — one point-in-time snapshot, not a dashboard tracking results across many scans over time.
|
|
35
|
+
This is a single-scan report — one point-in-time snapshot, not a dashboard tracking results across many scans over time. Tracking results across many runs is a separate, larger concern and isn't part of this tool: keep the JSON from each scan and diff it yourself, or feed SARIF to a dashboard that already does history ([`SARIF.md`](./SARIF.md)).
|
package/docs/RULE_AUTHORING.md
CHANGED
|
@@ -52,7 +52,16 @@ function runInPage(ctx) { /* see Runtime Contract */ }
|
|
|
52
52
|
module.exports = { id, meta, runInPage };
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
One optional fourth export: `applicability(ctx)`, a predicate the engine calls before
|
|
56
|
+
`runInPage` to decide whether the rule is in scope for this run at all. Fourteen rules
|
|
57
|
+
use it today (see §11.2). Export it alongside the other three when you need it:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Nothing else. `npm run validate:rules` enforces exactly this set, and rejects a fifth
|
|
64
|
+
export.
|
|
56
65
|
|
|
57
66
|
---
|
|
58
67
|
|
|
@@ -78,7 +87,7 @@ Examples observed:
|
|
|
78
87
|
|
|
79
88
|
## 4) Meta Contract (all keys used by current rules)
|
|
80
89
|
|
|
81
|
-
Every rule defines a `meta` object.
|
|
90
|
+
Every rule defines a `meta` object. Across the shipped ruleset, the union of meta keys is:
|
|
82
91
|
|
|
83
92
|
### 4.1 Required top-level keys
|
|
84
93
|
|
|
@@ -132,6 +141,10 @@ The build/runtime resolves i18n by:
|
|
|
132
141
|
2) falling back to `en` if missing,
|
|
133
142
|
3) falling back to the literal `title`/`description` strings if still missing.
|
|
134
143
|
|
|
144
|
+
Add the key and its English text to `src/i18n/en.json`, then run
|
|
145
|
+
`npm run i18n:sync` so every other locale picks it up. `npm test` fails if you
|
|
146
|
+
forget. See [`I18N.md`](./I18N.md).
|
|
147
|
+
|
|
135
148
|
#### `meta.tags`
|
|
136
149
|
Tags are used for grouping/filtering. Typical tag families in this ruleset include:
|
|
137
150
|
- WCAG tagging: `wcag2a`, `wcag111`
|
|
@@ -163,6 +176,31 @@ const meta = {
|
|
|
163
176
|
|
|
164
177
|
---
|
|
165
178
|
|
|
179
|
+
## 4.3 Reporting an occurrence
|
|
180
|
+
|
|
181
|
+
Build occurrences with `helpers.reportOccurrence(element, { summary, hint, i18n, data })`
|
|
182
|
+
rather than assembling the object by hand:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
occurrences.push(helpers.reportOccurrence(el, { summary: '…', hint: '…' }));
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
It attaches the element for the engine to finalize, which is how `selector`,
|
|
189
|
+
`html` and `structuralPath` get filled in centrally instead of in each rule.
|
|
190
|
+
|
|
191
|
+
**This is a performance contract, not just a convenience.** Every occurrence
|
|
192
|
+
gets a `structuralPath`. Given the element, the engine computes it directly.
|
|
193
|
+
Given only a hand-built occurrence, it re-finds the element with
|
|
194
|
+
`document.querySelector(selector)` — one DOM query per occurrence. That is
|
|
195
|
+
fine for a rule reporting a single document-level finding, and quadratic for
|
|
196
|
+
one reporting many: `region` hand-built its occurrences and took four minutes
|
|
197
|
+
on a thousand-element page, against under a second afterwards.
|
|
198
|
+
|
|
199
|
+
`perfStats.counters['structuralPath.selectorFallback']` counts how often the
|
|
200
|
+
engine had to re-find an element, so a slow rule can be spotted without
|
|
201
|
+
guessing. `tests/structural-path-fallback.test.js` fails if that count starts
|
|
202
|
+
growing with page size.
|
|
203
|
+
|
|
166
204
|
## 5) i18n in occurrences (repo reality)
|
|
167
205
|
|
|
168
206
|
Occurrences also support i18n via keys + params.
|
|
@@ -189,6 +227,29 @@ At normalization time, the engine:
|
|
|
189
227
|
Translation strings use `{{paramName}}` placeholders.
|
|
190
228
|
`params` is shallow-copied and passed into interpolation.
|
|
191
229
|
|
|
230
|
+
**A param carries a value, never prose.** Element names, roles, attribute names,
|
|
231
|
+
selectors, ids, counts and ratios are values: they read the same in every
|
|
232
|
+
language, because the author will search their own source for them. An English
|
|
233
|
+
word or sentence is not, and passing one means it stays English in every locale
|
|
234
|
+
— with nothing to reveal it, since the key is present everywhere and coverage
|
|
235
|
+
reports look complete.
|
|
236
|
+
|
|
237
|
+
When a message varies by case, give each case its own key rather than
|
|
238
|
+
interpolating the differing text:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
// wrong: the sentence lives in the rule, so no locale can reach it
|
|
242
|
+
i18n: { hintKey: 'myRule_hint_fail', params: { advice: 'Replace it with role="list".' } }
|
|
243
|
+
|
|
244
|
+
// right: one key per case, each translatable on its own
|
|
245
|
+
i18n: { hintKey: 'myRule_hint_fail_directory', params: { role } }
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`tests/i18n/i18n-translatable-strings.test.js` fails any dictionary value with
|
|
249
|
+
no translatable text of its own, which catches the `"{{advice}}"` shape above.
|
|
250
|
+
It cannot catch a param carrying prose into an otherwise-normal sentence, so
|
|
251
|
+
that one is on you.
|
|
252
|
+
|
|
192
253
|
---
|
|
193
254
|
|
|
194
255
|
## 6) Helpers contract used by rules (ctx.helpers)
|
|
@@ -215,15 +276,19 @@ const nodes = helpers.queryAllSmart
|
|
|
215
276
|
: helpers.queryAll('img');
|
|
216
277
|
```
|
|
217
278
|
|
|
218
|
-
Shadow traversal is
|
|
279
|
+
Shadow traversal is on by default. It is the caller who opts out:
|
|
219
280
|
```js
|
|
220
|
-
engineOptions: { includeShadowDom:
|
|
281
|
+
engineOptions: { includeShadowDom: false } // light DOM only
|
|
221
282
|
```
|
|
283
|
+
So write the rule assuming open shadow roots are in scope; `queryAllSmart` honours the
|
|
284
|
+
caller's choice for you. Closed roots are unreachable either way.
|
|
222
285
|
|
|
223
286
|
### 6.2 Reporting note for Shadow DOM
|
|
224
287
|
|
|
225
|
-
Selectors do not pierce shadow boundaries, so a `selector` may not uniquely locate
|
|
226
|
-
|
|
288
|
+
Selectors do not pierce shadow boundaries, so a `selector` may not uniquely locate a node
|
|
289
|
+
inside a shadow root — which is why `html` matters as the "which element" signal there.
|
|
290
|
+
You get both for free by reporting the element through `helpers.reportOccurrence` (§4.3);
|
|
291
|
+
there is nothing extra to do for shadow DOM specifically.
|
|
227
292
|
|
|
228
293
|
---
|
|
229
294
|
|
|
@@ -237,7 +302,8 @@ data: {
|
|
|
237
302
|
}
|
|
238
303
|
```
|
|
239
304
|
|
|
240
|
-
|
|
305
|
+
Pass the `eligInfo` you already computed for the element; the fallback object above is
|
|
306
|
+
for the case where a rule has none to give.
|
|
241
307
|
|
|
242
308
|
---
|
|
243
309
|
|
|
@@ -266,34 +332,48 @@ Manual:
|
|
|
266
332
|
|
|
267
333
|
---
|
|
268
334
|
|
|
269
|
-
## 9) Occurrence object shape
|
|
335
|
+
## 9) Occurrence object shape
|
|
270
336
|
|
|
271
|
-
|
|
337
|
+
Report the element and let the engine finish the object (§4.3):
|
|
272
338
|
|
|
273
339
|
```js
|
|
274
|
-
occurrences.push(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
340
|
+
occurrences.push(
|
|
341
|
+
helpers.reportOccurrence(el, {
|
|
342
|
+
summary: '…',
|
|
343
|
+
hint: '…',
|
|
344
|
+
i18n: { summaryKey: '…', hintKey: '…', params: { element: 'img' } },
|
|
345
|
+
data: { visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] } }
|
|
346
|
+
})
|
|
347
|
+
);
|
|
282
348
|
```
|
|
283
349
|
|
|
284
|
-
|
|
285
|
-
- `selector` (or sometimes `selectorStr`)
|
|
286
|
-
- `html`
|
|
350
|
+
What a rule supplies:
|
|
287
351
|
- `summary`
|
|
288
352
|
- `hint`
|
|
289
353
|
- `i18n` (`summaryKey`, `hintKey`, `params`)
|
|
290
354
|
- `data` (includes `visibilityFilter`)
|
|
291
355
|
|
|
356
|
+
What the engine fills in from the reported element:
|
|
357
|
+
- `selector`
|
|
358
|
+
- `html`
|
|
359
|
+
- `structuralPath`
|
|
360
|
+
|
|
361
|
+
Setting `selector`/`html` yourself still works and still wins — a handful of rules whose
|
|
362
|
+
finding is not a single element (the contrast rules report text runs) do exactly that. It
|
|
363
|
+
is the exception, not the pattern to copy.
|
|
364
|
+
|
|
292
365
|
---
|
|
293
366
|
|
|
294
367
|
## 10) Structured doc comment block
|
|
295
368
|
|
|
296
|
-
Keep the structured header comment (`@
|
|
369
|
+
Keep the structured header comment (`@check`, `@atomic`, `@summary`, `@standard`, `@sc`,
|
|
370
|
+
`@applicability`, `@expectation`). The id goes on `@check` — `@rule` is not a tag this
|
|
371
|
+
repo uses. `docs/RULE_TEMPLATE.js` has the full block to copy.
|
|
372
|
+
|
|
373
|
+
`@applicability` and `@expectation` are consumer-facing: `scripts/generate-rule-catalog.js`
|
|
374
|
+
reads them straight from the source and publishes them per rule in
|
|
375
|
+
[`RULE_CATALOG.md`](./RULE_CATALOG.md#rule-reference). Write them for someone deciding
|
|
376
|
+
whether a result applies to their page, and rerun `npm run docs:rule-catalog` after editing them.
|
|
297
377
|
|
|
298
378
|
---
|
|
299
379
|
|
|
@@ -399,8 +479,9 @@ After adding or changing any fixture, regenerate the index:
|
|
|
399
479
|
npm run fixtures:index
|
|
400
480
|
```
|
|
401
481
|
|
|
402
|
-
This writes `tests/fixtures/INDEX.md` (human-readable)
|
|
482
|
+
This writes `tests/fixtures/INDEX.md` (human-readable), `tests/fixtures/index.json`
|
|
403
483
|
(machine-readable — every rule, its fixture path, and parsed pass/fail/cantTell case
|
|
404
|
-
counts, for external tooling to enumerate and load fixtures directly)
|
|
484
|
+
counts, for external tooling to enumerate and load fixtures directly) and
|
|
485
|
+
`tests/fixtures/index.html` (the same listing as a browsable page). Commit all three
|
|
405
486
|
alongside the fixture and test changes. A rule shipped without its fixture is treated
|
|
406
487
|
the same as a rule shipped without tests — not done.
|