@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,13 +1,17 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check region
5
7
  * @atomic true
6
8
  * @summary Page content should be contained within a landmark region
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 to direct children of <body> that carry visible text content
10
- * and are not themselves a landmark.
11
+ * Applies to any element under <body> that directly carries visible text
12
+ * (or other own content — see @implementation-notes) and is not itself a
13
+ * landmark, live region, dialog, button, <svg>, <iframe>/<frame>, or a
14
+ * resolvable skip-link.
11
15
  * @expectation
12
16
  * Every top-level piece of page content lives inside a landmark region
13
17
  * (main, navigation, banner, contentinfo, complementary, region, form,
@@ -18,19 +22,59 @@
18
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
19
23
  * header comment for the shared rationale/precedent and the landmark-
20
24
  * detection model.
21
- * - Deliberately scoped to DIRECT children of <body> only (not a full
22
- * recursive scan of every text node on the page) — a widely-used
23
- * reference engine's own region rule is known to be noisy in practice when applied
24
- * unconditionally; checking only the top level keeps false positives
25
- * low while still catching the common case (a page section authored
26
- * entirely outside any landmark).
25
+ * - Recursive tree walk, not a direct-<body>-children-only scan: a
26
+ * direct-children-only scope is nearly inert on the single most common
27
+ * real-world page shape, a modern framework's single root mount div
28
+ * (`<body><div id="root">...everything...</div></body>`) — that shape
29
+ * gives at most one candidate for the entire page and either misses
30
+ * every real gap inside it or collapses the whole page into one
31
+ * undifferentiated report.
32
+ * - Algorithm, using this engine's own helpers (see
33
+ * hasLandmarkScopingAncestor's header comment):
34
+ * 1. Depth-first walk from <body>'s children.
35
+ * 2. At each node: if it's ineligible for the accessibility tree
36
+ * (helpers.isAccTreeEligible), OR is itself a "stopper" (landmark,
37
+ * live region, dialog, button, <svg>, <iframe>/<frame>, or a
38
+ * resolvable skip-link), mark it and every ancestor up to <body> as
39
+ * "has a stopper" and don't recurse further into it (an <iframe>/
40
+ * <frame> is additionally reported as its own occurrence — its
41
+ * content is opaque to this engine, so from the outer page's
42
+ * perspective it IS unplaced content).
43
+ * 3. Otherwise, if the node has OWN content (a direct child text node,
44
+ * being an inherently visual element, or an aria-label) — checked
45
+ * non-recursively, so a plain wrapper <div> with only nested
46
+ * children never short-circuits the walk into its descendants —
47
+ * collect it as a candidate and stop recursing into it.
48
+ * 4. Otherwise recurse into its element children.
49
+ * Each collected candidate is then walked back UP through parents while
50
+ * the parent has no "stopper" marker and isn't <body> itself, collapsing
51
+ * contiguous unplaced content into one occurrence per real gap instead
52
+ * of reporting every individual text-bearing leaf — this is what keeps
53
+ * the walk from being noisy on ordinary pages that mix landmarked and
54
+ * stray content.
55
+ * - "Stopper" exemptions (button, dialog, <svg>, resolvable skip-links)
56
+ * are a deliberate scope choice, not an oversight: these
57
+ * are extremely common real-world patterns (floating action buttons,
58
+ * modal dialogs, decorative/icon SVGs, "skip to content" links) that
59
+ * aren't the kind of "content organization" gap this rule exists to
60
+ * catch, and flagging them would reintroduce the false-positive noise
61
+ * the original narrow scope was trying to avoid.
62
+ * - The "own content" check for aria-label deliberately does NOT resolve
63
+ * aria-labelledby — a known, narrow scope gap (an element named only via aria-labelledby, with no
64
+ * own text/aria-label, and no other content anywhere in its subtree,
65
+ * could be silently skipped) accepted to avoid a full accessible-name
66
+ * computation (recursive itself) inside an already-recursive structural
67
+ * walk.
68
+ * - Bounded by a node-visit budget (see MAX_VISITED_NODES) as a defensive
69
+ * guard against pathological pages, matching the existing pattern used
70
+ * by getContentNameInfo's maxContentNodes in src/core/dom-helpers.js.
27
71
  */
