@surea11y/core 1.7.0 → 1.8.1

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 (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -40,6 +40,33 @@ open shadow roots are in scope and let this helper honor the caller's choice.
40
40
  const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img') : helpers.queryAll('img');
41
41
  ```
42
42
 
43
+ ### `queryAllSource(selector)` → `Element[]`
44
+ The same query as `queryAllSmart` (shadow roots per `includeShadowDom`, context scope,
45
+ `excludeSelectors`) with no hidden-content filter: elements inside `hidden`,
46
+ `display:none`, closed `<details>` and the like are returned whatever
47
+ `includeHiddenElements` says. Only for rules that judge the markup itself rather than
48
+ what is rendered, such as a standard's tests on the generated source.
49
+ `<template>` content is not in the DOM tree and stays out. A WCAG rule should not use
50
+ it: hidden content is not presented to users.
51
+
52
+ ### `getDoctypeInfo()` → `{ kind, name, publicId, systemId }`
53
+ The document's doctype, classified by HTML version. `kind` is one of:
54
+
55
+ | `kind` | Doctype |
56
+ |---|---|
57
+ | `'html5'` | name `html`, no public id, and no system id or `about:legacy-compat` |
58
+ | `'xhtml10'` | a public id of XHTML 1.0 (strict, transitional or frameset) |
59
+ | `'xhtml11'` | any other W3C XHTML public id: XHTML 1.1, XHTML Basic, XHTML 1.1 plus MathML (and SVG), XHTML+RDFa |
60
+ | `'html4'` | a W3C or IETF HTML public id: HTML 2.0, 3.2, 4.0, 4.01, HTML 4.01+RDFa |
61
+ | `'other'` | any other doctype, including one whose name is not `html` |
62
+ | `'none'` | no doctype |
63
+
64
+ Public ids are compared without regard to case, as the HTML parser does. `name`,
65
+ `publicId` and `systemId` are the doctype's own values (empty strings when there is
66
+ none). For rules whose verdict depends on the HTML version: a requirement that
67
+ applies to HTML5 only, or `lang` read as `lang` or `xml:lang` by version. Whether a
68
+ doctype is valid at all is not this helper's question.
69
+
43
70
  ### `composedParent(node)` → `Node | null`
44
71
  One step up the *flat tree*: `assignedSlot` first (a slotted node's rendered parent is
45
72
  its slot, not its light-DOM `parentNode`), then `parentNode`, then `.host` once you're
@@ -176,7 +203,9 @@ a name exists.
176
203
  Recursive "name from content" (accname step 2F): walks children using each child's
177
204
  *own* accessible name (not just literal text), so `<a href="…"><img alt="Company
178
205
  Name"></a>` and `<button><span aria-label="Close"></span></button>` both name correctly.
179
- A plain `TreeWalker(SHOW_TEXT)` walk misses both.
206
+ A plain `TreeWalker(SHOW_TEXT)` walk misses both. An SVG element with a `<title>` child
207
+ speaks for itself through that title (SVG-AAM), after its own `aria-labelledby` and
208
+ `aria-label`, so `<button><svg><title>Search</title></svg></button>` is named "Search".
180
209
 
181
210
  ### `getAssociatedLabelElements(el)` → `Element[]`
182
211
  Real `<label>` element(s) associated with `el` — a `<label for="id">` pointing at it,
@@ -186,6 +215,15 @@ native `.labels`/`.control` API** — in this project's supported jsdom runtime,
186
215
  another one), which used to dominate whole-engine runtime on form-heavy pages. Use this
187
216
  whenever a rule needs the actual label element(s), not just a yes/no.
188
217
 
