@surea11y/core 1.2.0 → 1.3.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 +37 -7
- package/LICENSE +373 -21
- package/README.md +67 -1
- package/bin/core.js +144 -19
- package/docs/API_STABILITY.md +1 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +54 -1
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +18 -0
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +42 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +55 -16
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +42 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +30 -8
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +109 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
- package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +17 -6
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -7
- package/src/checks/manual/empty-table-header-manual.js +17 -6
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -7
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
- package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +26 -20
- package/src/checks/manual/landmark-unique-manual.js +41 -15
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +16 -5
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +31 -22
- package/src/checks/manual/page-title-patterns-manual.js +78 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +241 -48
- package/src/checks/manual/scope-attr-valid-manual.js +10 -3
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +10 -3
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1127 -886
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4175 -3917
- package/src/core/dom-runner.js +720 -604
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +58 -41
- package/src/core.js +36552 -29111
- package/src/i18n/en.js +1194 -889
- package/src/i18n/fr.js +1136 -795
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +63 -43
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -30,7 +30,8 @@ const id = 'image-redundant-alt';
|
|
|
30
30
|
|
|
31
31
|
const meta = {
|
|
32
32
|
title: 'Image alt text must not duplicate adjacent visible text',
|
|
33
|
-
description:
|
|
33
|
+
description:
|
|
34
|
+
'Checks that an <img> alt text is not identical to other visible text already present in its immediate parent element.',
|
|
34
35
|
i18n: {
|
|
35
36
|
titleKey: 'imageRedundantAlt_title',
|
|
36
37
|
descriptionKey: 'imageRedundantAlt_description'
|
|
@@ -47,10 +48,12 @@ const meta = {
|
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
function runInPage(ctx) {
|
|
50
|
-
const {
|
|
51
|
+
const { helpers, rule } = ctx;
|
|
51
52
|
|
|
52
53
|
function normalizeWs(s) {
|
|
53
|
-
return String(s || '')
|
|
54
|
+
return String(s || '')
|
|
55
|
+
.replace(/\s+/g, ' ')
|
|
56
|
+
.trim();
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
function getOwnTextExcludingImg(parent, imgEl) {
|
|
@@ -66,7 +69,9 @@ function runInPage(ctx) {
|
|
|
66
69
|
return normalizeWs(text);
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
const nodes = helpers.queryAllSmart
|
|
72
|
+
const nodes = helpers.queryAllSmart
|
|
73
|
+
? helpers.queryAllSmart('img[alt]')
|
|
74
|
+
: helpers.queryAll('img[alt]');
|
|
70
75
|
|
|
71
76
|
const occurrences = [];
|
|
72
77
|
let applicableCount = 0;
|
|
@@ -87,12 +92,12 @@ function runInPage(ctx) {
|
|
|
87
92
|
if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
|
|
88
93
|
|
|
89
94
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
90
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
95
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
91
96
|
|
|
92
97
|
occurrences.push({
|
|
93
98
|
selector: stableSelector,
|
|
94
99
|
html,
|
|
95
|
-
summary:
|
|
100
|
+
summary: "This image's alt text duplicates other visible text right next to it.",
|
|
96
101
|
hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
|
|
97
102
|
i18n: {
|
|
98
103
|
summaryKey: 'imageRedundantAlt_summary_cantTell',
|
|
@@ -109,7 +114,12 @@ function runInPage(ctx) {
|
|
|
109
114
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
110
115
|
}
|
|
111
116
|
if (occurrences.length) {
|
|
112
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
ruleId: rule.ruleId,
|
|
119
|
+
outcome: 'cantTell',
|
|
120
|
+
severity: rule.defaultSeverity || 'minor',
|
|
121
|
+
occurrences
|
|
122
|
+
};
|
|
113
123
|
}
|
|
114
124
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
115
125
|
}
|
|
@@ -11,20 +11,27 @@
|
|
|
11
11
|
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const id =
|
|
14
|
+
const id = 'img-alt-decorative';
|
|
15
15
|
|
|
16
16
|
const meta = {
|
|
17
|
-
title:
|
|
18
|
-
description:
|
|
17
|
+
title: '<img> with alt="" must be decorative (manual review)',
|
|
18
|
+
description:
|
|
19
|
+
'Flags <img> elements with empty alt for human review that they are purely decorative.',
|
|
19
20
|
i18n: {
|
|
20
|
-
titleKey:
|
|
21
|
-
descriptionKey:
|
|
21
|
+
titleKey: 'img_altDecorative_title',
|
|
22
|
+
descriptionKey: 'img_altDecorative_description'
|
|
22
23
|
},
|
|
23
24
|
helpUrl: null,
|
|
24
|
-
tags: [
|
|
25
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'manual', 'atomic'],
|
|
25
26
|
wcagSc: ['1.1.1'],
|
|
26
27
|
normativeMappings: [
|
|
27
|
-
{
|
|
28
|
+
{
|
|
29
|
+
standard: 'WCAG',
|
|
30
|
+
version: '2.2',
|
|
31
|
+
requirement: '1.1.1',
|
|
32
|
+
title: 'Non-text Content',
|
|
33
|
+
conformanceLevel: 'A'
|
|
34
|
+
}
|
|
28
35
|
],
|
|
29
36
|
defaultSeverity: 'minor',
|
|
30
37
|
category: 'perceivable',
|
|
@@ -38,111 +45,139 @@ const meta = {
|
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
function runInPage(ctx) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const { document, root, helpers, rule } = ctx;
|
|
49
|
+
const safeRoot = root || document;
|
|
50
|
+
|
|
51
|
+
const queryAllSmart =
|
|
52
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
53
|
+
const queryAll =
|
|
54
|
+
helpers && typeof helpers.queryAll === 'function'
|
|
55
|
+
? helpers.queryAll
|
|
56
|
+
: (sel) => {
|
|
57
|
+
try {
|
|
58
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
59
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
60
|
+
: [];
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
51
64
|
};
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
const isAccTreeEligible =
|
|
67
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
68
|
+
|
|
69
|
+
const getFocusableInfo =
|
|
70
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
71
|
+
|
|
72
|
+
function isRolePresentationExcluded(el) {
|
|
73
|
+
const role = (() => {
|
|
74
|
+
try {
|
|
75
|
+
return String(el.getAttribute('role') || '')
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase();
|
|
78
|
+
} catch {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
83
|
+
|
|
84
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
85
|
+
let focusable;
|
|
86
|
+
if (getFocusableInfo) {
|
|
87
|
+
const fi = (() => {
|
|
88
|
+
try {
|
|
89
|
+
return getFocusableInfo(el, ctx);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
76
92
|
}
|
|
77
|
-
|
|
93
|
+
})();
|
|
94
|
+
focusable = !!(fi && fi.focusable);
|
|
95
|
+
} else {
|
|
96
|
+
const tabindex = el.getAttribute('tabindex');
|
|
97
|
+
focusable =
|
|
98
|
+
tabindex != null &&
|
|
99
|
+
String(tabindex).trim() !== '' &&
|
|
100
|
+
!Number.isNaN(Number(String(tabindex).trim()));
|
|
78
101
|
}
|
|
102
|
+
return !focusable;
|
|
103
|
+
}
|
|
79
104
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const seen = new Set();
|
|
90
|
-
for (const el of els) {
|
|
91
|
-
if (!seen.has(el)) { seen.add(el); uniqueEls.push(el); }
|
|
105
|
+
const els = (() => {
|
|
106
|
+
// Only likely candidates:
|
|
107
|
+
// - alt="" (exact)
|
|
108
|
+
// - alt that starts/ends with space (to catch whitespace-only like " ")
|
|
109
|
+
const sel = 'img[alt=""], img[alt^=" "], img[alt$=" "]';
|
|
110
|
+
try {
|
|
111
|
+
return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
|
|
112
|
+
} catch {
|
|
113
|
+
return queryAll(sel);
|
|
92
114
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
})();
|
|
116
|
+
const uniqueEls = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const el of els) {
|
|
119
|
+
if (!seen.has(el)) {
|
|
120
|
+
seen.add(el);
|
|
121
|
+
uniqueEls.push(el);
|
|
96
122
|
}
|
|
123
|
+
}
|
|
97
124
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
for (const el of uniqueEls) {
|
|
102
|
-
if (!el || !el.getAttribute) continue;
|
|
125
|
+
if (!els.length) {
|
|
126
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
127
|
+
}
|
|
103
128
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
|
|
107
|
-
})();
|
|
108
|
-
if (elig && elig.eligible === false) continue;
|
|
109
|
-
}
|
|
129
|
+
const occurrences = [];
|
|
130
|
+
let applicableCount = 0;
|
|
110
131
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
114
|
-
const rawAlt = el.getAttribute('alt');
|
|
115
|
-
if (rawAlt == null) continue;
|
|
116
|
-
if (String(rawAlt).trim() !== '') continue;
|
|
117
|
-
|
|
118
|
-
applicableCount += 1;
|
|
119
|
-
|
|
120
|
-
const baseOccurrence = {
|
|
121
|
-
summary: 'Review whether <img> is decorative (alt="").',
|
|
122
|
-
hint: 'Confirm the image is purely decorative. If it conveys information or function, provide meaningful alt text.',
|
|
123
|
-
i18n: {
|
|
124
|
-
summaryKey: 'img_altDecorative_summary_cantTell',
|
|
125
|
-
hintKey: 'img_altDecorative_hint_cantTell',
|
|
126
|
-
params: { element: 'img' }
|
|
127
|
-
},
|
|
128
|
-
data: {
|
|
129
|
-
visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
130
|
-
details: null
|
|
131
|
-
}
|
|
132
|
-
};
|
|
132
|
+
for (const el of uniqueEls) {
|
|
133
|
+
if (!el || !el.getAttribute) continue;
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
if (isAccTreeEligible) {
|
|
136
|
+
const elig = (() => {
|
|
137
|
+
try {
|
|
138
|
+
return isAccTreeEligible(el, ctx);
|
|
139
|
+
} catch {
|
|
140
|
+
return { eligible: true, reasons: [] };
|
|
138
141
|
}
|
|
142
|
+
})();
|
|
143
|
+
if (elig && elig.eligible === false) continue;
|
|
139
144
|
}
|
|
140
145
|
|
|
141
|
-
if (
|
|
142
|
-
|
|
146
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
147
|
+
|
|
148
|
+
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
149
|
+
const rawAlt = el.getAttribute('alt');
|
|
150
|
+
if (rawAlt == null) continue;
|
|
151
|
+
if (String(rawAlt).trim() !== '') continue;
|
|
152
|
+
|
|
153
|
+
applicableCount += 1;
|
|
154
|
+
|
|
155
|
+
const baseOccurrence = {
|
|
156
|
+
summary: 'Review whether <img> is decorative (alt="").',
|
|
157
|
+
hint: 'Confirm the image is purely decorative. If it conveys information or function, provide meaningful alt text.',
|
|
158
|
+
i18n: {
|
|
159
|
+
summaryKey: 'img_altDecorative_summary_cantTell',
|
|
160
|
+
hintKey: 'img_altDecorative_hint_cantTell',
|
|
161
|
+
params: { element: 'img' }
|
|
162
|
+
},
|
|
163
|
+
data: {
|
|
164
|
+
visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
165
|
+
details: null
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
170
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
171
|
+
} else {
|
|
172
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
143
173
|
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (applicableCount === 0) {
|
|
177
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
178
|
+
}
|
|
144
179
|
|
|
145
|
-
|
|
180
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
|
|
146
181
|
}
|
|
147
182
|
|
|
148
183
|
module.exports = { id, meta, runInPage };
|
|
@@ -11,20 +11,26 @@
|
|
|
11
11
|
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const id =
|
|
14
|
+
const id = 'img-alt-quality';
|
|
15
15
|
|
|
16
16
|
const meta = {
|
|
17
|
-
title:
|
|
18
|
-
description:
|
|
17
|
+
title: '<img> alt text must be appropriate (manual review)',
|
|
18
|
+
description: 'Flags <img> elements with non-empty alt text for human review of appropriateness.',
|
|
19
19
|
i18n: {
|
|
20
|
-
titleKey:
|
|
21
|
-
descriptionKey:
|
|
20
|
+
titleKey: 'img_altQuality_title',
|
|
21
|
+
descriptionKey: 'img_altQuality_description'
|
|
22
22
|
},
|
|
23
23
|
helpUrl: null,
|
|
24
|
-
tags: [
|
|
24
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'manual', 'atomic'],
|
|
25
25
|
wcagSc: ['1.1.1'],
|
|
26
26
|
normativeMappings: [
|
|
27
|
-
{
|
|
27
|
+
{
|
|
28
|
+
standard: 'WCAG',
|
|
29
|
+
version: '2.2',
|
|
30
|
+
requirement: '1.1.1',
|
|
31
|
+
title: 'Non-text Content',
|
|
32
|
+
conformanceLevel: 'A'
|
|
33
|
+
}
|
|
28
34
|
],
|
|
29
35
|
defaultSeverity: 'minor',
|
|
30
36
|
category: 'perceivable',
|
|
@@ -38,145 +44,188 @@ const meta = {
|
|
|
38
44
|
};
|
|
39
45
|
|
|
40
46
|
function runInPage(ctx) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
const { document, root, helpers, rule } = ctx;
|
|
48
|
+
const safeRoot = root || document;
|
|
49
|
+
|
|
50
|
+
// Cap occurrences to keep manual “quality” checks fast on large pages.
|
|
51
|
+
// Deterministic: we keep DOM order, just stop collecting after N.
|
|
52
|
+
const MAX_OCCURRENCES = 50;
|
|
53
|
+
|
|
54
|
+
const queryAllSmart =
|
|
55
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
56
|
+
const queryAll =
|
|
57
|
+
helpers && typeof helpers.queryAll === 'function'
|
|
58
|
+
? helpers.queryAll
|
|
59
|
+
: (sel) => {
|
|
60
|
+
try {
|
|
61
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
62
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
63
|
+
: [];
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
54
67
|
};
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
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 { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
|
|
71
|
-
catch { return ''; }
|
|
72
|
-
})();
|
|
73
|
-
if (role !== 'presentation' && role !== 'none') return false;
|
|
74
|
-
|
|
75
|
-
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
76
|
-
let focusable = false;
|
|
77
|
-
if (getFocusableInfo) {
|
|
78
|
-
const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
|
|
79
|
-
focusable = !!(fi && fi.focusable);
|
|
80
|
-
} else {
|
|
81
|
-
const tabindex = el.getAttribute('tabindex');
|
|
82
|
-
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
83
|
-
}
|
|
84
|
-
return !focusable;
|
|
85
|
-
}
|
|
69
|
+
const getEligibilityInfo =
|
|
70
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
86
71
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
try { return Array.from((queryAllSmart ? queryAllSmart(selector) : queryAll(selector)) || []); }
|
|
90
|
-
catch { return queryAll(selector); }
|
|
91
|
-
})();
|
|
72
|
+
const isAccTreeEligible =
|
|
73
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
92
74
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
ruleId: rule.ruleId,
|
|
96
|
-
outcome: 'notApplicable',
|
|
97
|
-
severity: 'minor',
|
|
98
|
-
occurrences: [],
|
|
99
|
-
data: { details: { applicableCount: 0, reportedCount: 0, maxOccurrences: MAX_OCCURRENCES, truncated: false } }
|
|
100
|
-
};
|
|
101
|
-
}
|
|
75
|
+
const getFocusableInfo =
|
|
76
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
102
77
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
78
|
+
function isRolePresentationExcluded(el) {
|
|
79
|
+
const role = (() => {
|
|
80
|
+
try {
|
|
81
|
+
return String(el.getAttribute('role') || '')
|
|
82
|
+
.trim()
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
} catch {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
89
|
+
|
|
90
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
91
|
+
let focusable;
|
|
92
|
+
if (getFocusableInfo) {
|
|
93
|
+
const fi = (() => {
|
|
94
|
+
try {
|
|
95
|
+
return getFocusableInfo(el, ctx);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
focusable = !!(fi && fi.focusable);
|
|
101
|
+
} else {
|
|
102
|
+
const tabindex = el.getAttribute('tabindex');
|
|
103
|
+
focusable =
|
|
104
|
+
tabindex != null &&
|
|
105
|
+
String(tabindex).trim() !== '' &&
|
|
106
|
+
!Number.isNaN(Number(String(tabindex).trim()));
|
|
107
|
+
}
|
|
108
|
+
return !focusable;
|
|
109
|
+
}
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
const selector = 'img[alt]:not([alt=""])';
|
|
112
|
+
const els = (() => {
|
|
113
|
+
try {
|
|
114
|
+
return Array.from((queryAllSmart ? queryAllSmart(selector) : queryAll(selector)) || []);
|
|
115
|
+
} catch {
|
|
116
|
+
return queryAll(selector);
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
109
119
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
if (!els.length) {
|
|
121
|
+
return {
|
|
122
|
+
ruleId: rule.ruleId,
|
|
123
|
+
outcome: 'notApplicable',
|
|
124
|
+
severity: 'minor',
|
|
125
|
+
occurrences: [],
|
|
126
|
+
data: {
|
|
127
|
+
details: {
|
|
128
|
+
applicableCount: 0,
|
|
129
|
+
reportedCount: 0,
|
|
130
|
+
maxOccurrences: MAX_OCCURRENCES,
|
|
131
|
+
truncated: false
|
|
115
132
|
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
116
136
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const alt = (() => {
|
|
121
|
-
try { return String(el.getAttribute('alt') || '').trim(); } catch { return ''; }
|
|
122
|
-
})();
|
|
123
|
-
if (!alt) continue;
|
|
124
|
-
|
|
125
|
-
applicableCount += 1;
|
|
126
|
-
|
|
127
|
-
// IMPORTANT: stop doing expensive occurrence building after we hit the cap
|
|
128
|
-
if (collectedCount >= MAX_OCCURRENCES) continue;
|
|
129
|
-
|
|
130
|
-
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
131
|
-
const baseOccurrence = {
|
|
132
|
-
summary: 'Review alt text on <img> for accuracy and appropriateness.',
|
|
133
|
-
hint: 'Ensure the alt text conveys the image’s purpose/information in context (not redundant, not filename-like).',
|
|
134
|
-
i18n: {
|
|
135
|
-
summaryKey: 'img_altQuality_summary_cantTell',
|
|
136
|
-
hintKey: 'img_altQuality_hint_cantTell',
|
|
137
|
-
params: { element: 'img' }
|
|
138
|
-
},
|
|
139
|
-
data: {
|
|
140
|
-
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
141
|
-
details: null
|
|
142
|
-
}
|
|
143
|
-
};
|
|
137
|
+
const occurrences = [];
|
|
138
|
+
let applicableCount = 0; // total applicable elements
|
|
139
|
+
let collectedCount = 0; // how many occurrences we actually reported
|
|
144
140
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
} else {
|
|
148
|
-
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
149
|
-
}
|
|
141
|
+
for (const el of els) {
|
|
142
|
+
if (!el || !el.getAttribute) continue;
|
|
150
143
|
|
|
151
|
-
|
|
144
|
+
if (isAccTreeEligible) {
|
|
145
|
+
const elig = (() => {
|
|
146
|
+
try {
|
|
147
|
+
return isAccTreeEligible(el, ctx);
|
|
148
|
+
} catch {
|
|
149
|
+
return { eligible: true, reasons: [] };
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
if (elig && elig.eligible === false) continue;
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
155
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
156
|
+
|
|
157
|
+
// Rule-specific applicability: non-empty alt
|
|
158
|
+
const alt = (() => {
|
|
159
|
+
try {
|
|
160
|
+
return String(el.getAttribute('alt') || '').trim();
|
|
161
|
+
} catch {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
if (!alt) continue;
|
|
166
|
+
|
|
167
|
+
applicableCount += 1;
|
|
168
|
+
|
|
169
|
+
// IMPORTANT: stop doing expensive occurrence building after we hit the cap
|
|
170
|
+
if (collectedCount >= MAX_OCCURRENCES) continue;
|
|
171
|
+
|
|
172
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
173
|
+
const baseOccurrence = {
|
|
174
|
+
summary: 'Review alt text on <img> for accuracy and appropriateness.',
|
|
175
|
+
hint: 'Ensure the alt text conveys the image’s purpose/information in context (not redundant, not filename-like).',
|
|
176
|
+
i18n: {
|
|
177
|
+
summaryKey: 'img_altQuality_summary_cantTell',
|
|
178
|
+
hintKey: 'img_altQuality_hint_cantTell',
|
|
179
|
+
params: { element: 'img' }
|
|
180
|
+
},
|
|
181
|
+
data: {
|
|
182
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
183
|
+
details: null
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
188
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
189
|
+
} else {
|
|
190
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
162
191
|
}
|
|
163
192
|
|
|
164
|
-
|
|
193
|
+
collectedCount += 1;
|
|
194
|
+
}
|
|
165
195
|
|
|
196
|
+
if (applicableCount === 0) {
|
|
166
197
|
return {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
198
|
+
ruleId: rule.ruleId,
|
|
199
|
+
outcome: 'notApplicable',
|
|
200
|
+
severity: 'minor',
|
|
201
|
+
occurrences: [],
|
|
202
|
+
data: {
|
|
203
|
+
details: {
|
|
204
|
+
applicableCount: 0,
|
|
205
|
+
reportedCount: 0,
|
|
206
|
+
maxOccurrences: MAX_OCCURRENCES,
|
|
207
|
+
truncated: false
|
|
178
208
|
}
|
|
209
|
+
}
|
|
179
210
|
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const truncated = applicableCount > collectedCount;
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
ruleId: rule.ruleId,
|
|
217
|
+
outcome: 'cantTell',
|
|
218
|
+
severity: 'minor',
|
|
219
|
+
occurrences,
|
|
220
|
+
data: {
|
|
221
|
+
details: {
|
|
222
|
+
applicableCount,
|
|
223
|
+
reportedCount: collectedCount,
|
|
224
|
+
maxOccurrences: MAX_OCCURRENCES,
|
|
225
|
+
truncated
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
180
229
|
}
|
|
181
230
|
|
|
182
231
|
module.exports = { id, meta, runInPage };
|