@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,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check label-title-only
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Form controls should not rely on the title attribute as their only label
|
|
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 labelable form controls (input, excluding
|
|
10
|
+
* hidden/submit/reset/button/image; select; textarea) that have a
|
|
11
|
+
* non-empty title attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* The control also has a real label — a wrapping/associated <label>,
|
|
14
|
+
* aria-label, or aria-labelledby — rather than depending on the title
|
|
15
|
+
* attribute alone. A title-only tooltip is not reliably exposed by all
|
|
16
|
+
* assistive technology and is not visible at all until hover/focus,
|
|
17
|
+
* unlike a persistent visible label.
|
|
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
|
+
* - Distinct from form-control-programmatic-label-present (that
|
|
23
|
+
* rule accepts title as one of several valid presence mechanisms;
|
|
24
|
+
* this rule flags the narrower case where title is the ONLY one).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const id = 'label-title-only';
|
|
28
|
+
|
|
29
|
+
const meta = {
|
|
30
|
+
title: 'Form controls should not use title as their only label',
|
|
31
|
+
description: 'Checks that a form control with a title attribute also has a real label (label element, aria-label, or aria-labelledby).',
|
|
32
|
+
i18n: {
|
|
33
|
+
titleKey: 'labelTitleOnly_title',
|
|
34
|
+
descriptionKey: 'labelTitleOnly_description'
|
|
35
|
+
},
|
|
36
|
+
helpUrl: null,
|
|
37
|
+
tags: ['best-practice', 'forms', 'structure', 'atomic', 'manual'],
|
|
38
|
+
wcagSc: [],
|
|
39
|
+
normativeMappings: [],
|
|
40
|
+
defaultSeverity: 'minor',
|
|
41
|
+
category: 'understandable',
|
|
42
|
+
type: 'manual',
|
|
43
|
+
defaultConfidence: 'medium',
|
|
44
|
+
coverage: {}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function runInPage(ctx) {
|
|
48
|
+
const { document, root, helpers, rule } = ctx;
|
|
49
|
+
const safeRoot = root || document;
|
|
50
|
+
|
|
51
|
+
const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
52
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
53
|
+
|
|
54
|
+
const labelsByFor = new Map();
|
|
55
|
+
const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
|
|
56
|
+
for (const lab of allLabels) {
|
|
57
|
+
if (!lab || !lab.getAttribute) continue;
|
|
58
|
+
const forValue = String(lab.getAttribute('for') || '').trim();
|
|
59
|
+
if (!forValue) continue;
|
|
60
|
+
if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
|
|
61
|
+
labelsByFor.get(forValue).push(lab);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const occurrences = [];
|
|
65
|
+
let applicableCount = 0;
|
|
66
|
+
|
|
67
|
+
for (const el of nodes) {
|
|
68
|
+
if (!el || !el.getAttribute) continue;
|
|
69
|
+
|
|
70
|
+
const title = String(el.getAttribute('title') || '').trim();
|
|
71
|
+
if (!title) continue;
|
|
72
|
+
|
|
73
|
+
applicableCount += 1;
|
|
74
|
+
|
|
75
|
+
const ariaLabel = String(el.getAttribute('aria-label') || '').trim();
|
|
76
|
+
if (ariaLabel) continue;
|
|
77
|
+
const ariaLabelledby = String(el.getAttribute('aria-labelledby') || '').trim();
|
|
78
|
+
if (ariaLabelledby) continue;
|
|
79
|
+
|
|
80
|
+
const wrappingLabel = el.closest ? el.closest('label') : null;
|
|
81
|
+
if (wrappingLabel) continue;
|
|
82
|
+
|
|
83
|
+
const controlId = String(el.getAttribute('id') || '').trim();
|
|
84
|
+
if (controlId && labelsByFor.has(controlId)) continue;
|
|
85
|
+
|
|
86
|
+
const tag = el.tagName.toLowerCase();
|
|
87
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
88
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
89
|
+
|
|
90
|
+
occurrences.push({
|
|
91
|
+
selector: stableSelector,
|
|
92
|
+
html,
|
|
93
|
+
summary: 'This form control relies on the title attribute as its only label.',
|
|
94
|
+
hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
|
|
95
|
+
i18n: {
|
|
96
|
+
summaryKey: 'labelTitleOnly_summary_cantTell',
|
|
97
|
+
hintKey: 'labelTitleOnly_hint_cantTell',
|
|
98
|
+
params: { element: tag }
|
|
99
|
+
},
|
|
100
|
+
data: {
|
|
101
|
+
details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (applicableCount === 0) {
|
|
107
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
108
|
+
}
|
|
109
|
+
if (occurrences.length) {
|
|
110
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
111
|
+
}
|
|
112
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-banner-is-top-level
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary The banner landmark must not be nested inside another landmark
|
|
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 whenever the page contains at least one banner landmark
|
|
10
|
+
* (explicit role="banner", or an implicit <header> that is not itself
|
|
11
|
+
* nested inside <article>/<aside>/<main>/<nav>/<section> — see
|
|
12
|
+
* implementation notes).
|
|
13
|
+
* @expectation
|
|
14
|
+
* No banner landmark has an ancestor that is itself any landmark
|
|
15
|
+
* region. A banner nested inside another landmark is not a top-level,
|
|
16
|
+
* whole-page banner and confuses landmark-based navigation for
|
|
17
|
+
* assistive technology users.
|
|
18
|
+
* @implementation-notes
|
|
19
|
+
* - Not WCAG-normative (a widely-used reference engine classifies this as "Best Practices," no SC
|
|
20
|
+
* tag) — authored as an advisory, cantTell-capped `type: 'manual'`
|
|
21
|
+
* rule per ROADMAP.md Tier 1b and the design doc's policy model
|
|
22
|
+
* ("Advisory / best-practice rules may exist, but must not produce
|
|
23
|
+
* `fail`"). Matches the existing `page-title-patterns-manual.js`
|
|
24
|
+
* precedent: deterministic DOM analysis, no human required, but
|
|
25
|
+
* capped at `cantTell`/`notApplicable` rather than `fail`/`pass`.
|
|
26
|
+
* - Landmark detection here models WAI-ARIA APG landmark roles and the
|
|
27
|
+
* HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
|
|
28
|
+
* main→main, nav→navigation, aside→complementary, section/form→
|
|
29
|
+
* region/form only when accessibly named), not a byte-for-byte port
|
|
30
|
+
* of a widely-used reference engine's internal algorithm — verify against upstream if exact
|
|
31
|
+
* parity is ever required.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const id = 'landmark-banner-is-top-level';
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: 'Banner landmark must be top-level',
|
|
38
|
+
description: 'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
|
|
39
|
+
i18n: {
|
|
40
|
+
titleKey: 'landmarkBannerIsTopLevel_title',
|
|
41
|
+
descriptionKey: 'landmarkBannerIsTopLevel_description'
|
|
42
|
+
},
|
|
43
|
+
helpUrl: null,
|
|
44
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
45
|
+
wcagSc: [],
|
|
46
|
+
normativeMappings: [],
|
|
47
|
+
defaultSeverity: 'minor',
|
|
48
|
+
category: 'operable',
|
|
49
|
+
type: 'manual',
|
|
50
|
+
defaultConfidence: 'medium',
|
|
51
|
+
coverage: {}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function runInPage(ctx) {
|
|
55
|
+
const { document, helpers, rule } = ctx;
|
|
56
|
+
|
|
57
|
+
// Declared inside runInPage — see scripts/build-core.js header
|
|
58
|
+
// ("runInPage MUST be self-contained").
|
|
59
|
+
function normalizeWs(s) {
|
|
60
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
64
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
65
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
|
|
66
|
+
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
67
|
+
// copy of this logic.
|
|
68
|
+
function getAccessibleLandmarkName(el) {
|
|
69
|
+
try {
|
|
70
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
71
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
72
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getExplicitRoleToken(el) {
|
|
79
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
80
|
+
if (!raw) return '';
|
|
81
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
|
|
85
|
+
|
|
86
|
+
function isSuppressedBySectioningAncestor(el) {
|
|
87
|
+
let p = el.parentElement;
|
|
88
|
+
while (p) {
|
|
89
|
+
const tag = p.tagName ? p.tagName.toLowerCase() : '';
|
|
90
|
+
if (SECTIONING_ANCESTORS.has(tag)) return true;
|
|
91
|
+
p = p.parentElement;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getImplicitLandmarkRole(el) {
|
|
97
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
98
|
+
if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
|
|
99
|
+
if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
|
|
100
|
+
if (tag === 'main') return 'main';
|
|
101
|
+
if (tag === 'nav') return 'navigation';
|
|
102
|
+
if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
|
|
103
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
104
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
|
|
109
|
+
|
|
110
|
+
function getLandmarkRole(el) {
|
|
111
|
+
if (!el || !el.getAttribute) return '';
|
|
112
|
+
const explicit = getExplicitRoleToken(el);
|
|
113
|
+
if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
|
|
114
|
+
return getImplicitLandmarkRole(el);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasLandmarkAncestor(el) {
|
|
118
|
+
let p = el.parentElement;
|
|
119
|
+
while (p) {
|
|
120
|
+
if (getLandmarkRole(p)) return true;
|
|
121
|
+
p = p.parentElement;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
127
|
+
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
128
|
+
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
129
|
+
// invisible to a light-DOM-only query.
|
|
130
|
+
let nodes = [];
|
|
131
|
+
try {
|
|
132
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
133
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
134
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
135
|
+
} catch {
|
|
136
|
+
nodes = [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const banners = [];
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
for (const el of nodes) {
|
|
142
|
+
if (!el || seen.has(el)) continue;
|
|
143
|
+
seen.add(el);
|
|
144
|
+
if (getLandmarkRole(el) === 'banner') banners.push(el);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (banners.length === 0) {
|
|
148
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const occurrences = [];
|
|
152
|
+
for (const el of banners) {
|
|
153
|
+
if (!hasLandmarkAncestor(el)) continue;
|
|
154
|
+
|
|
155
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
156
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
157
|
+
|
|
158
|
+
occurrences.push({
|
|
159
|
+
selector: stableSelector,
|
|
160
|
+
html,
|
|
161
|
+
summary: 'This banner landmark is nested inside another landmark region.',
|
|
162
|
+
hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
|
|
163
|
+
i18n: {
|
|
164
|
+
summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
|
|
165
|
+
hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
|
|
166
|
+
params: {}
|
|
167
|
+
},
|
|
168
|
+
data: {
|
|
169
|
+
details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (occurrences.length) {
|
|
175
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
176
|
+
}
|
|
177
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-contentinfo-is-top-level
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary The contentinfo landmark must not be nested inside another landmark
|
|
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 whenever the page contains at least one contentinfo landmark
|
|
10
|
+
* (explicit role="contentinfo", or an implicit <footer> that is not
|
|
11
|
+
* itself nested inside <article>/<aside>/<main>/<nav>/<section> — see
|
|
12
|
+
* implementation notes).
|
|
13
|
+
* @expectation
|
|
14
|
+
* No contentinfo landmark has an ancestor that is itself any landmark
|
|
15
|
+
* region. A contentinfo nested inside another landmark is not a
|
|
16
|
+
* top-level, whole-page footer region and confuses landmark-based
|
|
17
|
+
* navigation for assistive technology users.
|
|
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 (this rule mirrors
|
|
22
|
+
* its structure with contentinfo/footer in place of banner/header).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const id = 'landmark-contentinfo-is-top-level';
|
|
26
|
+
|
|
27
|
+
const meta = {
|
|
28
|
+
title: 'Contentinfo landmark must be top-level',
|
|
29
|
+
description: 'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
|
|
30
|
+
i18n: {
|
|
31
|
+
titleKey: 'landmarkContentinfoIsTopLevel_title',
|
|
32
|
+
descriptionKey: 'landmarkContentinfoIsTopLevel_description'
|
|
33
|
+
},
|
|
34
|
+
helpUrl: null,
|
|
35
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
36
|
+
wcagSc: [],
|
|
37
|
+
normativeMappings: [],
|
|
38
|
+
defaultSeverity: 'minor',
|
|
39
|
+
category: 'operable',
|
|
40
|
+
type: 'manual',
|
|
41
|
+
defaultConfidence: 'medium',
|
|
42
|
+
coverage: {}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function runInPage(ctx) {
|
|
46
|
+
const { document, helpers, rule } = ctx;
|
|
47
|
+
|
|
48
|
+
function normalizeWs(s) {
|
|
49
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
53
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
54
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
|
|
55
|
+
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
56
|
+
// copy of this logic.
|
|
57
|
+
function getAccessibleLandmarkName(el) {
|
|
58
|
+
try {
|
|
59
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
60
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
61
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getExplicitRoleToken(el) {
|
|
68
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
69
|
+
if (!raw) return '';
|
|
70
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
|
|
74
|
+
|
|
75
|
+
function isSuppressedBySectioningAncestor(el) {
|
|
76
|
+
let p = el.parentElement;
|
|
77
|
+
while (p) {
|
|
78
|
+
const tag = p.tagName ? p.tagName.toLowerCase() : '';
|
|
79
|
+
if (SECTIONING_ANCESTORS.has(tag)) return true;
|
|
80
|
+
p = p.parentElement;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getImplicitLandmarkRole(el) {
|
|
86
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
87
|
+
if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
|
|
88
|
+
if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
|
|
89
|
+
if (tag === 'main') return 'main';
|
|
90
|
+
if (tag === 'nav') return 'navigation';
|
|
91
|
+
if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
|
|
92
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
93
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
|
|
98
|
+
|
|
99
|
+
function getLandmarkRole(el) {
|
|
100
|
+
if (!el || !el.getAttribute) return '';
|
|
101
|
+
const explicit = getExplicitRoleToken(el);
|
|
102
|
+
if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
|
|
103
|
+
return getImplicitLandmarkRole(el);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasLandmarkAncestor(el) {
|
|
107
|
+
let p = el.parentElement;
|
|
108
|
+
while (p) {
|
|
109
|
+
if (getLandmarkRole(p)) return true;
|
|
110
|
+
p = p.parentElement;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
116
|
+
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
117
|
+
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
118
|
+
// invisible to a light-DOM-only query.
|
|
119
|
+
let nodes = [];
|
|
120
|
+
try {
|
|
121
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
122
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
123
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
124
|
+
} catch {
|
|
125
|
+
nodes = [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const contentinfos = [];
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const el of nodes) {
|
|
131
|
+
if (!el || seen.has(el)) continue;
|
|
132
|
+
seen.add(el);
|
|
133
|
+
if (getLandmarkRole(el) === 'contentinfo') contentinfos.push(el);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (contentinfos.length === 0) {
|
|
137
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const occurrences = [];
|
|
141
|
+
for (const el of contentinfos) {
|
|
142
|
+
if (!hasLandmarkAncestor(el)) continue;
|
|
143
|
+
|
|
144
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
145
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
146
|
+
|
|
147
|
+
occurrences.push({
|
|
148
|
+
selector: stableSelector,
|
|
149
|
+
html,
|
|
150
|
+
summary: 'This contentinfo landmark is nested inside another landmark region.',
|
|
151
|
+
hint: 'Move the contentinfo landmark (footer/role="contentinfo") so it is not contained by another landmark; contentinfo should be a top-level region of the page.',
|
|
152
|
+
i18n: {
|
|
153
|
+
summaryKey: 'landmarkContentinfoIsTopLevel_summary_cantTell',
|
|
154
|
+
hintKey: 'landmarkContentinfoIsTopLevel_hint_cantTell',
|
|
155
|
+
params: {}
|
|
156
|
+
},
|
|
157
|
+
data: {
|
|
158
|
+
details: { reasonCode: 'LANDMARK_CONTENTINFO_NOT_TOP_LEVEL' }
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (occurrences.length) {
|
|
164
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
165
|
+
}
|
|
166
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check landmark-main-is-top-level
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary The main landmark must not be nested inside another landmark
|
|
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 whenever the page contains at least one main landmark
|
|
10
|
+
* (explicit role="main", or an implicit <main> element).
|
|
11
|
+
* @expectation
|
|
12
|
+
* No main landmark has an ancestor that is itself any landmark region.
|
|
13
|
+
* A main region nested inside another landmark is not a top-level,
|
|
14
|
+
* whole-page main content area and confuses landmark-based navigation
|
|
15
|
+
* for assistive technology users.
|
|
16
|
+
* @implementation-notes
|
|
17
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
18
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
|
+
* header comment for the shared rationale/precedent (this rule mirrors
|
|
20
|
+
* its structure with main in place of banner/header).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const id = 'landmark-main-is-top-level';
|
|
24
|
+
|
|
25
|
+
const meta = {
|
|
26
|
+
title: 'Main landmark must be top-level',
|
|
27
|
+
description: 'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
|
|
28
|
+
i18n: {
|
|
29
|
+
titleKey: 'landmarkMainIsTopLevel_title',
|
|
30
|
+
descriptionKey: 'landmarkMainIsTopLevel_description'
|
|
31
|
+
},
|
|
32
|
+
helpUrl: null,
|
|
33
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
34
|
+
wcagSc: [],
|
|
35
|
+
normativeMappings: [],
|
|
36
|
+
defaultSeverity: 'minor',
|
|
37
|
+
category: 'operable',
|
|
38
|
+
type: 'manual',
|
|
39
|
+
defaultConfidence: 'medium',
|
|
40
|
+
coverage: {}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function runInPage(ctx) {
|
|
44
|
+
const { document, helpers, rule } = ctx;
|
|
45
|
+
|
|
46
|
+
function normalizeWs(s) {
|
|
47
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
51
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
52
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
|
|
53
|
+
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
54
|
+
// copy of this logic.
|
|
55
|
+
function getAccessibleLandmarkName(el) {
|
|
56
|
+
try {
|
|
57
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
58
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
59
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getExplicitRoleToken(el) {
|
|
66
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
67
|
+
if (!raw) return '';
|
|
68
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
|
|
72
|
+
|
|
73
|
+
function isSuppressedBySectioningAncestor(el) {
|
|
74
|
+
let p = el.parentElement;
|
|
75
|
+
while (p) {
|
|
76
|
+
const tag = p.tagName ? p.tagName.toLowerCase() : '';
|
|
77
|
+
if (SECTIONING_ANCESTORS.has(tag)) return true;
|
|
78
|
+
p = p.parentElement;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getImplicitLandmarkRole(el) {
|
|
84
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
85
|
+
if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
|
|
86
|
+
if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
|
|
87
|
+
if (tag === 'main') return 'main';
|
|
88
|
+
if (tag === 'nav') return 'navigation';
|
|
89
|
+
if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
|
|
90
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
91
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
|
|
96
|
+
|
|
97
|
+
function getLandmarkRole(el) {
|
|
98
|
+
if (!el || !el.getAttribute) return '';
|
|
99
|
+
const explicit = getExplicitRoleToken(el);
|
|
100
|
+
if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
|
|
101
|
+
return getImplicitLandmarkRole(el);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasLandmarkAncestor(el) {
|
|
105
|
+
let p = el.parentElement;
|
|
106
|
+
while (p) {
|
|
107
|
+
if (getLandmarkRole(p)) return true;
|
|
108
|
+
p = p.parentElement;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
114
|
+
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
115
|
+
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
116
|
+
// invisible to a light-DOM-only query.
|
|
117
|
+
let nodes = [];
|
|
118
|
+
try {
|
|
119
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
120
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
121
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
122
|
+
} catch {
|
|
123
|
+
nodes = [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const mains = [];
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
for (const el of nodes) {
|
|
129
|
+
if (!el || seen.has(el)) continue;
|
|
130
|
+
seen.add(el);
|
|
131
|
+
if (getLandmarkRole(el) === 'main') mains.push(el);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (mains.length === 0) {
|
|
135
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const occurrences = [];
|
|
139
|
+
for (const el of mains) {
|
|
140
|
+
if (!hasLandmarkAncestor(el)) continue;
|
|
141
|
+
|
|
142
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
143
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
144
|
+
|
|
145
|
+
occurrences.push({
|
|
146
|
+
selector: stableSelector,
|
|
147
|
+
html,
|
|
148
|
+
summary: 'This main landmark is nested inside another landmark region.',
|
|
149
|
+
hint: 'Move the main landmark (<main>/role="main") so it is not contained by another landmark; main should be a top-level region of the page.',
|
|
150
|
+
i18n: {
|
|
151
|
+
summaryKey: 'landmarkMainIsTopLevel_summary_cantTell',
|
|
152
|
+
hintKey: 'landmarkMainIsTopLevel_hint_cantTell',
|
|
153
|
+
params: {}
|
|
154
|
+
},
|
|
155
|
+
data: {
|
|
156
|
+
details: { reasonCode: 'LANDMARK_MAIN_NOT_TOP_LEVEL' }
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (occurrences.length) {
|
|
162
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
163
|
+
}
|
|
164
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { id, meta, runInPage };
|