@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,206 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check css-orientation-lock
5
+ * @atomic true
6
+ * @summary CSS must not lock the page to a single display orientation via a rotate() hack
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.4
9
+ * @applicability
10
+ * Any accessible (same-document, non-cross-origin) stylesheet — inline
11
+ * `<style>` blocks and same-origin `<link>` stylesheets already loaded
12
+ * into `document.styleSheets`.
13
+ * @expectation
14
+ * No `@media (orientation: portrait)` or `@media (orientation:
15
+ * landscape)` block sets a `transform`/`-webkit-transform`/`rotate`
16
+ * rotation of approximately 90 degrees (mod 180, i.e. ~90 or ~270) —
17
+ * the well-known technique for visually forcing one orientation
18
+ * regardless of the device's actual orientation, which defeats WCAG
19
+ * 1.3.4's requirement that content not restrict its view to a single
20
+ * display orientation unless that orientation is essential.
21
+ * @implementation-notes
22
+ * - The rotation DEGREE is what makes this the exploit signature, not
23
+ * merely the presence of a `rotate()` function — verified against a
24
+ * widely-used reference engine's own `css-orientation-lock` check
25
+ * (`cssOrientationLockEvaluate`), which computes the actual rotation angle and only
26
+ * flags ~90/~270 degrees, explicitly excluding ~0/~180 (a no-op or a
27
+ * flip, neither of which changes portrait<->landscape). A prior
28
+ * version of this rule flagged the bare presence of "rotate(" in an
29
+ * orientation-media-query rule regardless of angle — a real, confirmed
30
+ * false positive found via the live-DOM cross-engine corpus 2026-07-23
31
+ * on a real page (Times of India) whose `@media (orientation:portrait)`
32
+ * block only rotated a small decorative arrow icon 45 degrees, nothing
33
+ * resembling a page-orientation lock.
34
+ * - Only `rotate`/`rotateZ` (transform functions) and the standalone CSS
35
+ * `rotate` property are parsed for degrees — matches that reference engine's
36
+ * own two sources (`style.transform`/`style.rotate`) but, like that engine,
37
+ * does not attempt to decompose `matrix()`/`matrix3d()`/`rotate3d()` into an
38
+ * equivalent angle (that engine does handle those; deliberately deferred here
39
+ * as the same class of "genuinely more complex, lower real-world
40
+ * value" work already deferred elsewhere in this engine, e.g.
41
+ * `table-th-has-data-cells`'s narrower positional-header algorithm).
42
+ * - Cross-origin stylesheets throw on `.cssRules` access (browser
43
+ * security model) and are skipped — same class of limitation as any
44
+ * check that can only see same-origin/inspectable content (compare
45
+ * `iframe-focusable-content`).
46
+ * - Not per-element: this is a whole-document/whole-stylesheet concern,
47
+ * so occurrences are reported against `document.documentElement`,
48
+ * matching the "whole-document checks" precedent documented in
49
+ * `docs/RULE_AUTHORING.md` §11.2 (e.g. `aria-hidden-body`,
50
+ * `meta-viewport-zoom-enabled`).
51
+ */
52
+
53
+ const id = 'css-orientation-lock';
54
+
55
+ const meta = {
56
+ title: 'CSS must not lock the page to a single orientation',
57
+ description:
58
+ 'Checks that no @media (orientation: portrait|landscape) rule sets a transform: rotate(...) on the page, a known technique for defeating device orientation.',
59
+ i18n: {
60
+ titleKey: 'cssOrientationLock_title',
61
+ descriptionKey: 'cssOrientationLock_description'
62
+ },
63
+ helpUrl: null,
64
+ tags: ['wcag21aa', 'wcag134', 'structure', 'atomic', 'automatic'],
65
+ wcagSc: ['1.3.4'],
66
+ normativeMappings: [
67
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.4', title: 'Orientation', conformanceLevel: 'AA' }
68
+ ],
69
+ defaultSeverity: 'serious',
70
+ category: 'perceivable',
71
+ type: 'automatic',
72
+ defaultConfidence: 'high',
73
+ coverage: { facetsBySc: { '1.3.4': ['css-orientation-lock'] } }
74
+ };
75
+
76
+ function runInPage(ctx) {
77
+ const { document, helpers, rule } = ctx;
78
+
79
+ const CSS_MEDIA_RULE = 4;
80
+ const CSS_STYLE_RULE = 1;
81
+
82
+ function trim(v) { return (v == null ? '' : String(v)).trim(); }
83
+
84
+ // Converts a rotate()/rotateZ() angle argument (with unit) to degrees.
85
+ // Matches a widely-used reference engine's own getAngleInDegrees: only deg/grad/rad/turn are
86
+ // recognized; a unitless or unrecognized value contributes 0 (ignored,
87
+ // not treated as a lock -- an unparseable angle isn't a confirmed exploit).
88
+ function angleToDegrees(raw) {
89
+ const m = /(-?[\d.]+)\s*(deg|grad|rad|turn)/i.exec(raw);
90
+ if (!m) return 0;
91
+ const value = parseFloat(m[1]);
92
+ if (!Number.isFinite(value)) return 0;
93
+ const unit = m[2].toLowerCase();
94
+ if (unit === 'rad') return value * (180 / Math.PI);
95
+ if (unit === 'grad') return value * (360 / 400);
96
+ if (unit === 'turn') return value * 360;
97
+ return value; // deg
98
+ }
99
+
100
+ // Sums the degrees of every rotate()/rotateZ() function found in a
101
+ // transform value (a real transform can legitimately compose more than
102
+ // one, e.g. "translate(-50%) rotate(90deg)").
103
+ function rotateDegreesFromTransform(t) {
104
+ if (!t) return 0;
105
+ let total = 0;
106
+ const re = /rotate(?:Z)?\s*\(([^)]*)\)/gi;
107
+ let m;
108
+ while ((m = re.exec(t)) !== null) {
109
+ total += angleToDegrees(m[1]);
110
+ }
111
+ return total;
112
+ }
113
+
114
+ // Whole-page-orientation-lock detection, matching a widely-used reference engine's own degree
115
+ // math exactly (see @implementation-notes): a rotation near 0 or 180
116
+ // degrees (mod 180) is a no-op or a flip, neither of which changes
117
+ // portrait<->landscape, so it's explicitly NOT a lock; only a rotation
118
+ // near 90 or 270 degrees (mod 90, once the 0/180 case is excluded) is.
119
+ function isLockingRotation(styleDecl) {
120
+ if (!styleDecl) return false;
121
+ const transformVal = trim(styleDecl.transform) || trim(styleDecl.getPropertyValue ? styleDecl.getPropertyValue('-webkit-transform') : '');
122
+ // The standalone CSS `rotate` property (distinct from `transform: rotate()`) -- that reference engine checks this too.
123
+ const rotatePropVal = trim(styleDecl.getPropertyValue ? styleDecl.getPropertyValue('rotate') : '');
124
+
125
+ let degrees = rotateDegreesFromTransform(transformVal);
126
+ if (!degrees && rotatePropVal) degrees = angleToDegrees(rotatePropVal);
127
+
128
+ if (!degrees) return false;
129
+
130
+ const abs = Math.abs(degrees);
131
+ if (Math.abs(abs - 180) % 180 <= 0) return false; // near 0/180: not a lock
132
+ return Math.abs(abs - 90) % 90 <= 0; // near 90/270: a lock
133
+ }
134
+
135
+ function isOrientationMedia(mediaText) {
136
+ const m = trim(mediaText).toLowerCase();
137
+ return m.includes('orientation') && (m.includes('portrait') || m.includes('landscape'));
138
+ }
139
+
140
+ function scanRuleList(rules, mediaText, findings) {
141
+ if (!rules) return;
142
+ for (const r of rules) {
143
+ if (!r) continue;
144
+ if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
145
+ findings.push({ mediaText, selectorText: trim(r.selectorText) || '(unknown selector)' });
146
+ }
147
+ }
148
+ }
149
+
150
+ const findings = [];
151
+ let sheetCount = 0;
152
+
153
+ try {
154
+ const sheets = document.styleSheets || [];
155
+ for (const sheet of sheets) {
156
+ let rules = null;
157
+ try {
158
+ rules = sheet && sheet.cssRules ? sheet.cssRules : null;
159
+ } catch {
160
+ continue; // cross-origin stylesheet, not inspectable
161
+ }
162
+ if (!rules) continue;
163
+ sheetCount += 1;
164
+
165
+ for (const rule2 of rules) {
166
+ if (!rule2 || rule2.type !== CSS_MEDIA_RULE) continue;
167
+ const mediaText = rule2.media ? rule2.media.mediaText : '';
168
+ if (!isOrientationMedia(mediaText)) continue;
169
+ scanRuleList(rule2.cssRules, mediaText, findings);
170
+ }
171
+ }
172
+ } catch {
173
+ // no-throw: treat as no accessible stylesheets
174
+ }
175
+
176
+ if (sheetCount === 0) {
177
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
178
+ }
179
+
180
+ if (!findings.length) {
181
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
182
+ }
183
+
184
+ const target = document.documentElement || (document.body || null);
185
+ const stableSelector = helpers.buildSelector && target ? helpers.buildSelector(target) : 'html';
186
+ const html = helpers.getOuterHtmlSnippet && target ? helpers.getOuterHtmlSnippet(target) : '<html>';
187
+
188
+ const occurrences = findings.map((f) => ({
189
+ selector: stableSelector,
190
+ html,
191
+ summary: `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`,
192
+ hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
193
+ i18n: {
194
+ summaryKey: 'cssOrientationLock_summary_fail',
195
+ hintKey: 'cssOrientationLock_hint_fail',
196
+ params: { mediaText: f.mediaText, selectorText: f.selectorText }
197
+ },
198
+ data: {
199
+ details: { reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM', mediaText: f.mediaText, selectorText: f.selectorText }
200
+ }
201
+ }));
202
+
203
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
204
+ }
205
+
206
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check definition-list-children-valid
5
+ * @atomic true
6
+ * @summary <dl> must only contain <dt>/<dd> groups (optionally wrapped in one <div>), <script>, <template>, or <style>
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.1
9
+ * @applicability
10
+ * Applies to <dl> elements that have at least one direct element child.
11
+ * @expectation
12
+ * Every direct element child is <dt>, <dd>, <script>, <template>, <style>,
13
+ * or a <div> whose own children are drawn from that same set (a single
14
+ * level of wrapping div is allowed, matching how authors commonly group
15
+ * dt/dd pairs). If the flattened set contains any <dt> or <dd> at all, it
16
+ * must contain BOTH (an unbalanced dt-without-dd or dd-without-dt is
17
+ * invalid) — a flattened set with neither is vacuously fine, not a
18
+ * violation (see implementation-notes). Any other direct or wrapped child
19
+ * breaks the description-list semantics assistive technologies rely on.
20
+ * @implementation-notes
21
+ * - Only one level of <div> wrapping is flattened, matching a widely-used
22
+ * reference engine's definition-list algorithm — a <div> nested inside
23
+ * another wrapping <div> is not flattened further and its contents are
24
+ * reported invalid.
25
+ * - Verified 2026-07-20 against a widely-used reference engine: the dt/dd pairing is
26
+ * only required "when not empty". A flattened set with NEITHER dt nor dd
27
+ * — whether from an empty wrapping <div>, only <script>/<template>/
28
+ * <style> content, or a genuinely childless <dl> — is not flagged; only
29
+ * an unbalanced dt/dd pairing is a real structural problem.
30
+ * - Distinct, atomic decision from dlitem-parent-valid (the
31
+ * inverse relationship: does a given <dt>/<dd> have a valid parent).
32
+ */
33
+
34
+ const id = 'definition-list-children-valid';
35
+
36
+ const meta = {
37
+ title: 'Description lists must be structured correctly',
38
+ description: 'Checks that <dl> elements only directly contain <dt>/<dd> groups (optionally wrapped in one <div>), <script>, <template>, or <style>.',
39
+ i18n: {
40
+ titleKey: 'definitionListChildrenValid_title',
41
+ descriptionKey: 'definitionListChildrenValid_description'
42
+ },
43
+ helpUrl: null,
44
+ tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
45
+ wcagSc: ['1.3.1'],
46
+ normativeMappings: [
47
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
48
+ ],
49
+ defaultSeverity: 'serious',
50
+ category: 'perceivable',
51
+ type: 'automatic',
52
+ defaultConfidence: 'high',
53
+ coverage: { facetsBySc: { '1.3.1': ['definition-list-children-valid'] } }
54
+ };
55
+
56
+ function runInPage(ctx) {
57
+ const { document, root, helpers, rule } = ctx;
58
+ const safeRoot = root || document;
59
+
60
+ // Declared inside runInPage — see scripts/build-core.js header
61
+ // ("runInPage MUST be self-contained").
62
+ const PASSTHROUGH_TAGS = new Set(['dt', 'dd', 'script', 'template', 'style']);
63
+
64
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dl', safeRoot) : helpers.queryAll('dl', safeRoot);
65
+
66
+ const occurrences = [];
67
+ let applicableCount = 0;
68
+
69
+ for (const el of nodes) {
70
+ if (!el || !el.children) continue;
71
+ if (!el.children.length) continue;
72
+
73
+ applicableCount += 1;
74
+
75
+ // Flatten one level of wrapping <div> (common dt/dd grouping pattern).
76
+ const flattened = [];
77
+ for (const child of el.children) {
78
+ if (!child || !child.tagName) continue;
79
+ if (child.tagName.toLowerCase() === 'div') {
80
+ for (const grandchild of child.children || []) {
81
+ if (grandchild && grandchild.tagName) flattened.push(grandchild);
82
+ }
83
+ } else {
84
+ flattened.push(child);
85
+ }
86
+ }
87
+
88
+ let hasDt = false;
89
+ let hasDd = false;
90
+ const invalidTags = [];
91
+ for (const node of flattened) {
92
+ const tag = node.tagName.toLowerCase();
93
+ if (tag === 'dt') { hasDt = true; continue; }
94
+ if (tag === 'dd') { hasDd = true; continue; }
95
+ if (!PASSTHROUGH_TAGS.has(tag)) invalidTags.push(tag);
96
+ }
97
+
98
+ // Verified 2026-07-20 against a widely-used reference engine's definition-list check:
99
+ // the dt/dd pairing is only required "when not empty" — a <dl> with
100
+ // NEITHER dt nor dd (whether genuinely childless after flattening, only
101
+ // passthrough script/template/style content, or an empty wrapping div)
102
+ // is vacuously fine, not a violation. Only an UNBALANCED pairing (dt
103
+ // present without any dd, or vice versa) is a real structural problem.
104
+ const reasonCode = invalidTags.length
105
+ ? 'DL_INVALID_CHILD'
106
+ : ((hasDt || hasDd) && !(hasDt && hasDd) ? 'DL_NO_DT_DD' : null);
107
+ if (!reasonCode) continue;
108
+
109
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
110
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
111
+
112
+ const summary = invalidTags.length
113
+ ? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
114
+ : 'This description list has no <dt>/<dd> term-definition group.';
115
+ const hint = invalidTags.length
116
+ ? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
117
+ : 'Add at least one <dt>/<dd> pair inside this <dl>.';
118
+
119
+ occurrences.push({
120
+ selector: stableSelector,
121
+ html,
122
+ summary,
123
+ hint,
124
+ i18n: {
125
+ summaryKey: invalidTags.length
126
+ ? 'definitionListChildrenValid_summary_fail_invalidChild'
127
+ : 'definitionListChildrenValid_summary_fail_noDtDd',
128
+ hintKey: invalidTags.length
129
+ ? 'definitionListChildrenValid_hint_fail_invalidChild'
130
+ : 'definitionListChildrenValid_hint_fail_noDtDd',
131
+ params: { invalidChildren: invalidTags.join(', ') }
132
+ },
133
+ data: {
134
+ details: { reasonCode, invalidChildren: invalidTags }
135
+ }
136
+ });
137
+ }
138
+
139
+ if (applicableCount === 0) {
140
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
141
+ }
142
+ if (occurrences.length) {
143
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
144
+ }
145
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
146
+ }
147
+
148
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check deprecated-elements-not-used
5
+ * @atomic true
6
+ * @summary Obsolete non-stoppable elements must not be used
7
+ * @standard WCAG 2.2
8
+ * @sc 2.2.2
9
+ * @applicability
10
+ * Applies to any <blink> or <marquee> element present in scope. These are
11
+ * obsolete, non-standard HTML elements whose defining behavior (blinking
12
+ * or auto-scrolling text) has no built-in user mechanism to pause, stop,
13
+ * or hide it.
14
+ * @expectation
15
+ * Neither element is present. Since their movement can never be paused,
16
+ * stopped, or hidden by the user, presence is itself the violation — this
17
+ * rule has no partial-pass case, matching a widely-used reference engine's `blink`/`marquee`
18
+ * rules (which report only when the element is found).
19
+ * @implementation-notes
20
+ * - Not gated on isAccTreeEligible: presence in markup is itself the
21
+ * violation, independent of visibility (moving/blinking content inside a
22
+ * hidden ancestor could still become visible later without a code
23
+ * change, so hiding it today does not remove the underlying defect).
24
+ */
25
+
26
+ const id = 'deprecated-elements-not-used';
27
+
28
+ const meta = {
29
+ title: 'Obsolete non-stoppable elements (<blink>, <marquee>) must not be used',
30
+ description: 'Checks that deprecated, non-standard HTML elements whose blinking/scrolling content cannot be paused, stopped, or hidden by the user (<blink>, <marquee>) are not present.',
31
+ i18n: {
32
+ titleKey: 'deprecatedElements_title',
33
+ descriptionKey: 'deprecatedElements_description'
34
+ },
35
+ helpUrl: null,
36
+ tags: ['wcag2a', 'wcag222', 'structure', 'atomic', 'automatic'],
37
+ wcagSc: ['2.2.2'],
38
+ normativeMappings: [
39
+ { standard: 'WCAG', version: '2.2', requirement: '2.2.2', title: 'Pause, Stop, Hide', conformanceLevel: 'A' }
40
+ ],
41
+ defaultSeverity: 'serious',
42
+ category: 'operable',
43
+ type: 'automatic',
44
+ defaultConfidence: 'high',
45
+ coverage: { facetsBySc: { '2.2.2': ['deprecated-non-stoppable-elements-absent'] } }
46
+ };
47
+
48
+ function runInPage(ctx) {
49
+ const { document, root, helpers, rule } = ctx;
50
+ const safeRoot = root || document;
51
+
52
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('blink, marquee', safeRoot) : helpers.queryAll('blink, marquee', safeRoot);
53
+
54
+ const occurrences = [];
55
+ let applicableCount = 0;
56
+
57
+ for (const el of nodes) {
58
+ if (!el || !el.tagName) continue;
59
+
60
+ applicableCount += 1;
61
+
62
+ const tag = el.tagName.toLowerCase();
63
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
64
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
65
+
66
+ occurrences.push({
67
+ selector: stableSelector,
68
+ html,
69
+ summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
70
+ hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
71
+ i18n: {
72
+ summaryKey: 'deprecatedElements_summary_fail',
73
+ hintKey: 'deprecatedElements_hint_fail',
74
+ params: { element: tag }
75
+ },
76
+ data: {
77
+ details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
78
+ }
79
+ });
80
+ }
81
+
82
+ if (applicableCount === 0) {
83
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
84
+ }
85
+ if (occurrences.length) {
86
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
87
+ }
88
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
89
+ }
90
+
91
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const id = 'dialog-name-present';
4
+
5
+ const meta = {
6
+ title: 'Dialogs have an accessible name',
7
+ description: 'Checks that elements with role="dialog" or role="alertdialog" expose a non-empty accessible name.',
8
+ i18n: {
9
+ titleKey: 'dialogNamePresent_title',
10
+ descriptionKey: 'dialogNamePresent_description'
11
+ },
12
+ helpUrl: null,
13
+ tags: ['wcag2a','wcag412','structure','atomic','automatic','name','dialog'],
14
+ wcagSc: ['4.1.2'],
15
+ normativeMappings: [
16
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
17
+ ],
18
+ defaultSeverity: 'serious',
19
+ category: 'robust',
20
+ type: 'automatic',
21
+ defaultConfidence: 'high',
22
+ coverage: { facetsBySc: { '4.1.2': ['dialog-name-present'] } }
23
+ };
24
+
25
+ function runInPage(ctx) {
26
+ const { document, root, helpers, rule } = ctx;
27
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
+ ? helpers.getEligibilityInfo
29
+ : null;
30
+ const safeRoot = root || document;
31
+
32
+
33
+ function normalizeWs(s) {
34
+ return String(s || '').replace(/\s+/g, ' ').trim();
35
+ }
36
+
37
+ function getAttr(el, name) {
38
+ try {
39
+ if (!el || !el.getAttribute) return '';
40
+ return normalizeWs(el.getAttribute(name));
41
+ } catch { return ''; }
42
+ }
43
+
44
+ function hasAttr(el, name) {
45
+ try { return !!(el && el.hasAttribute && el.hasAttribute(name)); } catch { return false; }
46
+ }
47
+
48
+ function isExplicitProgrammatic(el) {
49
+ // Programmatic name mechanisms we treat as authoritative (presence-only):
50
+ // - aria-label (non-empty)
51
+ // - aria-labelledby (non-empty)
52
+ // - title (non-empty) [weak but allowed for presence]
53
+ const al = getAttr(el, 'aria-label');
54
+ if (al) return true;
55
+ const alb = getAttr(el, 'aria-labelledby');
56
+ if (alb) return true;
57
+ const t = getAttr(el, 'title');
58
+ if (t) return true;
59
+ return false;
60
+ }
61
+
62
+ function buildLabelForMap(doc) {
63
+ const map = new Map(); // id -> label element (first)
64
+ try {
65
+ const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
66
+ for (let i = 0; i < labels.length; i += 1) {
67
+ const lab = labels[i];
68
+ if (!lab || !lab.getAttribute) continue;
69
+ const f = normalizeWs(lab.getAttribute('for'));
70
+ if (!f) continue;
71
+ if (!map.has(f)) map.set(f, lab);
72
+ }
73
+ } catch {}
74
+ return map;
75
+ }
76
+
77
+ function getConservativeSubtreeText(document, container) {
78
+ // "Name from content" — recurses into descendants and uses each one's
79
+ // own accessible name (img alt, aria-label/aria-labelledby, title) when
80
+ // it has one, not just literal text nodes. See getContentNameInfo's
81
+ // header comment in src/core/dom-helpers.js for the full rationale
82
+ // (this replaced a text-node-only TreeWalker that missed the common
83
+ // "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
84
+ // icon-button pattern).
85
+ if (helpers.getContentNameInfo) {
86
+ const info = helpers.getContentNameInfo(container, ctx);
87
+ return info && info.present ? info.value : '';
88
+ }
89
+ const t = (container && container.textContent) ? String(container.textContent) : '';
90
+ return t.replace(/\s+/g, ' ').trim();
91
+ }
92
+
93
+ function resolveAriaLabelledbyText(document, el, maxRefs) {
94
+ const raw = getAttr(el, 'aria-labelledby');
95
+ if (!raw) return '';
96
+ // Resolve via the shared getTextFromIdRefs helper — computes each
97
+ // referenced element's own ACCESSIBLE NAME (aria-label, then
98
+ // aria-labelledby, then a value-like name, then content, then title),
99
+ // not just its content text. Found via a real page (BBC News' cookie-
100
+ // consent dialog, 2026-07-22): aria-labelledby pointed at an
101
+ // <iframe title="SP Consent Message">, whose only name source is its
102
+ // title attribute (an iframe's content is opaque/cross-origin per
103
+ // HTML-AAM, so "name from content" is always empty). The previous
104
+ // version here only ever computed name-from-content of the referenced
105
+ // node (via getConservativeSubtreeText), silently missing the title
106
+ // fallback and reporting no accessible name at all — the identical
107
+ // pattern was hand-copied into 15 other *-name-present rules; all
108
+ // fixed the same way.
109
+ if (helpers.getTextFromIdRefs) {
110
+ try {
111
+ const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
112
+ return normalizeWs(r && r.text);
113
+ } catch {}
114
+ }
115
+ return '';
116
+ }
117
+
118
+ function getInputValueName(el) {
119
+ try {
120
+ const type = getAttr(el, 'type').toLowerCase();
121
+ if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
122
+ const v = getAttr(el, 'value');
123
+ return v;
124
+ } catch { return ''; }
125
+ }
126
+
127
+ function isEligibleAcc(helpers, el, ctx) {
128
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
129
+ if (!fn) return true;
130
+ try {
131
+ const r = fn(el, ctx);
132
+ if (typeof r === 'boolean') return r;
133
+ return !!(r && r.eligible);
134
+ } catch {
135
+ return true;
136
+ }
137
+ }
138
+
139
+
140
+ const occurrences = [];
141
+ let applicableCount = 0;
142
+
143
+ const selector = '[role="dialog"],[role="alertdialog"]';
144
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
145
+
146
+
147
+ function evaluate(el) {
148
+ const ariaLabel = getAttr(el, 'aria-label');
149
+ if (ariaLabel) return { ok: true, method: 'aria-label' };
150
+
151
+ const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
152
+ if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
153
+
154
+ const title = getAttr(el, 'title');
155
+ if (title) return { ok: true, method: 'title' };
156
+
157
+ // role="dialog"/"alertdialog" is name-from-author-only per WAI-ARIA: it
158
+ // must NOT fall back to subtree content. A dialog's heading or body
159
+ // text is not reliably exposed to assistive technologies as the
160
+ // dialog's accessible name unless explicitly wired via aria-labelledby.
161
+ return { ok: false, method: 'none' };
162
+ }
163
+
164
+ for (const el of nodes) {
165
+ if (!el) continue;
166
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
167
+
168
+ const role = getAttr(el, 'role').toLowerCase();
169
+ if (role !== 'dialog' && role !== 'alertdialog') continue;
170
+
171
+ applicableCount += 1;
172
+
173
+ const res = evaluate(el);
174
+ if (res.ok) continue;
175
+
176
+ const eligInfo = getEligibilityInfo
177
+ ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
178
+ : null;
179
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
180
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
181
+
182
+ occurrences.push({
183
+ selector: stableSelector,
184
+ html,
185
+ summary: 'This dialog has no accessible name.',
186
+ hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
187
+ i18n: {
188
+ summaryKey: 'dialogNamePresent_summary_fail',
189
+ hintKey: 'dialogNamePresent_hint_fail',
190
+ params: { role }
191
+ },
192
+ data: {
193
+ details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
194
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
195
+ }
196
+ });
197
+ }
198
+
199
+
200
+ if (applicableCount === 0) {
201
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
202
+ }
203
+ if (occurrences.length) {
204
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
205
+ }
206
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
207
+ }
208
+
209
+ module.exports = { id, meta, runInPage };