@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,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check no-autoplay-audio
5
+ * @atomic true
6
+ * @summary Autoplaying, unmuted <audio>/<video> should provide a pause/stop or volume-control mechanism
7
+ * @standard WCAG 2.2
8
+ * @sc 1.4.2
9
+ * @applicability
10
+ * Any <audio autoplay> or <video autoplay> element that is not `muted`.
11
+ * @expectation
12
+ * SC 1.4.2 only applies when audio plays automatically for MORE than 3
13
+ * seconds; clip duration is not knowable from static markup (jsdom does
14
+ * not decode media), so this rule cannot determine whether the SC even
15
+ * applies to a given element. It is deliberately authored as `type:
16
+ * 'manual'` (cantTell-capped, never fail) rather than guessing: an
17
+ * autoplaying unmuted element with no `controls` attribute (the native,
18
+ * statically-verifiable mechanism to pause/stop or adjust volume) is
19
+ * flagged for human review rather than treated as a deterministic
20
+ * violation.
21
+ * @implementation-notes
22
+ * - Elements with `controls` present are not flagged: native controls
23
+ * provide pause/stop and volume adjustment, satisfying the SC's
24
+ * mechanism requirement regardless of duration.
25
+ * - Elements with `muted` present are not flagged: muted playback is not
26
+ * audible, so the SC's condition ("plays automatically... audio")
27
+ * does not apply.
28
+ * - Custom (JS-built) controls that don't use the native `controls`
29
+ * attribute cannot be detected statically — a documented limitation,
30
+ * same class as `iframe-focusable-content`'s `contentDocument` gap.
31
+ * - Not gated on `isAccTreeEligible`: unlike most rules, a `display:none`
32
+ * or `aria-hidden` audio/video element still plays audible sound in a
33
+ * real browser, so visual/AT-tree eligibility is not a relevant filter
34
+ * here.
35
+ */
36
+
37
+ const id = 'no-autoplay-audio';
38
+
39
+ const meta = {
40
+ title: 'Autoplaying audio should provide a pause/stop or volume-control mechanism',
41
+ description:
42
+ 'Flags <audio>/<video> elements that autoplay unmuted with no native controls attribute, for manual review against the 3-second exemption in WCAG 1.4.2.',
43
+ i18n: {
44
+ titleKey: 'noAutoplayAudio_title',
45
+ descriptionKey: 'noAutoplayAudio_description'
46
+ },
47
+ helpUrl: null,
48
+ tags: ['wcag2a', 'wcag142', 'media', 'atomic', 'manual'],
49
+ wcagSc: ['1.4.2'],
50
+ normativeMappings: [
51
+ { standard: 'WCAG', version: '2.2', requirement: '1.4.2', title: 'Audio Control', conformanceLevel: 'A' }
52
+ ],
53
+ defaultSeverity: 'moderate',
54
+ category: 'perceivable',
55
+ type: 'manual',
56
+ defaultConfidence: 'low',
57
+ coverage: { facetsBySc: { '1.4.2': ['no-autoplay-audio-evidence'] } }
58
+ };
59
+
60
+ function runInPage(ctx) {
61
+ const { document, root, helpers, rule } = ctx;
62
+ const safeRoot = root || document;
63
+
64
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]', safeRoot) : helpers.queryAll('audio[autoplay], video[autoplay]', safeRoot);
65
+
66
+ const occurrences = [];
67
+ let applicableCount = 0;
68
+
69
+ for (const el of nodes) {
70
+ if (!el || !el.hasAttribute) continue;
71
+ if (el.hasAttribute('muted')) continue;
72
+
73
+ applicableCount += 1;
74
+
75
+ if (el.hasAttribute('controls')) continue;
76
+
77
+ const mediaTag = (el.tagName || '').toLowerCase();
78
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
79
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
80
+
81
+ const baseOccurrence = {
82
+ selector: stableSelector,
83
+ html,
84
+ summary: 'This element autoplays audio without a native pause/stop or volume-control mechanism.',
85
+ hint: 'If this clip plays for more than 3 seconds, add a `controls` attribute (or an equivalent custom mechanism) so users can pause/stop it or control its volume independently of the system volume.',
86
+ i18n: {
87
+ summaryKey: 'noAutoplayAudio_summary_cantTell',
88
+ hintKey: 'noAutoplayAudio_hint_cantTell',
89
+ params: { element: mediaTag }
90
+ },
91
+ data: {
92
+ details: { reasonCode: 'AUTOPLAY_NO_CONTROLS_MECHANISM', mediaTag }
93
+ }
94
+ };
95
+
96
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
97
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
98
+ } else {
99
+ occurrences.push(baseOccurrence);
100
+ }
101
+ }
102
+
103
+ if (applicableCount === 0) {
104
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
105
+ }
106
+
107
+ if (occurrences.length) {
108
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'moderate', occurrences };
109
+ }
110
+
111
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail):
112
+ // every applicable autoplaying element already has a controls mechanism.
113
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
114
+ }
115
+
116
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check object-text-alternative-quality
5
+ * @atomic true
6
+ * @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
7
+ * @standard WCAG 2.2
8
+ * @sc 1.1.1
9
+ * @type manual
10
+ * @expectation
11
+ * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
+ */
13
+
14
+ const id = "object-text-alternative-quality";
15
+
16
+ const meta = {
17
+ title: "<object> text alternative must be appropriate (manual review)",
18
+ description: "Flags <object> elements with detected fallback or name for human review of equivalence and appropriateness.",
19
+ i18n: {
20
+ titleKey: "object_textAltQuality_title",
21
+ descriptionKey: "object_textAltQuality_description"
22
+ },
23
+ helpUrl: null,
24
+ tags: ["wcag2a", "wcag111", "nontext", "object", "manual", "atomic"],
25
+ wcagSc: ['1.1.1'],
26
+ normativeMappings: [
27
+ {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
28
+ ],
29
+ defaultSeverity: 'minor',
30
+ category: 'perceivable',
31
+ type: 'manual',
32
+ defaultConfidence: 'medium',
33
+ coverage: {
34
+ facetsBySc: {
35
+ '1.1.1': ['text-alternative-quality']
36
+ }
37
+ }
38
+ };
39
+
40
+ function runInPage(ctx) {
41
+
42
+ const {document, root, helpers, rule} = ctx;
43
+ const safeRoot = root || document;
44
+
45
+ const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
46
+ const queryAll = helpers && typeof helpers.queryAll === 'function'
47
+ ? helpers.queryAll
48
+ : (sel) => {
49
+ try {
50
+ return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
51
+ } catch {
52
+ return [];
53
+ }
54
+ };
55
+
56
+ const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
57
+ ? helpers.getEligibilityInfo
58
+ : null;
59
+
60
+ const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
61
+ ? helpers.isAccTreeEligible
62
+ : null;
63
+
64
+ const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
65
+ ? helpers.getFocusableInfo
66
+ : null;
67
+
68
+ function isRolePresentationExcluded(el) {
69
+ const role = (() => {
70
+ try {
71
+ return String(el.getAttribute('role') || '').trim().toLowerCase();
72
+ } catch {
73
+ return '';
74
+ }
75
+ })();
76
+ if (role !== 'presentation' && role !== 'none') return false;
77
+
78
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
79
+ let focusable = false;
80
+ if (getFocusableInfo) {
81
+ const fi = (() => {
82
+ try {
83
+ return getFocusableInfo(el, ctx);
84
+ } catch {
85
+ return null;
86
+ }
87
+ })();
88
+ focusable = !!(fi && fi.focusable);
89
+ } else {
90
+ const tabindex = el.getAttribute('tabindex');
91
+ focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
92
+ }
93
+ return !focusable;
94
+ }
95
+
96
+
97
+ const els = (() => {
98
+ try {
99
+ return Array.from((queryAllSmart ? queryAllSmart('object') : queryAll('object')) || []);
100
+ } catch {
101
+ return queryAll('object');
102
+ }
103
+ })();
104
+
105
+ if (!els.length) {
106
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
107
+ }
108
+
109
+ const occurrences = [];
110
+ let applicableCount = 0;
111
+
112
+ for (const el of els) {
113
+ if (!el || !el.getAttribute) continue;
114
+
115
+ if (isAccTreeEligible) {
116
+ const elig = (() => {
117
+ try {
118
+ return isAccTreeEligible(el, ctx);
119
+ } catch {
120
+ return {eligible: true, reasons: []};
121
+ }
122
+ })();
123
+ if (elig && elig.eligible === false) continue;
124
+ }
125
+
126
+ if (isRolePresentationExcluded(el)) continue;
127
+
128
+ const trim = (v) => (v == null ? '' : String(v)).trim();
129
+
130
+ let fallbackText = '';
131
+ let ariaLabel = '';
132
+ let ariaLabelledBy = '';
133
+ let title = '';
134
+ let labelledByText = '';
135
+
136
+ try {
137
+ fallbackText = trim(el.textContent || '');
138
+ ariaLabel = trim(el.getAttribute('aria-label'));
139
+ ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
140
+ title = trim(el.getAttribute('title'));
141
+ } catch {}
142
+
143
+ // Only resolve idrefs if needed/present
144
+ if (!ariaLabel && ariaLabelledBy && helpers && typeof helpers.getTextFromIdRefs === 'function') {
145
+ try {
146
+ const t = helpers.getTextFromIdRefs(ariaLabelledBy, ctx);
147
+ labelledByText = trim(t && t.text);
148
+ } catch {}
149
+ }
150
+
151
+ const details = {
152
+ fallbackText: fallbackText || null,
153
+ ariaLabel: ariaLabel || null,
154
+ ariaLabelledBy: ariaLabelledBy || null,
155
+ ariaLabelledByText: labelledByText || null,
156
+ title: title || null
157
+ };
158
+
159
+ const hasMechanism = !!(details.fallbackText || details.ariaLabel || details.ariaLabelledByText || details.title);
160
+ if (!hasMechanism) continue;
161
+
162
+ applicableCount += 1;
163
+
164
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
165
+
166
+ const baseOccurrence = {
167
+ summary: 'Review text alternative for <object> for equivalence and appropriateness.',
168
+ hint: 'Confirm the fallback content or ARIA name provides an equivalent alternative for the embedded content.',
169
+ i18n: {
170
+ summaryKey: 'object_textAltQuality_summary_cantTell',
171
+ hintKey: 'object_textAltQuality_hint_cantTell',
172
+ params: { element: 'object' }
173
+ },
174
+ data: {
175
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
176
+ details
177
+ }
178
+ };
179
+
180
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
181
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
182
+ } else {
183
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
184
+ }
185
+ }
186
+
187
+ if (applicableCount === 0) {
188
+ return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
189
+ }
190
+
191
+ return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
192
+ }
193
+
194
+ module.exports = {id, meta, runInPage};
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check p-as-heading
5
+ * @atomic true
6
+ * @summary A <p> styled to look like a heading should probably be a real heading
7
+ * @standard WCAG 2.2
8
+ * @sc 1.3.1
9
+ * @applicability
10
+ * `<p>` elements with short (<=120 char), non-empty trimmed text
11
+ * content that is entirely bold (the `<p>`'s own computed
12
+ * `font-weight` >= 700, OR its entire text is wrapped in a single
13
+ * `<strong>`/`<b>` child) and rendered at >=18px.
14
+ * @expectation
15
+ * Text styled to visually read as a heading (bold, larger-than-body
16
+ * size, short) should be marked up with a real heading element
17
+ * (`<h1>`-`<h6>` or `role="heading"`) so its structural role is
18
+ * programmatically determinable — the same 1.3.1 concern as any other
19
+ * "structure conveyed through presentation only" issue.
20
+ * @implementation-notes
21
+ * - This is a stylistic heuristic (bold + large + short), not a
22
+ * deterministic structural check — a short bold sentence is not
23
+ * necessarily wrong as a `<p>`. Authored as `type: 'manual'`
24
+ * (cantTell-capped, never fail) to avoid false-flagging legitimate
25
+ * emphasis, matching this repo's other heuristic-heavy Tier 2/3
26
+ * rules (e.g. `scrollable-region-focusable`).
27
+ * - Uses an absolute 18px size threshold rather than comparing against
28
+ * surrounding text (unlike `link-in-text-block`) — deliberately
29
+ * simpler, since "looks like a heading" is closer to an absolute
30
+ * judgment than a relative-contrast one.
31
+ */
32
+
33
+ const id = 'p-as-heading';
34
+
35
+ const meta = {
36
+ title: 'A <p> styled to look like a heading should probably be a real heading',
37
+ description: 'Flags short <p> elements whose entire text is bold and rendered at >=18px, for manual review of whether a real heading element should be used instead.',
38
+ i18n: {
39
+ titleKey: 'pAsHeading_title',
40
+ descriptionKey: 'pAsHeading_description'
41
+ },
42
+ helpUrl: null,
43
+ tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'manual'],
44
+ wcagSc: ['1.3.1'],
45
+ normativeMappings: [
46
+ { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
47
+ ],
48
+ defaultSeverity: 'minor',
49
+ category: 'perceivable',
50
+ type: 'manual',
51
+ defaultConfidence: 'low',
52
+ coverage: { facetsBySc: { '1.3.1': ['p-as-heading-evidence'] } }
53
+ };
54
+
55
+ function runInPage(ctx) {
56
+ const { document, root, helpers, rule } = ctx;
57
+ const safeRoot = root || document;
58
+
59
+ const MAX_HEADING_LIKE_CHARS = 120;
60
+ const MIN_FONT_SIZE_PX = 18;
61
+
62
+ function trim(v) { return (v == null ? '' : String(v)).trim(); }
63
+
64
+ function safeComputedStyle(el) {
65
+ try {
66
+ if (!el || el.nodeType !== 1) return null;
67
+ if (helpers && typeof helpers.computedStyle === 'function') {
68
+ const cs = helpers.computedStyle(el);
69
+ if (cs) return cs;
70
+ }
71
+ const view = (el.ownerDocument && el.ownerDocument.defaultView) ? el.ownerDocument.defaultView : null;
72
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
73
+ } catch {}
74
+ return null;
75
+ }
76
+
77
+ function isBoldWeight(cs) {
78
+ if (!cs) return false;
79
+ const w = trim(cs.fontWeight).toLowerCase();
80
+ if (w === 'bold' || w === 'bolder') return true;
81
+ const n = Number.parseInt(w, 10);
82
+ return Number.isFinite(n) && n >= 700;
83
+ }
84
+
85
+ function isEntirelyBold(p, text) {
86
+ const cs = safeComputedStyle(p);
87
+ if (isBoldWeight(cs)) return true;
88
+
89
+ // A single <strong>/<b> child that wraps the whole text also counts.
90
+ const children = Array.from(p.children || []);
91
+ const boldWrap = children.find((c) => {
92
+ const tag = (c.tagName || '').toLowerCase();
93
+ return tag === 'strong' || tag === 'b';
94
+ });
95
+ if (boldWrap && children.length === 1) {
96
+ const wrapText = trim(boldWrap.textContent || '');
97
+ if (wrapText && wrapText === text) return true;
98
+ }
99
+ return false;
100
+ }
101
+
102
+ function getFontSizePx(p) {
103
+ const cs = safeComputedStyle(p);
104
+ if (!cs) return 0;
105
+ const px = Number.parseFloat(cs.fontSize);
106
+ return Number.isFinite(px) ? px : 0;
107
+ }
108
+
109
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p', safeRoot) : helpers.queryAll('p', safeRoot);
110
+
111
+ const occurrences = [];
112
+ let applicableCount = 0;
113
+
114
+ for (const el of nodes) {
115
+ if (!el || !el.getAttribute) continue;
116
+
117
+ const text = trim(el.textContent || '');
118
+ if (!text || text.length > MAX_HEADING_LIKE_CHARS) continue;
119
+
120
+ applicableCount += 1;
121
+
122
+ if (!isEntirelyBold(el, text)) continue;
123
+
124
+ const fontSizePx = getFontSizePx(el);
125
+ if (fontSizePx < MIN_FONT_SIZE_PX) continue;
126
+
127
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
128
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
129
+
130
+ const baseOccurrence = {
131
+ selector: stableSelector,
132
+ html,
133
+ summary: 'This paragraph is entirely bold and rendered at a heading-like size.',
134
+ hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a paragraph to look like one.',
135
+ i18n: {
136
+ summaryKey: 'pAsHeading_summary_cantTell',
137
+ hintKey: 'pAsHeading_hint_cantTell',
138
+ params: { fontSizePx: String(fontSizePx) }
139
+ },
140
+ data: {
141
+ details: { reasonCode: 'BOLD_LARGE_PARAGRAPH', fontSizePx }
142
+ }
143
+ };
144
+
145
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
146
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
147
+ } else {
148
+ occurrences.push(baseOccurrence);
149
+ }
150
+ }
151
+
152
+ if (applicableCount === 0) {
153
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
154
+ }
155
+
156
+ if (occurrences.length) {
157
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
158
+ }
159
+
160
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
161
+ }
162
+
163
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check page-has-heading-one
5
+ * @atomic true
6
+ * @summary The page should have at least one level-one heading
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Always applicable to any HTML document with a <body> element —
10
+ * "does the page have an h1" is a whole-page concern, matching
11
+ * bypass-blocks-present's pattern of evaluating the document
12
+ * directly.
13
+ * @expectation
14
+ * At least one heading with level 1 exists (native <h1>, or
15
+ * role="heading" with aria-level="1"). A page with no top-level
16
+ * heading has no clear entry point for assistive technology users
17
+ * navigating by heading.
18
+ * @implementation-notes
19
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
21
+ * header comment for the shared rationale/precedent.
22
+ */
23
+
24
+ const id = 'page-has-heading-one';
25
+
26
+ const meta = {
27
+ title: 'Page should have a level-one heading',
28
+ description: 'Checks that the page has at least one level-one heading (<h1> or role="heading" with aria-level="1").',
29
+ i18n: {
30
+ titleKey: 'pageHasHeadingOne_title',
31
+ descriptionKey: 'pageHasHeadingOne_description'
32
+ },
33
+ helpUrl: null,
34
+ tags: ['best-practice', 'headings', 'structure', 'atomic', 'manual'],
35
+ wcagSc: [],
36
+ normativeMappings: [],
37
+ defaultSeverity: 'minor',
38
+ category: 'operable',
39
+ type: 'manual',
40
+ defaultConfidence: 'medium',
41
+ coverage: {}
42
+ };
43
+
44
+ function runInPage(ctx) {
45
+ const { document, helpers, rule } = ctx;
46
+
47
+ const body = document && document.body ? document.body : null;
48
+ if (!body) {
49
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
50
+ }
51
+
52
+ function normalizeWs(s) {
53
+ return String(s || '').replace(/\s+/g, ' ').trim();
54
+ }
55
+
56
+ function getExplicitRoleToken(el) {
57
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
58
+ if (!raw) return '';
59
+ return raw.split(/\s+/)[0].toLowerCase();
60
+ }
61
+
62
+ function isLevelOneHeading(el) {
63
+ const explicit = getExplicitRoleToken(el);
64
+ if (explicit) {
65
+ if (explicit !== 'heading') return false;
66
+ const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
67
+ return raw === '1';
68
+ }
69
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
70
+ return tag === 'h1';
71
+ }
72
+
73
+ let nodes = [];
74
+ try {
75
+ nodes = document.querySelectorAll('h1, [role]');
76
+ } catch {
77
+ nodes = [];
78
+ }
79
+
80
+ const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el));
81
+
82
+ if (hasH1) {
83
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
84
+ }
85
+
86
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
87
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(body) : (body.outerHTML || '').slice(0, 200);
88
+
89
+ return {
90
+ ruleId: rule.ruleId,
91
+ outcome: 'cantTell',
92
+ severity: rule.defaultSeverity || 'minor',
93
+ occurrences: [{
94
+ selector: stableSelector,
95
+ html,
96
+ summary: 'This page has no level-one heading.',
97
+ hint: 'Add a level-one heading (<h1> or role="heading" aria-level="1") that identifies the page\'s main content.',
98
+ i18n: {
99
+ summaryKey: 'pageHasHeadingOne_summary_cantTell',
100
+ hintKey: 'pageHasHeadingOne_hint_cantTell',
101
+ params: {}
102
+ },
103
+ data: {
104
+ details: { reasonCode: 'HEADING_ONE_MISSING' }
105
+ }
106
+ }]
107
+ };
108
+ }
109
+
110
+ module.exports = { id, meta, runInPage };