@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check link-in-text-block
5
+ * @atomic true
6
+ * @summary Links surrounded by text must be distinguishable from that text by non-color means
7
+ * @standard WCAG 2.2
8
+ * @sc 1.4.1
9
+ * @applicability
10
+ * Applies to <a href> elements whose immediate parent element also has
11
+ * at least one direct-child text node with non-whitespace content
12
+ * (i.e. the link sits inline within a run of plain text, not as a
13
+ * standalone item — e.g. not the sole content of a <li> nav item).
14
+ * @expectation
15
+ * A link inside a text block must be visually distinguishable from the
16
+ * surrounding text by at least one non-color means:
17
+ * - text-decoration: underline, OR
18
+ * - a different font-weight than the surrounding text, OR
19
+ * - a different font-style than the surrounding text, OR
20
+ * - a contrast ratio of at least 3:1 between the link's text color and
21
+ * the surrounding text's color (WCAG technique G183's threshold —
22
+ * sufficient contrast alone is an accepted alternative to underline).
23
+ * Fails only when none of the above hold AND the color contrast between
24
+ * link and surrounding text is confidently computable and below 3:1 —
25
+ * i.e. color is demonstrably the only cue.
26
+ * @implementation-notes
27
+ * - "Surrounding text style" is approximated as the link's immediate
28
+ * parent element's own computed style, not a full inline-context walk
29
+ * of the actual adjacent text node(s) — a deliberate scope-down, since
30
+ * plain text nodes inherit their rendering from the parent in the
31
+ * overwhelming majority of real markup.
32
+ * - When contrast is not confidently computable (background image/
33
+ * gradient, blend mode, filter, non-opaque ancestor — same blockers
34
+ * `contrast-minimum`/`contrast-computable` use), the link is silently
35
+ * skipped rather than flagged or reported as cantTell, to keep `fail`
36
+ * reserved for deterministic, high-confidence violations. This means
37
+ * the rule never emits cantTell — outcome is notApplicable/pass/fail
38
+ * only, matching this repo's other Tier 2 mechanical rules.
39
+ * - Reuses the shared `helpers.contrast` subsystem (same
40
+ * computeEffectiveForeground/Background, getComputabilityBlocker,
41
+ * contrastRatio helpers as `contrast-minimum`), rather than re-deriving
42
+ * color math independently.
43
+ * - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`) —
44
+ * matches the common real-world shape of this issue (prose links).
45
+ */
46
+
47
+ const id = 'link-in-text-block';
48
+
49
+ const meta = {
50
+ title: 'Links in text blocks must be distinguishable from surrounding text without relying on color alone',
51
+ description:
52
+ 'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference — not by color alone.',
53
+ i18n: {
54
+ titleKey: 'linkInTextBlock_title',
55
+ descriptionKey: 'linkInTextBlock_description'
56
+ },
57
+ helpUrl: null,
58
+ tags: ['wcag2a', 'wcag141', 'navigation', 'color', 'links', 'contrast', 'atomic', 'automatic', 'dom'],
59
+ wcagSc: ['1.4.1'],
60
+ normativeMappings: [
61
+ { standard: 'WCAG', version: '2.2', requirement: '1.4.1', title: 'Use of Color', conformanceLevel: 'A' }
62
+ ],
63
+ defaultSeverity: 'serious',
64
+ category: 'perceivable',
65
+ type: 'automatic',
66
+ defaultConfidence: 'high',
67
+ coverage: { facetsBySc: { '1.4.1': ['link-in-text-block'] } }
68
+ };
69
+
70
+ function runInPage(ctx) {
71
+ const { document, root, helpers, rule, engineOptions } = ctx;
72
+ const safeRoot = root || document;
73
+
74
+ function safeComputedStyle(el) {
75
+ try {
76
+ if (!el || el.nodeType !== 1) return null;
77
+ if (helpers && typeof helpers.computedStyle === 'function') {
78
+ const cs = helpers.computedStyle(el);
79
+ if (cs) return cs;
80
+ }
81
+ const view = (el.ownerDocument && el.ownerDocument.defaultView) ? el.ownerDocument.defaultView : null;
82
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
83
+ } catch {}
84
+ return null;
85
+ }
86
+
87
+ function decorationTokens(cs) {
88
+ const raw = `${(cs && cs.textDecorationLine) || ''} ${(cs && cs.textDecoration) || ''}`.toLowerCase();
89
+ return raw.split(/\s+/).filter(Boolean);
90
+ }
91
+
92
+ function hasUnderline(cs) {
93
+ return decorationTokens(cs).includes('underline');
94
+ }
95
+
96
+ function hasSurroundingText(el, parent) {
97
+ if (!parent || !parent.childNodes) return false;
98
+ for (let i = 0; i < parent.childNodes.length; i++) {
99
+ const n = parent.childNodes[i];
100
+ if (n === el) continue;
101
+ if (n.nodeType === 3 && n.nodeValue && n.nodeValue.trim().length > 0) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ const contrastOpts = engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
107
+ ? engineOptions.contrast
108
+ : {};
109
+ const mode = contrastOpts.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
110
+ const rootCanvasFallback =
111
+ typeof contrastOpts.rootCanvasFallback === 'string' && contrastOpts.rootCanvasFallback.trim()
112
+ ? contrastOpts.rootCanvasFallback.trim()
113
+ : '#ffffff';
114
+
115
+ const c = helpers && helpers.contrast ? helpers.contrast : null;
116
+
117
+ const selector = 'a[href]';
118
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
119
+
120
+ const occurrences = [];
121
+ let applicableCount = 0;
122
+
123
+ for (const el of nodes) {
124
+ if (!el || !el.getAttribute) continue;
125
+
126
+ const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
127
+ const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
128
+ if (!eligible) continue;
129
+
130
+ const parent = el.parentElement;
131
+ if (!hasSurroundingText(el, parent)) continue;
132
+
133
+ applicableCount += 1;
134
+
135
+ const linkCs = safeComputedStyle(el);
136
+ const parentCs = safeComputedStyle(parent);
137
+
138
+ if (hasUnderline(linkCs)) continue;
139
+
140
+ const linkWeight = c && linkCs ? c.normalizeFontWeight(linkCs.fontWeight) : 400;
141
+ const parentWeight = c && parentCs ? c.normalizeFontWeight(parentCs.fontWeight) : 400;
142
+ if (linkWeight !== parentWeight) continue;
143
+
144
+ const linkStyle = (linkCs && linkCs.fontStyle) || 'normal';
145
+ const parentStyle = (parentCs && parentCs.fontStyle) || 'normal';
146
+ if (linkStyle !== parentStyle) continue;
147
+
148
+ if (!c) continue;
149
+
150
+ let flagged = false;
151
+ let ratio = null;
152
+ let fgLinkHex = '';
153
+ let fgParentHex = '';
154
+
155
+ try {
156
+ const blocker = c.getComputabilityBlocker(el);
157
+ if (blocker && blocker.ok === false) {
158
+ // Not confidently computable — skip (benefit of the doubt).
159
+ } else {
160
+ const bg = c.computeEffectiveBackground(el, { contrast: { mode, rootCanvasFallback }, collectStack: false });
161
+ const fgLink = c.computeEffectiveForeground(el);
162
+ const fgParent = c.computeEffectiveForeground(parent);
163
+
164
+ if (bg && bg.ok && bg.rgba && fgLink && fgLink.rgba && fgParent && fgParent.rgba) {
165
+ const fgLinkOpaque = fgLink.rgba.a < 1 ? c.compositeRgba(fgLink.rgba, bg.rgba) : { r: fgLink.rgba.r, g: fgLink.rgba.g, b: fgLink.rgba.b, a: 1 };
166
+ const fgParentOpaque = fgParent.rgba.a < 1 ? c.compositeRgba(fgParent.rgba, bg.rgba) : { r: fgParent.rgba.r, g: fgParent.rgba.g, b: fgParent.rgba.b, a: 1 };
167
+
168
+ ratio = c.contrastRatio(fgLinkOpaque, fgParentOpaque);
169
+ fgLinkHex = c.rgbToHex ? c.rgbToHex(fgLinkOpaque) : '';
170
+ fgParentHex = c.rgbToHex ? c.rgbToHex(fgParentOpaque) : '';
171
+
172
+ if (!(ratio >= 3)) flagged = true;
173
+ }
174
+ // else: not confidently computable — skip.
175
+ }
176
+ } catch {
177
+ // no-throw: treat as not computable, skip.
178
+ }
179
+
180
+ if (!flagged) continue;
181
+
182
+ const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
183
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
184
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
185
+ const tag = (el.tagName || '').toLowerCase();
186
+ const ratioStr = c.round2 ? c.round2(ratio) : String(ratio);
187
+
188
+ occurrences.push({
189
+ selector: stableSelector,
190
+ html,
191
+ summary: 'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
192
+ hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
193
+ i18n: {
194
+ summaryKey: 'linkInTextBlock_summary_fail',
195
+ hintKey: 'linkInTextBlock_hint_fail',
196
+ params: { element: tag, ratio: String(ratioStr), threshold: '3' }
197
+ },
198
+ data: {
199
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
200
+ details: {
201
+ reasonCode: 'COLOR_ONLY_DIFFERENTIATION',
202
+ metrics: { ratio, threshold: 3 },
203
+ colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
204
+ }
205
+ }
206
+ });
207
+ }
208
+
209
+ if (applicableCount === 0) {
210
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
211
+ }
212
+ if (occurrences.length) {
213
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
214
+ }
215
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
216
+ }
217
+
218
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ const id = 'link-name-present';
4
+
5
+ const meta = {
6
+ title: 'Links have an accessible name',
7
+ description: 'Checks that links expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'linkNamePresent_title',
10
+ descriptionKey: 'linkNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
14
+ wcagSc: ['4.1.2'],
15
+ normativeMappings: [
16
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
17
+ ],
18
+ defaultSeverity: 'serious',
19
+ category: 'robust',
20
+ type: 'automatic',
21
+ defaultConfidence: 'high',
22
+ // IMPORTANT: update your facets registry to include "link-name-present" under 4.1.2
23
+ coverage: { facetsBySc: { '4.1.2': ['link-name-present'] } }
24
+ };
25
+
26
+ function runInPage(ctx) {
27
+ const { document, root, helpers, rule } = ctx;
28
+ const safeRoot = root || document;
29
+
30
+ const occurrences = [];
31
+ let applicableCount = 0;
32
+
33
+ function getConservativeSubtreeText(container) {
34
+ // "Name from content" — recurses into descendants and uses each one's
35
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
36
+ // it has one, not just literal text nodes. See getContentNameInfo's
37
+ // header comment in src/core/dom-helpers.js for the full rationale
38
+ // (this replaced a text-node-only TreeWalker that missed the common
39
+ // "<a><img alt='...'></a>" logo-link pattern).
40
+ if (helpers.getContentNameInfo) {
41
+ const info = helpers.getContentNameInfo(container, ctx);
42
+ return info && info.present ? info.value : '';
43
+ }
44
+ const t = (container && container.textContent) ? String(container.textContent) : '';
45
+ return t.replace(/\s+/g, ' ').trim();
46
+ }
47
+
48
+ const selector = 'a[href], area[href], [role="link"]';
49
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
50
+
51
+ for (const el of nodes) {
52
+ // isAccTreeEligible returns { eligible, reasons }, not a boolean.
53
+ const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
54
+ const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
55
+ if (!eligible) continue;
56
+
57
+ applicableCount += 1;
58
+
59
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
60
+ const programmaticName = (nameInfo && typeof nameInfo.value === 'string') ? nameInfo.value : '';
61
+
62
+ const contentName = programmaticName.trim().length === 0 ? getConservativeSubtreeText(el) : '';
63
+ const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
64
+
65
+ if (finalName.length === 0) {
66
+ // Only compute the richer eligibility-info payload (used solely for
67
+ // the occurrence's visibilityFilter) once we know an occurrence is
68
+ // actually being built, rather than for every applicable element.
69
+ const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
70
+
71
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
72
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
73
+ const tag = (el.tagName || '').toLowerCase();
74
+
75
+ occurrences.push({
76
+ selector: stableSelector,
77
+ html,
78
+
79
+ // Human fallbacks (allowed)
80
+ summary: 'This link has no accessible name.',
81
+ hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
82
+
83
+ // Validator requires these keys to exist in the English dictionary
84
+ i18n: {
85
+ summaryKey: 'linkNamePresent_summary_fail',
86
+ hintKey: 'linkNamePresent_hint_fail',
87
+ params: { element: tag }
88
+ },
89
+
90
+ data: {
91
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
92
+ details: {
93
+ reasonCode: 'name_missing',
94
+ metrics: {
95
+ programmaticNameLength: programmaticName.trim().length,
96
+ contentNameLength: contentName.trim().length
97
+ },
98
+ refs: { accessibleName: nameInfo || null }
99
+ }
100
+ }
101
+ });
102
+ }
103
+ }
104
+
105
+ if (applicableCount === 0) {
106
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
107
+ }
108
+ if (occurrences.length) {
109
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
110
+ }
111
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
112
+ }
113
+
114
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check list-children-valid
5
+ * @atomic true
6
+ * @summary <ul>/<ol> must only directly contain <li>, <script>, or <template>
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.1
9
+ * @applicability
10
+ * Applies to <ul>/<ol> elements that have at least one direct element
11
+ * child.
12
+ * @expectation
13
+ * Every direct element child is <li>, <script>, or <template> — UNLESS it
14
+ * has an explicit `role` attribute, in which case the explicit role wins
15
+ * over the tag entirely: a child is valid iff that role is "listitem"
16
+ * (so `<li role="presentation">`/`<li role="menuitem">` are invalid
17
+ * despite the <li> tag, and conversely a non-<li> element explicitly
18
+ * given `role="listitem"` is valid). A wrapper <div> used for styling
19
+ * (no role at all) still breaks list semantics the same as before.
20
+ * @implementation-notes
21
+ * - Checked via el.children, which already excludes text/comment nodes —
22
+ * no whitespace-node filtering needed.
23
+ * - Distinct, atomic decision from listitem-parent-valid (the
24
+ * inverse relationship: does a given <li> have a valid parent).
25
+ * - Direct children that are not exposed to the accessibility tree (e.g.
26
+ * display:none, [hidden], aria-hidden="true") are excluded from
27
+ * consideration entirely — matching a widely-used reference engine's own
28
+ * `only-listitems` check, which likewise skips non-AT-visible children. Confirmed against
29
+ * two real sites: Spotify's newsroom has a stray `<input type="hidden">`
30
+ * as a direct <ul> child (UA-stylesheet display:none by spec for
31
+ * input[type=hidden]), and Stanford's main nav interleaves `<li>` with
32
+ * `<span style="display:none">` hydration markers. Neither is reachable
33
+ * by assistive technology, so neither breaks the list semantics a screen
34
+ * reader actually announces.
35
+ * - The explicit-role-overrides-tag behavior above was added after reading
36
+ * a widely-used reference engine's ACTUAL `only-listitems` evaluate function
37
+ * directly (`invalidChildrenEvaluate`/`getInvalidSelector` — note this is a
38
+ * DIFFERENT function from the similarly-named, unused `onlyListitemsEvaluate`
39
+ * elsewhere in that engine's source; don't confuse the two when re-verifying). Its
40
+ * exact rule: if a child has an explicit role, only `validRoles` (here,
41
+ * `['listitem']`) is consulted — the tag name is never checked. Only
42
+ * without an explicit role does the tag name (`validNodeNames`) matter.
43
+ * Found via two real sites: Notion's footer nav lists include a
44
+ * `<li role="none">` used purely to host the list's own visually-hidden
45
+ * label (referenced by the `<ul>`'s `aria-labelledby`), and LinkedIn has
46
+ * both an all-`<li role="menuitem">` menu list and a footer list mixing
47
+ * real `<li>`s with one `<li role="presentation">` — that reference engine
48
+ * correctly flags all of these, surea11y's tag-only check missed them entirely.
49
+ */
50
+
51
+ const id = 'list-children-valid';
52
+
53
+ const meta = {
54
+ title: 'Lists must only directly contain list items',
55
+ description: 'Checks that <ul>/<ol> elements only have <li>, <script>, or <template> as direct children.',
56
+ i18n: {
57
+ titleKey: 'listChildrenValid_title',
58
+ descriptionKey: 'listChildrenValid_description'
59
+ },
60
+ helpUrl: null,
61
+ tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
62
+ wcagSc: ['1.3.1'],
63
+ normativeMappings: [
64
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
65
+ ],
66
+ defaultSeverity: 'serious',
67
+ category: 'perceivable',
68
+ type: 'automatic',
69
+ defaultConfidence: 'high',
70
+ coverage: { facetsBySc: { '1.3.1': ['list-children-valid'] } }
71
+ };
72
+
73
+ function runInPage(ctx) {
74
+ const { document, root, helpers, rule } = ctx;
75
+ const safeRoot = root || document;
76
+
77
+ // Declared inside runInPage — see scripts/build-core.js header
78
+ // ("runInPage MUST be self-contained").
79
+ const ALLOWED_CHILD_TAGS = new Set(['li', 'script', 'template']);
80
+
81
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('ul, ol', safeRoot) : helpers.queryAll('ul, ol', safeRoot);
82
+
83
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
84
+
85
+ function isExposedToAt(el) {
86
+ if (!isAccTreeEligible) return true;
87
+ try {
88
+ const r = isAccTreeEligible(el, ctx);
89
+ if (typeof r === 'boolean') return r;
90
+ return !!(r && r.eligible);
91
+ } catch {
92
+ return true;
93
+ }
94
+ }
95
+
96
+ const occurrences = [];
97
+ let applicableCount = 0;
98
+
99
+ for (const el of nodes) {
100
+ if (!el || !el.children) continue;
101
+ if (!el.children.length) continue;
102
+
103
+ applicableCount += 1;
104
+
105
+ const invalidTags = [];
106
+ for (const child of el.children) {
107
+ if (!child || !child.tagName) continue;
108
+ if (!isExposedToAt(child)) continue;
109
+ const tag = child.tagName.toLowerCase();
110
+
111
+ const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
112
+ const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
113
+
114
+ // An explicit role always wins over the tag — see the header
115
+ // comment's Notion/LinkedIn examples.
116
+ const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
117
+
118
+ if (!valid) invalidTags.push(tag);
119
+ }
120
+
121
+ if (!invalidTags.length) continue;
122
+
123
+ const tag = el.tagName.toLowerCase();
124
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
125
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
126
+
127
+ occurrences.push({
128
+ selector: stableSelector,
129
+ html,
130
+ summary: 'This list contains a direct child that is not a list item.',
131
+ hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
132
+ i18n: {
133
+ summaryKey: 'listChildrenValid_summary_fail',
134
+ hintKey: 'listChildrenValid_hint_fail',
135
+ params: { element: tag, invalidChildren: invalidTags.join(', ') }
136
+ },
137
+ data: {
138
+ details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
139
+ }
140
+ });
141
+ }
142
+
143
+ if (applicableCount === 0) {
144
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
145
+ }
146
+ if (occurrences.length) {
147
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
148
+ }
149
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
150
+ }
151
+
152
+ module.exports = { id, meta, runInPage };