@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,220 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check svg-image-text-alternative-present
5
+ * @atomic true
6
+ * @summary Accessible SVG <image> elements must provide a text alternative
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to SVG <image> elements that are exposed to assistive technologies.
11
+ * Elements otherwise hidden from the accessibility tree remain applicable
12
+ * if they are tabbable or referenced by IDREF relationships (per engine eligibility checks).
13
+ * SVG <image> elements with role="presentation" or role="none" are excluded only when they are not focusable.
14
+ * @expectation
15
+ * Each applicable SVG <image> element has a text alternative via:
16
+ * - a non-empty direct <title> child, OR
17
+ * - a non-empty direct <desc> child, OR
18
+ * - an accessible name (aria-label / aria-labelledby / title attribute).
19
+ */
20
+
21
+ const id = 'svg-image-text-alternative-present';
22
+
23
+ const meta = {
24
+ title: 'SVG <image> must have a text alternative',
25
+ description: 'Checks that SVG <image> elements provide a text alternative via <title>/<desc> or an ARIA accessible name.',
26
+ i18n: {
27
+ titleKey: 'svgImage_textAltPresent_title',
28
+ descriptionKey: 'svgImage_textAltPresent_description'
29
+ },
30
+ helpUrl: null,
31
+ tags: ['wcag2a', 'wcag111', 'nontext', 'svg', 'image', 'atomic', 'automatic'],
32
+ wcagSc: ['1.1.1'],
33
+ normativeMappings: [
34
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
35
+ ],
36
+ defaultSeverity: 'serious',
37
+ category: 'perceivable',
38
+ type: 'automatic',
39
+ defaultConfidence: 'medium',
40
+ coverage: {
41
+ facetsBySc: {
42
+ '1.1.1': ['svg-image-text-alt-present']
43
+ }
44
+ }
45
+ };
46
+
47
+ function runInPage(ctx) {
48
+ const { document, root, helpers, rule } = ctx;
49
+ const safeRoot = root || document;
50
+
51
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
52
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
53
+ ? helpers.queryAll
54
+ : (sel) => {
55
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
56
+ catch { return []; }
57
+ };
58
+
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 isFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
68
+ ? helpers.getFocusableInfo
69
+ : null;
70
+
71
+ const getAccessibleNameInfo = helpers && typeof helpers.getAccessibleNameInfo === 'function'
72
+ ? helpers.getAccessibleNameInfo
73
+ : null;
74
+
75
+ function trim(v) { return (v == null ? '' : String(v)).trim(); }
76
+
77
+ // Per SVG accessible-name conventions, only a <title> that is literally
78
+ // the first child element is used by assistive technologies as the
79
+ // element's accessible name; a <title> appearing later among the
80
+ // children is commonly ignored by AT even though it's still a valid DOM
81
+ // child.
82
+ function firstChildTitleText(el) {
83
+ try {
84
+ if (!el) return '';
85
+ const first = el.firstElementChild;
86
+ const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
87
+ if (tn === 'title') {
88
+ const t = trim(first.textContent);
89
+ if (t) return t;
90
+ }
91
+ } catch {}
92
+ return '';
93
+ }
94
+
95
+ // <desc> counts when it is the first child, or the second child
96
+ // immediately following a <title> — the standard <title>+<desc> pairing.
97
+ // A <desc> appearing later than that is not reliably read by AT.
98
+ function descText(el) {
99
+ try {
100
+ if (!el) return '';
101
+ const first = el.firstElementChild;
102
+ const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
103
+ if (firstTag === 'desc') {
104
+ const t = trim(first.textContent);
105
+ if (t) return t;
106
+ } else if (firstTag === 'title' && first.nextElementSibling) {
107
+ const second = first.nextElementSibling;
108
+ const secondTag = (second.localName || second.tagName || '').toLowerCase();
109
+ if (secondTag === 'desc') {
110
+ const t = trim(second.textContent);
111
+ if (t) return t;
112
+ }
113
+ }
114
+ } catch {}
115
+ return '';
116
+ }
117
+
118
+ const rawImages = (() => {
119
+ try { return Array.from((queryAllSmart ? queryAllSmart('image') : queryAll('image')) || []); }
120
+ catch { return queryAll('svg image'); }
121
+ })();
122
+
123
+ const images = rawImages.filter((el) => {
124
+ try { return el && (el.namespaceURI === 'http://www.w3.org/2000/svg') && (String(el.localName).toLowerCase() === 'image'); }
125
+ catch { return false; }
126
+ });
127
+
128
+ if (!images.length) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
129
+
130
+ const occurrences = [];
131
+ let applicableCount = 0;
132
+
133
+ for (const el of images) {
134
+ if (!el || !el.getAttribute) continue;
135
+
136
+ if (isAccTreeEligible) {
137
+ const elig = (() => {
138
+ try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
139
+ })();
140
+ if (elig && elig.eligible === false) continue;
141
+ }
142
+
143
+ const role = trim(el.getAttribute('role')).toLowerCase();
144
+ if (role === 'presentation' || role === 'none') {
145
+ let focusable = false;
146
+ if (isFocusableInfo) {
147
+ const fi = (() => { try { return isFocusableInfo(el, ctx); } catch { return null; } })();
148
+ focusable = !!(fi && fi.focusable);
149
+ } else {
150
+ const tabindex = el.getAttribute('tabindex');
151
+ focusable = tabindex != null && trim(tabindex) !== '' && !Number.isNaN(Number(trim(tabindex)));
152
+ }
153
+ if (!focusable) continue;
154
+ }
155
+
156
+ applicableCount += 1;
157
+
158
+ const titleText = firstChildTitleText(el);
159
+ if (titleText) continue;
160
+
161
+ const desc = descText(el);
162
+ if (desc) continue;
163
+
164
+ // Only now check “accessible name” (but scoped to allowed mechanisms)
165
+ const ariaLabelRaw = (() => { try { return el.getAttribute('aria-label'); } catch { return null; } })();
166
+ const ariaLabelledbyRaw = (() => { try { return el.getAttribute('aria-labelledby'); } catch { return null; } })();
167
+ const titleAttrRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
168
+
169
+ const ariaLabel = trim(ariaLabelRaw);
170
+ const ariaLabelledby = trim(ariaLabelledbyRaw);
171
+ const titleAttr = trim(titleAttrRaw);
172
+
173
+ const hasMechanism =
174
+ (ariaLabelRaw !== null && ariaLabel.length > 0) ||
175
+ (ariaLabelledbyRaw !== null && ariaLabelledby.length > 0) ||
176
+ (titleAttrRaw !== null && titleAttr.length > 0);
177
+
178
+ let nameInfo = null;
179
+ let hasName = false;
180
+
181
+ if (hasMechanism) {
182
+ if (getAccessibleNameInfo) {
183
+ nameInfo = (() => { try { return getAccessibleNameInfo(el, ctx); } catch { return null; } })();
184
+ hasName = !!(nameInfo && nameInfo.present && trim(nameInfo.value));
185
+ } else {
186
+ // Without helper, accept presence of non-empty allowed attributes
187
+ hasName = true;
188
+ }
189
+ }
190
+
191
+ if (hasName) continue;
192
+
193
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
194
+
195
+ const baseOccurrence = {
196
+ summary: 'Missing text alternative on SVG <image>.',
197
+ hint: 'Add a <title> (and optionally <desc>) inside <image>, or provide aria-label/aria-labelledby.',
198
+ i18n: {
199
+ summaryKey: 'svgImage_textAltPresent_summary_fail',
200
+ hintKey: 'svgImage_textAltPresent_hint_fail',
201
+ params: { element: 'image' }
202
+ },
203
+ data: {
204
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
205
+ }
206
+ };
207
+
208
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
209
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
210
+ } else {
211
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
212
+ }
213
+ }
214
+
215
+ if (applicableCount === 0) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
216
+ if (!occurrences.length) return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
217
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
218
+ }
219
+
220
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,298 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check svg-text-alternative-present
5
+ * @atomic true
6
+ * @summary Accessible <svg> elements must provide a text alternative
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to inline <svg> elements that are exposed to assistive technologies AND appear intended to be conveyed.
11
+ * "Intended to be conveyed" is approximated deterministically by at least one of:
12
+ * - role="img"
13
+ * - aria-label / aria-labelledby present
14
+ * - <title> or <desc> present (desc alone is an applicability signal only — see @expectation)
15
+ * - focusable/tabbable (e.g., tabindex, native focusability)
16
+ *
17
+ * Images with role="presentation" or role="none" are excluded only when they are not focusable.
18
+ * Elements otherwise hidden from the accessibility tree remain applicable
19
+ * if they are tabbable-focusable or referenced by IDREF relationships (per engine eligibility checks).
20
+ * @expectation
21
+ * Each applicable <svg> element provides a text alternative via:
22
+ * - non-empty <title> text, OR
23
+ * - an ARIA name (aria-label / aria-labelledby).
24
+ * A <desc> element alone does NOT satisfy this — per the SVG Accessibility
25
+ * API Mappings spec §7.1, <desc> only ever contributes to the accessible
26
+ * DESCRIPTION, never the accessible NAME (verified 2026-07-20 directly
27
+ * against the SVG-AAM spec text, not assumed). An <svg> with only a
28
+ * <desc> and no <title>/ARIA name is still "applicable" (desc signals
29
+ * authorial intent) but fails, matching a widely-used reference engine's svg-img-alt.
30
+ */
31
+
32
+ const id = 'svg-text-alternative-present';
33
+
34
+ const meta = {
35
+ title: '<svg> must provide a text alternative',
36
+ description: 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
37
+ i18n: {
38
+ titleKey: 'svg_textAltPresent_title',
39
+ descriptionKey: 'svg_textAltPresent_description'
40
+ },
41
+ helpUrl: null,
42
+ tags: ['wcag2a', 'wcag111', 'svg', 'nontext', 'images', 'atomic', 'automatic'],
43
+ wcagSc: ['1.1.1'],
44
+ normativeMappings: [
45
+ {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
46
+ ],
47
+ defaultSeverity: 'serious',
48
+ category: 'perceivable',
49
+ type: 'automatic',
50
+ defaultConfidence: 'high',
51
+ coverage: {
52
+ facetsBySc: {
53
+ '1.1.1': ['svg-text-alternative-present']
54
+ }
55
+ }
56
+ };
57
+
58
+ function runInPage(ctx) {
59
+ const {document, root, helpers, rule} = ctx;
60
+ const safeRoot = root || document;
61
+
62
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
63
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
64
+ ? helpers.queryAll
65
+ : (sel) => {
66
+ try {
67
+ return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
68
+ } catch {
69
+ return [];
70
+ }
71
+ };
72
+
73
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
74
+ ? helpers.getEligibilityInfo
75
+ : null;
76
+
77
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
78
+ ? helpers.isAccTreeEligible
79
+ : null;
80
+
81
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
82
+ ? helpers.getFocusableInfo
83
+ : null;
84
+
85
+ const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
86
+ ? helpers.getAriaNameInfo
87
+ : null;
88
+
89
+ function trim(v) {
90
+ try {
91
+ return (v == null ? '' : String(v)).trim();
92
+ } catch {
93
+ return '';
94
+ }
95
+ }
96
+
97
+ // Per SVG accessible-name conventions, only a <title> that is literally
98
+ // the first child element is used by assistive technologies as the
99
+ // SVG's accessible name; a <title> appearing later among the children
100
+ // is commonly ignored by AT even though it's still a valid DOM child.
101
+ function nonEmptyFirstChildTitleText(svg) {
102
+ try {
103
+ const first = svg.firstElementChild;
104
+ const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
105
+ if (tn === 'title') {
106
+ const txt = trim(first.textContent);
107
+ if (txt) return txt;
108
+ }
109
+ } catch {
110
+ }
111
+ return '';
112
+ }
113
+
114
+ // <desc> counts when it is the first child, or the second child
115
+ // immediately following a <title> — the standard <title>+<desc> pairing
116
+ // (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
117
+ // appearing later than that is not reliably read by AT.
118
+ function nonEmptyDescText(svg) {
119
+ try {
120
+ const first = svg.firstElementChild;
121
+ const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
122
+ if (firstTag === 'desc') {
123
+ const txt = trim(first.textContent);
124
+ if (txt) return txt;
125
+ } else if (firstTag === 'title' && first.nextElementSibling) {
126
+ const second = first.nextElementSibling;
127
+ const secondTag = (second.localName || second.tagName || '').toLowerCase();
128
+ if (secondTag === 'desc') {
129
+ const txt = trim(second.textContent);
130
+ if (txt) return txt;
131
+ }
132
+ }
133
+ } catch {
134
+ }
135
+ return '';
136
+ }
137
+
138
+ function isFocusable(svg) {
139
+ if (getFocusableInfo) {
140
+ const fi = (() => {
141
+ try {
142
+ return getFocusableInfo(svg, ctx);
143
+ } catch {
144
+ return null;
145
+ }
146
+ })();
147
+ return !!(fi && fi.focusable);
148
+ }
149
+ // deterministic fallback: tabindex presence/valid number
150
+ try {
151
+ const tabindex = svg && svg.getAttribute ? svg.getAttribute('tabindex') : null;
152
+ return tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ const svgs = (() => {
159
+ try {
160
+ return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
161
+ } catch {
162
+ return queryAll('svg');
163
+ }
164
+ })();
165
+
166
+ if (!svgs.length) {
167
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
168
+ }
169
+
170
+ const occurrences = [];
171
+ let applicableCount = 0;
172
+
173
+ for (const el of svgs) {
174
+ if (!el || !el.getAttribute) continue;
175
+
176
+ // Applicability step 1: only acc-tree eligible nodes (with helper exceptions)
177
+ if (isAccTreeEligible) {
178
+ const elig = (() => {
179
+ try {
180
+ return isAccTreeEligible(el, ctx);
181
+ } catch {
182
+ return {eligible: true, reasons: []};
183
+ }
184
+ })();
185
+ if (elig && elig.eligible === false) continue;
186
+ }
187
+
188
+ // Applicability step 2: role (presentation/none) exclusion only when not focusable
189
+ const role = (() => {
190
+ try {
191
+ return String(el.getAttribute('role') || '').trim().toLowerCase();
192
+ } catch {
193
+ return '';
194
+ }
195
+ })();
196
+
197
+ const focusable = isFocusable(el);
198
+
199
+ if (role === 'presentation' || role === 'none') {
200
+ if (!focusable) continue;
201
+ }
202
+
203
+ // Applicability step 3: intent signal gating (computed once per element)
204
+ let hasAriaNamingAttr = false;
205
+ try {
206
+ hasAriaNamingAttr = (el.getAttribute('aria-label') != null) || (el.getAttribute('aria-labelledby') != null);
207
+ } catch {
208
+ }
209
+
210
+ const titleText = nonEmptyFirstChildTitleText(el);
211
+ const descText = titleText ? '' : nonEmptyDescText(el); // avoid second scan if title already passes
212
+ const hasTitleOrDesc = !!(titleText || descText);
213
+
214
+ const hasIntent =
215
+ role === 'img' ||
216
+ hasAriaNamingAttr ||
217
+ hasTitleOrDesc ||
218
+ focusable;
219
+
220
+ if (!hasIntent) continue;
221
+
222
+ applicableCount += 1;
223
+
224
+ // Expectation: non-empty title/desc OR ARIA name (but only resolve name if attrs exist)
225
+ let hasAriaName = false;
226
+ if (hasAriaNamingAttr) {
227
+ if (getAriaNameInfo) {
228
+ const info = (() => {
229
+ try {
230
+ return getAriaNameInfo(el, ctx);
231
+ } catch {
232
+ return null;
233
+ }
234
+ })();
235
+ hasAriaName = !!(info && info.present && trim(info.value));
236
+ } else {
237
+ // minimal deterministic fallback
238
+ const ariaLabel = trim((() => {
239
+ try {
240
+ return el.getAttribute('aria-label');
241
+ } catch {
242
+ return '';
243
+ }
244
+ })());
245
+ const ariaLabelledby = trim((() => {
246
+ try {
247
+ return el.getAttribute('aria-labelledby');
248
+ } catch {
249
+ return '';
250
+ }
251
+ })());
252
+ hasAriaName = !!(ariaLabel || ariaLabelledby);
253
+ }
254
+ }
255
+
256
+ // Per SVG-AAM §7.1: <desc> contributes only to the accessible
257
+ // DESCRIPTION, never the accessible NAME — so descText does not
258
+ // count here even though it does count toward applicability above.
259
+ const ok = !!titleText || hasAriaName;
260
+ if (ok) continue;
261
+
262
+ let eligInfo = null;
263
+ if (getEligibilityInfo) {
264
+ try { eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { eligInfo = null; }
265
+ }
266
+
267
+ const baseOccurrence = {
268
+ summary: 'Missing text alternative for <svg>.',
269
+ hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
270
+ i18n: {
271
+ summaryKey: 'svg_textAltPresent_summary_fail',
272
+ hintKey: 'svg_textAltPresent_hint_fail',
273
+ params: {element: 'svg'}
274
+ },
275
+ data: {
276
+ visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []}
277
+ }
278
+ };
279
+
280
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
281
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
282
+ } else {
283
+ occurrences.push({selector: '', html: '', ...baseOccurrence});
284
+ }
285
+ }
286
+
287
+ if (applicableCount === 0) {
288
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
289
+ }
290
+
291
+ if (!occurrences.length) {
292
+ return {ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: []};
293
+ }
294
+
295
+ return {ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences};
296
+ }
297
+
298
+ module.exports = {id, meta, runInPage};