218
+ ### `getNativeHostNameInfo(el, ctx, opts)` → `{ present, value, mechanism }`
219
+ The name an element gets from its HTML host markup rather than from ARIA: an associated
220
+ `<label>` on a labelable element, the first child `<legend>` of a `<fieldset>`, the first
221
+ child `<caption>` of a `<table>`, and, only with `opts.placeholder: true`, the
222
+ `placeholder` of a text-like `<input>` or a `<textarea>` (HTML-AAM's last name source).
223
+ `mechanism` is `'label'`, `'legend'`, `'caption'`, `'placeholder'` or `'none'`. For rules
224
+ on name-from-author-only roles (`role="textbox"`, `"slider"`, `"radiogroup"`, …) whose
225
+ ARIA check does not read host markup, although the browser still computes it.
226
+
189
227
  ### `labelContributesAccessibleName(labelEl)` → `boolean`
190
228
  Whether a `<label>` element itself carries text that would name its control: own
191
229
  ARIA name, else rendered content (`getContentNameInfo`, so `aria-hidden`/`display:none`
@@ -267,6 +305,13 @@ subtag (the registry only lists a three-letter subtag when no two-letter one exi
267
305
  so `"en"` is registered and `"eng"` is not). Use `isValidLanguageTag` for any
268
306
  `lang`/`xml:lang`-checking rule instead of a regex-only check.
269
307
 
308
+ ### `hasSkipLinkWording(text)` → `boolean`
309
+ Whether a link's text reads as a skip link ("Skip to content", "Aller au contenu",
310
+ "Zum Inhalt", "Saltar al contenido", "本文へ"...), in the languages the engine ships.
311
+ One list for every rule that looks for a skip link, core's `skip-link` and any a
312
+ profile brings, so they recognise the same links; add a phrasing here, not in a
313
+ rule.
314
+
270
315
  ### `reportOccurrence(node, partial)` → occurrence object
271
316
  **Use this to build every occurrence.** Attaches the element so the engine fills in
272
317
  `selector`, `html`, and `structuralPath` centrally — see `RULE_AUTHORING.md` §4.3/§9 for
@@ -307,11 +352,23 @@ flat `helpers.*` list above:
307
352
  Color/contrast math and text-run analysis: `parseCssColorToRgba`, `compositeRgba`,
308
353
  `relativeLuminance`, `contrastRatio`, `requiredRatio`, `isLargeText`,
309
354
  `computeEffectiveForeground`/`computeEffectiveBackground`, `getComputabilityBlocker`,
355
+ `hasBackgroundImageOrGradient`, `hasBlendMode`, `hasFilter`, `computeOpacityProduct`,
310
356
  `getTextScan`, `isInactiveUiComponent`, plus small numeric/formatting utilities
311
- (`clamp01`, `round2`, `toHex2`, `pxToPt`, `fontWeightLabel`, …). Backs the
312
- `contrast-*` rule family (`contrast-minimum`, `contrast-enhanced`,
313
- `contrast-computable`) — see `src/core/contrast-helpers.js` if you're extending that
314
- family specifically.
357
+ (`clamp01`, `clamp255`, `round2`, `toHex2`, `rgbToHex`, `rgbaToString`, `parsePx`,
358
+ `normalizeFontWeight`, `pxToPt`, `fontWeightLabel`). That is the whole namespace,
359
+ apart from `sharedCache`: a plain object that lives for one scan and lets the contrast
360
+ rules reuse per-element work. Treat it as an optimisation, never as data a rule
361
+ depends on: a key may be absent, and a rule stores only under keys of its own unless
362
+ it computes exactly what that key's other users compute (`contrast-minimum`,
363
+ `contrast-enhanced` and `contrast-computable`, and `contrast-minimum`'s variants,
364
+ share `__elBgCache`, `__elFgCache` and `__elBlockerCache`,
365
+ WeakMaps of each element's effective background, foreground and computability blocker;
366
+ a variant with other thresholds keeps its own font and analysis caches, keyed by them). Backs the
367
+ `contrast-*` rule family (`contrast-minimum` and its variants, `contrast-enhanced`,
368
+ `contrast-computable`) — see `src/core/contrast-helpers.js`
369
+ if you're extending that family specifically. `isLargeText(fontSizePx, fontWeightNum,
370
+ boldLargeMinPx)` takes an optional third argument, the size from which bold text is large:
371
+ WCAG's 14pt when it is left out, 18.5 for a standard that puts it there.
315
372
 
316
373
  ### `helpers.aria.*`
317
374
  ARIA validity/taxonomy data and checks: `isValidAriaAttrName`, `getAttrValueType`,
@@ -45,7 +45,7 @@ decidable from markup.
45
45
  ### 1.2 Intent
46
46
  Encoded by: **rule id suffix**
47
47
 
48
- Illustrative intents (from the image-alternatives family used as the running example in §2, not an exhaustive list — the ruleset's 130 rules use dozens of distinct suffixes; `docs/RULE_CATALOG.md` is the generated, always-current list):
48
+ Illustrative intents (from the image-alternatives family used as the running example in §2, not an exhaustive list — the ruleset's rules use dozens of distinct suffixes; `docs/RULE_CATALOG.md` is the generated, always-current list):
49
49
 
50
50
  - `present`
51
51
  - Verifies that a required **mechanism exists**
@@ -64,7 +64,7 @@ Intent determines whether a rule can be automatic.
64
64
  ### 1.3 Target Family
65
65
  Encoded by: **rule id prefix**
66
66
 
67
- Illustrative families, from the image-alternatives cluster (WCAG 1.1.1) used as the running example in §2 — not an exhaustive list. The ruleset's 130 rules span dozens of families (`aria-*`, `contrast-*`, `dialog-*`, `iframe-*`, `label-*`, `link-*`, `list-*`, `landmark-*`, and more); see `docs/RULE_CATALOG.md` for the generated, always-current list:
67
+ Illustrative families, from the image-alternatives cluster (WCAG 1.1.1) used as the running example in §2 — not an exhaustive list. The ruleset's rules span dozens of families (`aria-*`, `contrast-*`, `dialog-*`, `iframe-*`, `label-*`, `link-*`, `list-*`, `landmark-*`, and more); see `docs/RULE_CATALOG.md` for the generated, always-current list:
68
68
 
69
69
  - `img`
70
70
  - `area`
package/docs/SARIF.md CHANGED
@@ -52,7 +52,8 @@ Every rule that ran (regardless of whether it produced a result) is listed once
52
52
  | `results[].locations[].logicalLocations[].fullyQualifiedName` | `occurrence.selector`, when present. |
53
53
  | `results[].partialFingerprints["surea11y/violation/v1"]` | The same `ruleId + reasonCode + html` identity key used by [`BASELINE.md`](./BASELINE.md) (`computeBaselineKey`) — a stable, content-based fingerprint rather than a position-based one. |
54
54
  | `results[].properties.severity` / `.confidence` | `checksResults[i].severity` / `.confidence` — informational, not part of SARIF's own schema. |
55
- | `tool.driver.rules[].properties.tags` | `accessibility`, `automatic`/`manual`, and a `wcag-<SC>` tag per `meta.normativeMappings[].requirement`. |
55
+ | `tool.driver.rules[].properties.tags` | `accessibility`, `automatic`/`manual`, and a `wcag-<SC>` tag per WCAG Success Criterion in `meta.normativeMappings`. Understanding-document entries get no tag. Each EN 301 549 clause the result carries (only when the scan asked for them, see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#other-standards-mappings)) gets an `en301549-<clause>` tag, e.g. `en301549-9.1.1.1`: clause numbers are the same in every version that has them, so the tag carries no version. |
56
+ | `runs[0].properties` | `wcagVersion`, `profile` and `optInRules` from the result's `engine`: the conformance target the run used, so a dashboard can tell a WCAG 2.1 run from a 2.2 one, and the opt-in rule tags it added beyond that target when it added any. Omitted for results from engines that predate those fields. |
56
57
 
57
58
  ## Locations
58
59
 
@@ -4,9 +4,9 @@ How individual rule results relate to a WCAG Success Criterion (SC), and what su
4
4
 
5
5
  ## The three layers
6
6
 
7
- 1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for all 130.
7
+ 1. **Atomic rules** (`checksResults[]`) — one normative decision each, e.g. "does this `<img>` have an `alt` attribute." See [`RULE_CATALOG.md`](./RULE_CATALOG.md) for every one.
8
8
  2. **Facets** — a WCAG SC is usually bigger than any one rule can decide deterministically. Internally, each SC is broken into named "facets" (e.g. 1.1.1 Non-text Content has facets like `img-alt-attr-present`, `text-alternative-quality`, `decorative-null`) tracked in `src/coverage/wcag-facets.js`, each marked `full` (a rule decides it with high confidence), `partial` (a rule decides *part* of it — see each rule's own scope notes), or `manual` (no safe automated heuristic exists at all). Run `npm run coverage` to regenerate `coverage/coverage-report.md`, the per-SC facet breakdown.
9
- 3. **Composite (WCAG-SC rollup) rules** (`rulesResults[]`) — a generated aggregate of every atomic rule mapped to one SC, giving you one pass/fail/cantTell/notApplicable verdict per SC instead of having to roll up dozens of atomic results yourself. See [`RULE_CATALOG.md`](./RULE_CATALOG.md#composite-wcag-sc-rollup-rules-33) for the full list (e.g. `wcag-1.1.1-non-text-content` rolls up 22 atomic rules).
9
+ 3. **Composite (WCAG-SC rollup) rules** (`rulesResults[]`) — a generated aggregate of every atomic rule mapped to one SC, giving you one pass/fail/cantTell/notApplicable verdict per SC instead of having to roll up dozens of atomic results yourself. See the composite section of [`RULE_CATALOG.md`](./RULE_CATALOG.md) for the full list (e.g. `wcag-1.1.1-non-text-content` rolls up 22 atomic rules).
10
10
 
11
11
  ## How a composite's outcome is computed
12
12
 
@@ -37,6 +37,8 @@ carries one level tag per Success Criterion it maps to, and nothing more: a rule
37
37
  mapped only to an AA criterion is tagged `wcag2aa` and *not* `wcag2a`. Asking for
38
38
  `{ tags: ['wcag2aa'] }` on its own therefore runs the 10 rules mapped to a 2.0 AA
39
39
  criterion, not the ~100 that make up an A + AA target. List every level you mean.
40
+ `engineOptions.profile` (`wcag22-aa`, `en301549-v4.1.1`, `en301549-v3.2.1`, `section508`) does
41
+ this for you; see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#conformance-profiles).
40
42
 
41
43
  The same applies across WCAG versions — a criterion introduced in 2.1 or 2.2 carries
42
44
  only its own origin tag — so a full conformance target is a union of tag sets. See
@@ -61,12 +63,63 @@ you need the exact precedence.
61
63
  Omit `tags` entirely (the default) and every rule at every level runs, with no composite
62
64
  suppression.
63
65
 
66
+ ## EN 301 549
67
+
68
+ Chapter 9 of EN 301 549 restates the WCAG Level A and AA Success Criteria as clauses numbered `9.` plus the criterion's own number: WCAG 1.4.3 is clause 9.1.4.3. When a scan asks for them, every atomic and composite result carries, after its WCAG entries in `meta.normativeMappings`, the clause for each of its criteria, once per version of the standard that includes that criterion. A scan asks with `engineOptions.mappings: ['en301549']` (both versions) or `['en301549:V3.2.1']` (one), or by targeting an EN 301 549 profile, which adds the version it names; by default results name WCAG only (see [`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#other-standards-mappings)):
69
+
70
+ | Version | Built on | Differs from the other in |
71
+ |---|---|---|
72
+ | V3.2.1 (2021-03) | WCAG 2.1 A and AA, 50 criteria | Includes 9.4.1.1 Parsing |
73
+ | V4.1.1 (2026-09) | WCAG 2.2 A and AA, 55 criteria | Adds 2.4.11, 2.5.7, 2.5.8, 3.2.6, 3.3.7, 3.3.8; 9.4.1.1 is void |
74
+
75
+ Each clause entry also names the criterion it restates, as `wcagSc: ["1.4.3"]`, so a view grouped by criterion (JUnit's suites, the HTML report's rollup) puts it under the right one even on a rule mapped to several.
76
+
77
+ AAA criteria have no clause in either version. The table lives in `src/coverage/en301549-map.js`, taken from the ETSI text; rules never declare these entries themselves, the build derives them from each rule's WCAG mapping. A rule added through `engineOptions.customRules` keeps exactly the mappings it declares. The catalogs name the clauses under the same options: `getChecksCatalog({ mappings: ['en301549'] })` in each entry's `normativeMappings`, and composite entries returned by `getRulesCatalog({ mappings: ['en301549'] })` as `meta.standardMappings`, so the clauses a criterion maps to can be read without running a scan. Called with no options, they name WCAG only, as a result does.
78
+
79
+ The table is public as `@surea11y/core/en301549`, for tools that need the reverse view, such as which criteria a version requires that a scan did not cover:
80
+
81
+ ```js
82
+ const { EN301549_CLAUSES, en301549ClausesForSc } = require('@surea11y/core/en301549');
83
+
84
+ Object.keys(EN301549_CLAUSES['V3.2.1']); // the 50 WCAG 2.1 A and AA criteria V3.2.1 restates
85
+ en301549ClausesForSc('2.5.8'); // [{ version: 'V4.1.1', clause: '9.2.5.8', title: 'Target size (minimum)' }]
86
+ ```
87
+
88
+ This is a correspondence between two published documents, not a conformance claim: a clause on a result says which EN 301 549 requirement that WCAG criterion is, nothing more. Which version a given law requires is outside the engine.
89
+
90
+ ## Adding another standard
91
+
92
+ EN 301 549 is an entry in a registry, `src/coverage/standards.js`. The build, the runner, the rule catalog and the reporters read it, so a new standard goes the same way. Where its entry lives depends on the standard:
93
+
94
+ - A standard that restates WCAG one criterion at a time, as EN 301 549 does, only renumbers WCAG's verdicts. Its table goes in `src/coverage/<name>-map.js` and its entry in `NORMATIVE_STANDARDS`, next to EN 301 549's.
95
+ - A standard with verdicts of its own is a **profile**: a folder under `profiles/` holding its entry, its tables, and the scripts and tests that go with them, listed in `profiles/index.js`. The registry appends each profile's entry after its own. `npm run profile:new -- <key>` creates one, working and empty, to fill in. See [`profiles/README.md`](../profiles/README.md) for the layout.
96
+
97
+ Either way, the entry needs:
98
+
99
+ 1. A table taken from the published text, with a function that returns a rule's entries given the rule's id and WCAG criteria. Each entry is `{ standard, version, requirement, title, wcagSc }`, where `wcagSc` lists the WCAG criteria that requirement corresponds to. A standard that restates WCAG derives its entries from the criteria. One organised differently looks the rule up by id, in a table of the rules it maps.
100
+ 2. Its `key` (what `engineOptions.mappings` accepts, and the SARIF tag prefix and JUnit property name), its `standard` (the name its entries carry and the report shows), its `versions`, and that function as `mappingsFor`.
101
+
102
+ The rest is optional, and the comment at the top of the registry describes each field:
103
+
104
+ - `profiles`: named conformance targets. Each gives the WCAG tags it runs and the version it targets, and switches that version's mappings on. With `mappedRules: true` it also runs every rule the standard maps, which matters when the standard requires things WCAG leaves to best practice. With `exclude: { rules, criteria }` it leaves rules out: the rules it names, and the WCAG criteria a standard narrower than WCAG waives (their WCAG rollups, and every rule that checks nothing else). The build refuses an unknown rule or criterion, and a rule the profile both maps and excludes. A standard that replaces a WCAG check with a stricter one of its own excludes the WCAG rule and maps its own.
105
+ - `ruleTag`: a tag for rules that check requirements only this standard makes. Another standard may not map them or derive variants from them; the build refuses it, so standards stay independent of each other, and a rule two of them need belongs in core. These rules are opt-in (see [`RULE_AUTHORING.md`](./RULE_AUTHORING.md)), so a WCAG scan never runs them. In a profile, they go in its `rules/` folder, which its `index.js` exports as `rulesDir`.
106
+ - `ruleMapped: true`: the entries come from each rule rather than from its WCAG criterion, so a WCAG rollup names only the entries of the rules that decided its outcome. A standard mapped that way for some requirements and restating WCAG for others lists the prefixes of the restating ones in `restatedPrefixes` (`['A.']`): a rollup names those whatever decided it, as it names EN 301 549's.
107
+ - `composites()`: rollups of the standard's own, such as one per requirement. They carry the rule tag, so only a run that asks for the standard produces them, and the HTML report shows them in a section of their own.
108
+ - `report`: the dictionary key of the note above that section (`noteKey`), and the language of the rollup titles when it is not the scan's (`titleLang`).
109
+ - `validate(rules)`: checks the standard's own tables against the rules that exist. The build fails on any problem it returns.
110
+
111
+ A standard built on a WCAG version reads that version from `@surea11y/core/wcag` (`src/wcag.js`) rather than keeping its own copy: `wcagCriteria('2.1', { levels: ['A', 'AA'] })` lists the criteria in force in 2.1 with their 2.1 titles and levels (4.1.1 Parsing is Level A there, and gone in 2.2), `wcagCriterion(sc, version)` looks one up, and `wcagTags('2.1')` gives the rule tags a profile on 2.1 A and AA selects, as EN 301 549's profiles do. It is the one core module a profile's own tables may require (see [`profiles/README.md`](../profiles/README.md#what-a-profile-may-use)).
112
+
113
+ `tests/coverage/standards.test.js` holds every registered standard to the contract. What stays per standard is its table, its tests, its rules, and a public export if tools need the reverse view, as `@surea11y/core/en301549` does.
114
+
115
+ A standard is compiled into the engine. At run time, a custom rule (`engineOptions.customRules`) can name any standard in its own `normativeMappings`, and the result keeps those entries as written, but only registered standards get a profile, a `mappings` switch, opt-in rules, rollups, or a place in SARIF, JUnit and the HTML report.
116
+
64
117
  ## What this engine cannot tell you
65
118
 
66
119
  No automated tool — this one included — can certify full WCAG conformance. That's not a limitation specific to surea11y; it's inherent to WCAG itself; a meaningful fraction of Success Criteria require human judgment (is this alt text *accurate*, not just *present*; is this error message *understandable*) or dynamic testing this engine's static-DOM-scan architecture cannot do at all (keyboard-trap detection, real layout/reflow at zoom). See [`LIMITATIONS.md`](./LIMITATIONS.md) for the full, explicit list of what's out of scope and why.
67
120
 
68
121
  What surea11y *can* give you, honestly:
69
- - Every `fail` is a real, deterministic, normative violation under the version you targeted — never a guess.
122
+ - Every `fail` is a real, deterministic, normative violation of the standard and version you targeted — never a guess. By default that standard is WCAG: a rule for a requirement only another standard makes (a rule for a national standard's own requirement, say) is opt-in and runs only when you target that standard ([`ENGINE_OPTIONS.md`](./ENGINE_OPTIONS.md#opt-in-rules)), so a WCAG scan never fails a page for something WCAG does not require. The one exception is a run that asks for every rule (`engineOptions.optInRules`): it targets no single standard, and says so in `engine.optInRules`.
70
123
  - Every `cantTell` is an explicit flag for human review, not a swallowed uncertainty.
71
124
  - The facet coverage table tells you exactly which parts of which SCs have zero automated coverage, so you know where a `pass` is silent rather than exhaustive.
72
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
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",
@@ -14,6 +14,7 @@
14
14
  "aria",
15
15
  "contrast",
16
16
  "sarif",
17
+ "junit",
17
18
  "zero-dependencies",
18
19
  "jsdom",
19
20
  "playwright",
@@ -30,7 +31,10 @@
30
31
  "./baseline": "./src/baseline.js",
31
32
  "./report": "./src/report.js",
32
33
  "./sarif": "./src/sarif.js",
34
+ "./junit": "./src/junit.js",
33
35
  "./earl": "./src/earl.js",
36
+ "./en301549": "./src/en301549.js",
37
+ "./wcag": "./src/wcag.js",
34
38
  "./browser": "./surea11y.browser.js",
35
39
  "./i18n/*": "./surea11y.i18n.*.js",
36
40
  "./package.json": "./package.json"
@@ -41,7 +45,7 @@
41
45
  "type": "git",
42
46
  "url": "git+https://github.com/SureA11y/core.git"
43
47
  },
44
- "homepage": "https://github.com/SureA11y/core#readme",
48
+ "homepage": "https://surea11y.dev/",
45
49
  "bugs": {
46
50
  "url": "https://github.com/SureA11y/core/issues"
47
51
  },
@@ -57,7 +61,18 @@
57
61
  "src/baseline.js",
58
62
  "src/report.js",
59
63
  "src/sarif.js",
64
+ "src/junit.js",
60
65
  "src/earl.js",
66
+ "src/en301549.js",
67
+ "src/coverage/en301549-map.js",
68
+ "src/coverage/standards.js",
69
+ "src/wcag.js",
70
+ "src/profile-kit.js",
71
+ "src/coverage/wcag-facets.js",
72
+ "src/coverage/wcag-version-map.js",
73
+ "profiles/index.js",
74
+ "profiles/*/*.js",
75
+ "profiles/*/rules/**/*.js",
61
76
  "src/checks/**/*.js",
62
77
  "surea11y.browser.js",
63
78
  "surea11y.i18n.*.js",
@@ -73,29 +88,37 @@
73
88
  "scripts": {
74
89
  "lint": "eslint .",
75
90
  "lint:fix": "eslint . --fix",
76
- "format": "prettier --write \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"eslint.config.js\"",
77
- "format:check": "prettier --check \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"eslint.config.js\"",
91
+ "format": "prettier --write \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"profiles/**/*.js\" \"eslint.config.js\"",
92
+ "format:check": "prettier --check \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"profiles/**/*.js\" \"eslint.config.js\"",
78
93
  "build": "node scripts/build-core.js && node scripts/build-browser.js",
79
94
  "pretest": "playwright install chromium",
80
95
  "test": "npm run lint && npm run format:check && npm run build && npm run validate:rules && node scripts/run-tests.js",
81
- "test:coverage": "npm run build && node scripts/run-tests.js --experimental-test-coverage --test-coverage-include=src/**/*.js",
96
+ "test:coverage": "npm run build && node scripts/run-tests.js --experimental-test-coverage --test-coverage-include=src/**/*.js --test-coverage-include=profiles/**/*.js",
82
97
  "test:contrast-helpers": "node tests/contrast-helpers.test.js",
83
98
  "helpers-perf-bench": "node --expose-gc scripts/dom-helpers-perf-bench.js",
84
99
  "engine-perf-bench": "node --expose-gc scripts/engine-perf-bench.js --profileRules=true --iters=25 --top=15",
85
100
  "engine-perf-bench-transparent": "node --expose-gc scripts/engine-perf-bench.js --profileRules=true --top=15 --iters=25 --generated=contrastTransparent",
86
101
  "engine-perf-bench-opaque": "node --expose-gc scripts/engine-perf-bench.js --profileRules=true --top=15 --iters=25 --generated=contrastOpaque",
87
102
  "test:typography-helpers": "node tests/contrast-typography.test.js",
88
- "coverage": "node scripts/generate-wcag-coverage.js --rulesDir src/checks",
89
- "coverage:strict": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --strictFacets",
90
- "coverage:check": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --check",
103
+ "coverage": "node scripts/generate-wcag-coverage.js",
104
+ "coverage:strict": "node scripts/generate-wcag-coverage.js --strictFacets",
105
+ "coverage:check": "node scripts/generate-wcag-coverage.js --check",
91
106
  "finding-ids": "npm run build && node scripts/generate-finding-ids.js",
107
+ "finding-ids:release": "node scripts/freeze-finding-ids.js",
92
108
  "fixtures:index": "node scripts/generate-fixture-index.js",
93
109
  "fixtures:check": "node scripts/generate-fixture-index.js --check",
110
+ "fixtures:markers": "npm run build && node scripts/generate-fixture-markers.js",
111
+ "fixtures:markers:check": "npm run build && node scripts/generate-fixture-markers.js --check",
94
112
  "docs:rule-catalog": "npm run build && node scripts/generate-rule-catalog.js",
95
- "validate:automatic-rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic",
96
- "validate:manual-rules": "npm run build && node scripts/validate-all-rules.js src/checks/manual",
97
- "validate:rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic src/checks/manual",
113
+ "docs:rule-catalog:check": "npm run build && node scripts/generate-rule-catalog.js --check",
114
+ "rule-examples:coverage": "npm run build && node scripts/generate-rule-examples-coverage.js",
115
+ "rule-examples:coverage:check": "npm run build && node scripts/generate-rule-examples-coverage.js --check",
116
+ "docs:rule-review": "npm run build && node scripts/generate-rule-review.js",
117
+ "validate:automatic-rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic profiles/*/rules/automatic",
118
+ "validate:manual-rules": "npm run build && node scripts/validate-all-rules.js src/checks/manual profiles/*/rules/manual",
119
+ "validate:rules": "npm run build && node scripts/validate-all-rules.js",
98
120
  "i18n:new": "node scripts/i18n-scaffold.js",
121
+ "profile:new": "node scripts/profile-new.js",
99
122
  "i18n:sync": "node scripts/i18n-sync.js",
100
123
  "i18n:check": "node scripts/i18n-sync.js --check",
101
124
  "i18n:report": "node scripts/i18n-report.js"
@@ -0,0 +1,14 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * The profiles built into the engine, in registry order. Each one is a
7
+ * standard whose verdicts are not WCAG's renumbered (see profiles/README.md),
8
+ * and exports `standard`, its registry entry, which src/coverage/standards.js
9
+ * appends after the standards core keeps for itself, `rulesDir`, the folder
10
+ * of its own rules, which the build compiles with core's, and `i18nDir`, the
11
+ * folder of its dictionaries, which the build adds to core's.
12
+ */
13
+
14
+ module.exports = [];
@@ -11,19 +11,30 @@
11
11
  * @applicability
12
12
  * Applies to <area> elements that:
13
13
  * 1) are in a <map> that is referenced by an <img usemap>, AND
14
- * 2) the referencing <img> is eligible in the accessibility tree (best-effort), AND
15
- * 3) the <area> itself is eligible in the accessibility tree (with engine exceptions).
14
+ * 2) carry a non-empty href (an <area> with no href is not a hyperlink
15
+ * at all per the HTML spec, and has nothing for this rule to name), AND
16
+ * 3) the referencing <img> is actually rendered (hidden/display:none/
17
+ * visibility exclude it; aria-hidden does not, since <area> is not a
18
+ * DOM descendant of <img>), AND
19
+ * 4) the <area> itself is eligible in the accessibility tree. hidden,
20
+ * display:none and inert on the <area> or its <map> do not exclude it:
21
+ * neither element generates a box, so a real browser's image-map
22
+ * hit-testing ignores all three there (verified against Chromium and
23
+ * Firefox); only those mechanisms on a genuine ancestor of the whole
24
+ * <img>+<map> pairing do.
16
25
  * @expectation
