@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,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check scope-attr-valid
5
+ * @atomic true
6
+ * @summary The scope attribute must have a valid value
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to elements with a non-empty scope attribute.
10
+ * @expectation
11
+ * The scope value is one of "row", "col", "rowgroup", or "colgroup"
12
+ * (case-insensitive). An invalid scope value is not recognized by
13
+ * assistive technology, silently losing the row/column header
14
+ * association it was meant to declare.
15
+ * @implementation-notes
16
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
17
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
18
+ * header comment for the shared rationale/precedent.
19
+ */
20
+
21
+ const id = 'scope-attr-valid';
22
+
23
+ const meta = {
24
+ title: 'scope attribute must have a valid value',
25
+ description: 'Checks that scope="..." is one of row, col, rowgroup, or colgroup.',
26
+ i18n: {
27
+ titleKey: 'scopeAttrValid_title',
28
+ descriptionKey: 'scopeAttrValid_description'
29
+ },
30
+ helpUrl: null,
31
+ tags: ['best-practice', 'tables', 'structure', 'atomic', 'manual'],
32
+ wcagSc: [],
33
+ normativeMappings: [],
34
+ defaultSeverity: 'minor',
35
+ category: 'perceivable',
36
+ type: 'manual',
37
+ defaultConfidence: 'medium',
38
+ coverage: {}
39
+ };
40
+
41
+ function runInPage(ctx) {
42
+ const { document, helpers, rule } = ctx;
43
+
44
+ const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
45
+
46
+ let nodes = [];
47
+ try {
48
+ nodes = document.querySelectorAll('[scope]');
49
+ } catch {
50
+ nodes = [];
51
+ }
52
+
53
+ const occurrences = [];
54
+ let applicableCount = 0;
55
+
56
+ for (const el of nodes) {
57
+ if (!el || !el.getAttribute) continue;
58
+ const raw = String(el.getAttribute('scope') || '').trim();
59
+ if (!raw) continue;
60
+
61
+ applicableCount += 1;
62
+
63
+ if (VALID_SCOPES.has(raw.toLowerCase())) continue;
64
+
65
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
66
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
67
+
68
+ occurrences.push({
69
+ selector: stableSelector,
70
+ html,
71
+ summary: 'This scope attribute value is not recognized.',
72
+ hint: 'Use one of row, col, rowgroup, or colgroup for the scope attribute.',
73
+ i18n: {
74
+ summaryKey: 'scopeAttrValid_summary_cantTell',
75
+ hintKey: 'scopeAttrValid_hint_cantTell',
76
+ params: { value: raw }
77
+ },
78
+ data: {
79
+ details: { reasonCode: 'SCOPE_ATTR_INVALID', value: raw }
80
+ }
81
+ });
82
+ }
83
+
84
+ if (applicableCount === 0) {
85
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
86
+ }
87
+ if (occurrences.length) {
88
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
89
+ }
90
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
91
+ }
92
+
93
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check scrollable-region-focusable
5
+ * @atomic true
6
+ * @summary A scrollable region with no focusable content should itself be keyboard-focusable
7
+ * @standard WCAG 2.2
8
+ * @sc 2.1.1, 2.1.3
9
+ * @applicability
10
+ * Deliberately scoped to a fixed set of likely-to-scroll container tags
11
+ * (div, section, article, aside, main, nav, pre, table, blockquote, ul,
12
+ * ol, textarea) with computed `overflow-x`/`overflow-y` of `auto` or
13
+ * `scroll` — not every element on the page, to keep this deterministic
14
+ * and performant (same style of scope-down as `region`).
15
+ * @expectation
16
+ * A region whose CSS declares it may scroll (`auto`/`scroll`) should be
17
+ * reachable by keyboard: either it already contains a focusable
18
+ * descendant (a link, button, form control, or `tabindex`-bearing
19
+ * element a keyboard user could tab into and then use arrow keys to
20
+ * scroll from), or the region itself carries a non-negative `tabindex`.
21
+ * @implementation-notes
22
+ * - jsdom does not perform layout, so `scrollHeight`/`clientHeight` are
23
+ * not available to confirm the region's content actually overflows —
24
+ * only that the CSS declares it *may*. Many elements declare
25
+ * `overflow: auto` defensively without their content ever actually
26
+ * overflowing, which would be a false positive if treated as a hard
27
+ * `fail`. For that reason this is authored as `type: 'manual'`
28
+ * (cantTell-capped, never fail) rather than `automatic` — same class of
29
+ * layout-dependent gap as `iframe-focusable-content`'s
30
+ * `contentDocument` limitation.
31
+ * - "Has a focusable descendant" is a presence check (link/button/form
32
+ * control/`[tabindex]`/`iframe`/`[contenteditable]`), not a full
33
+ * focusability computation (disabled state, visibility, etc.) — a
34
+ * deliberate simplification to keep this rule self-contained and fast.
35
+ */
36
+
37
+ const id = 'scrollable-region-focusable';
38
+
39
+ const meta = {
40
+ title: 'Scrollable regions with no focusable content should be keyboard-focusable',
41
+ description:
42
+ 'Flags elements whose CSS declares overflow:auto/scroll, contain no focusable descendant, and are not themselves keyboard-focusable, for manual review of whether their content actually overflows and needs keyboard scroll access.',
43
+ i18n: {
44
+ titleKey: 'scrollableRegionFocusable_title',
45
+ descriptionKey: 'scrollableRegionFocusable_description'
46
+ },
47
+ helpUrl: null,
48
+ tags: ['wcag2a', 'wcag2aaa', 'wcag211', 'wcag213', 'structure', 'atomic', 'manual'],
49
+ wcagSc: ['2.1.1', '2.1.3'],
50
+ normativeMappings: [
51
+ { standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' },
52
+ { standard: 'WCAG', version: '2.2', requirement: '2.1.3', title: 'Keyboard (No Exception)', conformanceLevel: 'AAA' }
53
+ ],
54
+ defaultSeverity: 'moderate',
55
+ category: 'operable',
56
+ type: 'manual',
57
+ defaultConfidence: 'low',
58
+ coverage: {
59
+ facetsBySc: {
60
+ '2.1.1': ['scrollable-region-focusable-evidence'],
61
+ '2.1.3': ['scrollable-region-focusable-evidence']
62
+ }
63
+ }
64
+ };
65
+
66
+ function runInPage(ctx) {
67
+ const { document, root, helpers, rule } = ctx;
68
+ const safeRoot = root || document;
69
+
70
+ function safeComputedStyle(el) {
71
+ try {
72
+ if (!el || el.nodeType !== 1) return null;
73
+ if (helpers && typeof helpers.computedStyle === 'function') {
74
+ const cs = helpers.computedStyle(el);
75
+ if (cs) return cs;
76
+ }
77
+ const view = (el.ownerDocument && el.ownerDocument.defaultView) ? el.ownerDocument.defaultView : null;
78
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
79
+ } catch {}
80
+ return null;
81
+ }
82
+
83
+ function isScrollableOverflow(cs) {
84
+ if (!cs) return false;
85
+ const x = String(cs.overflowX || '').trim().toLowerCase();
86
+ const y = String(cs.overflowY || '').trim().toLowerCase();
87
+ return x === 'auto' || x === 'scroll' || y === 'auto' || y === 'scroll';
88
+ }
89
+
90
+ function isSelfFocusable(el) {
91
+ try {
92
+ const tabindexAttr = el.getAttribute ? el.getAttribute('tabindex') : null;
93
+ if (tabindexAttr != null) {
94
+ const n = Number.parseInt(tabindexAttr, 10);
95
+ if (Number.isFinite(n) && n >= 0) return true;
96
+ }
97
+ } catch {}
98
+ return false;
99
+ }
100
+
101
+ const FOCUSABLE_DESCENDANT_SELECTOR =
102
+ 'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
103
+
104
+ function hasFocusableDescendant(el) {
105
+ try {
106
+ return !!(el.querySelector && el.querySelector(FOCUSABLE_DESCENDANT_SELECTOR));
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
113
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR, safeRoot) : helpers.queryAll(CANDIDATE_SELECTOR, safeRoot);
114
+
115
+ const occurrences = [];
116
+ let applicableCount = 0;
117
+
118
+ for (const el of nodes) {
119
+ if (!el || !el.getAttribute) continue;
120
+
121
+ const cs = safeComputedStyle(el);
122
+ if (!isScrollableOverflow(cs)) continue;
123
+
124
+ applicableCount += 1;
125
+
126
+ if (isSelfFocusable(el)) continue;
127
+ if (hasFocusableDescendant(el)) continue;
128
+
129
+ const tag = (el.tagName || '').toLowerCase();
130
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
131
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
132
+
133
+ const baseOccurrence = {
134
+ selector: stableSelector,
135
+ html,
136
+ summary: 'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
137
+ hint: 'If this region’s content actually overflows, add tabindex="0" (and a suitable label) so keyboard users can focus it and scroll with the arrow keys.',
138
+ i18n: {
139
+ summaryKey: 'scrollableRegionFocusable_summary_cantTell',
140
+ hintKey: 'scrollableRegionFocusable_hint_cantTell',
141
+ params: { element: tag }
142
+ },
143
+ data: {
144
+ details: { reasonCode: 'SCROLLABLE_OVERFLOW_NOT_FOCUSABLE', element: tag }
145
+ }
146
+ };
147
+
148
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
149
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
150
+ } else {
151
+ occurrences.push(baseOccurrence);
152
+ }
153
+ }
154
+
155
+ if (applicableCount === 0) {
156
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
157
+ }
158
+
159
+ if (occurrences.length) {
160
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'moderate', occurrences };
161
+ }
162
+
163
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail):
164
+ // every scrollable-overflow candidate is already reachable by keyboard.
165
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
166
+ }
167
+
168
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check skip-link
5
+ * @atomic true
6
+ * @summary A "skip" link must resolve to a real, focusable target
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to <a href="#fragment"> elements whose accessible name
10
+ * matches a common "skip to ..." / "jump to ..." authoring convention
11
+ * (case-insensitive "skip" or "jump to" in the name) — the recognizable
12
+ * pattern for a skip-navigation link, not every same-page anchor link
13
+ * on the page. "jump to" added 2026-07-23 after a real page (Wish.com)
14
+ * surfaced a skip link reading "Jump to section" with a genuinely
15
+ * missing target — invisible to the original "skip"-only pattern,
16
+ * while a widely-used reference engine's own (purely positional, not text-based) matching
17
+ * caught it. Text-pattern matching itself stays deliberate (see
18
+ * implementation-notes) — this only widens the known-convention list.
19
+ * @expectation
20
+ * The link's fragment resolves to a real element in the document
21
+ * (via a matching id, or a legacy <a name="...">). A skip link whose
22
+ * target does not exist silently does nothing when activated.
23
+ * @implementation-notes
24
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
25
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
26
+ * header comment for the shared rationale/precedent.
27
+ * - Keyed on the "skip" text-pattern convention rather than positional
28
+ * heuristics (first link in tab order, etc.), matching the same
29
+ * deliberate-leniency reasoning documented in
30
+ * bypass-blocks-present's implementation notes.
31
+ */
32
+
33
+ const id = 'skip-link';
34
+
35
+ const meta = {
36
+ title: 'Skip link must have a resolvable target',
37
+ description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real element in the document.',
38
+ i18n: {
39
+ titleKey: 'skipLink_title',
40
+ descriptionKey: 'skipLink_description'
41
+ },
42
+ helpUrl: null,
43
+ tags: ['best-practice', 'keyboard', 'navigation', 'atomic', 'manual'],
44
+ wcagSc: [],
45
+ normativeMappings: [],
46
+ defaultSeverity: 'minor',
47
+ category: 'operable',
48
+ type: 'manual',
49
+ defaultConfidence: 'medium',
50
+ coverage: {}
51
+ };
52
+
53
+ function runInPage(ctx) {
54
+ const { document, root, helpers, rule } = ctx;
55
+ const safeRoot = root || document;
56
+
57
+ function normalizeWs(s) {
58
+ return String(s || '').replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ function getAccessibleNameText(el) {
62
+ const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
63
+ if (al) return al;
64
+ const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
65
+ if (alb) {
66
+ const parts = [];
67
+ for (const refId of alb.split(/\s+/).filter(Boolean)) {
68
+ try {
69
+ const ref = document.getElementById(refId);
70
+ if (ref) {
71
+ const t = normalizeWs(ref.textContent);
72
+ if (t) parts.push(t);
73
+ }
74
+ } catch {}
75
+ }
76
+ const joined = normalizeWs(parts.join(' '));
77
+ if (joined) return joined;
78
+ }
79
+ return normalizeWs(el.textContent);
80
+ }
81
+
82
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
83
+
84
+ const occurrences = [];
85
+ let applicableCount = 0;
86
+
87
+ for (const el of nodes) {
88
+ if (!el || !el.getAttribute) continue;
89
+
90
+ const href = String(el.getAttribute('href') || '').trim();
91
+ if (href.length < 2 || href.charAt(0) !== '#') continue;
92
+
93
+ const name = getAccessibleNameText(el);
94
+ if (!/skip/i.test(name) && !/jump\s*to/i.test(name)) continue;
95
+
96
+ applicableCount += 1;
97
+
98
+ let fragment = href.slice(1);
99
+ try {
100
+ fragment = decodeURIComponent(fragment);
101
+ } catch {}
102
+ fragment = fragment.trim();
103
+
104
+ let target = null;
105
+ if (fragment) {
106
+ try {
107
+ target = document.getElementById(fragment);
108
+ } catch {
109
+ target = null;
110
+ }
111
+ if (!target) {
112
+ try {
113
+ target = document.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]');
114
+ } catch {
115
+ target = null;
116
+ }
117
+ }
118
+ }
119
+
120
+ if (target) continue;
121
+
122
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
123
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
124
+
125
+ occurrences.push({
126
+ selector: stableSelector,
127
+ html,
128
+ summary: 'This skip link\'s target does not exist.',
129
+ hint: 'Point the skip link\'s href at an id that exists in the document, or add the missing target element.',
130
+ i18n: {
131
+ summaryKey: 'skipLink_summary_cantTell',
132
+ hintKey: 'skipLink_hint_cantTell',
133
+ params: { href }
134
+ },
135
+ data: {
136
+ details: { reasonCode: 'SKIP_LINK_TARGET_MISSING', href }
137
+ }
138
+ });
139
+ }
140
+
141
+ if (applicableCount === 0) {
142
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
143
+ }
144
+ if (occurrences.length) {
145
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
146
+ }
147
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
+ }
149
+
150
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check svg-text-alternative-quality
5
+ * @atomic true
6
+ * @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @type manual
10
+ * @expectation
11
+ * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
+ */
13
+
14
+ const id = "svg-text-alternative-quality";
15
+
16
+ const meta = {
17
+ title: "<svg> text alternative must be appropriate (manual review)",
18
+ description: "Flags applicable <svg> graphics with a detected text alternative for human review of appropriateness.",
19
+ i18n: {
20
+ titleKey: "svg_textAltQuality_title",
21
+ descriptionKey: "svg_textAltQuality_description"
22
+ },
23
+ helpUrl: null,
24
+ tags: ["wcag2a", "wcag111", "nontext", "svg", "manual", "atomic"],
25
+ wcagSc: ['1.1.1'],
26
+ normativeMappings: [
27
+ {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
28
+ ],
29
+ defaultSeverity: 'minor',
30
+ category: 'perceivable',
31
+ type: 'manual',
32
+ defaultConfidence: 'medium',
33
+ coverage: {
34
+ facetsBySc: {
35
+ '1.1.1': ['text-alternative-quality']
36
+ }
37
+ }
38
+ };
39
+
40
+ function runInPage(ctx) {
41
+ const {document, root, helpers, rule} = ctx;
42
+ const safeRoot = root || document;
43
+
44
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
45
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
46
+ ? helpers.queryAll
47
+ : (sel) => {
48
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
49
+ catch { return []; }
50
+ };
51
+
52
+ const buildSelector = helpers && typeof helpers.buildSelector === 'function'
53
+ ? helpers.buildSelector
54
+ : (el) => {
55
+ try {
56
+ if (!el || !el.tagName) return 'html';
57
+ const tag = (el.tagName || 'html').toLowerCase();
58
+ return el.id ? `${tag}#${el.id}` : tag;
59
+ } catch {
60
+ return 'html';
61
+ }
62
+ };
63
+
64
+ const getOuterHtmlSnippet = helpers && typeof helpers.getOuterHtmlSnippet === 'function'
65
+ ? helpers.getOuterHtmlSnippet
66
+ : (el) => { try { return (el && el.outerHTML) ? String(el.outerHTML).slice(0, 2000) : ''; } catch { return ''; } };
67
+
68
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
69
+ ? helpers.getEligibilityInfo
70
+ : null;
71
+
72
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
73
+ ? helpers.isAccTreeEligible
74
+ : null;
75
+
76
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
77
+ ? helpers.getFocusableInfo
78
+ : null;
79
+
80
+ function isRolePresentationExcluded(el) {
81
+ const role = (() => {
82
+ try {
83
+ return String(el.getAttribute('role') || '').trim().toLowerCase();
84
+ } catch {
85
+ return '';
86
+ }
87
+ })();
88
+ if (role !== 'presentation' && role !== 'none') return false;
89
+
90
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
91
+ let focusable = false;
92
+ if (getFocusableInfo) {
93
+ const fi = (() => {
94
+ try {
95
+ return getFocusableInfo(el, ctx);
96
+ } catch {
97
+ return null;
98
+ }
99
+ })();
100
+ focusable = !!(fi && fi.focusable);
101
+ } else {
102
+ const tabindex = el.getAttribute('tabindex');
103
+ focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
104
+ }
105
+ return !focusable;
106
+ }
107
+
108
+
109
+ const els = (() => {
110
+ try {
111
+ return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
112
+ } catch {
113
+ return queryAll('svg');
114
+ }
115
+ })();
116
+
117
+ if (!els.length) {
118
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
119
+ }
120
+
121
+ const occurrences = [];
122
+ let applicableCount = 0;
123
+
124
+ const trim = (v) => (v == null ? '' : String(v)).trim();
125
+
126
+ for (const el of els) {
127
+ if (!el || !el.getAttribute) continue;
128
+
129
+ // acc eligibility
130
+ if (isAccTreeEligible) {
131
+ const elig = (() => { try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; } })();
132
+ if (elig && elig.eligible === false) continue;
133
+ }
134
+
135
+ if (isRolePresentationExcluded(el)) continue;
136
+
137
+ // Detect mechanisms once
138
+ let titleText = '';
139
+ let descText = '';
140
+ let ariaLabel = '';
141
+ let ariaLabelledBy = '';
142
+ let labelledByText = '';
143
+
144
+ try {
145
+ const titleEl = el.querySelector ? el.querySelector('title') : null;
146
+ const descEl = el.querySelector ? el.querySelector('desc') : null;
147
+ titleText = trim(titleEl && titleEl.textContent);
148
+ descText = trim(descEl && descEl.textContent);
149
+
150
+ ariaLabel = trim(el.getAttribute('aria-label'));
151
+ ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
152
+ } catch {}
153
+
154
+ if (!ariaLabel && ariaLabelledBy && helpers && typeof helpers.getTextFromIdRefs === 'function') {
155
+ try {
156
+ const t = helpers.getTextFromIdRefs(ariaLabelledBy, ctx);
157
+ labelledByText = trim(t && t.text);
158
+ } catch {}
159
+ }
160
+
161
+ const hasNonEmptyTitle = !!titleText;
162
+ const hasNonEmptyDesc = !!descText;
163
+ const hasAriaLabel = !!ariaLabel;
164
+ const hasResolvedLabelledBy = !!labelledByText;
165
+
166
+ const hasMechanism = hasNonEmptyTitle || hasNonEmptyDesc || hasAriaLabel || hasResolvedLabelledBy;
167
+ if (!hasMechanism) continue;
168
+
169
+ applicableCount += 1;
170
+
171
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
172
+
173
+ const baseOccurrence = {
174
+ summary: 'Review text alternative for <svg> for accuracy and appropriateness.',
175
+ hint: 'Confirm the <title>/<desc> or ARIA name conveys the meaning/purpose of the graphic in context.',
176
+ i18n: {
177
+ summaryKey: 'svg_textAltQuality_summary_cantTell',
178
+ hintKey: 'svg_textAltQuality_hint_cantTell',
179
+ params: { element: 'svg' }
180
+ },
181
+ data: {
182
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
183
+ details: {
184
+ hasNonEmptyTitle,
185
+ hasNonEmptyDesc,
186
+ ariaLabel: ariaLabel || null,
187
+ ariaLabelledBy: ariaLabelledBy || null,
188
+ ariaLabelledByText: labelledByText ? labelledByText.slice(0, 120) : null // deterministic truncation
189
+ }
190
+ }
191
+ };
192
+
193
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
194
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
195
+ } else {
196
+ const selectorStr = (() => { try { return buildSelector(el); } catch { return 'html'; } })();
197
+ const html = getOuterHtmlSnippet(el);
198
+ occurrences.push({ selector: selectorStr, html, ...baseOccurrence });
199
+ }
200
+ }
201
+
202
+ if (applicableCount === 0) {
203
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
204
+ }
205
+
206
+ return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
207
+ }
208
+
209
+ module.exports = {id, meta, runInPage};