@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,17 +1,19 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-contentinfo-is-top-level
5
7
  * @atomic true
6
8
  * @summary The contentinfo landmark must not be nested inside another 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
- * Applies whenever the page contains at least one contentinfo landmark
10
- * (explicit role="contentinfo", or an implicit <footer> that is not
11
- * itself nested inside <article>/<aside>/<main>/<nav>/<section> — see
12
- * implementation notes).
11
+ * Applies whenever the page contains at least one contentinfo
12
+ * candidate: explicit role="contentinfo", OR a <footer> with NO role
13
+ * attribute at all, regardless of nesting (see implementation notes on
14
+ * why candidate selection is deliberately unconditional).
13
15
  * @expectation
14
- * No contentinfo landmark has an ancestor that is itself any landmark
16
+ * No contentinfo candidate has an ancestor that is itself any landmark
15
17
  * region. A contentinfo nested inside another landmark is not a
16
18
  * top-level, whole-page footer region and confuses landmark-based
17
19
  * navigation for assistive technology users.
@@ -20,13 +22,20 @@
20
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
23
  * header comment for the shared rationale/precedent (this rule mirrors
22
24
  * its structure with contentinfo/footer in place of banner/header).
25
+ * - Candidate selection (`isContentinfoCandidate` below) is deliberately
26
+ * unconditional, instead of reusing the sectioning-ancestor suppression
27
+ * that the violation check itself depends on — same
28
+ * self-defeating-candidate-selection reasoning as
29
+ * landmark-banner-is-top-level (see that file's header comment for the
30
+ * full root cause).
23
31
  */
24
32
 
25
33
  const id = 'landmark-contentinfo-is-top-level';
26
34
 
