@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,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -11,194 +13,208 @@
11
13
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
14
  */
13
15
 
14
- const id = "embed-text-alternative-quality";
16
+ const id = 'embed-text-alternative-quality';
15
17
 
16
18
  const meta = {
17
- title: "<embed> text alternative must be appropriate (manual review)",
18
- description: "Flags <embed> elements with a detected name for human review of appropriateness.",
19
- i18n: {
20
- titleKey: "embed_textAltQuality_title",
21
- descriptionKey: "embed_textAltQuality_description"
22
- },
23
- helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "nontext", "embed", "manual", "atomic"],
25
- wcagSc: ['1.1.1'],
26
- normativeMappings: [
27
- {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
28
- ],
29
- defaultSeverity: 'minor',
30
- category: 'perceivable',
31
- type: 'manual',
32
- defaultConfidence: 'medium',
33
- coverage: {
34
- facetsBySc: {
35
- '1.1.1': ['text-alternative-quality']
36
- }
19
+ title: '<embed> text alternative must be appropriate (manual review)',
20
+ description: 'Flags <embed> elements with a detected name for human review of appropriateness.',
21
+ i18n: {
22
+ titleKey: 'embed_textAltQuality_title',
23
+ descriptionKey: 'embed_textAltQuality_description'
24
+ },
25
+ helpUrl: null,
26
+ tags: ['wcag2a', 'wcag111', 'nontext', 'embed', 'manual', 'atomic'],
27
+ wcagSc: ['1.1.1'],
28
+ normativeMappings: [
29
+ {
30
+ standard: 'WCAG',
31
+ version: '2.2',
32
+ requirement: '1.1.1',
33
+ title: 'Non-text Content',
34
+ conformanceLevel: 'A'
35
+ }
36
+ ],
37
+ defaultSeverity: 'minor',
38
+ category: 'perceivable',
39
+ type: 'manual',
40
+ defaultConfidence: 'medium',
41
+ coverage: {
42
+ facetsBySc: {
43
+ '1.1.1': ['text-alternative-quality']
37
44
  }
45
+ }
38
46
  };
39
47
 
40
48
  function runInPage(ctx) {
41
- const {document, root, helpers, rule} = ctx;
42
- const safeRoot = root || document;
43
-
44
- const trim = (v) => (v == null ? '' : String(v)).trim();
45
- const getTextFromIdRefs =
46
- helpers && typeof helpers.getTextFromIdRefs === 'function' ? helpers.getTextFromIdRefs : null;
47
-
48
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
49
- const queryAll = helpers && typeof helpers.queryAll === 'function'
50
- ? helpers.queryAll
51
- : (sel) => {
52
- try {
53
- return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
54
- } catch {
55
- return [];
56
- }
49
+ const { document, root, helpers, rule } = ctx;
50
+ const safeRoot = root || document;
51
+
52
+ const trim = (v) => (v == null ? '' : String(v)).trim();
53
+ const getTextFromIdRefs =
54
+ helpers && typeof helpers.getTextFromIdRefs === 'function' ? helpers.getTextFromIdRefs : null;
55
+
56
+ const queryAllSmart =
57
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
58
+ const queryAll =
59
+ helpers && typeof helpers.queryAll === 'function'
60
+ ? helpers.queryAll
61
+ : (sel) => {
62
+ try {
63
+ return safeRoot && safeRoot.querySelectorAll
64
+ ? Array.from(safeRoot.querySelectorAll(sel))
65
+ : [];
66
+ } catch {
67
+ return [];
68
+ }
57
69
  };
58
70
 
59
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
60
- ? helpers.getEligibilityInfo
61
- : null;
62
-
63
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
64
- ? helpers.isAccTreeEligible
65
- : null;
66
-
67
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
68
- ? helpers.getFocusableInfo
69
- : null;
70
-
71
- function isRolePresentationExcluded(el) {
72
- const role = (() => {
73
- try {
74
- return String(el.getAttribute('role') || '').trim().toLowerCase();
75
- } catch {
76
- return '';
77
- }
78
- })();
79
- if (role !== 'presentation' && role !== 'none') return false;
80
-
81
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
82
- let focusable = false;
83
- if (getFocusableInfo) {
84
- const fi = (() => {
85
- try {
86
- return getFocusableInfo(el, ctx);
87
- } catch {
88
- return null;
89
- }
90
- })();
91
- focusable = !!(fi && fi.focusable);
92
- } else {
93
- const tabindex = el.getAttribute('tabindex');
94
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
95
- }
96
- return !focusable;
97
- }
71
+ const getEligibilityInfo =
72
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
73
+
74
+ const isAccTreeEligible =
75
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
98
76
 
77
+ const getFocusableInfo =
78
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
99
79
 
100
- const els = (() => {
80
+ function isRolePresentationExcluded(el) {
81
+ const role = (() => {
82
+ try {
83
+ return String(el.getAttribute('role') || '')
84
+ .trim()
85
+ .toLowerCase();
86
+ } catch {
87
+ return '';
88
+ }
89
+ })();
90
+ if (role !== 'presentation' && role !== 'none') return false;
91
+
92
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
93
+ let focusable;
94
+ if (getFocusableInfo) {
95
+ const fi = (() => {
101
96
  try {
102
- return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
97
+ return getFocusableInfo(el, ctx);
103
98
  } catch {
104
- return queryAll('embed');
99
+ return null;
105
100
  }
106
- })();
107
-
108
- if (!els.length) {
109
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
101
+ })();
102
+ focusable = !!(fi && fi.focusable);
103
+ } else {
104
+ const tabindex = el.getAttribute('tabindex');
105
+ focusable =
106
+ tabindex != null &&
107
+ String(tabindex).trim() !== '' &&
108
+ !Number.isNaN(Number(String(tabindex).trim()));
109
+ }
110
+ return !focusable;
111
+ }
112
+
113
+ const els = (() => {
114
+ try {
115
+ return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
116
+ } catch {
117
+ return queryAll('embed');
110
118
  }
119
+ })();
111
120
 
112
- const occurrences = [];
113
- let applicableCount = 0;
114
-
115
- for (const el of els) {
116
- if (!el || !el.getAttribute) continue;
117
-
118
- if (isAccTreeEligible) {
119
- const elig = (() => {
120
- try {
121
- return isAccTreeEligible(el, ctx);
122
- } catch {
123
- return {eligible: true, reasons: []};
124
- }
125
- })();
126
- if (elig && elig.eligible === false) continue;
127
- }
121
+ if (!els.length) {
122
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
123
+ }
124
+
125
+ const occurrences = [];
126
+ let applicableCount = 0;
128
127
 
129
- if (isRolePresentationExcluded(el)) continue;
128
+ for (const el of els) {
129
+ if (!el || !el.getAttribute) continue;
130
130
 
131
- let ariaLabel = '';
132
- let ariaLabelledBy = '';
133
- let title = '';
131
+ if (isAccTreeEligible) {
132
+ const elig = (() => {
134
133
  try {
135
- ariaLabel = trim(el.getAttribute('aria-label'));
136
- ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
137
- title = trim(el.getAttribute('title'));
134
+ return isAccTreeEligible(el, ctx);
138
135
  } catch {
139
- ariaLabel = '';
140
- ariaLabelledBy = '';
141
- title = '';
142
- }
143
-
144
- // Only resolve IDREF text if aria-labelledby is present and aria-label is not already sufficient
145
- let labelledByText = '';
146
- let hasLabelledByMechanism = false;
147
-
148
- if (!ariaLabel && ariaLabelledBy) {
149
- hasLabelledByMechanism = true;
150
-
151
- if (getTextFromIdRefs) {
152
- try {
153
- const t = getTextFromIdRefs(ariaLabelledBy, ctx);
154
- labelledByText = trim(t && t.text);
155
- // If it resolves to empty, still treat as a mechanism present for manual review.
156
- } catch {
157
- labelledByText = '';
158
- }
159
- }
136
+ return { eligible: true, reasons: [] };
160
137
  }
138
+ })();
139
+ if (elig && elig.eligible === false) continue;
140
+ }
161
141
 
162
- const hasNameMechanism = !!(ariaLabel || title || hasLabelledByMechanism);
163
- if (!hasNameMechanism) continue;
164
-
165
- const details = {
166
- ariaLabel: ariaLabel || null,
167
- ariaLabelledBy: ariaLabelledBy || null,
168
- ariaLabelledByText: labelledByText || null,
169
- title: title || null
170
- };
142
+ if (isRolePresentationExcluded(el)) continue;
143
+
144
+ let ariaLabel;
145
+ let ariaLabelledBy;
146
+ let title;
147
+ try {
148
+ ariaLabel = trim(el.getAttribute('aria-label'));
149
+ ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
150
+ title = trim(el.getAttribute('title'));
151
+ } catch {
152
+ ariaLabel = '';
153
+ ariaLabelledBy = '';
154
+ title = '';
155
+ }
171
156
 
172
- applicableCount += 1;
173
-
174
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, {targetSet: 'acc'}) : null;
175
-
176
- const baseOccurrence = {
177
- summary: 'Review text alternative for <embed> for accuracy and appropriateness.',
178
- hint: 'Confirm the ARIA name or title accurately identifies the embedded content in context.',
179
- i18n: {
180
- summaryKey: 'embed_textAltQuality_summary_cantTell',
181
- hintKey: 'embed_textAltQuality_hint_cantTell',
182
- params: {element: (el.tagName || '').toLowerCase()}
183
- },
184
- data: {
185
- visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []},
186
- details
187
- }
188
- };
157
+ // Only resolve IDREF text if aria-labelledby is present and aria-label is not already sufficient
158
+ let labelledByText = '';
189
159
 
190
- if (helpers && typeof helpers.reportOccurrence === 'function') {
191
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
192
- } else {
193
- occurrences.push({selector: '', html: '', ...baseOccurrence});
194
- }
160
+ if (!ariaLabel && ariaLabelledBy && getTextFromIdRefs) {
161
+ try {
162
+ const t = getTextFromIdRefs(ariaLabelledBy, ctx);
163
+ labelledByText = trim(t && t.text);
164
+ } catch {
165
+ labelledByText = '';
166
+ }
195
167
  }
196
168
 
197
- if (applicableCount === 0) {
198
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
169
+ // A broken/empty-resolving aria-labelledby (e.g. pointing at a
170
+ // nonexistent id) is not a "detected" text alternative to review the
171
+ // QUALITY of -- there's no text here at all, and this element's
172
+ // sibling automatic rule (embed-text-alternative-present) already
173
+ // reports it as a fail (no accessible name). Like object-/svg-/
174
+ // canvas-text-alternative-quality-manual, require aria-labelledby to
175
+ // actually resolve to non-empty text rather than merely being present
176
+ // as an attribute; a present-but-broken aria-labelledby has no text
177
+ // alternative to review.
178
+ const hasNameMechanism = !!(ariaLabel || title || labelledByText);
179
+ if (!hasNameMechanism) continue;
180
+
181
+ const details = {
182
+ ariaLabel: ariaLabel || null,
183
+ ariaLabelledBy: ariaLabelledBy || null,
184
+ ariaLabelledByText: labelledByText || null,
185
+ title: title || null
186
+ };
187
+
188
+ applicableCount += 1;
189
+
190
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
191
+
192
+ const baseOccurrence = {
193
+ summary: 'Review text alternative for <embed> for accuracy and appropriateness.',
194
+ hint: 'Confirm the ARIA name or title accurately identifies the embedded content in context.',
195
+ i18n: {
196
+ summaryKey: 'embed_textAltQuality_summary_cantTell',
197
+ hintKey: 'embed_textAltQuality_hint_cantTell',
198
+ params: { element: (el.tagName || '').toLowerCase() }
199
+ },
200
+ data: {
201
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
202
+ details
203
+ }
204
+ };
205
+
206
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
207
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
208
+ } else {
209
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
199
210
  }
211
+ }
212
+
213
+ if (applicableCount === 0) {
214
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
215
+ }
200
216
 
201
- return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
217
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
202
218
  }
203
219
 
204
- module.exports = {id, meta, runInPage};
220
+ 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 empty-heading
5
7
  * @atomic true
6
8
  * @summary Heading elements must not be empty
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 heading role: native <h1>-<h6>, or any
10
12
  * element with explicit role="heading" (unless overridden by another
@@ -19,30 +21,29 @@
19
21
  * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
23
  * header comment for the shared rationale/precedent.
22
- * - This is also the reconciliation point for the "heading-name-present"
23
- * gap surfaced by the 2026-07-19 ACT-rules research pass (see
24
- * ROADMAP.md "Tier 5 candidates"): that gap was already closed by this
25
- * pre-existing rule under a different name, not a real gap. Fixed two
26
- * real false-positive bugs found while confirming that (2026-07-19):
27
- * `title` wasn't accepted as a naming fallback, and hidden/aria-hidden/
28
- * display:none headings weren't excluded (not gated on
29
- * `isAccTreeEligible`), so an empty heading no AT user could ever
30
- * reach was still flagged.
31
- * - 2026-07-21: the subtree-text walker's own hand-rolled descendant
32
- * handling (aria-label/aria-labelledby only) never checked an `<img>`
33
- * descendant's `alt` text — found via Party City's
34
- * `<h1><a><div><img alt="..."></div></a></h1>` logo header, a false
35
- * "empty heading" cantTell. Replaced with the shared, accname-aligned
24
+ * - This is also the reconciliation point for the ACT-rules
25
+ * "heading-name-present" requirement (see ROADMAP.md "Tier 5
26
+ * candidates"): already covered by this pre-existing rule under a
27
+ * different name, not a separate gap. `title` is accepted as a naming
28
+ * fallback, and hidden/aria-hidden/display:none headings are excluded
29
+ * (gated on `isAccTreeEligible`), so an empty heading no AT user could
30
+ * ever reach is not flagged.
31
+ * - Descendant name resolution uses the shared, accname-aligned
36
32
  * `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
37
- * from content" implementation the 19 `-name-present` rules already use
38
- * — instead of maintaining a third, narrower copy of this logic here.
33
+ * from content" implementation the 19 `-name-present` rules already
34
+ * use — rather than a narrower hand-rolled walker, so an `<img alt="...">`
35
+ * descendant's alt text (e.g. a
36
+ * `<h1><a><div><img alt="..."></div></a></h1>` logo header) is correctly
37
+ * picked up as the heading's name instead of producing a false "empty
38
+ * heading" cantTell.
39
39
  */
40
40
 
41
41
  const id = 'empty-heading';
42
42
 
43
43
  const meta = {
44
44
  title: 'Headings must not be empty',
45
- description: 'Checks that heading elements (<h1>-<h6> or role="heading") have a non-empty accessible name.',
45
+ description:
46
+ 'Checks that heading elements (<h1>-<h6> or role="heading") have a non-empty accessible name.',
46
47
  i18n: {
47
48
  titleKey: 'emptyHeading_title',
48
49
  descriptionKey: 'emptyHeading_description'
@@ -62,7 +63,9 @@ function runInPage(ctx) {
62
63
  const { document, helpers, rule } = ctx;
63
64
 
64
65
  function normalizeWs(s) {
65
- return String(s || '').replace(/\s+/g, ' ').trim();
66
+ return String(s || '')
67
+ .replace(/\s+/g, ' ')
68
+ .trim();
66
69
  }
67
70
 
68
71
  function getExplicitRoleToken(el) {
@@ -112,13 +115,16 @@ function runInPage(ctx) {
112
115
  return normalizeWs(el.getAttribute && el.getAttribute('title'));
113
116
  }
114
117
 
115
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]') : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
118
+ const nodes = helpers.queryAllSmart
119
+ ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]')
120
+ : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
116
121
 
117
122
  const occurrences = [];
118
123
  let applicableCount = 0;
119
124
  const seen = new Set();
120
125
 
121
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
126
+ const isAccTreeEligible =
127
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
122
128
 
123
129
  function isEligible(el) {
124
130
  if (!isAccTreeEligible) return true;
@@ -143,9 +149,15 @@ function runInPage(ctx) {
143
149
  if (name) continue;
144
150
 
145
151
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
146
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
152
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
147
153
  const eligInfo = helpers.getEligibilityInfo
148
- ? (() => { try { return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
154
+ ? (() => {
155
+ try {
156
+ return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' });
157
+ } catch {
158
+ return null;
159
+ }
160
+ })()
149
161
  : null;
150
162
 
151
163
  occurrences.push({
@@ -169,7 +181,12 @@ function runInPage(ctx) {
169
181
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
170
182
  }
171
183
  if (occurrences.length) {
172
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
184
+ return {
185
+ ruleId: rule.ruleId,
186
+ outcome: 'cantTell',
187
+ severity: rule.defaultSeverity || 'minor',
188
+ occurrences
189
+ };
173
190
  }
174
191
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
175
192
  }