@surea11y/core 1.5.0 → 1.6.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 (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
@@ -6,10 +6,10 @@
6
6
  * @check heading-order
7
7
  * @atomic true
8
8
  * @summary Heading levels must not skip a level going deeper
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever the page contains two or more heading elements
12
- * (native <h1>-<h6>, or explicit role="heading" with aria-level —
12
+ * (native <h1>-<h6>, or explicit role="heading" with aria-level;
13
13
  * default level 2 per the ARIA spec when aria-level is absent/invalid).
14
14
  * @expectation
15
15
  * In document order, each heading's level is no more than one greater
@@ -19,7 +19,7 @@
19
19
  * when navigating by heading. Going back to a shallower level at any
20
20
  * point is always fine.
21
21
  * @implementation-notes
22
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
23
23
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
24
24
  * header comment for the shared rationale/precedent.
25
25
  * - Tracks the highest level reached so far (not just the immediately
@@ -0,0 +1,338 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check heading-quality
7
+ * @atomic true
8
+ * @summary Heading text should describe the content that follows, not be a placeholder
9
+ * @standard WCAG 2.2
10
+ * @sc 2.4.6
11
+ * @applicability
12
+ * Elements with a heading role (native <h1>-<h6>, or any element with
13
+ * an explicit role="heading") that are included in the accessibility
14
+ * tree and have a non-empty accessible name. A heading with no name at
15
+ * all is `empty-heading`'s concern, not this rule's.
16
+ * @expectation
17
+ * The heading's accessible name, normalized (whitespace-collapsed,
18
+ * case-folded, trailing punctuation stripped), is not a placeholder
19
+ * left in the markup: a known generic word ("heading", "title",
20
+ * "untitled", "lorem ipsum", ...), a numbered template slot ("Heading
21
+ * 2", "Section 3"), a filename, or a URL. None of these describe the
22
+ * topic or purpose of the content they introduce.
23
+ * @implementation-notes
24
+ * - Authored as `type: 'manual'` (cantTell-capped, never fail), for the
25
+ * same reason `link-name-quality` is: whether a heading describes the
26
+ * content that follows it is a reading-comprehension judgment. What is
27
+ * deterministic is that a placeholder string cannot describe anything,
28
+ * which is the signal this rule raises for review.
29
+ * - Only the placeholder half of ACT b49b2e is reachable this way. Its
30
+ * real expectation (the heading describes the first perceivable
31
+ * content after it) needs a human to read both, and ACT's own failed
32
+ * examples are all well-formed headings on the wrong topic
33
+ * ("Weather" over opening hours), which no markup-level check can
34
+ * catch. Mapped as a partial match; see docs/ACT_RULE_MAPPING.md.
35
+ * - Matching is EXACT against the curated list, never a substring check,
36
+ * the same precision-over-recall trade-off `link-name-quality`'s own
37
+ * header comment reasons through: "Heading into the storm" is a real
38
+ * heading and must not match "heading".
39
+ * - Short headings are intentionally NOT flagged. ACT b49b2e is explicit
40
+ * that a heading may be a single word or even a single character and
41
+ * still be descriptive; its own passed example is `<h1>A</h1>` above a
42
+ * glossary section, so length carries no signal here, unlike in
43
+ * `page-title-patterns` where a <8-character page title is a real one.
44
+ * - Heading resolution (native tag, explicit role, and the
45
+ * role="none"-plus-global-ARIA-attribute conflict-resolution case)
46
+ * mirrors `empty-heading`, which owns the same applicability question;
47
+ * the two rules split the decision, one on presence, one on quality.
48
+ */
49
+
50
+ const id = 'heading-quality';
51
+
52
+ const meta = {
53
+ title: 'Heading text should be descriptive, not a placeholder',
54
+ description:
55
+ 'Flags headings whose accessible name is a placeholder rather than a description of the content that follows: a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL.',
56
+ i18n: {
57
+ titleKey: 'headingQuality_title',
58
+ descriptionKey: 'headingQuality_description'
59
+ },
60
+ helpUrl: null,
61
+ tags: ['wcag2aa', 'wcag246', 'headings', 'structure', 'quality', 'atomic', 'manual'],
62
+ wcagSc: ['2.4.6'],
63
+ normativeMappings: [
64
+ {
65
+ standard: 'WCAG',
66
+ version: '2.2',
67
+ requirement: '2.4.6',
68
+ title: 'Headings and Labels',
69
+ conformanceLevel: 'AA'
70
+ }
71
+ ],
72
+ defaultSeverity: 'minor',
73
+ category: 'operable',
74
+ type: 'manual',
75
+ defaultConfidence: 'medium',
76
+ coverage: { facetsBySc: { '2.4.6': ['heading-text-descriptive-evidence'] } }
77
+ };
78
+
79
+ function runInPage(ctx) {
80
+ const { document, helpers, rule } = ctx;
81
+
82
+ // Declared inside runInPage; see scripts/build-core.js header
83
+ // ("runInPage MUST be self-contained").
84
+ const PLACEHOLDER_HEADING_TEXT = new Set([
85
+ 'heading',
86
+ 'header',
87
+ 'headline',
88
+ 'subheading',
89
+ 'sub heading',
90
+ 'sub-heading',
91
+ 'title',
92
+ 'subtitle',
93
+ 'sub title',
94
+ 'untitled',
95
+ 'section',
96
+ 'new section',
97
+ 'chapter',
98
+ 'content',
99
+ 'main content',
100
+ 'text',
101
+ 'sample text',
102
+ 'placeholder',
103
+ 'placeholder text',
104
+ 'lorem ipsum',
105
+ 'insert title here',
106
+ 'your title here',
107
+ 'add a title',
108
+ 'tbd',
109
+ 'to be defined',
110
+ 'todo',
111
+ 'to do',
112
+ 'n/a',
113
+ 'test',
114
+ 'example',
115
+ 'default'
116
+ ]);
117
+
118
+ // A numbered template slot left as authored: "Heading 2", "Section 3",
119
+ // "Chapter #1". The word alone is already in the set above; this catches
120
+ // the same words carrying an index.
121
+ const NUMBERED_PLACEHOLDER =
122
+ /^(heading|header|headline|subheading|title|subtitle|section|chapter|part|step)\s*[-#:.]?\s*\d+$/;
123
+
124
+ const FILENAME_LIKE =
125
+ /^[\w\s\-.,()[\]]+\.(png|jpe?g|gif|svg|webp|avif|bmp|ico|tiff?|pdf|docx?|xlsx?|pptx?|html?|txt|csv|zip)$/;
126
+
127
+ const URL_LIKE = /^(https?:\/\/|www\.)\S+$/;
128
+
129
+ function normalizeWs(s) {
130
+ return String(s || '')
131
+ .replace(/\s+/g, ' ')
132
+ .trim();
133
+ }
134
+
135
+ function normalize(s) {
136
+ return normalizeWs(s)
137
+ .toLowerCase()
138
+ .replace(/[.,;:!?]+$/g, '')
139
+ .trim();
140
+ }
141
+
142
+ function getExplicitRoleToken(el) {
143
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
144
+ if (!raw) return '';
145
+ return raw.split(/\s+/)[0].toLowerCase();
146
+ }
147
+
148
+ // The WAI-ARIA Global States and Properties set, same list and same
149
+ // purpose as empty-heading's copy: a native h1-h6 marked
150
+ // role="none"/"presentation" reverts to its heading role when it carries
151
+ // any of these, the attribute's presence being what triggers conflict
152
+ // resolution, not its value.
153
+ const GLOBAL_ARIA_ATTRS = [
154
+ 'aria-atomic',
155
+ 'aria-braillelabel',
156
+ 'aria-brailleroledescription',
157
+ 'aria-busy',
158
+ 'aria-controls',
159
+ 'aria-current',
160
+ 'aria-describedby',
161
+ 'aria-description',
162
+ 'aria-details',
163
+ 'aria-disabled',
164
+ 'aria-dropeffect',
165
+ 'aria-errormessage',
166
+ 'aria-flowto',
167
+ 'aria-grabbed',
168
+ 'aria-haspopup',
169
+ 'aria-hidden',
170
+ 'aria-invalid',
171
+ 'aria-keyshortcuts',
172
+ 'aria-label',
173
+ 'aria-labelledby',
174
+ 'aria-live',
175
+ 'aria-owns',
176
+ 'aria-relevant',
177
+ 'aria-roledescription'
178
+ ];
179
+
180
+ function hasGlobalAriaAttr(el) {
181
+ for (const attr of GLOBAL_ARIA_ATTRS) {
182
+ if (el.getAttribute && el.getAttribute(attr) != null) return true;
183
+ }
184
+ return false;
185
+ }
186
+
187
+ function isHeading(el) {
188
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
189
+ const isNativeHeadingTag = /^h[1-6]$/.test(tag);
190
+
191
+ const explicit = getExplicitRoleToken(el);
192
+ if (!explicit) return isNativeHeadingTag;
193
+ if (explicit === 'heading') return true;
194
+ if ((explicit === 'none' || explicit === 'presentation') && isNativeHeadingTag) {
195
+ return hasGlobalAriaAttr(el);
196
+ }
197
+ return false;
198
+ }
199
+
200
+ // Same name resolution empty-heading uses, so the two rules agree on
201
+ // what a heading is called: aria-label, then aria-labelledby, then the
202
+ // shared accname-aligned "name from content" helper, then title.
203
+ function getAccessibleNameText(el) {
204
+ const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
205
+ if (al) return al;
206
+
207
+ const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
208
+ if (alb) {
209
+ const parts = [];
210
+ for (const refId of alb.split(/\s+/).filter(Boolean)) {
211
+ try {
212
+ const ref = document.getElementById(refId);
213
+ if (ref) {
214
+ const t = normalizeWs(ref.textContent);
215
+ if (t) parts.push(t);
216
+ }
217
+ } catch {
218
+ // ignore an unusable reference
219
+ }
220
+ }
221
+ const joined = normalizeWs(parts.join(' '));
222
+ if (joined) return joined;
223
+ }
224
+
225
+ if (helpers && typeof helpers.getContentNameInfo === 'function') {
226
+ try {
227
+ const info = helpers.getContentNameInfo(el, ctx);
228
+ if (info && info.present && info.value) return normalizeWs(info.value);
229
+ } catch {
230
+ // fall through to title
231
+ }
232
+ }
233
+
234
+ return normalizeWs(el.getAttribute && el.getAttribute('title'));
235
+ }
236
+
237
+ const isAccTreeEligible =
238
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
239
+
240
+ function isEligible(el) {
241
+ if (!isAccTreeEligible) return true;
242
+ try {
243
+ const r = isAccTreeEligible(el, ctx);
244
+ if (typeof r === 'boolean') return r;
245
+ return !!(r && r.eligible);
246
+ } catch {
247
+ return true;
248
+ }
249
+ }
250
+
251
+ function classify(normalized) {
252
+ if (PLACEHOLDER_HEADING_TEXT.has(normalized) || NUMBERED_PLACEHOLDER.test(normalized)) {
253
+ return 'PLACEHOLDER_HEADING_TEXT';
254
+ }
255
+ if (URL_LIKE.test(normalized)) return 'URL_LIKE_HEADING';
256
+ if (FILENAME_LIKE.test(normalized)) return 'FILENAME_LIKE_HEADING';
257
+ return '';
258
+ }
259
+
260
+ const SUMMARY_KEY_BY_REASON = {
261
+ PLACEHOLDER_HEADING_TEXT: 'headingQuality_summary_cantTell_placeholder',
262
+ FILENAME_LIKE_HEADING: 'headingQuality_summary_cantTell_filename',
263
+ URL_LIKE_HEADING: 'headingQuality_summary_cantTell_url'
264
+ };
265
+
266
+ const selector = 'h1, h2, h3, h4, h5, h6, [role]';
267
+ const nodes = helpers.queryAllSmart
268
+ ? helpers.queryAllSmart(selector)
269
+ : helpers.queryAll(selector);
270
+
271
+ const occurrences = [];
272
+ let applicableCount = 0;
273
+
274
+ for (const el of nodes) {
275
+ if (!el || !el.getAttribute) continue;
276
+ if (!isHeading(el)) continue;
277
+ if (!isEligible(el)) continue;
278
+
279
+ const rawName = getAccessibleNameText(el);
280
+ const normalized = normalize(rawName);
281
+ if (!normalized) continue; // no name at all: empty-heading's concern.
282
+
283
+ applicableCount += 1;
284
+
285
+ const reasonCode = classify(normalized);
286
+ if (!reasonCode) continue;
287
+
288
+ const eligInfo = helpers.getEligibilityInfo
289
+ ? (() => {
290
+ try {
291
+ return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' });
292
+ } catch {
293
+ return null;
294
+ }
295
+ })()
296
+ : null;
297
+
298
+ const name = normalizeWs(rawName);
299
+ const summaryByReason = {
300
+ PLACEHOLDER_HEADING_TEXT: `This heading's accessible name ("${name}") is a placeholder rather than a description of the content it introduces.`,
301
+ FILENAME_LIKE_HEADING: `This heading's accessible name ("${name}") is a filename rather than a description of the content it introduces.`,
302
+ URL_LIKE_HEADING: `This heading's accessible name ("${name}") is a URL rather than a description of the content it introduces.`
303
+ };
304
+
305
+ occurrences.push(
306
+ helpers.reportOccurrence(el, {
307
+ summary: summaryByReason[reasonCode],
308
+ hint: 'Rewrite the heading so it names the topic or purpose of the content that follows it.',
309
+ i18n: {
310
+ summaryKey: SUMMARY_KEY_BY_REASON[reasonCode],
311
+ hintKey: 'headingQuality_hint_cantTell',
312
+ params: { name }
313
+ },
314
+ data: {
315
+ details: { reasonCode, name, normalizedName: normalized },
316
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
317
+ }
318
+ })
319
+ );
320
+ }
321
+
322
+ if (applicableCount === 0) {
323
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
324
+ }
325
+
326
+ if (occurrences.length) {
327
+ return {
328
+ ruleId: rule.ruleId,
329
+ outcome: 'cantTell',
330
+ severity: rule.defaultSeverity || 'minor',
331
+ occurrences
332
+ };
333
+ }
334
+
335
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
336
+ }
337
+
338
+ module.exports = { id, meta, runInPage };
@@ -9,22 +9,30 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.4.9
11
11
  * @applicability
12
- * Any `a[href]` with a non-empty accessible name, grouped by that name
13
- * (trimmed, whitespace-collapsed, case-folded).
12
+ * Any `a[href]` or `[role="link"]` with a non-empty accessible name,
13
+ * grouped by that name (trimmed, whitespace-collapsed, case-folded).
14
14
  * @expectation
15
15
  * Within a page, links that share the same accessible name are expected
16
- * to serve the same purpose (i.e. resolve to the same destination — the
16
+ * to serve the same purpose (i.e. resolve to the same destination, the
17
17
  * full resolved URL, including any fragment). Same-text-different-
18
18
  * destination links are common and frequently intentional in real
19
19
  * sites (e.g. repeated "Read more" links per article card), so this is
20
20
  * authored as `type: 'manual'` (cantTell-capped, never fail) rather
21
- * than a hard fail — flagging a real name/destination mismatch for
21
+ * than a hard fail, flagging a real name/destination mismatch for
22
22
  * human judgment instead of guessing intent.
23
23
  * @implementation-notes
24
24
  * - Destination comparison uses the DOM `.href` property (already
25
25
  * resolved to an absolute URL by the engine/browser), not the raw
26
- * `href` attribute — so relative vs. absolute forms of the same target
26
+ * `href` attribute, so relative vs. absolute forms of the same target
27
27
  * are correctly treated as identical.
28
+ * - A `role="link"` element with no `href` (ACT fd3a94/b20e66's own
29
+ * `<span role="link" onclick="location='...'">`-style examples) falls
30
+ * back to extracting the destination from a `location`/`location.href`
31
+ * assignment or `location.assign(...)`/`location.replace(...)` call in
32
+ * its `onclick` attribute, a literal string already present in
33
+ * markup, not something that requires executing script. An unrecognized
34
+ * onclick shape is simply not resolved (this rule is cantTell-capped,
35
+ * so a missed destination costs recall, never a false fail).
28
36
  * - Only flags when a name group actually contains more than one
29
37
  * distinct destination; a name reused for links that all point to the
30
38
  * same place is not flagged.
@@ -66,9 +74,34 @@ function runInPage(ctx) {
66
74
  return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
67
75
  }
68
76
 
77
+ // A location-assignment call in an onclick attribute is a literal
78
+ // string already present in markup (`location='...'`,
79
+ // `location.href='...'`, `window.location.assign('...')`, etc.) --
80
+ // reading it needs no script execution, just a regex over the
81
+ // attribute value.
82
+ const ONCLICK_LOCATION_RE =
83
+ /(?:window\s*\.\s*)?location(?:\s*\.\s*href)?\s*=\s*['"]([^'"]+)['"]|(?:window\s*\.\s*)?location\s*\.\s*(?:assign|replace)\s*\(\s*['"]([^'"]+)['"]/;
84
+
85
+ function resolveOnclickLocation(el) {
86
+ try {
87
+ const onclick = el.getAttribute('onclick') || '';
88
+ if (!onclick) return '';
89
+ const m = onclick.match(ONCLICK_LOCATION_RE);
90
+ const raw = m ? m[1] || m[2] : '';
91
+ if (!raw) return '';
92
+ try {
93
+ return new URL(raw, el.ownerDocument.baseURI).href;
94
+ } catch {
95
+ return raw;
96
+ }
97
+ } catch {
98
+ return '';
99
+ }
100
+ }
101
+
69
102
  const nodes = helpers.queryAllSmart
70
- ? helpers.queryAllSmart('a[href]')
71
- : helpers.queryAll('a[href]');
103
+ ? helpers.queryAllSmart('a[href], [role="link"]')
104
+ : helpers.queryAll('a[href], [role="link"]');
72
105
 
73
106
  const groups = new Map(); // normName -> [{ el, href }]
74
107
  let applicableCount = 0;
@@ -81,20 +114,48 @@ function runInPage(ctx) {
81
114
  typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
82
115
  if (!eligible) continue;
83
116
 
117
+ // getAccessibleNameInfo alone only covers ARIA/label/title naming, not
118
+ // name-from-content -- a raw el.textContent fallback misses a link
119
+ // named only by a descendant image's alt text (e.g.
120
+ // <a href="..."><img alt="ACT rules"></a> has no text nodes at all).
121
+ // getContentNameInfo is the same content-aware helper link-name-present
122
+ // uses for exactly this reason.
84
123
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
85
- const rawName =
86
- nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim()
87
- ? nameInfo.value
88
- : el.textContent || '';
124
+ let rawName =
125
+ nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim() ? nameInfo.value : '';
126
+ if (!rawName) {
127
+ const contentInfo = helpers.getContentNameInfo ? helpers.getContentNameInfo(el, ctx) : null;
128
+ rawName =
129
+ contentInfo && typeof contentInfo.value === 'string' && contentInfo.value.trim()
130
+ ? contentInfo.value
131
+ : el.textContent || '';
132
+ }
89
133
  const name = normName(rawName);
90
134
  if (!name) continue;
91
135
 
136
+ // `.href` on an SVG <a> is an SVGAnimatedString, not a plain string
137
+ // IDL property like HTMLAnchorElement.href -- `String(el.href)` would
138
+ // stringify the wrapper object itself, not its value, silently
139
+ // grouping every SVG link under the same bogus "destination". Fall
140
+ // back to the raw attribute, resolved against the document's base
141
+ // URL, whenever `.href` isn't already a usable string.
92
142
  let href;
93
143
  try {
94
- href = String(el.href || '');
144
+ href = typeof el.href === 'string' ? el.href : '';
145
+ if (!href) {
146
+ const raw = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
147
+ if (raw) {
148
+ try {
149
+ href = new URL(raw, el.ownerDocument.baseURI).href;
150
+ } catch {
151
+ href = raw;
152
+ }
153
+ }
154
+ }
95
155
  } catch {
96
156
  href = '';
97
157
  }
158
+ if (!href) href = resolveOnclickLocation(el);
98
159
  if (!href) continue;
99
160
 
100
161
  applicableCount += 1;
@@ -6,11 +6,11 @@
6
6
  * @check image-redundant-alt
7
7
  * @atomic true
8
8
  * @summary An image's alt text must not duplicate adjacent visible text
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies to <img> elements with non-empty alt text whose immediate
12
12
  * parent element also has other visible text content (i.e. text nodes
13
- * besides the image itself — commonly an <a> or <button> wrapping both
13
+ * besides the image itself, commonly an <a> or <button> wrapping both
14
14
  * an icon image and a text label).
15
15
  * @expectation
16
16
  * The image's alt text is not the same (case-insensitive, normalized)
@@ -19,10 +19,10 @@
19
19
  * single control (e.g. an icon-plus-text link where the icon's alt
20
20
  * duplicates the link text).
21
21
  * @implementation-notes
22
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
23
23
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
24
24
  * header comment for the shared rationale/precedent.
25
- * - Deliberately scoped to the immediate parent only (not the whole
25
+ * - Scoped to the immediate parent only on purpose (not the whole
26
26
  * ancestor chain or arbitrary siblings), to keep the "redundant"
27
27
  * judgment high-confidence and avoid false positives from unrelated
28
28
  * text elsewhere on the page that coincidentally matches.