@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,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'page-title-patterns';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Page title patterns that may indicate low descriptiveness',
|
|
7
|
+
description:
|
|
8
|
+
'Flags page titles that are likely too generic or templated as review signals (WCAG 2.2 SC 2.4.2). ' +
|
|
9
|
+
'This rule is conservative and does not fail based on patterns alone.',
|
|
10
|
+
i18n: {
|
|
11
|
+
titleKey: 'pageTitlePatterns_title',
|
|
12
|
+
descriptionKey: 'pageTitlePatterns_description'
|
|
13
|
+
},
|
|
14
|
+
helpUrl: null,
|
|
15
|
+
tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'manual'],
|
|
16
|
+
wcagSc: ['2.4.2'],
|
|
17
|
+
normativeMappings: [
|
|
18
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.4.2', title: 'Page Titled', conformanceLevel: 'A' }
|
|
19
|
+
],
|
|
20
|
+
defaultSeverity: 'minor',
|
|
21
|
+
category: 'operable',
|
|
22
|
+
type: 'manual',
|
|
23
|
+
defaultConfidence: 'medium',
|
|
24
|
+
coverage: { facetsBySc: { '2.4.2': ['page-title-patterns'] } }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function runInPage(ctx) {
|
|
28
|
+
const { document, helpers, rule } = ctx;
|
|
29
|
+
const probes = ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
|
|
30
|
+
? ctx.inputs.probes
|
|
31
|
+
: null;
|
|
32
|
+
|
|
33
|
+
const pageTitlesProbe = probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
|
|
34
|
+
? probes['crawl.pageTitles']
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
const occurrences = [];
|
|
38
|
+
let applicableCount = 1;
|
|
39
|
+
|
|
40
|
+
const titleEl = document.querySelector('head > title');
|
|
41
|
+
const rawTitle = document.title || '';
|
|
42
|
+
const titleText = rawTitle.replace(/\s+/g, ' ').trim();
|
|
43
|
+
const titleLc = titleText.toLowerCase();
|
|
44
|
+
// =========================
|
|
45
|
+
// Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
|
|
46
|
+
// =========================
|
|
47
|
+
if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
|
|
48
|
+
const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
|
|
49
|
+
|
|
50
|
+
// Require enough data to avoid noisy conclusions
|
|
51
|
+
const MIN_PAGES = 10;
|
|
52
|
+
const analyzable = pages
|
|
53
|
+
.map(p => ({
|
|
54
|
+
url: p.url ? String(p.url) : null,
|
|
55
|
+
title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
|
|
56
|
+
}))
|
|
57
|
+
.filter(p => p.url && p.title);
|
|
58
|
+
|
|
59
|
+
if (analyzable.length >= MIN_PAGES) {
|
|
60
|
+
// Build normalized title groups (case-insensitive)
|
|
61
|
+
const groups = new Map(); // normTitle -> { title, urls: [] }
|
|
62
|
+
for (const p of analyzable) {
|
|
63
|
+
const norm = p.title.toLowerCase();
|
|
64
|
+
if (!groups.has(norm)) groups.set(norm, { title: p.title, urls: [] });
|
|
65
|
+
groups.get(norm).urls.push(p.url);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Duplicate titles across distinct URLs is a strong "review" signal (not a guaranteed failure)
|
|
69
|
+
const dupGroups = Array.from(groups.values()).filter(g => g.urls.length >= 2);
|
|
70
|
+
|
|
71
|
+
// Boilerplate-ish: detect a long common suffix/prefix across most titles.
|
|
72
|
+
// Keep conservative: only flag if the common part is long and shared by many.
|
|
73
|
+
const titles = analyzable.map(p => p.title);
|
|
74
|
+
function commonPrefix(a, b) {
|
|
75
|
+
const n = Math.min(a.length, b.length);
|
|
76
|
+
let i = 0;
|
|
77
|
+
for (; i < n; i++) if (a[i] !== b[i]) break;
|
|
78
|
+
return a.slice(0, i);
|
|
79
|
+
}
|
|
80
|
+
function commonSuffix(a, b) {
|
|
81
|
+
const ra = a.split('').reverse().join('');
|
|
82
|
+
const rb = b.split('').reverse().join('');
|
|
83
|
+
return commonPrefix(ra, rb).split('').reverse().join('');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let sharedPrefix = titles[0] || '';
|
|
87
|
+
let sharedSuffix = titles[0] || '';
|
|
88
|
+
for (let i = 1; i < titles.length; i++) {
|
|
89
|
+
sharedPrefix = commonPrefix(sharedPrefix, titles[i]);
|
|
90
|
+
sharedSuffix = commonSuffix(sharedSuffix, titles[i]);
|
|
91
|
+
if (sharedPrefix.length === 0 && sharedSuffix.length === 0) break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const prefixLen = sharedPrefix.trim().length;
|
|
95
|
+
const suffixLen = sharedSuffix.trim().length;
|
|
96
|
+
|
|
97
|
+
const hasStrongTemplateSignal =
|
|
98
|
+
(prefixLen >= 12 || suffixLen >= 12) &&
|
|
99
|
+
(prefixLen >= 12 ? sharedPrefix.trim().length : 0) + (suffixLen >= 12 ? sharedSuffix.trim().length : 0) >= 12;
|
|
100
|
+
|
|
101
|
+
// If any cross-page signal exists, emit cantTell occurrence(s)
|
|
102
|
+
if (dupGroups.length || hasStrongTemplateSignal) {
|
|
103
|
+
const reasonCode = dupGroups.length
|
|
104
|
+
? 'duplicateTitlesAcrossPages'
|
|
105
|
+
: 'templatedTitlesAcrossPages';
|
|
106
|
+
|
|
107
|
+
// Deterministic example title for i18n params (lexicographic, case-insensitive)
|
|
108
|
+
const exampleTitle = dupGroups.length
|
|
109
|
+
? dupGroups
|
|
110
|
+
.map(g => String(g.title || '').replace(/\s+/g, ' ').trim())
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
|
|
113
|
+
: '';
|
|
114
|
+
|
|
115
|
+
const summaryKey = dupGroups.length
|
|
116
|
+
? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
|
|
117
|
+
: 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
|
|
118
|
+
|
|
119
|
+
const i18nParams = dupGroups.length
|
|
120
|
+
? {
|
|
121
|
+
reasonCode,
|
|
122
|
+
pagesAnalyzed: analyzable.length,
|
|
123
|
+
duplicateGroups: dupGroups.length,
|
|
124
|
+
exampleTitle
|
|
125
|
+
}
|
|
126
|
+
: {
|
|
127
|
+
reasonCode,
|
|
128
|
+
pagesAnalyzed: analyzable.length
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const occBase = {
|
|
132
|
+
selector: 'head > title',
|
|
133
|
+
html: '',
|
|
134
|
+
summary: 'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
|
|
135
|
+
hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
|
|
136
|
+
i18n: {
|
|
137
|
+
summaryKey,
|
|
138
|
+
hintKey: 'pageTitlePatterns_hint_cantTell',
|
|
139
|
+
params: i18nParams
|
|
140
|
+
},
|
|
141
|
+
data: {
|
|
142
|
+
details: {
|
|
143
|
+
reasonCode,
|
|
144
|
+
metrics: {
|
|
145
|
+
pagesAnalyzed: analyzable.length,
|
|
146
|
+
duplicateGroups: dupGroups.length,
|
|
147
|
+
largestDuplicateGroupSize: dupGroups.length ? Math.max(...dupGroups.map(g => g.urls.length)) : 0,
|
|
148
|
+
sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
|
|
149
|
+
sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
|
|
150
|
+
},
|
|
151
|
+
refs: {
|
|
152
|
+
exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
|
|
153
|
+
title: g.title,
|
|
154
|
+
urls: g.urls.slice(0, 5)
|
|
155
|
+
}))
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
if (titleEl && helpers && typeof helpers.reportOccurrence === 'function') {
|
|
163
|
+
occurrences.push(helpers.reportOccurrence(titleEl, occBase));
|
|
164
|
+
} else {
|
|
165
|
+
// No node available: keep deterministic fallback snippet (no helper calls).
|
|
166
|
+
occurrences.push({
|
|
167
|
+
...occBase,
|
|
168
|
+
html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Cross-page analysis is authoritative when present; do not also run single-page heuristics.
|
|
173
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If we had enough pages and found no review signal, there is nothing
|
|
177
|
+
// to flag for manual review.
|
|
178
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Not enough analyzable pages -> notApplicable for cross-page patterns, fall back to single-page logic.
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// If there's no title (or empty), defer to the hard-fail rule.
|
|
185
|
+
if (!titleEl || titleText.length === 0) {
|
|
186
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const GENERIC_TITLES = new Set([
|
|
190
|
+
'home',
|
|
191
|
+
'homepage',
|
|
192
|
+
'welcome',
|
|
193
|
+
'untitled',
|
|
194
|
+
'page',
|
|
195
|
+
'document'
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
// Conservative signals:
|
|
199
|
+
// - very short title (likely non-descriptive)
|
|
200
|
+
// - title is one of a small set of generic titles
|
|
201
|
+
const isVeryShort = titleText.length > 0 && titleText.length < 8;
|
|
202
|
+
const isGeneric = GENERIC_TITLES.has(titleLc);
|
|
203
|
+
|
|
204
|
+
// Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
|
|
205
|
+
const templateLike = /\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
|
|
206
|
+
/.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
|
|
207
|
+
|
|
208
|
+
if (isGeneric || isVeryShort || templateLike) {
|
|
209
|
+
const reasonCode = isGeneric
|
|
210
|
+
? 'genericTitle'
|
|
211
|
+
: (isVeryShort ? 'veryShortTitle' : 'templateLikeTitle');
|
|
212
|
+
|
|
213
|
+
const summaryKey =
|
|
214
|
+
reasonCode === 'genericTitle'
|
|
215
|
+
? 'pageTitlePatterns_summary_cantTell_generic'
|
|
216
|
+
: (reasonCode === 'veryShortTitle'
|
|
217
|
+
? 'pageTitlePatterns_summary_cantTell_veryShort'
|
|
218
|
+
: 'pageTitlePatterns_summary_cantTell_templateLike');
|
|
219
|
+
const occBase = {
|
|
220
|
+
selector: 'head > title',
|
|
221
|
+
html: '',
|
|
222
|
+
summary:
|
|
223
|
+
'The page title may not be descriptive enough to identify the page topic or purpose.',
|
|
224
|
+
hint:
|
|
225
|
+
'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
|
|
226
|
+
i18n: {
|
|
227
|
+
summaryKey,
|
|
228
|
+
hintKey: 'pageTitlePatterns_hint_cantTell',
|
|
229
|
+
params: {}
|
|
230
|
+
},
|
|
231
|
+
data: {
|
|
232
|
+
details: {
|
|
233
|
+
reasonCode,
|
|
234
|
+
titleText
|
|
235
|
+
},
|
|
236
|
+
visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (titleEl && helpers && typeof helpers.reportOccurrence === 'function') {
|
|
241
|
+
occurrences.push(helpers.reportOccurrence(titleEl, occBase));
|
|
242
|
+
} else {
|
|
243
|
+
occurrences.push({
|
|
244
|
+
...occBase,
|
|
245
|
+
html: titleEl && titleEl.outerHTML ? String(titleEl.outerHTML).slice(0, 2000) : '<title>(unknown)</title>'
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (applicableCount === 0) {
|
|
251
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (occurrences.length) {
|
|
255
|
+
// Patterns are review signals: cantTell rather than fail.
|
|
256
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check presentation-role-conflict
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary role="presentation"/"none" must not be combined with a global ARIA naming attribute or focusability
|
|
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 an explicit role="presentation" or
|
|
10
|
+
* role="none", OR an <img alt=""> (empty alt gives an <img> an implicit
|
|
11
|
+
* presentation role per HTML-AAM, even with no explicit role attribute
|
|
12
|
+
* at all — verified against a widely-used reference engine's own selector
|
|
13
|
+
* for this exact check, `img[alt=''], [role="none"], [role="presentation"]`).
|
|
14
|
+
* @expectation
|
|
15
|
+
* The element does not also carry a WAI-ARIA *global* state/property
|
|
16
|
+
* (aria-label, aria-hidden, aria-describedby, aria-live, aria-current,
|
|
17
|
+
* ... — the full global-attribute set, not just the naming ones), AND
|
|
18
|
+
* is not focusable. Per the WAI-ARIA spec's Presentational Roles
|
|
19
|
+
* Conflict Resolution section, a presentational role is "restored" to
|
|
20
|
+
* the element's implicit semantic role when either condition holds —
|
|
21
|
+
* the presentation/none role silently stops working, contradicting the
|
|
22
|
+
* author's evident intent to hide the element from the accessibility
|
|
23
|
+
* tree.
|
|
24
|
+
* @implementation-notes
|
|
25
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
26
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
27
|
+
* header comment for the shared rationale/precedent.
|
|
28
|
+
* - The conflicting-attribute set matches a widely-used reference engine's
|
|
29
|
+
* own `none: ['is-element-focusable', 'has-global-aria-attribute']` condition
|
|
30
|
+
* exactly — `has-global-aria-attribute` checks against the full list of
|
|
31
|
+
* ARIA attributes marked `global: true` in that engine's own standards data
|
|
32
|
+
* (confirmed by reading `standards.ariaAttrs` directly at runtime, not
|
|
33
|
+
* guessed), which is much broader than the 3-attribute naming-only list
|
|
34
|
+
* this rule originally checked (added 2026-07-20, then widened
|
|
35
|
+
* 2026-07-20 after a real page — Slack's homepage has
|
|
36
|
+
* `<img alt="" aria-hidden="">`, both gaps at once: the img[alt=''] case
|
|
37
|
+
* wasn't in the applicability selector at all, and aria-hidden wasn't in
|
|
38
|
+
* the conflicting-attribute set even if it had been).
|
|
39
|
+
* - Deliberately NOT replicating that reference engine's
|
|
40
|
+
* `hasImplicitChromiumRoleMatches` applicability gate, which (per direct
|
|
41
|
+
* probing of the reference engine's runtime) makes its own check
|
|
42
|
+
* inapplicable to role="presentation" on elements with no
|
|
43
|
+
* native implicit role to suppress in the first place (e.g.
|
|
44
|
+
* `<div role="presentation" aria-hidden="true">` — a <div> has no native
|
|
45
|
+
* role, so there's nothing for the presentational role to "conflict"
|
|
46
|
+
* with per that engine's own scope decision). Replicating that would require a
|
|
47
|
+
* full native-implicit-role table and isn't evidenced by any real false
|
|
48
|
+
* positive in this corpus yet — surea11y stays broader/more cautious
|
|
49
|
+
* here rather than narrower, which is the safer direction to diverge in.
|
|
50
|
+
* - Focusability is computed via helpers.getFocusableInfo (native +
|
|
51
|
+
* tabindex), same helper aria-hidden-focus already relies on — a
|
|
52
|
+
* `:disabled` or otherwise non-focusable element is not flagged.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const id = 'presentation-role-conflict';
|
|
56
|
+
|
|
57
|
+
const meta = {
|
|
58
|
+
title: 'Presentational role must not conflict with a global ARIA attribute or focusability',
|
|
59
|
+
description: 'Checks that role="presentation"/"none" (including an <img alt=""> implicit presentation role) is not combined with a global ARIA attribute (aria-label, aria-hidden, aria-describedby, ...) or focusability (tabindex/native).',
|
|
60
|
+
i18n: {
|
|
61
|
+
titleKey: 'presentationRoleConflict_title',
|
|
62
|
+
descriptionKey: 'presentationRoleConflict_description'
|
|
63
|
+
},
|
|
64
|
+
helpUrl: null,
|
|
65
|
+
tags: ['best-practice', 'aria', 'structure', 'atomic', 'manual'],
|
|
66
|
+
wcagSc: [],
|
|
67
|
+
normativeMappings: [],
|
|
68
|
+
defaultSeverity: 'minor',
|
|
69
|
+
category: 'robust',
|
|
70
|
+
type: 'manual',
|
|
71
|
+
defaultConfidence: 'medium',
|
|
72
|
+
coverage: {}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function runInPage(ctx) {
|
|
76
|
+
const { document, root, helpers, rule } = ctx;
|
|
77
|
+
const safeRoot = root || document;
|
|
78
|
+
|
|
79
|
+
// The full set of ARIA attributes marked `global: true` per the WAI-ARIA
|
|
80
|
+
// spec (confirmed against a widely-used reference engine's own
|
|
81
|
+
// `standards.ariaAttrs` data at runtime, 2026-07-20) — any of these present on a presentational
|
|
82
|
+
// element restores its implicit role, not just the naming ones.
|
|
83
|
+
const CONFLICTING_ATTRS = [
|
|
84
|
+
'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
|
|
85
|
+
'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
|
|
86
|
+
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed',
|
|
87
|
+
'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label',
|
|
88
|
+
'aria-labelledby', 'aria-live', 'aria-owns', 'aria-relevant', 'aria-roledescription'
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
92
|
+
|
|
93
|
+
const nodes = helpers.queryAllSmart
|
|
94
|
+
? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]', safeRoot)
|
|
95
|
+
: helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]', safeRoot);
|
|
96
|
+
|
|
97
|
+
const occurrences = [];
|
|
98
|
+
let applicableCount = 0;
|
|
99
|
+
|
|
100
|
+
for (const el of nodes) {
|
|
101
|
+
if (!el || !el.getAttribute) continue;
|
|
102
|
+
|
|
103
|
+
applicableCount += 1;
|
|
104
|
+
|
|
105
|
+
// Presence, not value truthiness: the WAI-ARIA role-conflict-resolution
|
|
106
|
+
// rule triggers on a global ARIA attribute being SPECIFIED at all, even
|
|
107
|
+
// with an empty value — found on a real site, Slack's homepage has
|
|
108
|
+
// <img alt="" aria-hidden="">, where aria-hidden="" (empty string) is
|
|
109
|
+
// still a specified attribute. A truthy-value check would have missed
|
|
110
|
+
// this even after aria-hidden was added to CONFLICTING_ATTRS above.
|
|
111
|
+
const present = CONFLICTING_ATTRS.filter((attr) => (el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null));
|
|
112
|
+
|
|
113
|
+
let isFocusable = false;
|
|
114
|
+
if (getFocusableInfo) {
|
|
115
|
+
try {
|
|
116
|
+
const fi = getFocusableInfo(el, ctx);
|
|
117
|
+
isFocusable = !!(fi && fi.focusable);
|
|
118
|
+
} catch {
|
|
119
|
+
isFocusable = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!present.length && !isFocusable) continue;
|
|
124
|
+
|
|
125
|
+
const parts = present.slice();
|
|
126
|
+
if (isFocusable) parts.push('focusable');
|
|
127
|
+
|
|
128
|
+
// No explicit role attribute means this matched via the img[alt=""]
|
|
129
|
+
// implicit-presentation case.
|
|
130
|
+
const role = String(el.getAttribute('role') || '').trim().toLowerCase() || 'presentation';
|
|
131
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
132
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
133
|
+
|
|
134
|
+
occurrences.push({
|
|
135
|
+
selector: stableSelector,
|
|
136
|
+
html,
|
|
137
|
+
summary: `This role="${role}" element also has a conflicting condition (${parts.join(', ')}), which restores its implicit role and cancels the presentational intent.`,
|
|
138
|
+
hint: 'Remove the conflicting naming attribute(s) and/or focusability (tabindex/native) if the element should stay presentational, or remove role="presentation"/"none" if it should be exposed to assistive technology.',
|
|
139
|
+
i18n: {
|
|
140
|
+
summaryKey: 'presentationRoleConflict_summary_cantTell',
|
|
141
|
+
hintKey: 'presentationRoleConflict_hint_cantTell',
|
|
142
|
+
params: { role, attrs: parts.join(', ') }
|
|
143
|
+
},
|
|
144
|
+
data: {
|
|
145
|
+
details: { reasonCode: 'PRESENTATION_ROLE_CONFLICT', role, conflictingAttrs: present, focusable: isFocusable }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (applicableCount === 0) {
|
|
151
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
152
|
+
}
|
|
153
|
+
if (occurrences.length) {
|
|
154
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
155
|
+
}
|
|
156
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check region
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Page content should be contained within a landmark region
|
|
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 direct children of <body> that carry visible text content
|
|
10
|
+
* and are not themselves a landmark.
|
|
11
|
+
* @expectation
|
|
12
|
+
* Every top-level piece of page content lives inside a landmark region
|
|
13
|
+
* (main, navigation, banner, contentinfo, complementary, region, form,
|
|
14
|
+
* search), so assistive technology users navigating by landmark do not
|
|
15
|
+
* miss content that was never placed inside one.
|
|
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 and the landmark-
|
|
20
|
+
* detection model.
|
|
21
|
+
* - Deliberately scoped to DIRECT children of <body> only (not a full
|
|
22
|
+
* recursive scan of every text node on the page) — a widely-used
|
|
23
|
+
* reference engine's own region rule is known to be noisy in practice when applied
|
|
24
|
+
* unconditionally; checking only the top level keeps false positives
|
|
25
|
+
* low while still catching the common case (a page section authored
|
|
26
|
+
* entirely outside any landmark).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const id = 'region';
|
|
30
|
+
|
|
31
|
+
const meta = {
|
|
32
|
+
title: 'Page content should be inside a landmark region',
|
|
33
|
+
description: 'Checks that direct children of <body> with visible text content are contained within a landmark region.',
|
|
34
|
+
i18n: {
|
|
35
|
+
titleKey: 'region_title',
|
|
36
|
+
descriptionKey: 'region_description'
|
|
37
|
+
},
|
|
38
|
+
helpUrl: null,
|
|
39
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
40
|
+
wcagSc: [],
|
|
41
|
+
normativeMappings: [],
|
|
42
|
+
defaultSeverity: 'minor',
|
|
43
|
+
category: 'operable',
|
|
44
|
+
type: 'manual',
|
|
45
|
+
defaultConfidence: 'medium',
|
|
46
|
+
coverage: {}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function runInPage(ctx) {
|
|
50
|
+
const { document, helpers, rule } = ctx;
|
|
51
|
+
|
|
52
|
+
const body = document && document.body ? document.body : null;
|
|
53
|
+
if (!body) {
|
|
54
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeWs(s) {
|
|
58
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
62
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
63
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js for the real bug
|
|
64
|
+
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
65
|
+
// copy of this logic.
|
|
66
|
+
function getAccessibleLandmarkName(el) {
|
|
67
|
+
try {
|
|
68
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
69
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
70
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getExplicitRoleToken(el) {
|
|
77
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
78
|
+
if (!raw) return '';
|
|
79
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const SECTIONING_ANCESTORS = new Set(['article', 'aside', 'main', 'nav', 'section']);
|
|
83
|
+
|
|
84
|
+
function isSuppressedBySectioningAncestor(el) {
|
|
85
|
+
let p = el.parentElement;
|
|
86
|
+
while (p) {
|
|
87
|
+
const tag = p.tagName ? p.tagName.toLowerCase() : '';
|
|
88
|
+
if (SECTIONING_ANCESTORS.has(tag)) return true;
|
|
89
|
+
p = p.parentElement;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getImplicitLandmarkRole(el) {
|
|
95
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
96
|
+
if (tag === 'header') return isSuppressedBySectioningAncestor(el) ? '' : 'banner';
|
|
97
|
+
if (tag === 'footer') return isSuppressedBySectioningAncestor(el) ? '' : 'contentinfo';
|
|
98
|
+
if (tag === 'main') return 'main';
|
|
99
|
+
if (tag === 'nav') return 'navigation';
|
|
100
|
+
if (tag === 'aside') return isSuppressedBySectioningAncestor(el) ? '' : 'complementary';
|
|
101
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
102
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const LANDMARK_ROLES = new Set(['banner', 'contentinfo', 'main', 'navigation', 'complementary', 'region', 'form', 'search']);
|
|
107
|
+
|
|
108
|
+
function isLandmark(el) {
|
|
109
|
+
if (!el || !el.getAttribute) return false;
|
|
110
|
+
const explicit = getExplicitRoleToken(el);
|
|
111
|
+
if (explicit) return LANDMARK_ROLES.has(explicit);
|
|
112
|
+
return !!getImplicitLandmarkRole(el);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta', 'title']);
|
|
116
|
+
|
|
117
|
+
function isIneligible(el) {
|
|
118
|
+
if (el.getAttribute && el.getAttribute('aria-hidden') === 'true') return true;
|
|
119
|
+
if (el.hasAttribute && el.hasAttribute('hidden')) return true;
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getConservativeSubtreeText(container) {
|
|
124
|
+
const SHOW_TEXT = 4;
|
|
125
|
+
try {
|
|
126
|
+
const walker = document.createTreeWalker(container, SHOW_TEXT, null);
|
|
127
|
+
const parts = [];
|
|
128
|
+
let n = walker.nextNode();
|
|
129
|
+
while (n) {
|
|
130
|
+
const raw = normalizeWs(n.nodeValue || '');
|
|
131
|
+
if (raw) parts.push(raw);
|
|
132
|
+
n = walker.nextNode();
|
|
133
|
+
}
|
|
134
|
+
return normalizeWs(parts.join(' '));
|
|
135
|
+
} catch {
|
|
136
|
+
return normalizeWs(container.textContent);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const occurrences = [];
|
|
141
|
+
let applicableCount = 0;
|
|
142
|
+
|
|
143
|
+
for (const el of body.children || []) {
|
|
144
|
+
if (!el || !el.tagName) continue;
|
|
145
|
+
const tag = el.tagName.toLowerCase();
|
|
146
|
+
if (SKIP_TAGS.has(tag)) continue;
|
|
147
|
+
if (isIneligible(el)) continue;
|
|
148
|
+
if (isLandmark(el)) continue;
|
|
149
|
+
|
|
150
|
+
const text = getConservativeSubtreeText(el);
|
|
151
|
+
if (!text) continue;
|
|
152
|
+
|
|
153
|
+
applicableCount += 1;
|
|
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 content is not contained within a landmark region.',
|
|
162
|
+
hint: 'Move this content inside a landmark region (main, nav, aside, a labeled section, etc.).',
|
|
163
|
+
i18n: {
|
|
164
|
+
summaryKey: 'region_summary_cantTell',
|
|
165
|
+
hintKey: 'region_hint_cantTell',
|
|
166
|
+
params: { element: tag }
|
|
167
|
+
},
|
|
168
|
+
data: {
|
|
169
|
+
details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (applicableCount === 0) {
|
|
175
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
176
|
+
}
|
|
177
|
+
if (occurrences.length) {
|
|
178
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
179
|
+
}
|
|
180
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { id, meta, runInPage };
|