@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,141 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check iframe-focusable-content
5
+ * @atomic true
6
+ * @summary <iframe>/<frame> elements with tabindex="-1" must not contain focusable content
7
+ * @standard WCAG 2.2
8
+ * @sc 2.1.1
9
+ * @applicability
10
+ * Applies to <iframe>/<frame> elements with an explicit negative
11
+ * tabindex, whose embedded document is same-origin and reachable via
12
+ * contentDocument (cross-origin/unreachable frames assert nothing — see
13
+ * implementation notes).
14
+ * @expectation
15
+ * The frame's embedded document contains no focusable element. Browsers
16
+ * do not propagate tabindex="-1" on the host <iframe> into its embedded
17
+ * document: Tab can still reach focusable content inside, even though
18
+ * the frame itself is skipped. An author who set tabindex="-1" intending
19
+ * to remove the frame from the tab order has not actually done so if the
20
+ * embedded document contains focusable content.
21
+ * @implementation-notes
22
+ * - Deliberately scoped to same-origin, currently-accessible content only
23
+ * (contentDocument access is wrapped in try/catch and treated as "no
24
+ * constraint asserted" — not counted as applicable — when unreachable),
25
+ * matching this engine's established scope-limiting rationale (see
26
+ * src/core/aria-helpers.js file header) rather than guessing at
27
+ * cross-origin content.
28
+ * - Focusability inside the embedded document is checked with a small,
29
+ * self-contained heuristic (native interactive tags + non-negative
30
+ * tabindex) rather than ctx.helpers.getFocusableInfo, since that helper
31
+ * is built for the outer document's realm/caches, not an embedded
32
+ * document that may be a distinct realm.
33
+ */
34
+
35
+ const id = 'iframe-focusable-content';
36
+
37
+ const meta = {
38
+ title: 'Frames with tabindex="-1" must not contain focusable content',
39
+ description: 'Checks that same-origin <iframe>/<frame> elements with tabindex="-1" do not contain focusable content, since browsers do not propagate that restriction into the frame’s embedded document.',
40
+ i18n: {
41
+ titleKey: 'iframeFocusableContent_title',
42
+ descriptionKey: 'iframeFocusableContent_description'
43
+ },
44
+ helpUrl: null,
45
+ tags: ['wcag2a', 'wcag211', 'structure', 'atomic', 'automatic', 'keyboard', 'iframe'],
46
+ wcagSc: ['2.1.1'],
47
+ normativeMappings: [
48
+ { standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' }
49
+ ],
50
+ defaultSeverity: 'moderate',
51
+ category: 'operable',
52
+ type: 'automatic',
53
+ defaultConfidence: 'high',
54
+ coverage: { facetsBySc: { '2.1.1': ['iframe-tabindex-negative-content-not-focusable'] } }
55
+ };
56
+
57
+ function runInPage(ctx) {
58
+ const { document, root, helpers, rule } = ctx;
59
+ const safeRoot = root || document;
60
+
61
+ function hasFocusableCandidate(doc) {
62
+ if (!doc || !doc.querySelectorAll) return false;
63
+ let els = [];
64
+ try {
65
+ els = doc.querySelectorAll(
66
+ 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), ' +
67
+ 'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
68
+ );
69
+ } catch {
70
+ return false;
71
+ }
72
+ for (const el of els) {
73
+ if (!el || !el.getAttribute) continue;
74
+ const raw = el.getAttribute('tabindex');
75
+ if (raw != null) {
76
+ const n = Number(String(raw).trim());
77
+ if (!Number.isNaN(n) && n < 0) continue; // explicitly removed from tab order
78
+ }
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ function getNegativeTabIndex(el) {
85
+ const raw = el.getAttribute('tabindex');
86
+ if (raw == null) return false;
87
+ const n = Number(String(raw).trim());
88
+ return !Number.isNaN(n) && n < 0;
89
+ }
90
+
91
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
92
+
93
+ const occurrences = [];
94
+ let applicableCount = 0;
95
+
96
+ for (const el of nodes) {
97
+ if (!el || !el.getAttribute) continue;
98
+ if (!getNegativeTabIndex(el)) continue;
99
+
100
+ let contentDoc = null;
101
+ try {
102
+ contentDoc = el.contentDocument || null;
103
+ } catch {
104
+ contentDoc = null;
105
+ }
106
+ if (!contentDoc || !contentDoc.querySelectorAll) continue; // cross-origin/unreachable: no constraint asserted
107
+
108
+ applicableCount += 1;
109
+
110
+ if (!hasFocusableCandidate(contentDoc)) continue;
111
+
112
+ const tag = el.tagName.toLowerCase();
113
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
114
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
115
+
116
+ occurrences.push({
117
+ selector: stableSelector,
118
+ html,
119
+ summary: 'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
120
+ hint: 'Remove focusable content from the frame, or remove tabindex="-1" if the frame is meant to be reachable.',
121
+ i18n: {
122
+ summaryKey: 'iframeFocusableContent_summary_fail',
123
+ hintKey: 'iframeFocusableContent_hint_fail',
124
+ params: { element: tag }
125
+ },
126
+ data: {
127
+ details: { reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_FOCUSABLE', element: tag }
128
+ }
129
+ });
130
+ }
131
+
132
+ if (applicableCount === 0) {
133
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
134
+ }
135
+ if (occurrences.length) {
136
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
137
+ }
138
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
139
+ }
140
+
141
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check iframe-name-present
5
+ * @atomic true
6
+ * @summary <iframe>/<frame> elements must have an accessible name
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to <iframe>/<frame> elements that are eligible for the
11
+ * accessibility tree (isAccTreeEligible).
12
+ * @expectation
13
+ * The element has a non-empty accessible name via aria-labelledby,
14
+ * aria-label, or the title attribute. Unlike most interactive elements,
15
+ * an iframe's name is never derived from its rendered content (the
16
+ * embedded document is a separate browsing context) — this mirrors
17
+ * dialog-name-present's "name-from-author-only" reasoning.
18
+ * @implementation-notes
19
+ * - Uses helpers.getAccessibleNameInfo, which already stops at
20
+ * aria-label/aria-labelledby/label[for]/title without falling back to
21
+ * subtree text content — the right shape for this element.
22
+ */
23
+
24
+ const id = 'iframe-name-present';
25
+
26
+ const meta = {
27
+ title: 'Frames have an accessible name',
28
+ description: 'Checks that <iframe>/<frame> elements expose a non-empty accessible name via aria-label, aria-labelledby, or the title attribute.',
29
+ i18n: {
30
+ titleKey: 'iframeNamePresent_title',
31
+ descriptionKey: 'iframeNamePresent_description'
32
+ },
33
+ helpUrl: null,
34
+ tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
35
+ wcagSc: ['4.1.2'],
36
+ normativeMappings: [
37
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
38
+ ],
39
+ defaultSeverity: 'serious',
40
+ category: 'robust',
41
+ type: 'automatic',
42
+ defaultConfidence: 'high',
43
+ coverage: { facetsBySc: { '4.1.2': ['iframe-name-present'] } }
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, root, helpers, rule } = ctx;
48
+ const safeRoot = root || document;
49
+
50
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
51
+
52
+ const occurrences = [];
53
+ let applicableCount = 0;
54
+
55
+ for (const el of nodes) {
56
+ if (!el || !el.tagName) continue;
57
+
58
+ if (helpers.isAccTreeEligible) {
59
+ const elig = helpers.isAccTreeEligible(el);
60
+ const isEligible = typeof elig === 'boolean' ? elig : !!(elig && elig.eligible);
61
+ if (!isEligible) continue;
62
+ }
63
+
64
+ applicableCount += 1;
65
+
66
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 }) : null;
67
+ if (nameInfo && nameInfo.present && nameInfo.value) continue;
68
+
69
+ const eligInfo = helpers.getEligibilityInfo
70
+ ? (() => { try { return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
71
+ : null;
72
+ const tag = el.tagName.toLowerCase();
73
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
74
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
75
+
76
+ occurrences.push({
77
+ selector: stableSelector,
78
+ html,
79
+ summary: 'This frame has no accessible name.',
80
+ hint: 'Add a title attribute (or aria-label/aria-labelledby) describing the frame’s content or purpose.',
81
+ i18n: {
82
+ summaryKey: 'iframeNamePresent_summary_fail',
83
+ hintKey: 'iframeNamePresent_hint_fail',
84
+ params: { element: tag }
85
+ },
86
+ data: {
87
+ details: { reasonCode: 'IFRAME_NAME_MISSING', element: tag },
88
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
89
+ }
90
+ });
91
+ }
92
+
93
+ if (applicableCount === 0) {
94
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
95
+ }
96
+ if (occurrences.length) {
97
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
98
+ }
99
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
100
+ }
101
+
102
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check iframe-title-unique
5
+ * @atomic true
6
+ * @summary <iframe>/<frame> title attributes must be unique among frames
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to <iframe>/<frame> elements that carry a non-empty title
11
+ * attribute.
12
+ * @expectation
13
+ * No two frames in scope share the same (trimmed, case-sensitive) title
14
+ * attribute value — a duplicate title prevents assistive technology
15
+ * users from telling frames apart when scanning by name.
16
+ * @implementation-notes
17
+ * - Distinct, atomic decision from iframe-name-present (presence):
18
+ * a frame can have a non-empty title while still failing uniqueness.
19
+ * - Compares the title ATTRIBUTE specifically, not the full computed
20
+ * accessible name (aria-label could legitimately differ in wording even
21
+ * when title happens to collide) — matches a widely-used reference engine's frame-title-unique.
22
+ * - Not gated on isAccTreeEligible: a duplicate title is a static markup
23
+ * property independent of the frame's current visibility.
24
+ */
25
+
26
+ const id = 'iframe-title-unique';
27
+
28
+ const meta = {
29
+ title: 'Frame titles must be unique',
30
+ description: 'Checks that no two <iframe>/<frame> elements in scope share the same title attribute value.',
31
+ i18n: {
32
+ titleKey: 'iframeTitleUnique_title',
33
+ descriptionKey: 'iframeTitleUnique_description'
34
+ },
35
+ helpUrl: null,
36
+ tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
37
+ wcagSc: ['4.1.2'],
38
+ normativeMappings: [
39
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
40
+ ],
41
+ defaultSeverity: 'moderate',
42
+ category: 'robust',
43
+ type: 'automatic',
44
+ defaultConfidence: 'high',
45
+ coverage: { facetsBySc: { '4.1.2': ['iframe-title-unique'] } }
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('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
53
+
54
+ const groups = new Map(); // trimmed title -> elements[]
55
+ let applicableCount = 0;
56
+
57
+ for (const el of nodes) {
58
+ if (!el || !el.getAttribute) continue;
59
+
60
+ const title = String(el.getAttribute('title') || '').trim();
61
+ if (!title) continue;
62
+
63
+ applicableCount += 1;
64
+
65
+ const list = groups.get(title);
66
+ if (list) list.push(el);
67
+ else groups.set(title, [el]);
68
+ }
69
+
70
+ if (applicableCount === 0) {
71
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
72
+ }
73
+
74
+ const occurrences = [];
75
+
76
+ for (const [title, els] of groups) {
77
+ if (els.length < 2) continue;
78
+
79
+ for (const el of els) {
80
+ const tag = el.tagName.toLowerCase();
81
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
82
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
83
+
84
+ occurrences.push({
85
+ selector: stableSelector,
86
+ html,
87
+ summary: 'This frame’s title is not unique among the frames on this page.',
88
+ hint: 'Give each frame a distinct title describing its specific content or purpose.',
89
+ i18n: {
90
+ summaryKey: 'iframeTitleUnique_summary_fail',
91
+ hintKey: 'iframeTitleUnique_hint_fail',
92
+ params: { element: tag, title }
93
+ },
94
+ data: {
95
+ details: { reasonCode: 'IFRAME_TITLE_DUPLICATE', element: tag, title, duplicateCount: els.length }
96
+ }
97
+ });
98
+ }
99
+ }
100
+
101
+ if (occurrences.length) {
102
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
103
+ }
104
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
105
+ }
106
+
107
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check img-alt-present
5
+ * @atomic true
6
+ * @summary Accessible <img> elements must have an alt attribute
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @applicability
10
+ * Applies to <img> elements that are exposed to assistive technologies.
11
+ * Images with role="presentation" or role="none" are excluded only when they are not focusable.
12
+ * Elements otherwise hidden from the accessibility tree remain applicable
13
+ * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
14
+ * @expectation
15
+ * Each applicable <img> element has an alt attribute.
16
+ * The alt attribute may be empty (alt="").
17
+ */
18
+
19
+ const id = 'img-alt-present';
20
+
21
+ const meta = {
22
+ title: '<img> must have an alt attribute',
23
+ description: 'Checks that <img> elements provide an alt attribute to support a text alternative mechanism.',
24
+ i18n: {
25
+ titleKey: 'img_altPresent_title',
26
+ descriptionKey: 'img_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': ['img-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 getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
51
+
52
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
53
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
54
+ const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
55
+
56
+ // Prefer tagName collection when available (cheap), otherwise fall back.
57
+ function getImgsCollection() {
58
+ try {
59
+ if (queryAllSmart) {
60
+ const r = queryAllSmart('img');
61
+ return Array.isArray(r) ? r : Array.from(r || []);
62
+ }
63
+ } catch {
64
+ // fall through
65
+ }
66
+
67
+ try {
68
+ if (safeRoot && typeof safeRoot.getElementsByTagName === 'function') {
69
+ return safeRoot.getElementsByTagName('img'); // HTMLCollection (live)
70
+ }
71
+ } catch {
72
+ // fall through
73
+ }
74
+
75
+ try {
76
+ if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return safeRoot.querySelectorAll('img');
77
+ } catch {
78
+ // fall through
79
+ }
80
+
81
+ return [];
82
+ }
83
+
84
+ const imgs = getImgsCollection();
85
+ const imgLen = imgs && typeof imgs.length === 'number' ? imgs.length : 0;
86
+
87
+ if (!imgLen) {
88
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
89
+ }
90
+
91
+ const occurrences = [];
92
+ let applicableCount = 0;
93
+
94
+ // Helper-safe trim
95
+ const trim = (v) => (v == null ? '' : String(v)).trim();
96
+
97
+ // Deterministic tabindex fallback (only used if helper missing)
98
+ function isFocusableFallback(el) {
99
+ try {
100
+ const tabindex = el.getAttribute('tabindex');
101
+ if (tabindex == null) return false;
102
+ const s = String(tabindex).trim();
103
+ if (!s) return false;
104
+ return !Number.isNaN(Number(s));
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ for (let i = 0; i < imgLen; i++) {
111
+ const el = imgs[i];
112
+ if (!el || !el.getAttribute) continue;
113
+
114
+ // Eligibility: only imgs exposed to assistive tech (with focusable/IDREF exceptions handled by helper)
115
+ if (isAccTreeEligible) {
116
+ let elig;
117
+ try {
118
+ elig = isAccTreeEligible(el, ctx);
119
+ } catch {
120
+ elig = null;
121
+ }
122
+ if (elig && elig.eligible === false) continue;
123
+ // If helper returns boolean, treat false as ineligible.
124
+ if (typeof elig === 'boolean' && elig === false) continue;
125
+ }
126
+
127
+ // Role (presentation/none) exclusion only when NOT focusable.
128
+ let role = '';
129
+ try {
130
+ role = trim(el.getAttribute('role')).toLowerCase();
131
+ } catch {
132
+ role = '';
133
+ }
134
+
135
+ if (role === 'presentation' || role === 'none') {
136
+ let focusable = false;
137
+ if (getFocusableInfo) {
138
+ try {
139
+ const fi = getFocusableInfo(el, ctx);
140
+ // Preserve existing behavior: prefer fi.focusable; fall back to fi.tabbable if present.
141
+ focusable = !!(fi && (fi.focusable || fi.tabbable));
142
+ } catch {
143
+ focusable = false;
144
+ }
145
+ } else {
146
+ focusable = isFocusableFallback(el);
147
+ }
148
+
149
+ if (!focusable) continue;
150
+ }
151
+
152
+ // From here: applicable
153
+ applicableCount += 1;
154
+
155
+ // alt attribute presence check (empty allowed)
156
+ let hasAlt = false;
157
+ try {
158
+ hasAlt = el.getAttribute('alt') !== null;
159
+ } catch {
160
+ hasAlt = false;
161
+ }
162
+ if (hasAlt) continue;
163
+
164
+ // aria-label / aria-labelledby is also a valid, standards-recognized
165
+ // text-alternative mechanism for <img> (HTML-AAM accessible name
166
+ // computation includes ARIA naming before falling back to alt); an
167
+ // image with a non-empty ARIA name is not missing a text alternative
168
+ // just because it lacks an alt attribute.
169
+ if (getAriaNameInfo) {
170
+ let ariaName = null;
171
+ try {
172
+ ariaName = getAriaNameInfo(el, ctx);
173
+ } catch {
174
+ ariaName = null;
175
+ }
176
+ if (ariaName && ariaName.present) continue;
177
+ }
178
+
179
+ // A non-empty title attribute is HTML-AAM's own next fallback naming
180
+ // source for <img> once alt is entirely absent (not merely alt="",
181
+ // which explicitly marks decorative and stays excluded from this
182
+ // branch since hasAlt already short-circuited above) -- confirmed
183
+ // against a widely-used reference engine's own image-alt rule, which lists a non-empty
184
+ // title as one of its "any" satisfying conditions alongside has-alt/
185
+ // aria-label/aria-labelledby. Found via a real page (AliExpress's
186
+ // logo, 2026-07-23): <img src="..." title="...">, no alt attribute
187
+ // at all -- a real false positive, not a missing text alternative.
188
+ const title = trim(el.getAttribute('title'));
189
+ if (title) continue;
190
+
191
+ const eligInfo = getEligibilityInfo ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })() : null;
192
+
193
+ const baseOccurrence = {
194
+ summary: 'Missing alt attribute on <img>.',
195
+ hint: 'Add an alt attribute (use alt="" only for decorative images).',
196
+ i18n: {
197
+ summaryKey: 'img_altPresent_summary_fail',
198
+ hintKey: 'img_altPresent_hint_fail',
199
+ params: {}
200
+ },
201
+ data: { visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] } }
202
+ };
203
+
204
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
205
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
206
+ } else {
207
+ // Never compute selector/snippet in the rule.
208
+ occurrences.push({ __node: el, selector: '', html: '', ...baseOccurrence });
209
+ }
210
+ }
211
+
212
+ if (applicableCount === 0) {
213
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
214
+ }
215
+
216
+ if (!occurrences.length) {
217
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
218
+ }
219
+
220
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
221
+ }
222
+
223
+ module.exports = { id, meta, runInPage };