@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,155 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check inputImage-alt-present
5
+ * @atomic true
6
+ * @summary Accessible <input type="image"> elements must have an alt attribute
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to <input type="image"> elements that are exposed to assistive technologies.
11
+ * Elements otherwise hidden from the accessibility tree remain applicable
12
+ * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
13
+ * @expectation
14
+ * Each applicable <input type="image"> element has an alt attribute.
15
+ * The alt attribute may be empty (alt="").
16
+ */
17
+
18
+ const id = 'input-image-alt-present';
19
+
20
+ const meta = {
21
+ title: '<input type="image"> must have an alt attribute',
22
+ description:
23
+ 'Checks that <input type="image"> elements provide an alt attribute to support a text alternative mechanism.',
24
+ i18n: {
25
+ titleKey: 'inputImage_altPresent_title',
26
+ descriptionKey: 'inputImage_altPresent_description'
27
+ },
28
+ helpUrl: null,
29
+ tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'atomic', 'automatic'],
30
+ wcagSc: ['1.1.1'],
31
+ normativeMappings: [
32
+ { standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
33
+ ],
34
+ defaultSeverity: 'serious',
35
+ category: 'perceivable',
36
+ type: 'automatic',
37
+ defaultConfidence: 'high',
38
+ coverage: {
39
+ facetsBySc: {
40
+ '1.1.1': ['input-image-alt-attr-present']
41
+ }
42
+ }
43
+ };
44
+
45
+ function runInPage(ctx) {
46
+ const { document, root, helpers, rule } = ctx;
47
+ const safeRoot = root || document;
48
+
49
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
50
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
51
+ ? helpers.queryAll
52
+ : (sel) => {
53
+ try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
54
+ catch { return []; }
55
+ };
56
+
57
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
58
+ ? helpers.getEligibilityInfo
59
+ : null;
60
+
61
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
62
+ ? helpers.isAccTreeEligible
63
+ : null;
64
+
65
+ const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
66
+ ? helpers.getAriaNameInfo
67
+ : null;
68
+
69
+ const inputs = (() => {
70
+ try { return Array.from((queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) || []); }
71
+ catch { return queryAll('input[type="image"]'); }
72
+ })();
73
+
74
+ if (!inputs.length) {
75
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
76
+ }
77
+
78
+ const occurrences = [];
79
+ let applicableCount = 0;
80
+
81
+ for (const el of inputs) {
82
+ if (!el || !el.getAttribute) continue;
83
+
84
+ // Applicability: eligible in the acc tree (with helper exceptions).
85
+ if (isAccTreeEligible) {
86
+ const elig = (() => {
87
+ try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
88
+ })();
89
+ if (elig && elig.eligible === false) continue;
90
+ }
91
+
92
+ applicableCount += 1;
93
+
94
+ const hasAlt = el.getAttribute('alt') !== null;
95
+ if (hasAlt) continue;
96
+
97
+ // aria-label / aria-labelledby is also a valid, standards-recognized
98
+ // text-alternative mechanism for <input type="image"> (HTML-AAM
99
+ // accessible name computation includes ARIA naming before falling
100
+ // back to alt).
101
+ if (getAriaNameInfo) {
102
+ let ariaName = null;
103
+ try {
104
+ ariaName = getAriaNameInfo(el, ctx);
105
+ } catch {
106
+ ariaName = null;
107
+ }
108
+ if (ariaName && ariaName.present) continue;
109
+ }
110
+
111
+ // A non-empty title attribute is HTML-AAM's own next fallback naming
112
+ // source once alt is entirely absent -- also accepted by a widely-used
113
+ // reference engine's equivalent input-image-alt rule (non-empty-title, same "any" list
114
+ // as non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
115
+ // sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
116
+ // the real page this was found via -- same gap, same fix, different
117
+ // element.
118
+ const titleRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
119
+ if (titleRaw !== null && String(titleRaw).trim()) continue;
120
+
121
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
122
+
123
+ const baseOccurrence = {
124
+ summary: 'Missing alt attribute on <input type="image">.',
125
+ hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
126
+ i18n: {
127
+ summaryKey: 'inputImage_altPresent_summary_fail',
128
+ hintKey: 'inputImage_altPresent_hint_fail',
129
+ params: { element: 'input[type=image]' }
130
+ },
131
+ data: {
132
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
133
+ }
134
+ };
135
+
136
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
137
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
138
+ } else {
139
+ // Never compute selector/snippet in the rule.
140
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
141
+ }
142
+ }
143
+
144
+ if (applicableCount === 0) {
145
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
146
+ }
147
+
148
+ if (!occurrences.length) {
149
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
150
+ }
151
+
152
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
153
+ }
154
+
155
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ const id = 'label-in-name';
4
+
5
+ const meta = {
6
+ title: 'Label in Name: accessible name contains visible text',
7
+ description:
8
+ 'Checks that when a control has a visible text label, the accessible name (from aria-label/aria-labelledby) contains that visible label text (WCAG 2.5.3).',
9
+ i18n: {
10
+ titleKey: 'labelInName_title',
11
+ descriptionKey: 'labelInName_description'
12
+ },
13
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html',
14
+ tags: ['wcag21a', 'wcag253', 'forms', 'atomic', 'automatic'],
15
+ wcagSc: ['2.5.3'],
16
+ normativeMappings: [
17
+ {
18
+ standard: 'WCAG',
19
+ version: '2.2',
20
+ requirement: '2.5.3',
21
+ title: 'Label in Name',
22
+ conformanceLevel: 'A'
23
+ }
24
+ ],
25
+ defaultSeverity: 'serious',
26
+ category: 'operable',
27
+ type: 'automatic',
28
+ defaultConfidence: 'high',
29
+ coverage: { facetsBySc: { '2.5.3': ['label-in-name'] } }
30
+ };
31
+
32
+ function runInPage(ctx) {
33
+ const { document, root, helpers, rule } = ctx;
34
+ const safeRoot = root || document;
35
+
36
+ const occurrences = [];
37
+ let applicableCount = 0;
38
+
39
+ // Applicability: focus/activation controls with explicit ARIA naming.
40
+ // NOTE: aria-hidden is intentionally NOT excluded here; it does not affect visual rendering.
41
+ const selector = ':is(button, a[href], summary, input:not([type="hidden"]), textarea, select, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="searchbox"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="treeitem"], [role="gridcell"]):not([hidden]):not([disabled]):not([aria-disabled="true"]):is([aria-label], [aria-labelledby])';
42
+
43
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
44
+
45
+ function norm(s) {
46
+ const v = (s == null ? '' : String(s));
47
+ // deterministic normalization: trim + collapse whitespace + case-fold
48
+ return v.replace(/\s+/g, ' ').trim().toLowerCase();
49
+ }
50
+
51
+ function getElementDescriptor(el) {
52
+ const tag = (el && el.tagName ? String(el.tagName).toLowerCase() : 'element');
53
+ let role = '';
54
+ try { role = el && el.getAttribute ? (el.getAttribute('role') || '') : ''; } catch { role = ''; }
55
+ const r = String(role || '').trim();
56
+ return r ? `${tag}[role="${r}"]` : tag;
57
+ }
58
+
59
+ function clipForSummary(s) {
60
+ const v = (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
61
+ if (v.length <= 80) return v;
62
+ return v.slice(0, 77) + '...';
63
+ }
64
+
65
+ function isNonRenderedTag(el) {
66
+ const tn = (el && el.tagName ? String(el.tagName).toLowerCase() : '');
67
+ return tn === 'script' || tn === 'style' || tn === 'template' || tn === 'noscript' || tn === 'meta' || tn === 'link';
68
+ }
69
+
70
+ function isDomVisible(el) {
71
+ if (!el) return false;
72
+ if (helpers.isDomVisibleEligible) return !!helpers.isDomVisibleEligible(el, ctx, { targetSet: 'dom' }).eligible;
73
+ if (helpers.getEligibilityInfo) return !!helpers.getEligibilityInfo(el, ctx, { targetSet: 'dom' }).eligible;
74
+ return true;
75
+ }
76
+
77
+ // Unlike isDomVisible above, this also excludes aria-hidden subtrees.
78
+ // Needed specifically for collectVisibleTextUnder's per-text-node check
79
+ // below: an aria-hidden icon-font glyph (e.g. Material Icons' ligature
80
+ // pattern, <mat-icon aria-hidden="true">format_color_fill</mat-icon>) is
81
+ // technically DOM-visible pixels, but is never perceived by a sighted or
82
+ // voice-control user as literal readable words the way real visible text
83
+ // is — a widely-used reference engine's own label-content-name-mismatch check reaches the same
84
+ // practical outcome via a canvas-based ligature-detection heuristic
85
+ // (measuring rendered glyph shapes), not replicable here since this rule
86
+ // runs against static markup with no real canvas/font rendering
87
+ // available; excluding aria-hidden content is a cheaper, static-markup
88
+ // signal that gets the common case (decorative icon fonts) right without
89
+ // needing one. Confirmed via a live-DOM cross-engine run 2026-07-21
90
+ // (Angular Material's theme-picker button, aria-label="Select a theme",
91
+ // aria-hidden icon rendering literally as "format_color_fill").
92
+ function isAccEligible(el) {
93
+ if (!el) return false;
94
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
95
+ if (!fn) return isDomVisible(el);
96
+ try {
97
+ const r = fn(el, ctx);
98
+ if (typeof r === 'boolean') return r;
99
+ return !!(r && r.eligible);
100
+ } catch {
101
+ return isDomVisible(el);
102
+ }
103
+ }
104
+
105
+ // DOM's NodeFilter.SHOW_TEXT constant, inlined as a numeric literal rather
106
+ // than referencing the global NodeFilter object directly: runInPage must
107
+ // have zero free vars (see docs/RULE_AUTHORING.md's free-var footgun) and
108
+ // NodeFilter is not itself present in the execution realm this function
109
+ // actually runs in, unlike window/document. Confirmed 2026-07-21 — this
110
+ // silently made createTreeWalker throw on every call, falling back to
111
+ // raw container.textContent (which respects none of isNonRenderedTag/
112
+ // isDomVisible/isAccEligible below, since that whole per-node loop is
113
+ // skipped in the fallback path). Same pattern already used correctly in
114
+ // region-manual.js's own createTreeWalker call.
115
+ const SHOW_TEXT = 4;
116
+
117
+ function collectVisibleTextUnder(container) {
118
+ if (!container) return '';
119
+ if (!isDomVisible(container)) return '';
120
+
121
+ // TreeWalker is deterministic in document order.
122
+ let walker = null;
123
+ try {
124
+ walker = document.createTreeWalker(container, SHOW_TEXT, null);
125
+ } catch {
126
+ walker = null;
127
+ }
128
+ if (!walker) {
129
+ try {
130
+ const t = container.textContent;
131
+ return t == null ? '' : String(t);
132
+ } catch {
133
+ return '';
134
+ }
135
+ }
136
+
137
+ const parts = [];
138
+ let n = null;
139
+ // eslint-disable-next-line no-cond-assign
140
+ while ((n = walker.nextNode())) {
141
+ try {
142
+ const raw = n && n.nodeValue != null ? String(n.nodeValue) : '';
143
+ const t = raw.replace(/\s+/g, ' ').trim();
144
+ if (!t) continue;
145
+
146
+ const p = n.parentElement || null;
147
+ if (!p || !p.tagName) continue;
148
+ if (isNonRenderedTag(p)) continue;
149
+
150
+ // Require the parent element to be visually eligible AND not inside
151
+ // an aria-hidden subtree (see isAccEligible's docblock above).
152
+ if (!isAccEligible(p)) continue;
153
+
154
+ parts.push(t);
155
+ } catch {
156
+ // no-throws
157
+ }
158
+ }
159
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
160
+ }
161
+
162
+ function getAssociatedLabelElements(control) {
163
+ const labels = [];
164
+ try {
165
+ if (control && control.labels && typeof control.labels.length === 'number') {
166
+ for (const l of control.labels) labels.push(l);
167
+ }
168
+ } catch {
169
+ // ignore
170
+ }
171
+
172
+ // Fallback: wrapped label
173
+ try {
174
+ const w = control && control.closest ? control.closest('label') : null;
175
+ if (w) labels.push(w);
176
+ } catch {
177
+ // ignore
178
+ }
179
+
180
+ // Fallback: label[for=id]. Uses `document` directly rather than
181
+ // ctx.root/safeRoot -- label[for] association is a document-wide
182
+ // relationship (IDs are document-unique), not bounded by whatever
183
+ // contextSelector region happens to be scanned, and ctx.root is an
184
+ // array (multi-region contextSelector support), not a single element
185
+ // with its own .querySelector to call directly.
186
+ try {
187
+ const idAttribute = control && control.getAttribute ? (control.getAttribute('id') || '') : '';
188
+ const key = String(idAttribute || '').trim();
189
+ if (key && document && document.querySelector) {
190
+ const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
191
+ if (l) labels.push(l);
192
+ }
193
+ } catch {
194
+ // ignore
195
+ }
196
+
197
+ // De-dupe in document order
198
+ const seen = new Set();
199
+ const out = [];
200
+ for (const l of labels) {
201
+ try {
202
+ if (!l || !l.tagName) continue;
203
+ if (seen.has(l)) continue;
204
+ seen.add(l);
205
+ out.push(l);
206
+ } catch {
207
+ // ignore
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ function getVisibleTextLabelInfo(el) {
214
+ // Returns { text, source } where source helps reporting.
215
+ // Source policy (deterministic):
216
+ // 1) <label> association for form controls
217
+ // 2) visible text inside the element
218
+ // 3) visible text from aria-labelledby referenced elements
219
+ let text = '';
220
+ let source = 'none';
221
+
222
+ const tn = (el && el.tagName ? String(el.tagName).toLowerCase() : '');
223
+
224
+ // 1) Label association (native form controls)
225
+ const isFormControl = tn === 'input' || tn === 'select' || tn === 'textarea';
226
+ if (isFormControl) {
227
+ const labels = getAssociatedLabelElements(el);
228
+ const labelParts = [];
229
+ for (const l of labels) {
230
+ if (!l || !isDomVisible(l)) continue;
231
+ const t = collectVisibleTextUnder(l);
232
+ if (t) labelParts.push(t);
233
+ }
234
+ const joined = labelParts.join(' ').replace(/\s+/g, ' ').trim();
235
+ if (joined) return { text: joined, source: 'label' };
236
+ }
237
+
238
+ // 2) Text inside the control itself
239
+ text = collectVisibleTextUnder(el);
240
+ if (text) return { text, source: 'self' };
241
+
242
+ // 3) aria-labelledby referenced visible text (only if refs exist and are visible)
243
+ try {
244
+ const idrefs = el && el.getAttribute ? el.getAttribute('aria-labelledby') : null;
245
+ if (idrefs && helpers.resolveIdRefs) {
246
+ const r = helpers.resolveIdRefs(idrefs, ctx, { maxRefs: 8 });
247
+ const parts = [];
248
+ for (const ref of (r && Array.isArray(r.refs) ? r.refs : [])) {
249
+ if (!ref || !ref.tagName) continue;
250
+ if (!isDomVisible(ref)) continue;
251
+ const t = collectVisibleTextUnder(ref);
252
+ if (t) parts.push(t);
253
+ }
254
+ const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
255
+ if (joined) return { text: joined, source: 'aria-labelledby' };
256
+ }
257
+ } catch {
258
+ // ignore
259
+ }
260
+
261
+ return { text: '', source };
262
+ }
263
+
264
+ for (const el of nodes) {
265
+ // Gate: control must be visually rendered (SC is about visual labels).
266
+ if (!isDomVisible(el)) continue;
267
+
268
+ const labelInfo = getVisibleTextLabelInfo(el);
269
+ const visibleLabel = labelInfo && labelInfo.text ? String(labelInfo.text) : '';
270
+ const visibleNorm = norm(visibleLabel);
271
+
272
+ // Applicability: only when we can deterministically extract visible label text.
273
+ if (!visibleNorm) continue;
274
+
275
+ applicableCount += 1;
276
+
277
+ let acc = { present: false, value: '', mechanism: 'none', flags: [] };
278
+ try {
279
+ acc = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 }) : acc;
280
+ } catch {
281
+ acc = { present: false, value: '', mechanism: 'none', flags: ['exception'] };
282
+ }
283
+ const accName = acc && acc.value != null ? String(acc.value) : '';
284
+ const accNorm = norm(accName);
285
+
286
+ const contains = !!(accNorm && visibleNorm && accNorm.indexOf(visibleNorm) !== -1);
287
+
288
+ if (!contains) {
289
+ const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
290
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
291
+
292
+ occurrences.push({
293
+ selector: selectorOut,
294
+ html,
295
+ summary: 'Accessible name does not contain the visible label text.',
296
+ hint: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
297
+ i18n: {
298
+ summaryKey: 'labelInName_summary_fail',
299
+ hintKey: 'labelInName_hint_fail',
300
+ params: {
301
+ element: getElementDescriptor(el),
302
+ visibleLabel: clipForSummary(visibleLabel),
303
+ labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
304
+ nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
305
+ }
306
+ },
307
+ data: {
308
+ details: {
309
+ reasonCode: 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
310
+ visibleLabel,
311
+ accessibleName: accName,
312
+ normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
313
+ labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
314
+ nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
315
+ }
316
+ }
317
+ });
318
+ }
319
+ }
320
+
321
+ if (applicableCount === 0) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
322
+ if (occurrences.length) return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
323
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
324
+ }
325
+
326
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check html-lang-attr-present
5
+ * @atomic true
6
+ * @summary The default language of the page must be programmatically declared
7
+ * @standard WCAG 2.2
8
+ * @sc 3.1.1
9
+ *
10
+ * @applicability
11
+ * Applies to HTML documents with a root <html> element.
12
+ * The rule evaluates the document element only and does not iterate over child nodes.
13
+ * Non-HTML documents or documents without a document element are not applicable.
14
+ *
15
+ * @expectation
16
+ * The <html> element has a lang attribute.
17
+ * The lang attribute is not empty and contains a syntactically valid language tag.
18
+ *
19
+ * @outcomes
20
+ * Pass:
21
+ * The <html> element has a non-empty lang attribute with a valid language tag.
22
+ *
23
+ * Fail:
24
+ * The <html> element does not have a lang attribute.
25
+ * The <html> element has a lang attribute that is present but empty or whitespace.
26
+ * The <html> element has a lang attribute with an invalid language tag.
27
+ *
28
+ * @notes
29
+ * This rule does not verify that the declared language matches the actual language
30
+ * of the page content. Language correctness is out of scope for SC 3.1.1.
31
+ * Changes of language within the page are covered by SC 3.1.2.
32
+ */
33
+
34
+ const id = 'html-lang-attr-present';
35
+
36
+ const meta = {
37
+ title: 'Page language is declared',
38
+ description: 'Checks that the default language of the page is programmatically declared.',
39
+ i18n: {
40
+ titleKey: 'html_lang_attr_title',
41
+ descriptionKey: 'html_lang_attr_description'
42
+ },
43
+ helpUrl: null,
44
+ tags: ['wcag2a', 'wcag311', 'structure', 'language', 'automatic', 'atomic'],
45
+ wcagSc: ['3.1.1'],
46
+ normativeMappings: [
47
+ {
48
+ standard: 'WCAG',
49
+ version: '2.2',
50
+ requirement: '3.1.1',
51
+ title: 'Language of Page',
52
+ conformanceLevel: 'A'
53
+ }
54
+ ],
55
+ defaultSeverity: 'serious',
56
+ category: 'understandable',
57
+ type: 'automatic',
58
+ defaultConfidence: 'high',
59
+ coverage: {
60
+ facetsBySc: {
61
+ '3.1.1': ['html-lang-attr-present']
62
+ }
63
+ }
64
+ };
65
+
66
+ function runInPage(ctx) {
67
+ const { document, rule, helpers } = ctx;
68
+ const html = document && document.documentElement;
69
+
70
+ const tag = (html && html.tagName) ? String(html.tagName).toLowerCase() : '';
71
+ if (!html || tag !== 'html') {
72
+ return {
73
+ ruleId: rule.ruleId,
74
+ outcome: 'notApplicable',
75
+ severity: 'minor',
76
+ occurrences: []
77
+ };
78
+ }
79
+
80
+ const visibilityFilter = { targetSet: 'acc', accEligible: true, reasons: [] };
81
+
82
+ function pushFail(summaryKey, hintKey, params, details) {
83
+ const baseOccurrence = {
84
+ summary: '',
85
+ hint: '',
86
+ i18n: {
87
+ summaryKey,
88
+ hintKey,
89
+ params: params && typeof params === 'object' ? params : {}
90
+ },
91
+ data: {
92
+ visibilityFilter,
93
+ details: details && typeof details === 'object' ? details : {}
94
+ }
95
+ };
96
+
97
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
98
+ return [helpers.reportOccurrence(html, baseOccurrence)];
99
+ }
100
+
101
+ // Never compute selector/snippet in the rule.
102
+ return [{ selector: '', html: '', ...baseOccurrence }];
103
+ }
104
+
105
+ const rawLang = html.getAttribute('lang'); // null if missing
106
+ const lang = (rawLang || '').trim();
107
+
108
+ if (rawLang === null) {
109
+ return {
110
+ ruleId: rule.ruleId,
111
+ outcome: 'fail',
112
+ severity: rule.defaultSeverity,
113
+ occurrences: pushFail(
114
+ 'html_lang_attr_missing_absent',
115
+ 'html_lang_attr_hint_missing_absent',
116
+ {},
117
+ { reasonCode: 'lang-missing', location: 'html' }
118
+ )
119
+ };
120
+ }
121
+
122
+ if (lang.length === 0) {
123
+ return {
124
+ ruleId: rule.ruleId,
125
+ outcome: 'fail',
126
+ severity: rule.defaultSeverity,
127
+ occurrences: pushFail(
128
+ 'html_lang_attr_missing_empty',
129
+ 'html_lang_attr_hint_missing_empty',
130
+ {},
131
+ { reasonCode: 'lang-empty', location: 'html' }
132
+ )
133
+ };
134
+ }
135
+
136
+ // Minimal BCP47 primary subtag check
137
+ if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang)) {
138
+ return {
139
+ ruleId: rule.ruleId,
140
+ outcome: 'fail',
141
+ severity: rule.defaultSeverity,
142
+ occurrences: pushFail(
143
+ 'html_lang_attr_invalid',
144
+ 'html_lang_attr_hint_invalid',
145
+ { lang },
146
+ { reasonCode: 'lang-invalid-bcp47', lang }
147
+ )
148
+ };
149
+ }
150
+
151
+ return {
152
+ ruleId: rule.ruleId,
153
+ outcome: 'pass',
154
+ severity: 'minor',
155
+ occurrences: []
156
+ };
157
+ }
158
+
159
+ module.exports = { id, meta, runInPage };