28
72
 
29
73
  const id = 'region';
30
74
 
31
75
  const meta = {
32
76
  title: 'Page content should be inside a landmark region',
33
- description: 'Checks that direct children of <body> with visible text content are contained within a landmark region.',
77
+ description: 'Checks that content under <body> is contained within a landmark region.',
34
78
  i18n: {
35
79
  titleKey: 'region_title',
36
80
  descriptionKey: 'region_description'
@@ -63,14 +107,18 @@ function runInPage(ctx) {
63
107
  }
64
108
 
65
109
  function normalizeWs(s) {
66
- return String(s || '').replace(/\s+/g, ' ').trim();
110
+ return String(s || '')
111
+ .replace(/\s+/g, ' ')
112
+ .trim();
113
+ }
114
+
115
+ function lower(s) {
116
+ return String(s || '').toLowerCase();
67
117
  }
68
118
 
69
119
  // Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
70
120
  // target's own accessible name, not raw textContent -> title attribute fallback) rather than a
71
- // local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
72
- // (missing title fallback) this replaced across all 7 landmark rule files that had their own
73
- // copy of this logic.
121
+ // local copy -- see that function's header comment in src/core/dom-helpers.js.
74
122
  function getAccessibleLandmarkName(el) {
75
123
  try {
76
124
  if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
@@ -84,7 +132,7 @@ function runInPage(ctx) {
84
132
  function getExplicitRoleToken(el) {
85
133
  const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
86
134
  if (!raw) return '';
87
- return raw.split(/\s+/)[0].toLowerCase();
135
+ return lower(raw.split(/\s+/)[0]);
88
136
  }
89
137
 
90
138
  // Delegates to the shared helpers.hasLandmarkScopingAncestor for the
@@ -93,10 +141,9 @@ function runInPage(ctx) {
93
141
  // (an ancestor's bare TAG only counts when it carries no role attribute
94
142
  // at all; an explicit role="dialog"-style override no longer suppresses)
95
143
  // rather than a local tag-only copy. See that function's header comment
96
- // in src/core/aria-helpers.js for the full algorithm and the real page
97
- // (handsontable.com's docs-assistant side panel, an
98
- // <aside role="dialog"> containing its own <header>) that surfaced this
99
- // rule's own former tag-only copy as a false negative.
144
+ // in src/core/aria-helpers.js for the full algorithm — e.g. an
145
+ // <aside role="dialog"> containing its own <header>, where the <header>
146
+ // keeps its banner role.
100
147
  function hasSectioningAncestor(el, includeMain) {
101
148
  return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
102
149
  ? helpers.hasLandmarkScopingAncestor(el, { includeMain })
@@ -104,17 +151,15 @@ function runInPage(ctx) {
104
151
  }
105
152
 
106
153
  function getImplicitLandmarkRole(el) {
107
- const tag = el.tagName ? el.tagName.toLowerCase() : '';
154
+ const tag = el.tagName ? lower(el.tagName) : '';
108
155
  if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
109
156
  if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
110
157
  if (tag === 'main') return 'main';
111
158
  if (tag === 'nav') return 'navigation';
112
159
  if (tag === 'aside') {
113
- // A named <aside> is never suppressed, even when nested — matches
114
- // landmark-unique's own verified-against-reference-engine precedent
115
- // (that engine's real `aside` implicit-role function keeps
160
+ // A named <aside> is never suppressed, even when nested: keeps
116
161
  // "complementary" when the element has an accessible name, even
117
- // inside sectioning content); propagated here for consistency.
162
+ // inside sectioning content. See landmark-unique-manual.js.
118
163
  if (!hasSectioningAncestor(el, false)) return 'complementary';
119
164
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
120
165
  }
@@ -123,7 +168,16 @@ function runInPage(ctx) {
123
168
  return '';
124
169
  }
125
170
 
126
- const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
171
+ const LANDMARK_ROLES = new Set([
172
+ 'banner',
173
+ 'contentinfo',
174
+ 'main',
175
+ 'navigation',
176
+ 'complementary',
177
+ 'region',
178
+ 'form',
179
+ 'search'
180
+ ]);
127
181
 
128
182
  function isLandmark(el) {
129
183
  if (!el || !el.getAttribute) return false;
@@ -134,48 +188,176 @@ function runInPage(ctx) {
134
188
 
135
189
  const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta', 'title']);
136
190
 
137
- function isIneligible(el) {
138
- if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return true;
139
- if (el.hasAttribute && el.hasAttribute('hidden')) return true;
191
+ // Roles/attributes that make an element its own self-contained
192
+ // announced area — not literally a WAI-ARIA landmark, but not "content
193
+ // that needs a landmark" either.
194
+ const LIVE_REGION_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer']);
195
+
196
+ function isAriaLive(el) {
197
+ const v = lower(normalizeWs(el.getAttribute && el.getAttribute('aria-live')));
198
+ return v === 'polite' || v === 'assertive';
199
+ }
200
+
201
+ function isDialogLike(el) {
202
+ const tag = el.tagName ? lower(el.tagName) : '';
203
+ if (tag === 'dialog') return true;
204
+ const role = getExplicitRoleToken(el);
205
+ return role === 'dialog' || role === 'alertdialog';
206
+ }
207
+
208
+ function isButtonLike(el) {
209
+ const role = getExplicitRoleToken(el);
210
+ if (role) return role === 'button';
211
+ const tag = el.tagName ? lower(el.tagName) : '';
212
+ if (tag === 'button' || tag === 'summary') return true;
213
+ if (tag === 'input') {
214
+ const type = lower(normalizeWs(el.getAttribute && el.getAttribute('type')));
215
+ return type === 'button' || type === 'submit' || type === 'reset' || type === 'image';
216
+ }
140
217
  return false;
141
218
  }
142
219
 
143
- function getConservativeSubtreeText(container) {
144
- const SHOW_TEXT = 4;
220
+ // A "skip to content" link is deliberately placed outside the main
221
+ // content flow at the very top of the page — exempting it (when its
222
+ // fragment actually resolves to a real target, not a dead "#"
223
+ // placeholder) avoids flagging a helpful, common accessibility pattern
224
+ // as the very thing this rule is meant to catch.
225
+ function isResolvableSkipLink(el) {
226
+ const tag = el.tagName ? lower(el.tagName) : '';
227
+ if (tag !== 'a') return false;
228
+ const href = el.getAttribute && el.getAttribute('href');
229
+ if (!href || href.charAt(0) !== '#' || href.length < 2) return false;
145
230
  try {
146
- const walker = document.createTreeWalker(container, SHOW_TEXT, null);
147
- const parts = [];
148
- let n = walker.nextNode();
149
- while (n) {
150
- const raw = normalizeWs(n.nodeValue || '');
151
- if (raw) parts.push(raw);
152
- n = walker.nextNode();
153
- }
154
- return normalizeWs(parts.join(' '));
231
+ return !!(document.getElementById && document.getElementById(href.slice(1)));
155
232
  } catch {
156
- return normalizeWs(container.textContent);
233
+ return false;
234
+ }
235
+ }
236
+
237
+ function isStopper(el) {
238
+ if (isLandmark(el)) return true;
239
+ if (isAriaLive(el)) return true;
240
+ const role = getExplicitRoleToken(el);
241
+ if (role && LIVE_REGION_ROLES.has(role)) return true;
242
+ if (isDialogLike(el)) return true;
243
+ if (isButtonLike(el)) return true;
244
+ const tag = el.tagName ? lower(el.tagName) : '';
245
+ if (tag === 'svg' || tag === 'iframe' || tag === 'frame') return true;
246
+ if (isResolvableSkipLink(el)) return true;
247
+ return false;
248
+ }
249
+
250
+ const VISUAL_CONTENT_TAGS = new Set(['img', 'video', 'audio', 'canvas', 'object', 'embed']);
251
+
252
+ // Non-recursive "does THIS element, on its own, carry content" check —
253
+ // deliberately mirrors only the direct-content half of getContentNameInfo,
254
+ // not a full name-from-content recursion: the whole point is to keep
255
+ // recursing through plain wrapper elements (a framework's root mount
256
+ // <div> included) until reaching the actual content-bearing node, rather
257
+ // than a coarse ancestor swallowing everything beneath it into one report.
258
+ function hasOwnContent(el) {
259
+ const kids = el.childNodes || [];
260
+ for (let i = 0; i < kids.length; i++) {
261
+ const k = kids[i];
262
+ if (k.nodeType === 3 && normalizeWs(k.nodeValue)) return true;
263
+ }
264
+ const tag = el.tagName ? lower(el.tagName) : '';
265
+ if (VISUAL_CONTENT_TAGS.has(tag)) return true;
266
+ if (
267
+ tag === 'input' &&
268
+ lower(normalizeWs(el.getAttribute && el.getAttribute('type'))) !== 'hidden'
269
+ )
270
+ return true;
271
+ if (normalizeWs(el.getAttribute && el.getAttribute('aria-label'))) return true;
272
+ return false;
273
+ }
274
+
275
+ // Defensive guard against pathological pages (mirrors the maxContentNodes
276
+ // pattern in src/core/dom-helpers.js's getContentNameInfo) -- a full-body
277
+ // structural walk visits more nodes than a single element's name
278
+ // computation, hence the larger budget.
279
+ const MAX_VISITED_NODES = 20000;
280
+ let visited = 0;
281
+ let truncated = false;
282
+
283
+ const leaves = [];
284
+ const stopperFlagged = new WeakSet();
285
+
286
+ function markFlaggedUpToBody(el) {
287
+ let cur = el;
288
+ while (cur) {
289
+ if (stopperFlagged.has(cur)) break; // everything above is already marked
290
+ stopperFlagged.add(cur);
291
+ if (cur === body) break;
292
+ cur = cur.parentElement;
157
293
  }
158
294
  }
159
295
 
160
- const occurrences = [];
161
- let applicableCount = 0;
296
+ function walk(el) {
297
+ if (truncated || !el || el.nodeType !== 1) return;
298
+ visited += 1;
299
+ if (visited > MAX_VISITED_NODES) {
300
+ truncated = true;
301
+ return;
302
+ }
303
+
304
+ const tag = el.tagName ? lower(el.tagName) : '';
305
+ if (SKIP_TAGS.has(tag)) return;
162
306
 
163
- for (const el of body.children || []) {
164
- if (!el || !el.tagName) continue;
165
- const tag = el.tagName.toLowerCase();
166
- if (SKIP_TAGS.has(tag)) continue;
167
- if (isIneligible(el)) continue;
168
- if (isLandmark(el)) continue;
307
+ const eligRes = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el) : { eligible: true };
308
+ if (!(eligRes && eligRes.eligible)) {
309
+ markFlaggedUpToBody(el);
310
+ return;
311
+ }
169
312
 
170
- const text = getConservativeSubtreeText(el);
171
- if (!text) continue;
313
+ if (isStopper(el)) {
314
+ markFlaggedUpToBody(el);
315
+ if (tag === 'iframe' || tag === 'frame') leaves.push(el);
316
+ return;
317
+ }
172
318
 
173
- applicableCount += 1;
319
+ if (hasOwnContent(el)) {
320
+ leaves.push(el);
321
+ return;
322
+ }
174
323
 
324
+ const kids = el.children || [];
325
+ for (let i = 0; i < kids.length; i++) {
326
+ walk(kids[i]);
327
+ if (truncated) return;
328
+ }
329
+ }
330
+
331
+ for (const child of body.children || []) walk(child);
332
+
333
+ // Collapse each candidate leaf upward through parents that have no OTHER
334
+ // stopper anywhere in their subtree, so contiguous unplaced content
335
+ // merges into ONE occurrence per real gap instead of one per text node --
336
+ // this collapsing is what keeps ordinary pages (landmarked content mixed
337
+ // with a little stray content) from producing noisy, one-per-leaf reports.
338
+ const collapsed = [];
339
+ const seen = new Set();
340
+ for (const leaf of leaves) {
341
+ let cur = leaf;
342
+ while (
343
+ cur.parentElement &&
344
+ cur.parentElement !== body &&
345
+ !stopperFlagged.has(cur.parentElement)
346
+ ) {
347
+ cur = cur.parentElement;
348
+ }
349
+ if (!seen.has(cur)) {
350
+ seen.add(cur);
351
+ collapsed.push(cur);
352
+ }
353
+ }
354
+
355
+ const occurrences = collapsed.map((el) => {
356
+ const tag = el.tagName ? lower(el.tagName) : '';
175
357
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
176
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
358
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
177
359
 
178
- occurrences.push({
360
+ return {
179
361
  selector: stableSelector,
180
362
  html,
181
363
  summary: 'This content is not contained within a landmark region.',
@@ -188,16 +370,18 @@ function runInPage(ctx) {
188
370
  data: {
189
371
  details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
190
372
  }
191
- });
192
- }
373
+ };
374
+ });
193
375
 
194
- if (applicableCount === 0) {
376
+ if (occurrences.length === 0) {
195
377
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
196
378
  }
197
- if (occurrences.length) {
198
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
199
- }
200
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
379
+ return {
380
+ ruleId: rule.ruleId,
381
+ outcome: 'cantTell',
382
+ severity: rule.defaultSeverity || 'minor',
383
+ occurrences
384
+ };
201
385
  }
202
386
 
203
387
  module.exports = { id, meta, runInPage, applicability };
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check scope-attr-valid
5
7
  * @atomic true
6
8
  * @summary The scope attribute must have a valid value
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 to elements with a non-empty scope attribute.
10
12
  * @expectation
@@ -43,7 +45,9 @@ function runInPage(ctx) {
43
45
 
44
46
  const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
45
47
 
46
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[scope]') : helpers.queryAll('[scope]');
48
+ const nodes = helpers.queryAllSmart
49
+ ? helpers.queryAllSmart('[scope]')
50
+ : helpers.queryAll('[scope]');
47
51
 
48
52
  const occurrences = [];
49
53
  let applicableCount = 0;
@@ -58,7 +62,7 @@ function runInPage(ctx) {
58
62
  if (VALID_SCOPES.has(raw.toLowerCase())) continue;
59
63
 
60
64
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
61
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
65
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
62
66
 
63
67
  occurrences.push({
64
68
  selector: stableSelector,
@@ -80,7 +84,12 @@ function runInPage(ctx) {
80
84
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
81
85
  }
82
86
  if (occurrences.length) {
83
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
87
+ return {
88
+ ruleId: rule.ruleId,
89
+ outcome: 'cantTell',
90
+ severity: rule.defaultSeverity || 'minor',
91
+ occurrences
92
+ };
84
93
  }
85
94
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
86
95
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -48,8 +50,20 @@ const meta = {
48
50
  tags: ['wcag2a', 'wcag2aaa', 'wcag211', 'wcag213', 'structure', 'atomic', 'manual'],
49
51
  wcagSc: ['2.1.1', '2.1.3'],
50
52
  normativeMappings: [
51
- { standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' },
52
- { standard: 'WCAG', version: '2.2', requirement: '2.1.3', title: 'Keyboard (No Exception)', conformanceLevel: 'AAA' }
53
+ {
54
+ standard: 'WCAG',
55
+ version: '2.2',
56
+ requirement: '2.1.1',
57
+ title: 'Keyboard',
58
+ conformanceLevel: 'A'
59
+ },
60
+ {
61
+ standard: 'WCAG',
62
+ version: '2.2',
63
+ requirement: '2.1.3',
64
+ title: 'Keyboard (No Exception)',
65
+ conformanceLevel: 'AAA'
66
+ }
53
67
  ],
54
68
  defaultSeverity: 'moderate',
55
69
  category: 'operable',
@@ -64,7 +78,7 @@ const meta = {
64
78
  };
65
79
 
66
80
  function runInPage(ctx) {
67
- const { document, helpers, rule } = ctx;
81
+ const { helpers, rule } = ctx;
68
82
 
69
83
  function safeComputedStyle(el) {
70
84
  try {
@@ -73,7 +87,8 @@ function runInPage(ctx) {
73
87
  const cs = helpers.computedStyle(el);
74
88
  if (cs) return cs;
75
89
  }
76
- const view = (el.ownerDocument && el.ownerDocument.defaultView) ? el.ownerDocument.defaultView : null;
90
+ const view =
91
+ el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : null;
77
92
  if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
78
93
  } catch {}
79
94
  return null;
@@ -81,8 +96,12 @@ function runInPage(ctx) {
81
96
 
82
97
  function isScrollableOverflow(cs) {
83
98
  if (!cs) return false;
84
- const x = String(cs.overflowX || '').trim().toLowerCase();
85
- const y = String(cs.overflowY || '').trim().toLowerCase();
99
+ const x = String(cs.overflowX || '')
100
+ .trim()
101
+ .toLowerCase();
102
+ const y = String(cs.overflowY || '')
103
+ .trim()
104
+ .toLowerCase();
86
105
  return x === 'auto' || x === 'scroll' || y === 'auto' || y === 'scroll';
87
106
  }
88
107
 
@@ -108,8 +127,11 @@ function runInPage(ctx) {
108
127
  }
109
128
  }
110
129
 
111
- const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
112
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR) : helpers.queryAll(CANDIDATE_SELECTOR);
130
+ const CANDIDATE_SELECTOR =
131
+ 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
132
+ const nodes = helpers.queryAllSmart
133
+ ? helpers.queryAllSmart(CANDIDATE_SELECTOR)
134
+ : helpers.queryAll(CANDIDATE_SELECTOR);
113
135
 
114
136
  const occurrences = [];
115
137
  let applicableCount = 0;
@@ -127,12 +149,13 @@ function runInPage(ctx) {
127
149
 
128
150
  const tag = (el.tagName || '').toLowerCase();
129
151
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
130
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
152
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
131
153
 
132
154
  const baseOccurrence = {
133
155
  selector: stableSelector,
134
156
  html,
135
- summary: 'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
157
+ summary:
158
+ 'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
136
159
  hint: 'If this region’s content actually overflows, add tabindex="0" (and a suitable label) so keyboard users can focus it and scroll with the arrow keys.',
137
160
  i18n: {
138
161
  summaryKey: 'scrollableRegionFocusable_summary_cantTell',
@@ -156,7 +179,12 @@ function runInPage(ctx) {
156
179
  }
157
180
 
158
181
  if (occurrences.length) {
159
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'moderate', occurrences };
182
+ return {
183
+ ruleId: rule.ruleId,
184
+ outcome: 'cantTell',
185
+ severity: rule.defaultSeverity || 'moderate',
186
+ occurrences
187
+ };
160
188
  }
161
189
 
162
190
  // Manual rules may only emit cantTell/notApplicable (never pass/fail):