@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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- 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 };
|