@surea11y/core 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-no-duplicate-contentinfo
5
7
  * @atomic true
6
8
  * @summary A page must not have more than one contentinfo landmark
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever the page contains at least one contentinfo landmark
10
12
  * (explicit role="contentinfo", or an implicit, non-nested <footer>).
@@ -16,18 +18,16 @@
16
18
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
17
19
  * header comment for the shared rationale/precedent.
18
20
  * - Only landmarks actually exposed to assistive technology can collide —
19
- * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
20
- * its source directly: `query_selector_all_filter_default(..., elm =>
21
- * _isVisibleToScreenReaders(elm))`) — same fix applied to the sibling
22
- * banner/main rules after finding real hidden-duplicate false positives
23
- * on Trello and Zoom.
21
+ * same as the sibling banner/main rules, avoiding hidden-duplicate false
22
+ * positives.
24
23
  */
25
24
 
26
25
  const id = 'landmark-no-duplicate-contentinfo';
27
26
 
28
27
  const meta = {
29
28
  title: 'Page must not have more than one contentinfo landmark',
30
- description: 'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
29
+ description:
30
+ 'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
31
31
  i18n: {
32
32
  titleKey: 'landmarkNoDuplicateContentinfo_title',
33
33
  descriptionKey: 'landmarkNoDuplicateContentinfo_description'
@@ -47,14 +47,15 @@ function runInPage(ctx) {
47
47
  const { document, helpers, rule } = ctx;
48
48
 
49
49
  function normalizeWs(s) {
50
- return String(s || '').replace(/\s+/g, ' ').trim();
50
+ return String(s || '')
51
+ .replace(/\s+/g, ' ')
52
+ .trim();
51
53
  }
52
54
 
53
55
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
54
56
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
55
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
56
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
57
- // copy of this logic.
57
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
58
+ // the title-attribute fallback consistent across the landmark rules.
58
59
  function getAccessibleLandmarkName(el) {
59
60
  try {
60
61
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -77,10 +78,8 @@ function runInPage(ctx) {
77
78
  // (an ancestor's bare TAG only counts when it carries no role attribute
78
79
  // at all; an explicit role="dialog"-style override no longer suppresses)
79
80
  // rather than a local tag-only copy. See that function's header comment
80
- // in src/core/aria-helpers.js for the full algorithm and the real page
81
- // (handsontable.com's docs-assistant side panel, an
82
- // <aside role="dialog"> containing its own <header>) that surfaced this
83
- // rule's own former tag-only copy as a false negative.
81
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
82
+ // <aside role="dialog"> containing its own <header>.
84
83
  function hasSectioningAncestor(el, includeMain) {
85
84
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
86
85
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -94,11 +93,9 @@ function runInPage(ctx) {
94
93
  if (tag === 'main') return 'main';
95
94
  if (tag === 'nav') return 'navigation';
96
95
  if (tag === 'aside') {
97
- // A named <aside> is never suppressed, even when nested — matches
98
- // landmark-unique's own verified-against-reference-engine precedent
99
- // (that engine's real `aside` implicit-role function keeps
100
- // "complementary" when the element has an accessible name, even
101
- // inside sectioning content); propagated here for consistency.
96
+ // A named <aside> is never suppressed, even when nested — it keeps
97
+ // "complementary" when it has an accessible name, even inside
98
+ // sectioning content. Matches landmark-unique's precedent.
102
99
  if (!hasSectioningAncestor(el, false)) return 'complementary';
103
100
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
104
101
  }
@@ -107,7 +104,16 @@ function runInPage(ctx) {
107
104
  return '';
108
105
  }
109
106
 
110
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
107
+ const LANDMARK_ROLES = new Set([
108
+ 'banner',
109
+ 'contentinfo',
110
+ 'main',
111
+ 'navigation',
112
+ 'complementary',
113
+ 'region',
114
+ 'form',
115
+ 'search'
116
+ ]);
111
117
 
112
118
  function getLandmarkRole(el) {
113
119
  if (!el || !el.getAttribute) return '';
@@ -117,19 +123,20 @@ function runInPage(ctx) {
117
123
  }
118
124
 
119
125
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
120
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
121
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
122
- // invisible to a light-DOM-only query.
123
- let nodes = [];
126
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
127
+ // widget's own landmark is invisible to a light-DOM-only query.
128
+ let nodes;
124
129
  try {
125
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
126
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
127
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
130
+ nodes =
131
+ helpers && typeof helpers.queryAllSmart === 'function'
132
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
133
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
128
134
  } catch {
129
135
  nodes = [];
130
136
  }
131
137
 
132
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
138
+ const isAccTreeEligible =
139
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
133
140
 
134
141
  function isExposedToAt(el) {
135
142
  if (!isAccTreeEligible) return true;
@@ -157,7 +164,7 @@ function runInPage(ctx) {
157
164
 
158
165
  const occurrences = contentinfos.map((el) => {
159
166
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
160
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
167
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
161
168
 
162
169
  return {
163
170
  selector: stableSelector,
@@ -175,7 +182,12 @@ function runInPage(ctx) {
175
182
  };
176
183
  });
177
184
 
178
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
185
+ return {
186
+ ruleId: rule.ruleId,
187
+ outcome: 'cantTell',
188
+ severity: rule.defaultSeverity || 'minor',
189
+ occurrences
190
+ };
179
191
  }
180
192
 
181
193
  module.exports = { id, meta, runInPage };
@@ -1,29 +1,27 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-no-duplicate-main
5
7
  * @atomic true
6
8
  * @summary A page must not have more than one main landmark
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever the page contains at least one main landmark
10
12
  * (explicit role="main", or an implicit <main>).
11
13
  * @expectation
12
14
  * At most one main landmark exists on the page. Distinct, atomic
13
15
  * decision from landmark-one-main (that rule flags zero
14
- * mains too; this one only flags more than one) — matches a widely-used
15
- * reference engine shipping both as separate rules with some overlap by design.
16
+ * mains too; this one only flags more than one).
16
17
  * @implementation-notes
17
18
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
19
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
20
  * header comment for the shared rationale/precedent.
20
- * - Only landmarks actually exposed to assistive technology can collide —
21
- * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
22
- * its source directly: `query_selector_all_filter_default(..., elm =>
23
- * _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
24
- * rendering both a visible and a CSS-hidden duplicate `<main>` (found on
25
- * a real site — Zoom's homepage) was wrongly flagged as a duplicate
26
- * landmark; the hidden copy is never actually reachable by AT.
21
+ * - Only landmarks actually exposed to assistive technology can collide.
22
+ * Without this, a responsive layout rendering both a visible and a
23
+ * CSS-hidden duplicate `<main>` is flagged as a duplicate landmark even
24
+ * though the hidden copy is never reachable by AT.
27
25
  */
28
26
 
29
27
  const id = 'landmark-no-duplicate-main';
@@ -50,7 +48,9 @@ function runInPage(ctx) {
50
48
  const { document, helpers, rule } = ctx;
51
49
 
52
50
  function normalizeWs(s) {
53
- return String(s || '').replace(/\s+/g, ' ').trim();
51
+ return String(s || '')
52
+ .replace(/\s+/g, ' ')
53
+ .trim();
54
54
  }
55
55
 
56
56
  function getExplicitRoleToken(el) {
@@ -68,19 +68,20 @@ function runInPage(ctx) {
68
68
  }
69
69
 
70
70
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
71
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
72
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
73
- // invisible to a light-DOM-only query.
74
- let nodes = [];
71
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
72
+ // widget's own landmark is invisible to a light-DOM-only query.
73
+ let nodes;
75
74
  try {
76
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
77
- ? helpers.queryAllSmart('main, [role]')
78
- : document.querySelectorAll('main, [role]');
75
+ nodes =
76
+ helpers && typeof helpers.queryAllSmart === 'function'
77
+ ? helpers.queryAllSmart('main, [role]')
78
+ : document.querySelectorAll('main, [role]');
79
79
  } catch {
80
80
  nodes = [];
81
81
  }
82
82
 
83
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
83
+ const isAccTreeEligible =
84
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
84
85
 
85
86
  function isExposedToAt(el) {
86
87
  if (!isAccTreeEligible) return true;
@@ -108,7 +109,7 @@ function runInPage(ctx) {
108
109
 
109
110
  const occurrences = mains.map((el) => {
110
111
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
111
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
112
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
112
113
 
113
114
  return {
114
115
  selector: stableSelector,
@@ -126,7 +127,12 @@ function runInPage(ctx) {
126
127
  };
127
128
  });
128
129
 
129
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
130
+ return {
131
+ ruleId: rule.ruleId,
132
+ outcome: 'cantTell',
133
+ severity: rule.defaultSeverity || 'minor',
134
+ occurrences
135
+ };
130
136
  }
131
137
 
132
138
  module.exports = { id, meta, runInPage };
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-one-main
5
7
  * @atomic true
6
8
  * @summary The page should have a main landmark
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Always applicable to any HTML document with a <body> element —
10
12
  * "does the page have a main landmark" is a whole-page concern,
@@ -18,27 +20,15 @@
18
20
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
21
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
20
22
  * header comment for the shared rationale/precedent.
21
- * - Presence-only, matching a widely-used reference engine's real
22
- * `landmark-one-main` scope exactly (confirmed 2026-07-22 by reading that
23
- * engine's source directly: its `page-has-main` check is a plain
24
- * descendant-exists test, `has-descendant-evaluate` — it does NOT flag
25
- * more than one). This rule previously ALSO flagged "more than one main,"
26
- * which was both a real scope mismatch against that reference engine (it
27
- * ships that as a fully separate rule, `landmark-no-duplicate-main` /
28
- * `page-no-duplicate-main`, already correctly implemented here as
29
- * `landmark-no-duplicate-main`) and missing that sibling rule's
30
- * accessibility-tree visibility filter, so it double-flagged cases the
31
- * sibling rule already handles correctly — found via a real page
32
- * (2026-07-22, live-DOM corpus): Resy's and DuckDuckGo's homepages each
33
- * genuinely have two visible `<main>` elements, which that reference
34
- * engine's `landmark-one-main` doesn't flag at all (out of its scope) but
35
- * this rule wrongly did, disagreeing with it for a
36
- * reason that wasn't a real coverage gap on either side — just a
37
- * redundant, incorrectly-scoped extra branch here.
23
+ * - Presence-only: a plain descendant-exists test. It does NOT flag more
24
+ * than one main. "More than one main" is a separate rule,
25
+ * `landmark-no-duplicate-main`, already implemented — deliberately not
26
+ * duplicated here, since a page can genuinely have two visible `<main>`
27
+ * elements, which is out of scope for "does a main landmark exist," not
28
+ * a violation this rule should report.
38
29
  * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
39
30
  * display:none/inert elements don't count as "a main landmark exists"),
40
- * matching `landmark-no-duplicate-main`'s own precedent and
41
- * that reference engine's own accessibility-tree-scoped matching.
31
+ * matching `landmark-no-duplicate-main`'s own precedent.
42
32
  */
43
33
 
44
34
  const id = 'landmark-one-main';
@@ -78,7 +68,7 @@ function runInPage(ctx) {
78
68
  }
79
69
 
80
70
  function getExplicitRoleToken(el) {
81
- const raw = (el.getAttribute && el.getAttribute('role') || '').trim();
71
+ const raw = ((el.getAttribute && el.getAttribute('role')) || '').trim();
82
72
  if (!raw) return '';
83
73
  return raw.split(/\s+/)[0].toLowerCase();
84
74
  }
@@ -91,7 +81,8 @@ function runInPage(ctx) {
91
81
  return tag === 'main';
92
82
  }
93
83
 
94
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
84
+ const isAccTreeEligible =
85
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
95
86
 
96
87
  function isExposedToAt(el) {
97
88
  if (!isAccTreeEligible) return true;
@@ -105,14 +96,14 @@ function runInPage(ctx) {
105
96
  }
106
97
 
107
98
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
108
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
109
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
110
- // invisible to a light-DOM-only query.
111
- let nodes = [];
99
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
100
+ // widget's own landmark is invisible to a light-DOM-only query.
101
+ let nodes;
112
102
  try {
113
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
114
- ? helpers.queryAllSmart('main, [role]')
115
- : document.querySelectorAll('main, [role]');
103
+ nodes =
104
+ helpers && typeof helpers.queryAllSmart === 'function'
105
+ ? helpers.queryAllSmart('main, [role]')
106
+ : document.querySelectorAll('main, [role]');
116
107
  } catch {
117
108
  nodes = [];
118
109
  }
@@ -133,26 +124,30 @@ function runInPage(ctx) {
133
124
  }
134
125
 
135
126
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
136
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(body) : (body.outerHTML || '').slice(0, 200);
127
+ const html = helpers.getOuterHtmlSnippet
128
+ ? helpers.getOuterHtmlSnippet(body)
129
+ : (body.outerHTML || '').slice(0, 200);
137
130
 
138
131
  return {
139
132
  ruleId: rule.ruleId,
140
133
  outcome: 'cantTell',
141
134
  severity: rule.defaultSeverity || 'minor',
142
- occurrences: [{
143
- selector: stableSelector,
144
- html,
145
- summary: 'This page has no main landmark.',
146
- hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
147
- i18n: {
148
- summaryKey: 'landmarkOneMain_summary_cantTell_missing',
149
- hintKey: 'landmarkOneMain_hint_cantTell_missing',
150
- params: {}
151
- },
152
- data: {
153
- details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
135
+ occurrences: [
136
+ {
137
+ selector: stableSelector,
138
+ html,
139
+ summary: 'This page has no main landmark.',
140
+ hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
141
+ i18n: {
142
+ summaryKey: 'landmarkOneMain_summary_cantTell_missing',
143
+ hintKey: 'landmarkOneMain_hint_cantTell_missing',
144
+ params: {}
145
+ },
146
+ data: {
147
+ details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
148
+ }
154
149
  }
155
- }]
150
+ ]
156
151
  };
157
152
  }
158
153
 
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-unique
5
7
  * @atomic true
6
8
  * @summary Landmarks sharing the same role must have unique accessible names
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies whenever two or more landmark regions on the page share the
10
12
  * same landmark role (banner, contentinfo, main, navigation,
@@ -31,7 +33,8 @@ const id = 'landmark-unique';
31
33
 
32
34
  const meta = {
33
35
  title: 'Landmarks with the same role must have unique names',
34
- description: 'Checks that when two or more landmarks share the same role, each has a distinct accessible name.',
36
+ description:
37
+ 'Checks that when two or more landmarks share the same role, each has a distinct accessible name.',
35
38
  i18n: {
36
39
  titleKey: 'landmarkUnique_title',
37
40
  descriptionKey: 'landmarkUnique_description'
@@ -51,14 +54,14 @@ function runInPage(ctx) {
51
54
  const { document, helpers, rule } = ctx;
52
55
 
53
56
  function normalizeWs(s) {
54
- return String(s || '').replace(/\s+/g, ' ').trim();
57
+ return String(s || '')
58
+ .replace(/\s+/g, ' ')
59
+ .trim();
55
60
  }
56
61
 
57
62
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
58
63
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
59
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
60
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
61
- // copy of this logic.
64
+ // local copy -- see that function's header comment in src/core/dom-helpers.js.
62
65
  function getAccessibleLandmarkName(el) {
63
66
  try {
64
67
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -78,27 +81,21 @@ function runInPage(ctx) {
78
81
  // Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
79
82
  // an ancestor's bare TAG only counts when it carries no role attribute at
80
83
  // all; an explicit role="dialog"-style override no longer suppresses —
81
- // see that function's header comment in src/core/aria-helpers.js) rather
82
- // than the two local tag-only Sets this file used to carry. Two distinct
83
- // ancestor scopes, verified 2026-07-20 against a widely-used reference
84
- // engine's own implicit-role functions directly rather than assumed from
85
- // one shared list: <header>/<footer> use "sectioning content PLUS <main>"
86
- // (includeMain: true) to decide banner/contentinfo suppression, but
87
- // <aside> uses PLAIN sectioning content only — NOT main (includeMain:
88
- // false) — to decide complementary suppression. The old single
89
- // SECTIONING_ANCESTORS set (which included 'main') was correct for
90
- // header/footer but wrong for aside — found via a real page: Know Your
91
- // Meme's two unnamed <aside class="extra-large-only"> elements are direct
92
- // children of <main>, which incorrectly suppressed their implicit
93
- // "complementary" role entirely, hiding a real duplicate-landmark
94
- // violation that reference engine correctly flags. The tag-only
95
- // (non-role-aware) half of this bug was separately found and fixed
96
- // 2026-07-30 via the cross-engine comparisons project, on
97
- // handsontable.com's docs-assistant side panel: an <aside role="dialog">
98
- // containing its own <header> — role="dialog" isn't one of the four
99
- // scoping roles, so the nested <header> keeps "banner" per spec, but a
100
- // tag-only check unconditionally suppressed it just because the ancestor
101
- // TAG was <aside>.
84
+ // see that function's header comment in src/core/aria-helpers.js), using
85
+ // two distinct ancestor scopes rather than one shared list: <header>/
86
+ // <footer> use "sectioning content PLUS <main>" (includeMain: true) to
87
+ // decide banner/contentinfo suppression, but <aside> uses PLAIN
88
+ // sectioning content only — NOT main (includeMain: false) — to decide
89
+ // complementary suppression. A single shared sectioning-ancestors set
90
+ // that includes 'main' is correct for header/footer but wrong for aside:
91
+ // e.g. two unnamed <aside> elements that are direct children of <main>
92
+ // would have their implicit "complementary" role incorrectly suppressed,
93
+ // hiding a real duplicate-landmark violation. The role-aware half matters
94
+ // too: e.g. an <aside role="dialog"> containing its own <header> —
95
+ // role="dialog" isn't one of the four scoping roles, so the nested
96
+ // <header> keeps "banner" per spec, but a tag-only (non-role-aware)
97
+ // check would unconditionally suppress it just because the ancestor TAG
98
+ // was <aside>.
102
99
  function hasSectioningAncestor(el, includeMain) {
103
100
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
104
101
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -112,10 +109,9 @@ function runInPage(ctx) {
112
109
  if (tag === 'main') return 'main';
113
110
  if (tag === 'nav') return 'navigation';
114
111
  if (tag === 'aside') {
115
- // Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
116
- // sectioning-content ancestor ONLY when the <aside> also has no
117
- // accessible name — a named <aside> is never suppressed, even when
118
- // nested.
112
+ // An <aside> is suppressed by a sectioning-content ancestor ONLY
113
+ // when it also has no accessible name — a named <aside> is never
114
+ // suppressed, even when nested.
119
115
  if (!hasSectioningAncestor(el, false)) return 'complementary';
120
116
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
121
117
  }
@@ -124,7 +120,16 @@ function runInPage(ctx) {
124
120
  return '';
125
121
  }
126
122
 
127
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
123
+ const LANDMARK_ROLES = new Set([
124
+ 'banner',
125
+ 'contentinfo',
126
+ 'main',
127
+ 'navigation',
128
+ 'complementary',
129
+ 'region',
130
+ 'form',
131
+ 'search'
132
+ ]);
128
133
 
129
134
  function getLandmarkRole(el) {
130
135
  if (!el || !el.getAttribute) return '';
@@ -134,15 +139,12 @@ function runInPage(ctx) {
134
139
  // <form>/<section> only count as landmarks when they have an
135
140
  // accessible name — a property of the ELEMENT, not of how the role
136
141
  // got there. This applies whether the role is implicit (already
137
- // handled in getImplicitLandmarkRole below) or explicit, but an
138
- // explicit role bypassed the check entirely before this fix. Verified
139
- // against a widely-used reference engine's isLandmarkVirtual (checks
140
- // nodeName === 'section' || 'form' unconditionally, regardless of role source) and the W3C
142
+ // handled in getImplicitLandmarkRole below) or explicit. Per the W3C
141
143
  // ARIA-in-HTML spec ("a form is not exposed as a landmark region
142
- // unless it has been provided an accessible name"). Found via a real
143
- // page: europa.eu's unnamed <form role="search"> nested inside an
144
- // unnamed <div role="search"> was wrongly counted as a second
145
- // distinct "search" landmark.
144
+ // unless it has been provided an accessible name"). Otherwise an
145
+ // unnamed <form role="search"> nested inside an unnamed
146
+ // <div role="search"> gets wrongly counted as a second distinct
147
+ // "search" landmark.
146
148
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
147
149
  if (tag === 'form' || tag === 'section') {
148
150
  return getAccessibleLandmarkName(el) ? explicit : '';
@@ -152,7 +154,8 @@ function runInPage(ctx) {
152
154
  return getImplicitLandmarkRole(el);
153
155
  }
154
156
 
155
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
157
+ const isAccTreeEligible =
158
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
156
159
 
157
160
  function isExposedToAt(el) {
158
161
  if (!isAccTreeEligible) return true;
@@ -166,30 +169,26 @@ function runInPage(ctx) {
166
169
  }
167
170
 
168
171
  // queryAllSmart (shadow-DOM-aware, includeShadowDom defaults true) instead of a plain
169
- // document.querySelectorAll -- a real page (Airtable's homepage, 2026-07-23) has a
170
- // third-party Transcend cookie-consent widget rendering its own unnamed <nav>/<footer>
171
- // inside a shadow root (#transcend-shadow-root), which a widely-used reference engine's
172
- // own landmark-unique (a real browser DOM, shadow roots included by design) correctly sees as colliding
173
- // with the page's own unnamed header <nav>/page <footer> -- a real, confirmed surea11y
174
- // false-negative miss, invisible to plain querySelectorAll's light-DOM-only reach.
175
- let nodes = [];
172
+ // document.querySelectorAll -- a third-party widget rendering its own
173
+ // unnamed <nav>/<footer> inside a shadow root collides with the page's
174
+ // own unnamed header <nav>/page <footer>, but is invisible to plain
175
+ // querySelectorAll's light-DOM-only reach.
176
+ let nodes;
176
177
  try {
177
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
178
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
179
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
178
+ nodes =
179
+ helpers && typeof helpers.queryAllSmart === 'function'
180
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
181
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
180
182
  } catch {
181
183
  nodes = [];
182
184
  }
183
185
 
184
- // Only landmarks actually exposed to assistive technology can collide —
185
- // matches a widely-used reference engine's own `landmarkUniqueMatches` gate
186
- // (`_isVisibleToScreenReaders`), confirmed by reading its source
187
- // directly. Without this, responsive layouts that render both a
188
- // desktop and a mobile copy of the same named nav (one hidden via CSS
189
- // at any given viewport — found on real sites: BuzzFeed, Kraken,
190
- // weather.com) were wrongly flagged as duplicate landmarks, since the
191
- // hidden copy is never actually reachable by AT and can't really
192
- // collide with the visible one.
186
+ // Only landmarks actually exposed to assistive technology can collide.
187
+ // Without this, responsive layouts that render both a desktop and a
188
+ // mobile copy of the same named nav (one hidden via CSS at any given
189
+ // viewport) are wrongly flagged as duplicate landmarks, since the hidden
190
+ // copy is never reachable by AT and can't really collide with the
191
+ // visible one.
193
192
  const byRole = new Map(); // role -> [{el, name}]
194
193
  const seen = new Set();
195
194
  for (const el of nodes) {
@@ -221,24 +220,31 @@ function runInPage(ctx) {
221
220
 
222
221
  for (const { el } of group) {
223
222
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
224
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
223
+ const html = helpers.getOuterHtmlSnippet
224
+ ? helpers.getOuterHtmlSnippet(el)
225
+ : el.outerHTML || '';
225
226
 
226
227
  occurrences.push({
227
228
  selector: stableSelector,
228
229
  html,
229
230
  summary: normalizedName
230
- ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
231
- : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
231
+ ? `This ${role} landmark shares its accessible name with another ${role} landmark.`
232
+ : `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
232
233
  hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
233
234
  i18n: {
234
235
  summaryKey: normalizedName
235
- ? 'landmarkUnique_summary_cantTell_duplicateName'
236
- : 'landmarkUnique_summary_cantTell_bothUnnamed',
236
+ ? 'landmarkUnique_summary_cantTell_duplicateName'
237
+ : 'landmarkUnique_summary_cantTell_bothUnnamed',
237
238
  hintKey: 'landmarkUnique_hint_cantTell',
238
239
  params: { role }
239
240
  },
240
241
  data: {
241
- details: { reasonCode: 'LANDMARK_NOT_UNIQUE', role, name: normalizedName, groupSize: group.length }
242
+ details: {
243
+ reasonCode: 'LANDMARK_NOT_UNIQUE',
244
+ role,
245
+ name: normalizedName,
246
+ groupSize: group.length
247
+ }
242
248
  }
243
249
  });
244
250
  }
@@ -248,7 +254,12 @@ function runInPage(ctx) {
248
254
  if (!occurrences.length) {
249
255
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
250
256
  }
251
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
257
+ return {
258
+ ruleId: rule.ruleId,
259
+ outcome: 'cantTell',
260
+ severity: rule.defaultSeverity || 'minor',
261
+ occurrences
262
+ };
252
263
  }
253
264
 
254
265
  module.exports = { id, meta, runInPage };