@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,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check embed-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 = "embed-text-alternative-quality";
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: "<embed> text alternative must be appropriate (manual review)",
|
|
18
|
+
description: "Flags <embed> elements with a detected name for human review of appropriateness.",
|
|
19
|
+
i18n: {
|
|
20
|
+
titleKey: "embed_textAltQuality_title",
|
|
21
|
+
descriptionKey: "embed_textAltQuality_description"
|
|
22
|
+
},
|
|
23
|
+
helpUrl: null,
|
|
24
|
+
tags: ["wcag2a", "wcag111", "nontext", "embed", "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
|
+
const {document, root, helpers, rule} = ctx;
|
|
42
|
+
const safeRoot = root || document;
|
|
43
|
+
|
|
44
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
45
|
+
const getTextFromIdRefs =
|
|
46
|
+
helpers && typeof helpers.getTextFromIdRefs === 'function' ? helpers.getTextFromIdRefs : null;
|
|
47
|
+
|
|
48
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
49
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
50
|
+
? helpers.queryAll
|
|
51
|
+
: (sel) => {
|
|
52
|
+
try {
|
|
53
|
+
return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
60
|
+
? helpers.getEligibilityInfo
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
64
|
+
? helpers.isAccTreeEligible
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
68
|
+
? helpers.getFocusableInfo
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
function isRolePresentationExcluded(el) {
|
|
72
|
+
const role = (() => {
|
|
73
|
+
try {
|
|
74
|
+
return String(el.getAttribute('role') || '').trim().toLowerCase();
|
|
75
|
+
} catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
})();
|
|
79
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
80
|
+
|
|
81
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
82
|
+
let focusable = false;
|
|
83
|
+
if (getFocusableInfo) {
|
|
84
|
+
const fi = (() => {
|
|
85
|
+
try {
|
|
86
|
+
return getFocusableInfo(el, ctx);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
focusable = !!(fi && fi.focusable);
|
|
92
|
+
} else {
|
|
93
|
+
const tabindex = el.getAttribute('tabindex');
|
|
94
|
+
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
95
|
+
}
|
|
96
|
+
return !focusable;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
const els = (() => {
|
|
101
|
+
try {
|
|
102
|
+
return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
|
|
103
|
+
} catch {
|
|
104
|
+
return queryAll('embed');
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
if (!els.length) {
|
|
109
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const occurrences = [];
|
|
113
|
+
let applicableCount = 0;
|
|
114
|
+
|
|
115
|
+
for (const el of els) {
|
|
116
|
+
if (!el || !el.getAttribute) continue;
|
|
117
|
+
|
|
118
|
+
if (isAccTreeEligible) {
|
|
119
|
+
const elig = (() => {
|
|
120
|
+
try {
|
|
121
|
+
return isAccTreeEligible(el, ctx);
|
|
122
|
+
} catch {
|
|
123
|
+
return {eligible: true, reasons: []};
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
if (elig && elig.eligible === false) continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
130
|
+
|
|
131
|
+
let ariaLabel = '';
|
|
132
|
+
let ariaLabelledBy = '';
|
|
133
|
+
let title = '';
|
|
134
|
+
try {
|
|
135
|
+
ariaLabel = trim(el.getAttribute('aria-label'));
|
|
136
|
+
ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
|
|
137
|
+
title = trim(el.getAttribute('title'));
|
|
138
|
+
} catch {
|
|
139
|
+
ariaLabel = '';
|
|
140
|
+
ariaLabelledBy = '';
|
|
141
|
+
title = '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Only resolve IDREF text if aria-labelledby is present and aria-label is not already sufficient
|
|
145
|
+
let labelledByText = '';
|
|
146
|
+
let hasLabelledByMechanism = false;
|
|
147
|
+
|
|
148
|
+
if (!ariaLabel && ariaLabelledBy) {
|
|
149
|
+
hasLabelledByMechanism = true;
|
|
150
|
+
|
|
151
|
+
if (getTextFromIdRefs) {
|
|
152
|
+
try {
|
|
153
|
+
const t = getTextFromIdRefs(ariaLabelledBy, ctx);
|
|
154
|
+
labelledByText = trim(t && t.text);
|
|
155
|
+
// If it resolves to empty, still treat as a mechanism present for manual review.
|
|
156
|
+
} catch {
|
|
157
|
+
labelledByText = '';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const hasNameMechanism = !!(ariaLabel || title || hasLabelledByMechanism);
|
|
163
|
+
if (!hasNameMechanism) continue;
|
|
164
|
+
|
|
165
|
+
const details = {
|
|
166
|
+
ariaLabel: ariaLabel || null,
|
|
167
|
+
ariaLabelledBy: ariaLabelledBy || null,
|
|
168
|
+
ariaLabelledByText: labelledByText || null,
|
|
169
|
+
title: title || null
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
applicableCount += 1;
|
|
173
|
+
|
|
174
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, {targetSet: 'acc'}) : null;
|
|
175
|
+
|
|
176
|
+
const baseOccurrence = {
|
|
177
|
+
summary: 'Review text alternative for <embed> for accuracy and appropriateness.',
|
|
178
|
+
hint: 'Confirm the ARIA name or title accurately identifies the embedded content in context.',
|
|
179
|
+
i18n: {
|
|
180
|
+
summaryKey: 'embed_textAltQuality_summary_cantTell',
|
|
181
|
+
hintKey: 'embed_textAltQuality_hint_cantTell',
|
|
182
|
+
params: {element: (el.tagName || '').toLowerCase()}
|
|
183
|
+
},
|
|
184
|
+
data: {
|
|
185
|
+
visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []},
|
|
186
|
+
details
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
191
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
192
|
+
} else {
|
|
193
|
+
occurrences.push({selector: '', html: '', ...baseOccurrence});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (applicableCount === 0) {
|
|
198
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {id, meta, runInPage};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check empty-heading
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Heading elements must not be empty
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies to elements with a heading role: native <h1>-<h6>, or any
|
|
10
|
+
* element with explicit role="heading" (unless overridden by another
|
|
11
|
+
* explicit role).
|
|
12
|
+
* @expectation
|
|
13
|
+
* The heading has a non-empty accessible name: aria-label,
|
|
14
|
+
* aria-labelledby, visible text content not hidden from assistive
|
|
15
|
+
* technology, or (as a last resort) a title attribute. An empty
|
|
16
|
+
* heading is announced as "heading, level N" with nothing else, which
|
|
17
|
+
* is confusing when 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
|
+
* - This is also the reconciliation point for the "heading-name-present"
|
|
23
|
+
* gap surfaced by the 2026-07-19 ACT-rules research pass (see
|
|
24
|
+
* ROADMAP.md "Tier 5 candidates"): that gap was already closed by this
|
|
25
|
+
* pre-existing rule under a different name, not a real gap. Fixed two
|
|
26
|
+
* real false-positive bugs found while confirming that (2026-07-19):
|
|
27
|
+
* `title` wasn't accepted as a naming fallback, and hidden/aria-hidden/
|
|
28
|
+
* display:none headings weren't excluded (not gated on
|
|
29
|
+
* `isAccTreeEligible`), so an empty heading no AT user could ever
|
|
30
|
+
* reach was still flagged.
|
|
31
|
+
* - 2026-07-21: the subtree-text walker's own hand-rolled descendant
|
|
32
|
+
* handling (aria-label/aria-labelledby only) never checked an `<img>`
|
|
33
|
+
* descendant's `alt` text — found via Party City's
|
|
34
|
+
* `<h1><a><div><img alt="..."></div></a></h1>` logo header, a false
|
|
35
|
+
* "empty heading" cantTell. Replaced with the shared, accname-aligned
|
|
36
|
+
* `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
|
|
37
|
+
* from content" implementation the 19 `-name-present` rules already use
|
|
38
|
+
* — instead of maintaining a third, narrower copy of this logic here.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const id = 'empty-heading';
|
|
42
|
+
|
|
43
|
+
const meta = {
|
|
44
|
+
title: 'Headings must not be empty',
|
|
45
|
+
description: 'Checks that heading elements (<h1>-<h6> or role="heading") have a non-empty accessible name.',
|
|
46
|
+
i18n: {
|
|
47
|
+
titleKey: 'emptyHeading_title',
|
|
48
|
+
descriptionKey: 'emptyHeading_description'
|
|
49
|
+
},
|
|
50
|
+
helpUrl: null,
|
|
51
|
+
tags: ['best-practice', 'headings', 'structure', 'atomic', 'manual'],
|
|
52
|
+
wcagSc: [],
|
|
53
|
+
normativeMappings: [],
|
|
54
|
+
defaultSeverity: 'minor',
|
|
55
|
+
category: 'operable',
|
|
56
|
+
type: 'manual',
|
|
57
|
+
defaultConfidence: 'medium',
|
|
58
|
+
coverage: {}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function runInPage(ctx) {
|
|
62
|
+
const { document, helpers, rule } = ctx;
|
|
63
|
+
|
|
64
|
+
function normalizeWs(s) {
|
|
65
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getExplicitRoleToken(el) {
|
|
69
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
70
|
+
if (!raw) return '';
|
|
71
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isHeading(el) {
|
|
75
|
+
const explicit = getExplicitRoleToken(el);
|
|
76
|
+
if (explicit) return explicit === 'heading';
|
|
77
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
78
|
+
return /^h[1-6]$/.test(tag);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getAccessibleNameText(el) {
|
|
82
|
+
const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
|
|
83
|
+
if (al) return al;
|
|
84
|
+
const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
|
|
85
|
+
if (alb) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const refId of alb.split(/\s+/).filter(Boolean)) {
|
|
88
|
+
try {
|
|
89
|
+
const ref = document.getElementById(refId);
|
|
90
|
+
if (ref) {
|
|
91
|
+
const t = normalizeWs(ref.textContent);
|
|
92
|
+
if (t) parts.push(t);
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
const joined = normalizeWs(parts.join(' '));
|
|
97
|
+
if (joined) return joined;
|
|
98
|
+
}
|
|
99
|
+
// Shared, accname-aligned "name from content" implementation (see
|
|
100
|
+
// dom-helpers.js's getContentNameInfo header comment) — resolves an
|
|
101
|
+
// <img> descendant's own alt text, an aria-label/aria-labelledby'd
|
|
102
|
+
// descendant's own name, etc., and already gates every descendant on
|
|
103
|
+
// full accessibility-tree eligibility (aria-hidden, display:none,
|
|
104
|
+
// visibility:hidden, [hidden], inert, ...) the same way this rule
|
|
105
|
+
// needs. Used instead of a fourth hand-rolled subtree walker here.
|
|
106
|
+
if (helpers && typeof helpers.getContentNameInfo === 'function') {
|
|
107
|
+
try {
|
|
108
|
+
const info = helpers.getContentNameInfo(el, ctx);
|
|
109
|
+
if (info && info.present && info.value) return info.value;
|
|
110
|
+
} catch {}
|
|
111
|
+
}
|
|
112
|
+
return normalizeWs(el.getAttribute && el.getAttribute('title'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let nodes = [];
|
|
116
|
+
try {
|
|
117
|
+
nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
118
|
+
} catch {
|
|
119
|
+
nodes = [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const occurrences = [];
|
|
123
|
+
let applicableCount = 0;
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
|
|
126
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
127
|
+
|
|
128
|
+
function isEligible(el) {
|
|
129
|
+
if (!isAccTreeEligible) return true;
|
|
130
|
+
try {
|
|
131
|
+
const r = isAccTreeEligible(el, ctx);
|
|
132
|
+
if (typeof r === 'boolean') return r;
|
|
133
|
+
return !!(r && r.eligible);
|
|
134
|
+
} catch {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const el of nodes) {
|
|
140
|
+
if (!el || seen.has(el)) continue;
|
|
141
|
+
seen.add(el);
|
|
142
|
+
if (!isHeading(el)) continue;
|
|
143
|
+
if (!isEligible(el)) continue;
|
|
144
|
+
|
|
145
|
+
applicableCount += 1;
|
|
146
|
+
|
|
147
|
+
const name = getAccessibleNameText(el);
|
|
148
|
+
if (name) continue;
|
|
149
|
+
|
|
150
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
151
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
152
|
+
const eligInfo = helpers.getEligibilityInfo
|
|
153
|
+
? (() => { try { return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
154
|
+
: null;
|
|
155
|
+
|
|
156
|
+
occurrences.push({
|
|
157
|
+
selector: stableSelector,
|
|
158
|
+
html,
|
|
159
|
+
summary: 'This heading has no accessible name.',
|
|
160
|
+
hint: 'Add text content (or aria-label/aria-labelledby) to this heading, or remove it if it is not needed.',
|
|
161
|
+
i18n: {
|
|
162
|
+
summaryKey: 'emptyHeading_summary_cantTell',
|
|
163
|
+
hintKey: 'emptyHeading_hint_cantTell',
|
|
164
|
+
params: {}
|
|
165
|
+
},
|
|
166
|
+
data: {
|
|
167
|
+
details: { reasonCode: 'HEADING_EMPTY' },
|
|
168
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (applicableCount === 0) {
|
|
174
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
175
|
+
}
|
|
176
|
+
if (occurrences.length) {
|
|
177
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
178
|
+
}
|
|
179
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check empty-table-header
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Table header cells must not be empty
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies to <th> elements that don't carry a conflicting explicit role,
|
|
10
|
+
* plus any element (native <th> or not) with role="columnheader" or
|
|
11
|
+
* role="rowheader" — matches a widely-used reference engine's own
|
|
12
|
+
* `empty-table-header` selector exactly (`th:not([role]), [role="columnheader"], [role="rowheader"]`,
|
|
13
|
+
* verified directly against its rule descriptor): a <th> that explicitly
|
|
14
|
+
* restates role="columnheader"/"rowheader" is still covered via the second
|
|
15
|
+
* clause, but a <th role="presentation"> (no longer meaningfully a header)
|
|
16
|
+
* is correctly excluded, and an ARIA-role-only header (e.g. a <div
|
|
17
|
+
* role="columnheader"> in a role="grid"/role="table" widget) is caught too
|
|
18
|
+
* — found missing entirely via the cross-engine diff tool 2026-07-23.
|
|
19
|
+
* @expectation
|
|
20
|
+
* The header cell has visible text content. A <th> named only via
|
|
21
|
+
* aria-label/aria-labelledby (no visible text) is ALSO flagged, not
|
|
22
|
+
* treated as equivalent — real screen-reader/browser testing (found via
|
|
23
|
+
* a live-DOM cross-engine run 2026-07-21, verified against
|
|
24
|
+
* https://butterpep.com/table-header-naming.html and
|
|
25
|
+
* https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/)
|
|
26
|
+
* confirms aria-label support on <th> is genuinely inconsistent in
|
|
27
|
+
* practice: NVDA+Firefox and iOS VoiceOver+Safari ignore it entirely
|
|
28
|
+
* (only visible text is announced), JAWS+Chrome/IE11 also only announce
|
|
29
|
+
* visible text in the header cell itself. Visible text is the one
|
|
30
|
+
* mechanism confirmed to work across every tested combination. A widely-used
|
|
31
|
+
* reference engine's own equivalent check (`has-visible-text` only, no aria-label/
|
|
32
|
+
* aria-labelledby alternative — confirmed directly against its rule
|
|
33
|
+
* descriptor) reaches the same conclusion.
|
|
34
|
+
* @implementation-notes
|
|
35
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
36
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
37
|
+
* header comment for the shared rationale/precedent.
|
|
38
|
+
* - Two distinct reasonCodes: TABLE_HEADER_EMPTY (no accessible name at
|
|
39
|
+
* all) vs. TABLE_HEADER_NAME_NOT_VISIBLE_TEXT (has aria-label/
|
|
40
|
+
* aria-labelledby but no visible text) — the latter gets a different
|
|
41
|
+
* summary/hint explaining the AT-support gap, since the header isn't
|
|
42
|
+
* literally nameless, just unreliably named in practice.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const id = 'empty-table-header';
|
|
46
|
+
|
|
47
|
+
const meta = {
|
|
48
|
+
title: 'Table header cells must not be empty',
|
|
49
|
+
description: 'Checks that table header cells (<th>, or any element with role="columnheader"/"rowheader") have visible text content — a header named only via aria-label/aria-labelledby is also flagged, since real screen-reader/browser support for that is inconsistent.',
|
|
50
|
+
i18n: {
|
|
51
|
+
titleKey: 'emptyTableHeader_title',
|
|
52
|
+
descriptionKey: 'emptyTableHeader_description'
|
|
53
|
+
},
|
|
54
|
+
helpUrl: null,
|
|
55
|
+
tags: ['best-practice', 'tables', 'structure', 'atomic', 'manual'],
|
|
56
|
+
wcagSc: [],
|
|
57
|
+
normativeMappings: [],
|
|
58
|
+
defaultSeverity: 'minor',
|
|
59
|
+
category: 'perceivable',
|
|
60
|
+
type: 'manual',
|
|
61
|
+
defaultConfidence: 'medium',
|
|
62
|
+
coverage: {}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function runInPage(ctx) {
|
|
66
|
+
const { document, helpers, rule } = ctx;
|
|
67
|
+
|
|
68
|
+
function normalizeWs(s) {
|
|
69
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getVisibleText(el) {
|
|
73
|
+
return normalizeWs(el.textContent);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getAriaOnlyName(el) {
|
|
77
|
+
const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
|
|
78
|
+
if (al) return al;
|
|
79
|
+
const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
|
|
80
|
+
if (alb) {
|
|
81
|
+
const parts = [];
|
|
82
|
+
for (const refId of alb.split(/\s+/).filter(Boolean)) {
|
|
83
|
+
try {
|
|
84
|
+
const ref = document.getElementById(refId);
|
|
85
|
+
if (ref) {
|
|
86
|
+
const t = normalizeWs(ref.textContent);
|
|
87
|
+
if (t) parts.push(t);
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
const joined = normalizeWs(parts.join(' '));
|
|
92
|
+
if (joined) return joined;
|
|
93
|
+
}
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let nodes = [];
|
|
98
|
+
try {
|
|
99
|
+
// Matches a widely-used reference engine's own empty-table-header selector exactly: a <th> with
|
|
100
|
+
// no conflicting explicit role, plus any element carrying an explicit
|
|
101
|
+
// columnheader/rowheader role (native or not).
|
|
102
|
+
nodes = document.querySelectorAll('th:not([role]), [role="columnheader"], [role="rowheader"]');
|
|
103
|
+
} catch {
|
|
104
|
+
nodes = [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const occurrences = [];
|
|
108
|
+
let applicableCount = 0;
|
|
109
|
+
|
|
110
|
+
for (const el of nodes) {
|
|
111
|
+
if (!el) continue;
|
|
112
|
+
applicableCount += 1;
|
|
113
|
+
|
|
114
|
+
if (getVisibleText(el)) continue;
|
|
115
|
+
|
|
116
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
117
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
118
|
+
|
|
119
|
+
const ariaName = getAriaOnlyName(el);
|
|
120
|
+
if (ariaName) {
|
|
121
|
+
occurrences.push({
|
|
122
|
+
selector: stableSelector,
|
|
123
|
+
html,
|
|
124
|
+
summary: 'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
|
|
125
|
+
hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
|
|
126
|
+
i18n: {
|
|
127
|
+
summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
|
|
128
|
+
hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
|
|
129
|
+
params: {}
|
|
130
|
+
},
|
|
131
|
+
data: {
|
|
132
|
+
details: { reasonCode: 'TABLE_HEADER_NAME_NOT_VISIBLE_TEXT', ariaName }
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
occurrences.push({
|
|
139
|
+
selector: stableSelector,
|
|
140
|
+
html,
|
|
141
|
+
summary: 'This table header cell has no accessible name.',
|
|
142
|
+
hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
|
|
143
|
+
i18n: {
|
|
144
|
+
summaryKey: 'emptyTableHeader_summary_cantTell',
|
|
145
|
+
hintKey: 'emptyTableHeader_hint_cantTell',
|
|
146
|
+
params: {}
|
|
147
|
+
},
|
|
148
|
+
data: {
|
|
149
|
+
details: { reasonCode: 'TABLE_HEADER_EMPTY' }
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (applicableCount === 0) {
|
|
155
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
156
|
+
}
|
|
157
|
+
if (occurrences.length) {
|
|
158
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
159
|
+
}
|
|
160
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check focus-order-semantics
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Elements added to the tab order should have interactive semantics
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Elements with an explicit `tabindex` of `0` or greater (in the tab
|
|
10
|
+
* order) AND an explicit `role` attribute that is one of a curated set
|
|
11
|
+
* of clearly non-interactive, structural/document roles.
|
|
12
|
+
* @expectation
|
|
13
|
+
* An element deliberately placed in the tab order should communicate
|
|
14
|
+
* why it's focusable — a role like `heading`, `list`, `region`, or
|
|
15
|
+
* `presentation` gives assistive technology no interactive semantic to
|
|
16
|
+
* announce, which is confusing for keyboard users who land on it and
|
|
17
|
+
* get no indication of what activating it (if anything) would do.
|
|
18
|
+
* @implementation-notes
|
|
19
|
+
* - Not WCAG-normative by a widely-used reference engine's own classification — authored as an
|
|
20
|
+
* advisory, cantTell-capped `type: 'manual'` rule.
|
|
21
|
+
* - The non-interactive role list is deliberately curated and
|
|
22
|
+
* conservative (structural/document roles only) — legitimate custom
|
|
23
|
+
* widget patterns using `tabindex` with a genuinely interactive role
|
|
24
|
+
* (`option`, `tab`, `menuitem`, etc.) are never flagged. Elements with
|
|
25
|
+
* `tabindex` and NO role at all are also not flagged: native semantics
|
|
26
|
+
* or an intentionally generic custom-interactive pattern cannot be
|
|
27
|
+
* distinguished from markup alone with the same confidence.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const id = 'focus-order-semantics';
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
title: 'Elements added to the tab order should have interactive semantics',
|
|
34
|
+
description: 'Flags elements with tabindex >= 0 whose explicit role is a non-interactive structural/document role (e.g. heading, list, region, presentation), for manual review.',
|
|
35
|
+
i18n: {
|
|
36
|
+
titleKey: 'focusOrderSemantics_title',
|
|
37
|
+
descriptionKey: 'focusOrderSemantics_description'
|
|
38
|
+
},
|
|
39
|
+
helpUrl: null,
|
|
40
|
+
tags: ['best-practice', 'aria', 'structure', 'atomic', 'manual'],
|
|
41
|
+
wcagSc: [],
|
|
42
|
+
normativeMappings: [],
|
|
43
|
+
defaultSeverity: 'minor',
|
|
44
|
+
category: 'operable',
|
|
45
|
+
type: 'manual',
|
|
46
|
+
defaultConfidence: 'medium',
|
|
47
|
+
coverage: {}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function runInPage(ctx) {
|
|
51
|
+
const { document, root, helpers, rule } = ctx;
|
|
52
|
+
const safeRoot = root || document;
|
|
53
|
+
|
|
54
|
+
const NON_INTERACTIVE_ROLES = new Set([
|
|
55
|
+
'generic', 'group', 'text', 'presentation', 'none', 'img', 'heading',
|
|
56
|
+
'region', 'article', 'banner', 'contentinfo', 'main', 'navigation',
|
|
57
|
+
'complementary', 'document', 'note', 'list', 'listitem', 'table',
|
|
58
|
+
'row', 'cell', 'columnheader', 'rowheader', 'figure', 'term',
|
|
59
|
+
'definition', 'paragraph', 'caption', 'status', 'alert', 'log', 'tooltip'
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]', safeRoot) : helpers.queryAll('[tabindex][role]', safeRoot);
|
|
63
|
+
|
|
64
|
+
const occurrences = [];
|
|
65
|
+
let applicableCount = 0;
|
|
66
|
+
|
|
67
|
+
for (const el of nodes) {
|
|
68
|
+
if (!el || !el.getAttribute) continue;
|
|
69
|
+
|
|
70
|
+
const tabindexAttr = el.getAttribute('tabindex');
|
|
71
|
+
const tabindex = Number.parseInt(tabindexAttr, 10);
|
|
72
|
+
if (!Number.isFinite(tabindex) || tabindex < 0) continue;
|
|
73
|
+
|
|
74
|
+
const role = (el.getAttribute('role') || '').trim().toLowerCase();
|
|
75
|
+
if (!role) continue;
|
|
76
|
+
|
|
77
|
+
applicableCount += 1;
|
|
78
|
+
|
|
79
|
+
if (!NON_INTERACTIVE_ROLES.has(role)) continue;
|
|
80
|
+
|
|
81
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
82
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
83
|
+
|
|
84
|
+
const baseOccurrence = {
|
|
85
|
+
selector: stableSelector,
|
|
86
|
+
html,
|
|
87
|
+
summary: `This element is in the tab order (tabindex="${tabindex}") but has a non-interactive role ("${role}").`,
|
|
88
|
+
hint: 'Remove tabindex if this element is not meant to be interactive, or use an interactive role that matches its actual behavior.',
|
|
89
|
+
i18n: {
|
|
90
|
+
summaryKey: 'focusOrderSemantics_summary_cantTell',
|
|
91
|
+
hintKey: 'focusOrderSemantics_hint_cantTell',
|
|
92
|
+
params: { tabindex: String(tabindex), role }
|
|
93
|
+
},
|
|
94
|
+
data: {
|
|
95
|
+
details: { reasonCode: 'TABBABLE_WITH_NON_INTERACTIVE_ROLE', tabindex, role }
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
100
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
101
|
+
} else {
|
|
102
|
+
occurrences.push(baseOccurrence);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (applicableCount === 0) {
|
|
107
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (occurrences.length) {
|
|
111
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { id, meta, runInPage };
|