17
- * Each applicable <area> element has an alt attribute.
18
- * The alt attribute may be empty (alt="").
26
+ * Each applicable <area> element has a non-empty accessible name, from alt,
27
+ * aria-label/aria-labelledby, or title. An <area> in a used map is always
28
+ * a link, so alt="" is not decorative here as it is on <img>: an empty alt
29
+ * fails the same as a missing one unless another mechanism names it.
19
30
  */
20
31
 
21
32
  const id = 'area-alt-present';
22
33
 
23
34
  const meta = {
24
- title: '<area> must have an alt attribute',
35
+ title: '<area> must have an accessible name',
25
36
  description:
26
- 'Checks that <area> elements provide an alt attribute to support a text alternative mechanism.',
37
+ 'Checks that <area> elements have a non-empty accessible name via alt, aria-label/aria-labelledby, or title.',
27
38
  i18n: {
28
39
  titleKey: 'area_altPresent_title',
29
40
  descriptionKey: 'area_altPresent_description'
@@ -76,6 +87,11 @@ function runInPage(ctx) {
76
87
  const isAccTreeEligible =
77
88
  helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
78
89
 
90
+ const isDomVisibleEligible =
91
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
92
+ ? helpers.isDomVisibleEligible
93
+ : null;
94
+
79
95
  const getAriaNameInfo =
80
96
  helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
81
97
 
@@ -156,17 +172,32 @@ function runInPage(ctx) {
156
172
  const img = getReferencingImgForArea(el);
157
173
  if (!img) continue;
158
174
 
159
- // 1) The referencing <img> must itself be eligible in the acc tree.
160
- // This is the "visibility of map/area doesn't matter; the image does" policy.
161
- if (isAccTreeEligible) {
162
- const imgElig = (() => {
175
+ // 0b) Without href an <area> is not a hyperlink at all per the HTML
176
+ // spec -- just a shape with no associated action -- so it has nothing
177
+ // for this rule to name.
178
+ const hrefRaw = el.getAttribute('href');
179
+ if (!hrefRaw || !hrefRaw.trim()) continue;
180
+
181
+ // 1) The referencing <img> must actually be rendered: a used map's
182
+ // hotspots depend on the img's box, not its accessibility-tree
183
+ // exposure. <area> is not a DOM descendant of <img> -- only linked by
184
+ // the usemap IDREF -- so aria-hidden on the img has nothing to
185
+ // propagate along (verified against Chromium and Firefox: the area
186
+ // stays reachable regardless). hidden/display:none/visibility on the
187
+ // img itself still excludes it, since that removes the box the
188
+ // hotspot geometry depends on.
189
+ if (isDomVisibleEligible) {
190
+ const imgVis = (() => {
163
191
  try {
164
- return isAccTreeEligible(img, ctx);
192
+ return isDomVisibleEligible(img, ctx, {
193
+ visibilityMode: 'styleOnly',
194
+ disableGeometry: true
195
+ });
165
196
  } catch {
166
197
  return { eligible: true, reasons: [] };
167
198
  }
168
199
  })();
169
- if (imgElig && imgElig.eligible === false) continue;
200
+ if (imgVis && imgVis.eligible === false) continue;
170
201
  }
171
202
 
172
203
  // 2) The <area> itself must be eligible (aria-hidden/inert exceptions handled by helper).
@@ -184,8 +215,18 @@ function runInPage(ctx) {
184
215
  // From here: applicable
185
216
  applicableCount += 1;
186
217
 
187
- const hasAlt = el.getAttribute('alt') !== null;
188
- if (hasAlt) continue;
218
+ let altRaw;
219
+ try {
220
+ altRaw = el.getAttribute('alt');
221
+ } catch {
222
+ altRaw = null;
223
+ }
224
+ const hasAltAttr = altRaw !== null;
225
+ const altGivesName = hasAltAttr && String(altRaw).trim() !== '';
226
+ if (altGivesName) continue;
227
+
228
+ // alt="" is not decorative on an <area>: a used map's area is always a
229
+ // link, so it needs a name from elsewhere or it fails.
189
230
 
190
231
  // aria-label / aria-labelledby is also a valid, standards-recognized
191
232
  // text-alternative mechanism for <area> (HTML-AAM accessible name
@@ -201,8 +242,8 @@ function runInPage(ctx) {
201
242
  }
202
243
 
203
244
  // A non-empty title attribute is HTML-AAM's own next fallback naming
204
- // source once alt is entirely absent. Same gap img-alt-present handles
205
- // for <img title="..."> with no alt.
245
+ // source once alt gives no name (missing or empty). Same gap
246
+ // img-alt-present handles for <img title="..."> with no alt.
206
247
  const titleRaw = (() => {
207
248
  try {
208
249
  return el.getAttribute('title');
@@ -214,21 +255,36 @@ function runInPage(ctx) {
214
255
 
215
256
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
216
257
 
217
- const baseOccurrence = {
218
- // Leave selector/html empty so the engine can fill them from __node.
219
- selector: '',
220
- html: '',
221
- summary: 'Missing alt attribute on &lt;area&gt;.',
222
- hint: 'Add an alt attribute (use alt="" only for decorative areas).',
223
- i18n: {
224
- summaryKey: 'area_altPresent_summary_fail',
225
- hintKey: 'area_altPresent_hint_fail',
226
- params: { element: 'area' }
227
- },
228
- data: {
229
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
230
- }
231
- };
258
+ const baseOccurrence = hasAltAttr
259
+ ? {
260
+ // Leave selector/html empty so the engine can fill them from __node.
261
+ selector: '',
262
+ html: '',
263
+ summary: 'Empty alt attribute leaves &lt;area&gt; link with no accessible name.',
264
+ hint: 'Describe the link destination in alt, or add aria-label/aria-labelledby (an <area> cannot be decorative once its map is used).',
265
+ i18n: {
266
+ summaryKey: 'area_altPresent_summary_fail_empty',
267
+ hintKey: 'area_altPresent_hint_fail_empty',
268
+ params: { element: 'area' }
269
+ },
270
+ data: {
271
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
272
+ }
273
+ }
274
+ : {
275
+ selector: '',
276
+ html: '',
277
+ summary: 'Missing alt attribute on &lt;area&gt;.',
278
+ hint: 'Add an alt attribute describing the link destination (an <area> cannot be decorative).',
279
+ i18n: {
280
+ summaryKey: 'area_altPresent_summary_fail',
281
+ hintKey: 'area_altPresent_hint_fail',
282
+ params: { element: 'area' }
283
+ },
284
+ data: {
285
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
286
+ }
287
+ };
232
288
 
233
289
  if (helpers && typeof helpers.reportOccurrence === 'function') {
234
290
  occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
@@ -113,13 +113,28 @@ function runInPage(ctx) {
113
113
  nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : ''
114
114
  );
115
115
  const name = programmaticName || getConservativeSubtreeText(el);
116
- if (!name) missing.push({ attr: 'aria-braillelabel', requires: 'an accessible name' });
116
+ if (!name)
117
+ missing.push({
118
+ attr: 'aria-braillelabel',
119
+ requires: 'an accessible name',
120
+ messageKey: 'label',
121
+ summary:
122
+ 'This element has aria-braillelabel but no accessible name, its non-braille equivalent.',
123
+ hint: 'aria-braillelabel is a Braille-specific supplement, not a replacement, so also give the element an accessible name (for example visible text, aria-label or aria-labelledby).'
124
+ });
117
125
  }
118
126
 
119
127
  if (brailleRoleDesc) {
120
128
  const roleDesc = trim(el.getAttribute('aria-roledescription'));
121
129
  if (!roleDesc)
122
- missing.push({ attr: 'aria-brailleroledescription', requires: 'aria-roledescription' });
130
+ missing.push({
131
+ attr: 'aria-brailleroledescription',
132
+ requires: 'aria-roledescription',
133
+ messageKey: 'roleDescription',
134
+ summary:
135
+ 'This element has aria-brailleroledescription but not aria-roledescription, its non-braille equivalent.',
136
+ hint: 'aria-brailleroledescription is a Braille-specific supplement, not a replacement, so also provide aria-roledescription.'
137
+ });
123
138
  }
124
139
 
125
140
  if (!missing.length) continue;
@@ -129,12 +144,15 @@ function runInPage(ctx) {
129
144
  for (const m of missing) {
130
145
  occurrences.push(
131
146
  helpers.reportOccurrence(el, {
132
- summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
133
- hint: `${m.attr} is a Braille-specific supplement, not a replacement, so also provide ${m.requires}.`,
147
+ // One message per missing equivalent rather than a shared template:
148
+ // `requires` is English prose for the label case, and interpolating
149
+ // it put English into every translated sentence.
150
+ summary: m.summary,
151
+ hint: m.hint,
134
152
  i18n: {
135
- summaryKey: 'ariaBrailleEquivalent_summary_fail',
136
- hintKey: 'ariaBrailleEquivalent_hint_fail',
137
- params: { element: tag, attr: m.attr, requires: m.requires }
153
+ summaryKey: `ariaBrailleEquivalent_summary_fail_${m.messageKey}`,
154
+ hintKey: `ariaBrailleEquivalent_hint_fail_${m.messageKey}`,
155
+ params: { element: tag, attr: m.attr }
138
156
  },
139
157
  uncertainty: {
140
158
  code: 'spec-only',