27
35
  const meta = {
28
36
  title: 'Contentinfo landmark must be top-level',
29
- description: 'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
37
+ description:
38
+ 'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
30
39
  i18n: {
31
40
  titleKey: 'landmarkContentinfoIsTopLevel_title',
32
41
  descriptionKey: 'landmarkContentinfoIsTopLevel_description'
@@ -46,14 +55,15 @@ function runInPage(ctx) {
46
55
  const { document, root, helpers, rule } = ctx;
47
56
 
48
57
  function normalizeWs(s) {
49
- return String(s || '').replace(/\s+/g, ' ').trim();
58
+ return String(s || '')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
50
61
  }
51
62
 
52
63
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
53
64
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
54
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
55
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
56
- // copy of this logic.
65
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
66
+ // the title-attribute fallback consistent across the landmark rules.
57
67
  function getAccessibleLandmarkName(el) {
58
68
  try {
59
69
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -76,10 +86,8 @@ function runInPage(ctx) {
76
86
  // (an ancestor's bare TAG only counts when it carries no role attribute
77
87
  // at all; an explicit role="dialog"-style override no longer suppresses)
78
88
  // rather than a local tag-only copy. See that function's header comment
79
- // in src/core/aria-helpers.js for the full algorithm and the real page
80
- // (handsontable.com's docs-assistant side panel, an
81
- // <aside role="dialog"> containing its own <header>) that surfaced this
82
- // rule's own former tag-only copy as a false negative.
89
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
90
+ // <aside role="dialog"> containing its own <header>.
83
91
  function hasSectioningAncestor(el, includeMain) {
84
92
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
85
93
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -93,11 +101,9 @@ function runInPage(ctx) {
93
101
  if (tag === 'main') return 'main';
94
102
  if (tag === 'nav') return 'navigation';
95
103
  if (tag === 'aside') {
96
- // A named <aside> is never suppressed, even when nested — matches
97
- // landmark-unique's own verified-against-reference-engine precedent
98
- // (that engine's real `aside` implicit-role function keeps
99
- // "complementary" when the element has an accessible name, even
100
- // inside sectioning content); propagated here for consistency.
104
+ // A named <aside> is never suppressed, even when nested — it keeps
105
+ // "complementary" when it has an accessible name, even inside
106
+ // sectioning content. Matches landmark-unique's precedent.
101
107
  if (!hasSectioningAncestor(el, false)) return 'complementary';
102
108
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
103
109
  }
@@ -106,7 +112,16 @@ function runInPage(ctx) {
106
112
  return '';
107
113
  }
108
114
 
109
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
115
+ const LANDMARK_ROLES = new Set([
116
+ 'banner',
117
+ 'contentinfo',
118
+ 'main',
119
+ 'navigation',
120
+ 'complementary',
121
+ 'region',
122
+ 'form',
123
+ 'search'
124
+ ]);
110
125
 
111
126
  function getLandmarkRole(el) {
112
127
  if (!el || !el.getAttribute) return '';
@@ -115,8 +130,20 @@ function runInPage(ctx) {
115
130
  return getImplicitLandmarkRole(el);
116
131
  }
117
132
 
133
+ // Candidate selection is deliberately NOT the same as getLandmarkRole()
134
+ // === 'contentinfo' — see the header comment above. A <footer> is
135
+ // a candidate purely by tag + absence of any role attribute, independent
136
+ // of whether sectioning-ancestor nesting would currently suppress its
137
+ // implicit role; an explicit role="contentinfo" is always a candidate too.
138
+ function isContentinfoCandidate(el) {
139
+ if (!el || !el.getAttribute) return false;
140
+ const explicit = getExplicitRoleToken(el);
141
+ if (explicit) return explicit === 'contentinfo';
142
+ return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
143
+ }
144
+
118
145
  function hasLandmarkAncestor(el) {
119
- const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
146
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
120
147
  let p = el.parentElement;
121
148
  while (p) {
122
149
  if (getLandmarkRole(p)) return true;
@@ -129,14 +156,14 @@ function runInPage(ctx) {
129
156
  }
130
157
 
131
158
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
132
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
133
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
134
- // invisible to a light-DOM-only query.
135
- let nodes = [];
159
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
160
+ // widget's own landmark is invisible to a light-DOM-only query.
161
+ let nodes;
136
162
  try {
137
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
138
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
139
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
163
+ nodes =
164
+ helpers && typeof helpers.queryAllSmart === 'function'
165
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
166
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
140
167
  } catch {
141
168
  nodes = [];
142
169
  }
@@ -146,7 +173,28 @@ function runInPage(ctx) {
146
173
  for (const el of nodes) {
147
174
  if (!el || seen.has(el)) continue;
148
175
  seen.add(el);
149
- if (getLandmarkRole(el) === 'contentinfo') contentinfos.push(el);
176
+ if (!isContentinfoCandidate(el)) continue;
177
+
178
+ // An aria-hidden contentinfo candidate is removed from the
179
+ // accessibility tree entirely -- it isn't part of the landmark
180
+ // structure assistive technology users navigate at all, so it
181
+ // shouldn't be flagged as "nested inside another landmark" (there's
182
+ // no real landmark there to begin with, from AT's perspective).
183
+ // queryAllSmart's default hidden-content policy only excludes "hard"
184
+ // CSS-based hiding (display:none, etc.), not the softer aria-hidden
185
+ // exclusion, so this needs its own check.
186
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
187
+ const elig = (() => {
188
+ try {
189
+ return helpers.isAccTreeEligible(el, ctx);
190
+ } catch {
191
+ return { eligible: true, reasons: [] };
192
+ }
193
+ })();
194
+ if (elig && elig.eligible === false) continue;
195
+ }
196
+
197
+ contentinfos.push(el);
150
198
  }
151
199
 
152
200
  if (contentinfos.length === 0) {
@@ -158,7 +206,7 @@ function runInPage(ctx) {
158
206
  if (!hasLandmarkAncestor(el)) continue;
159
207
 
160
208
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
161
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
209
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
162
210
 
163
211
  occurrences.push({
164
212
  selector: stableSelector,
@@ -177,7 +225,12 @@ function runInPage(ctx) {
177
225
  }
178
226
 
179
227
  if (occurrences.length) {
180
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
228
+ return {
229
+ ruleId: rule.ruleId,
230
+ outcome: 'cantTell',
231
+ severity: rule.defaultSeverity || 'minor',
232
+ occurrences
233
+ };
181
234
  }
182
235
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
183
236
  }
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check landmark-main-is-top-level
5
7
  * @atomic true
6
8
  * @summary The main landmark must not be nested inside another 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> element).
@@ -18,13 +20,21 @@
18
20
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
21
  * header comment for the shared rationale/precedent (this rule mirrors
20
22
  * its structure with main in place of banner/header).
23
+ * - Unlike landmark-banner-is-top-level/landmark-contentinfo-is-top-level
24
+ * (see that file's header comment), candidate selection here doesn't
25
+ * need to be unconditional: `<main>`'s implicit role is unconditional
26
+ * per HTML-AAM — unlike `<header>`/`<footer>`, nesting never suppresses
27
+ * it — so `getImplicitLandmarkRole`'s `main` branch is never subject to
28
+ * the self-defeating candidate-selection problem those two rules guard
29
+ * against.
21
30
  */
22
31
 
23
32
  const id = 'landmark-main-is-top-level';
24
33
 
25
34
  const meta = {
26
35
  title: 'Main landmark must be top-level',
27
- description: 'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
36
+ description:
37
+ 'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
28
38
  i18n: {
29
39
  titleKey: 'landmarkMainIsTopLevel_title',
30
40
  descriptionKey: 'landmarkMainIsTopLevel_description'
@@ -44,14 +54,15 @@ function runInPage(ctx) {
44
54
  const { document, root, helpers, rule } = ctx;
45
55
 
46
56
  function normalizeWs(s) {
47
- return String(s || '').replace(/\s+/g, ' ').trim();
57
+ return String(s || '')
58
+ .replace(/\s+/g, ' ')
59
+ .trim();
48
60
  }
49
61
 
50
62
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
51
63
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
52
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
53
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
54
- // copy of this logic.
64
+ // local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
65
+ // the title-attribute fallback consistent across the landmark rules.
55
66
  function getAccessibleLandmarkName(el) {
56
67
  try {
57
68
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -74,10 +85,8 @@ function runInPage(ctx) {
74
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
75
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
76
87
  // rather than a local tag-only copy. See that function's header comment
77
- // in src/core/aria-helpers.js for the full algorithm and the real page
78
- // (handsontable.com's docs-assistant side panel, an
79
- // <aside role="dialog"> containing its own <header>) that surfaced this
80
- // rule's own former tag-only copy as a false negative.
88
+ // in src/core/aria-helpers.js for the full algorithm. Example: an
89
+ // <aside role="dialog"> containing its own <header>.
81
90
  function hasSectioningAncestor(el, includeMain) {
82
91
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
83
92
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -91,11 +100,9 @@ function runInPage(ctx) {
91
100
  if (tag === 'main') return 'main';
92
101
  if (tag === 'nav') return 'navigation';
93
102
  if (tag === 'aside') {
94
- // A named <aside> is never suppressed, even when nested — matches
95
- // landmark-unique's own verified-against-reference-engine precedent
96
- // (that engine's real `aside` implicit-role function keeps
97
- // "complementary" when the element has an accessible name, even
98
- // inside sectioning content); propagated here for consistency.
103
+ // A named <aside> is never suppressed, even when nested — it keeps
104
+ // "complementary" when it has an accessible name, even inside
105
+ // sectioning content. Matches landmark-unique's precedent.
99
106
  if (!hasSectioningAncestor(el, false)) return 'complementary';
100
107
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
101
108
  }
@@ -104,7 +111,16 @@ function runInPage(ctx) {
104
111
  return '';
105
112
  }
106
113
 
107
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
114
+ const LANDMARK_ROLES = new Set([
115
+ 'banner',
116
+ 'contentinfo',
117
+ 'main',
118
+ 'navigation',
119
+ 'complementary',
120
+ 'region',
121
+ 'form',
122
+ 'search'
123
+ ]);
108
124
 
109
125
  function getLandmarkRole(el) {
110
126
  if (!el || !el.getAttribute) return '';
@@ -114,7 +130,7 @@ function runInPage(ctx) {
114
130
  }
115
131
 
116
132
  function hasLandmarkAncestor(el) {
117
- const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
133
+ const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
118
134
  let p = el.parentElement;
119
135
  while (p) {
120
136
  if (getLandmarkRole(p)) return true;
@@ -127,14 +143,14 @@ function runInPage(ctx) {
127
143
  }
128
144
 
129
145
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
130
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
131
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
132
- // invisible to a light-DOM-only query.
133
- let nodes = [];
146
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
147
+ // widget's own landmark is invisible to a light-DOM-only query.
148
+ let nodes;
134
149
  try {
135
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
136
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
137
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
150
+ nodes =
151
+ helpers && typeof helpers.queryAllSmart === 'function'
152
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
153
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
138
154
  } catch {
139
155
  nodes = [];
140
156
  }
@@ -144,7 +160,28 @@ function runInPage(ctx) {
144
160
  for (const el of nodes) {
145
161
  if (!el || seen.has(el)) continue;
146
162
  seen.add(el);
147
- if (getLandmarkRole(el) === 'main') mains.push(el);
163
+ if (getLandmarkRole(el) !== 'main') continue;
164
+
165
+ // An aria-hidden main candidate is removed from the accessibility
166
+ // tree entirely -- it isn't part of the landmark structure assistive
167
+ // technology users navigate at all, so it shouldn't be flagged as
168
+ // "nested inside another landmark" (there's no real landmark there to
169
+ // begin with, from AT's perspective). queryAllSmart's default hidden-
170
+ // content policy only excludes "hard" CSS-based hiding (display:none,
171
+ // etc.), not the softer aria-hidden exclusion, so this needs its own
172
+ // check.
173
+ if (helpers && typeof helpers.isAccTreeEligible === 'function') {
174
+ const elig = (() => {
175
+ try {
176
+ return helpers.isAccTreeEligible(el, ctx);
177
+ } catch {
178
+ return { eligible: true, reasons: [] };
179
+ }
180
+ })();
181
+ if (elig && elig.eligible === false) continue;
182
+ }
183
+
184
+ mains.push(el);
148
185
  }
149
186
 
150
187
  if (mains.length === 0) {
@@ -156,7 +193,7 @@ function runInPage(ctx) {
156
193
  if (!hasLandmarkAncestor(el)) continue;
157
194
 
158
195
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
159
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
196
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
160
197
 
161
198
  occurrences.push({
162
199
  selector: stableSelector,
@@ -175,7 +212,12 @@ function runInPage(ctx) {
175
212
  }
176
213
 
177
214
  if (occurrences.length) {
178
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
215
+ return {
216
+ ruleId: rule.ruleId,
217
+ outcome: 'cantTell',
218
+ severity: rule.defaultSeverity || 'minor',
219
+ occurrences
220
+ };
179
221
  }
180
222
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
181
223
  }
@@ -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-banner
5
7
  * @atomic true
6
8
  * @summary A page must not have more than one banner 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 banner landmark
10
12
  * (explicit role="banner", or an implicit, non-nested <header> — see
@@ -21,21 +23,19 @@
21
23
  * header comment for the shared rationale/precedent.
22
24
  * - Flags every banner instance (not just the "extra" ones) when more
23
25
  * than one exists, since which instance is "correct" is ambiguous.
24
- * - Only landmarks actually exposed to assistive technology can collide —
25
- * matches a widely-used reference engine's own `page-no-duplicate` check (confirmed by reading
26
- * its source directly: `query_selector_all_filter_default(..., elm =>
27
- * _isVisibleToScreenReaders(elm))`). Without this, a responsive layout
28
- * rendering both a visible and a CSS-hidden duplicate `<header>` (found
29
- * on a real site — Trello's homepage, a desktop/mobile header pair) was
30
- * wrongly flagged as a duplicate landmark; the hidden copy is never
31
- * actually reachable by AT.
26
+ * - Only landmarks actually exposed to assistive technology can collide.
27
+ * Without this, a responsive layout rendering both a visible and a
28
+ * CSS-hidden duplicate `<header>` (a desktop/mobile header pair) is
29
+ * flagged as a duplicate landmark even though the hidden copy is never
30
+ * reachable by AT.
32
31
  */
33
32
 
34
33
  const id = 'landmark-no-duplicate-banner';
35
34
 
36
35
  const meta = {
37
36
  title: 'Page must not have more than one banner landmark',
38
- description: 'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
37
+ description:
38
+ 'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
39
39
  i18n: {
40
40
  titleKey: 'landmarkNoDuplicateBanner_title',
41
41
  descriptionKey: 'landmarkNoDuplicateBanner_description'
@@ -55,14 +55,14 @@ function runInPage(ctx) {
55
55
  const { document, helpers, rule } = ctx;
56
56
 
57
57
  function normalizeWs(s) {
58
- return String(s || '').replace(/\s+/g, ' ').trim();
58
+ return String(s || '')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
59
61
  }
60
62
 
61
63
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
62
64
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
63
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
64
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
65
- // copy of this logic.
65
+ // local copy -- see that function's header comment in src/core/dom-helpers.js.
66
66
  function getAccessibleLandmarkName(el) {
67
67
  try {
68
68
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -85,10 +85,9 @@ function runInPage(ctx) {
85
85
  // (an ancestor's bare TAG only counts when it carries no role attribute
86
86
  // at all; an explicit role="dialog"-style override no longer suppresses)
87
87
  // rather than a local tag-only copy. See that function's header comment
88
- // in src/core/aria-helpers.js for the full algorithm and the real page
89
- // (handsontable.com's docs-assistant side panel, an
90
- // <aside role="dialog"> containing its own <header>) that surfaced this
91
- // rule's own former tag-only copy as a false negative.
88
+ // in src/core/aria-helpers.js for the full algorithm — e.g. an
89
+ // <aside role="dialog"> containing its own <header>, where the <header>
90
+ // keeps its banner role.
92
91
  function hasSectioningAncestor(el, includeMain) {
93
92
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
94
93
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -102,11 +101,9 @@ function runInPage(ctx) {
102
101
  if (tag === 'main') return 'main';
103
102
  if (tag === 'nav') return 'navigation';
104
103
  if (tag === 'aside') {
105
- // A named <aside> is never suppressed, even when nested — matches
106
- // landmark-unique's own verified-against-reference-engine precedent
107
- // (that engine's real `aside` implicit-role function keeps
104
+ // A named <aside> is never suppressed, even when nested: keeps
108
105
  // "complementary" when the element has an accessible name, even
109
- // inside sectioning content); propagated here for consistency.
106
+ // inside sectioning content. See landmark-unique-manual.js.
110
107
  if (!hasSectioningAncestor(el, false)) return 'complementary';
111
108
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
112
109
  }
@@ -115,7 +112,16 @@ function runInPage(ctx) {
115
112
  return '';
116
113
  }
117
114
 
118
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
115
+ const LANDMARK_ROLES = new Set([
116
+ 'banner',
117
+ 'contentinfo',
118
+ 'main',
119
+ 'navigation',
120
+ 'complementary',
121
+ 'region',
122
+ 'form',
123
+ 'search'
124
+ ]);
119
125
 
120
126
  function getLandmarkRole(el) {
121
127
  if (!el || !el.getAttribute) return '';
@@ -125,19 +131,20 @@ function runInPage(ctx) {
125
131
  }
126
132
 
127
133
  // queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
128
- // landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
129
- // that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
130
- // invisible to a light-DOM-only query.
131
- let nodes = [];
134
+ // landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
135
+ // widget's own landmark is invisible to a light-DOM-only query.
136
+ let nodes;
132
137
  try {
133
- nodes = helpers && typeof helpers.queryAllSmart === 'function'
134
- ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
135
- : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
138
+ nodes =
139
+ helpers && typeof helpers.queryAllSmart === 'function'
140
+ ? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
141
+ : document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
136
142
  } catch {
137
143
  nodes = [];
138
144
  }
139
145
 
140
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
146
+ const isAccTreeEligible =
147
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
141
148
 
142
149
  function isExposedToAt(el) {
143
150
  if (!isAccTreeEligible) return true;
@@ -165,7 +172,7 @@ function runInPage(ctx) {
165
172
 
166
173
  const occurrences = banners.map((el) => {
167
174
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
168
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
175
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
169
176
 
170
177
  return {
171
178
  selector: stableSelector,
@@ -183,7 +190,12 @@ function runInPage(ctx) {
183
190
  };
184
191
  });
185
192
 
186
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
193
+ return {
194
+ ruleId: rule.ruleId,
195
+ outcome: 'cantTell',
196
+ severity: rule.defaultSeverity || 'minor',
197
+ occurrences
198
+ };
187
199
  }
188
200
 
189
201
  module.exports = { id, meta, runInPage };