@surea11y/core 1.6.0 → 1.8.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 +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -20,9 +20,14 @@
|
|
|
20
20
|
* event equivalents), or `onfocus`/`onblur` (the standard substitute
|
|
21
21
|
* for hover-triggered behavior: focus/blur are the keyboard-
|
|
22
22
|
* navigable analog to mouseover/mouseout, per WCAG technique G90).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* A handler is reachable only if keyboard events can reach it:
|
|
24
|
+
* `onfocus`/`onblur` when the element itself can take focus, and a key
|
|
25
|
+
* handler when the element or one of its descendants can (key events
|
|
26
|
+
* bubble, focus events do not). Otherwise the element's mouse-driven
|
|
27
|
+
* behavior (a hover tooltip, a custom dropdown, a drag interaction) has
|
|
28
|
+
* no way to be triggered by a keyboard-only user, and it is flagged with
|
|
29
|
+
* a reason saying whether the keyboard handlers are missing or cannot
|
|
30
|
+
* run.
|
|
26
31
|
* @implementation-notes
|
|
27
32
|
* - Authored as `type: 'manual'` (cantTell-capped, never fail), not
|
|
28
33
|
* `automatic`: this can only see inline `on*="..."` HTML attributes.
|
|
@@ -93,6 +98,35 @@ function runInPage(ctx) {
|
|
|
93
98
|
return (v == null ? '' : String(v)).trim();
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
const FOCUS_ATTRS = ['onfocus', 'onblur'];
|
|
102
|
+
const FOCUSABLE_CANDIDATES =
|
|
103
|
+
'a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]';
|
|
104
|
+
|
|
105
|
+
function canTakeFocus(el) {
|
|
106
|
+
if (!helpers.getFocusableInfo) return true;
|
|
107
|
+
try {
|
|
108
|
+
const info = helpers.getFocusableInfo(el, ctx);
|
|
109
|
+
return !!(info && info.focusable);
|
|
110
|
+
} catch {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Focus and blur fire only on the element that takes focus. Key events are
|
|
116
|
+
// dispatched to the focused element and bubble, so a key handler also runs
|
|
117
|
+
// for a focusable descendant.
|
|
118
|
+
function keyboardCanReach(el, keyboardAttrs) {
|
|
119
|
+
if (canTakeFocus(el)) return true;
|
|
120
|
+
if (keyboardAttrs.every((a) => FOCUS_ATTRS.indexOf(a) !== -1)) return false;
|
|
121
|
+
let descendants;
|
|
122
|
+
try {
|
|
123
|
+
descendants = Array.from(el.querySelectorAll(FOCUSABLE_CANDIDATES));
|
|
124
|
+
} catch {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return descendants.some((d) => canTakeFocus(d));
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
|
|
97
131
|
const nodes = helpers.queryAllSmart
|
|
98
132
|
? helpers.queryAllSmart(selector)
|
|
@@ -114,13 +148,40 @@ function runInPage(ctx) {
|
|
|
114
148
|
|
|
115
149
|
applicableCount += 1;
|
|
116
150
|
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
151
|
+
const presentKeyboardAttrs = KEYBOARD_EQUIV_ATTRS.filter((a) => trim(el.getAttribute(a)));
|
|
152
|
+
if (presentKeyboardAttrs.length && keyboardCanReach(el, presentKeyboardAttrs)) continue;
|
|
153
|
+
const unreachable = presentKeyboardAttrs.length > 0;
|
|
119
154
|
|
|
120
155
|
const eligInfo = helpers.getEligibilityInfo
|
|
121
156
|
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
122
157
|
: null;
|
|
123
158
|
|
|
159
|
+
if (unreachable) {
|
|
160
|
+
occurrences.push(
|
|
161
|
+
helpers.reportOccurrence(el, {
|
|
162
|
+
summary: `This element has ${presentMouseAttrs.join(', ')} and ${presentKeyboardAttrs.join(', ')}, but it cannot take keyboard focus, so the keyboard handlers never run.`,
|
|
163
|
+
hint: 'Make the element focusable (use a native control, or add tabindex="0"), or move the handlers to a focusable element, so this functionality is also reachable by keyboard.',
|
|
164
|
+
i18n: {
|
|
165
|
+
summaryKey: 'mouseOnlyEventHandlers_summary_cantTell_notFocusable',
|
|
166
|
+
hintKey: 'mouseOnlyEventHandlers_hint_cantTell_notFocusable',
|
|
167
|
+
params: {
|
|
168
|
+
attrs: presentMouseAttrs.join(', '),
|
|
169
|
+
keyboardAttrs: presentKeyboardAttrs.join(', ')
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
data: {
|
|
173
|
+
details: {
|
|
174
|
+
reasonCode: 'MOUSE_ONLY_HANDLER_KEYBOARD_EQUIVALENT_NOT_FOCUSABLE',
|
|
175
|
+
mouseAttrs: presentMouseAttrs,
|
|
176
|
+
keyboardAttrs: presentKeyboardAttrs
|
|
177
|
+
},
|
|
178
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
occurrences.push(
|
|
125
186
|
helpers.reportOccurrence(el, {
|
|
126
187
|
summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
|
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check no-autoplay-audio
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Sound that plays automatically should have a pause/stop or volume-control mechanism
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.4.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Any <audio autoplay> or <video autoplay> element that is not `muted`.
|
|
13
|
+
* Also any <bgsound>, and any <embed> or <object> that loads sound or
|
|
14
|
+
* video, or a plugin (Flash) that may play it: its `type` is audio/*,
|
|
15
|
+
* video/* or a plugin type, or its `src`/`data` ends in a sound or video
|
|
16
|
+
* file extension. An <embed> or <object> with `autostart` or `autoplay`
|
|
17
|
+
* set to false (attribute or <param>) is left out.
|
|
13
18
|
* @expectation
|
|
14
19
|
* SC 1.4.2 only applies when audio plays automatically for MORE than 3
|
|
15
20
|
* seconds; clip duration is not knowable from static markup (jsdom does
|
|
@@ -27,13 +32,22 @@
|
|
|
27
32
|
* - Elements with `muted` present are not flagged: muted playback is not
|
|
28
33
|
* audible, so the SC's condition ("plays automatically... audio")
|
|
29
34
|
* does not apply.
|
|
35
|
+
* - <embed>, <object> and <bgsound> have no `controls` or `muted` to
|
|
36
|
+
* read, so each one found is asked about. <bgsound> is obsolete
|
|
37
|
+
* and current browsers ignore it, but it still plays in older ones.
|
|
38
|
+
* - Sound started by a script cannot be detected.
|
|
30
39
|
* - Custom (JS-built) controls that don't use the native `controls`
|
|
31
40
|
* attribute cannot be detected statically. That's a documented limitation,
|
|
32
41
|
* same class as `iframe-focusable-content`'s `contentDocument` gap.
|
|
33
42
|
* - Not gated on `isAccTreeEligible`: unlike most rules, a `display:none`
|
|
34
43
|
* or `aria-hidden` audio/video element still plays audible sound in a
|
|
35
44
|
* real browser, so visual/AT-tree eligibility is not a relevant filter
|
|
36
|
-
* here.
|
|
45
|
+
* here. For the same reason the rule does not use queryAllSmart, whose
|
|
46
|
+
* hidden-content filter would drop such elements: an <audio> without
|
|
47
|
+
* `controls` is always one, since browsers hide it with their own
|
|
48
|
+
* stylesheet (`display: none`). It queries the DOM directly (and open
|
|
49
|
+
* shadow roots, unless includeShadowDom is false), honouring only the
|
|
50
|
+
* scan scope and excludeSelectors.
|
|
37
51
|
*/
|
|
38
52
|
|
|
39
53
|
const id = 'no-autoplay-audio';
|
|
@@ -41,7 +55,7 @@ const id = 'no-autoplay-audio';
|
|
|
41
55
|
const meta = {
|
|
42
56
|
title: 'Autoplaying audio should provide a pause/stop or volume-control mechanism',
|
|
43
57
|
description:
|
|
44
|
-
'Flags <audio>/<video> elements that autoplay unmuted with no native controls attribute, for manual review against the 3-second exemption in WCAG 1.4.2.',
|
|
58
|
+
'Flags <audio>/<video> elements that autoplay unmuted with no native controls attribute, and <embed>, <object> or <bgsound> elements that may play sound, for manual review against the 3-second exemption in WCAG 1.4.2.',
|
|
45
59
|
i18n: {
|
|
46
60
|
titleKey: 'noAutoplayAudio_title',
|
|
47
61
|
descriptionKey: 'noAutoplayAudio_description'
|
|
@@ -68,9 +82,18 @@ const meta = {
|
|
|
68
82
|
function runInPage(ctx) {
|
|
69
83
|
const { helpers, rule } = ctx;
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
// Every match in scope, hidden or not (see @implementation-notes).
|
|
86
|
+
function queryAllUnfiltered(sel) {
|
|
87
|
+
const engineOptions = ctx.engineOptions || {};
|
|
88
|
+
const deep =
|
|
89
|
+
engineOptions.includeShadowDom !== false && typeof helpers.queryAllDeep === 'function';
|
|
90
|
+
const list = Array.from((deep ? helpers.queryAllDeep(sel) : helpers.queryAll(sel)) || []);
|
|
91
|
+
return typeof helpers.isExcluded === 'function'
|
|
92
|
+
? list.filter((el) => !helpers.isExcluded(el))
|
|
93
|
+
: list;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nodes = queryAllUnfiltered('audio[autoplay], video[autoplay]');
|
|
74
97
|
|
|
75
98
|
const occurrences = [];
|
|
76
99
|
let applicableCount = 0;
|
|
@@ -110,6 +133,70 @@ function runInPage(ctx) {
|
|
|
110
133
|
}
|
|
111
134
|
}
|
|
112
135
|
|
|
136
|
+
// <embed>, <object> and <bgsound>: no controls or muted attribute to read.
|
|
137
|
+
const MEDIA_EXT =
|
|
138
|
+
/\.(mp3|wav|wave|ogg|oga|opus|m4a|aac|flac|wma|mid|midi|mp4|m4v|webm|ogv|mov|avi|wmv|mpg|mpeg|swf)(?:[?#]|$)/i;
|
|
139
|
+
const PLUGIN_TYPES = /^(application\/x-shockwave-flash|application\/futuresplash)$/i;
|
|
140
|
+
|
|
141
|
+
function attr(el, name) {
|
|
142
|
+
return String(el.getAttribute(name) || '').trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mayPlaySound(el, urlAttr) {
|
|
146
|
+
const type = attr(el, 'type').toLowerCase().split(';')[0].trim();
|
|
147
|
+
if (type) return /^(audio|video)\//.test(type) || PLUGIN_TYPES.test(type);
|
|
148
|
+
return MEDIA_EXT.test(attr(el, urlAttr));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startsDisabled(el) {
|
|
152
|
+
const isOff = (v) => /^(false|0|no)$/i.test(String(v || '').trim());
|
|
153
|
+
if (isOff(el.getAttribute('autostart')) || isOff(el.getAttribute('autoplay'))) return true;
|
|
154
|
+
return Array.from(el.children || []).some((c) => {
|
|
155
|
+
if ((c.tagName || '').toLowerCase() !== 'param') return false;
|
|
156
|
+
const name = attr(c, 'name').toLowerCase();
|
|
157
|
+
return (
|
|
158
|
+
(name === 'autostart' || name === 'autoplay' || name === 'play') &&
|
|
159
|
+
isOff(c.getAttribute('value'))
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The fallback inside an <object> already asked about is the same sound.
|
|
165
|
+
const askedObjects = [];
|
|
166
|
+
|
|
167
|
+
for (const el of queryAllUnfiltered('embed, object, bgsound')) {
|
|
168
|
+
if (!el || !el.getAttribute) continue;
|
|
169
|
+
if (askedObjects.some((o) => o !== el && o.contains(el))) continue;
|
|
170
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
171
|
+
if (tag === 'embed' && !mayPlaySound(el, 'src')) continue;
|
|
172
|
+
if (tag === 'object' && !mayPlaySound(el, 'data')) continue;
|
|
173
|
+
if (tag !== 'bgsound' && startsDisabled(el)) continue;
|
|
174
|
+
|
|
175
|
+
applicableCount += 1;
|
|
176
|
+
if (tag === 'object') askedObjects.push(el);
|
|
177
|
+
|
|
178
|
+
const baseOccurrence = {
|
|
179
|
+
selector: helpers.buildSelector ? helpers.buildSelector(el) : 'html',
|
|
180
|
+
html: helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '',
|
|
181
|
+
summary: 'This element may play sound as soon as the page loads.',
|
|
182
|
+
hint: 'Check whether it plays sound on its own. If the sound lasts more than 3 seconds, users need a way to pause or stop it, or to change its volume without changing the system volume.',
|
|
183
|
+
i18n: {
|
|
184
|
+
summaryKey: 'noAutoplayAudio_summary_cantTell_embedded',
|
|
185
|
+
hintKey: 'noAutoplayAudio_hint_cantTell_embedded',
|
|
186
|
+
params: { element: tag }
|
|
187
|
+
},
|
|
188
|
+
data: {
|
|
189
|
+
details: { reasonCode: 'EMBEDDED_SOUND_SOURCE', mediaTag: tag }
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
194
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
195
|
+
} else {
|
|
196
|
+
occurrences.push(baseOccurrence);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
113
200
|
if (applicableCount === 0) {
|
|
114
201
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
115
202
|
}
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check p-as-heading
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Text styled to look like a heading should probably be a real heading
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* `<p>` elements
|
|
13
|
-
*
|
|
14
|
-
* `font-weight` >= 700,
|
|
15
|
-
* `<strong>`/`<b>`
|
|
12
|
+
* `<p>` elements, and `<div>` elements that hold only text and inline
|
|
13
|
+
* markup, with short (<=120 char), non-empty trimmed text in which every
|
|
14
|
+
* piece of text is bold (computed `font-weight` >= 700, however it got
|
|
15
|
+
* there: on the element itself, a `<strong>`/`<b>` or a styled `<span>`)
|
|
16
|
+
* and rendered at >=18px.
|
|
16
17
|
* @expectation
|
|
17
18
|
* Text styled to visually read as a heading (bold, larger-than-body
|
|
18
19
|
* size, short) should be marked up with a real heading element
|
|
@@ -30,14 +31,22 @@
|
|
|
30
31
|
* surrounding text (unlike `link-in-text-block`); it's simpler on
|
|
31
32
|
* purpose, since "looks like a heading" is closer to an absolute
|
|
32
33
|
* judgment than a relative-contrast one.
|
|
34
|
+
* - Weight and size are read from the element that holds each piece of
|
|
35
|
+
* text, so `<p><span style="font-weight:bold">` counts and a `<p>` with
|
|
36
|
+
* one normal-weight word does not.
|
|
37
|
+
* - A `<div>` is only considered when it has no block, list, table, form
|
|
38
|
+
* control or image inside it, so only the innermost block is asked
|
|
39
|
+
* about. A `<div>` with a role, and text inside a heading, button,
|
|
40
|
+
* label, legend, caption, table header or `<summary>`, are left out:
|
|
41
|
+
* that text already has a role of its own.
|
|
33
42
|
*/
|
|
34
43
|
|
|
35
44
|
const id = 'p-as-heading';
|
|
36
45
|
|
|
37
46
|
const meta = {
|
|
38
|
-
title: '
|
|
47
|
+
title: 'Text styled to look like a heading should probably be a real heading',
|
|
39
48
|
description:
|
|
40
|
-
'Flags short <p> elements whose
|
|
49
|
+
'Flags short <p> and <div> elements whose text is all bold and rendered at >=18px, for manual review of whether a real heading element should be used instead.',
|
|
41
50
|
i18n: {
|
|
42
51
|
titleKey: 'pAsHeading_title',
|
|
43
52
|
descriptionKey: 'pAsHeading_description'
|
|
@@ -93,65 +102,101 @@ function runInPage(ctx) {
|
|
|
93
102
|
return Number.isFinite(n) && n >= 700;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
// Elements whose text already has a role of its own.
|
|
106
|
+
const OWN_ROLE_ANCESTORS =
|
|
107
|
+
'h1, h2, h3, h4, h5, h6, [role="heading"], button, [role="button"], label, legend, caption, th, [role="columnheader"], [role="rowheader"], summary';
|
|
108
|
+
|
|
109
|
+
// Anything but text and inline markup makes a <div> a container, not a
|
|
110
|
+
// passage of text.
|
|
111
|
+
const NOT_INLINE =
|
|
112
|
+
'address, article, aside, blockquote, details, dialog, div, dl, fieldset, figure, figcaption, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, hr, li, main, nav, ol, p, pre, section, table, ul, img, svg, picture, video, audio, canvas, iframe, object, embed, input, select, textarea, button';
|
|
113
|
+
|
|
114
|
+
function textPieces(el) {
|
|
115
|
+
const pieces = [];
|
|
116
|
+
const doc = el.ownerDocument;
|
|
117
|
+
const walker = doc.createTreeWalker(el, 4 /* NodeFilter.SHOW_TEXT */);
|
|
118
|
+
let node = walker.nextNode();
|
|
119
|
+
while (node) {
|
|
120
|
+
if (trim(node.nodeValue) && node.parentElement) pieces.push(node.parentElement);
|
|
121
|
+
node = walker.nextNode();
|
|
109
122
|
}
|
|
110
|
-
return
|
|
123
|
+
return pieces;
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
126
|
+
// Every piece of text is bold, and the smallest is the size reported.
|
|
127
|
+
function boldSize(el) {
|
|
128
|
+
let minPx = Infinity;
|
|
129
|
+
for (const holder of textPieces(el)) {
|
|
130
|
+
const cs = safeComputedStyle(holder);
|
|
131
|
+
if (!isBoldWeight(cs)) return 0;
|
|
132
|
+
const px = Number.parseFloat(cs.fontSize);
|
|
133
|
+
if (!Number.isFinite(px)) return 0;
|
|
134
|
+
minPx = Math.min(minPx, px);
|
|
135
|
+
}
|
|
136
|
+
return Number.isFinite(minPx) ? minPx : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isCandidate(el) {
|
|
140
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
141
|
+
if (el.closest && el.closest(OWN_ROLE_ANCESTORS)) return false;
|
|
142
|
+
if (tag === 'p') return true;
|
|
143
|
+
if (tag !== 'div') return false;
|
|
144
|
+
if (trim(el.getAttribute('role'))) return false;
|
|
145
|
+
return !el.querySelector(NOT_INLINE);
|
|
118
146
|
}
|
|
119
147
|
|
|
120
|
-
const nodes = helpers.queryAllSmart
|
|
148
|
+
const nodes = helpers.queryAllSmart
|
|
149
|
+
? helpers.queryAllSmart('p, div')
|
|
150
|
+
: helpers.queryAll('p, div');
|
|
121
151
|
|
|
122
152
|
const occurrences = [];
|
|
123
153
|
let applicableCount = 0;
|
|
124
154
|
|
|
125
155
|
for (const el of nodes) {
|
|
126
156
|
if (!el || !el.getAttribute) continue;
|
|
157
|
+
if (!isCandidate(el)) continue;
|
|
127
158
|
|
|
128
159
|
const text = trim(el.textContent || '');
|
|
129
160
|
if (!text || text.length > MAX_HEADING_LIKE_CHARS) continue;
|
|
130
161
|
|
|
131
162
|
applicableCount += 1;
|
|
132
163
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const fontSizePx = getFontSizePx(el);
|
|
164
|
+
const fontSizePx = boldSize(el);
|
|
136
165
|
if (fontSizePx < MIN_FONT_SIZE_PX) continue;
|
|
137
166
|
|
|
167
|
+
const isParagraph = (el.tagName || '').toLowerCase() === 'p';
|
|
138
168
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
139
169
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
140
170
|
|
|
141
|
-
const baseOccurrence =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
171
|
+
const baseOccurrence = isParagraph
|
|
172
|
+
? {
|
|
173
|
+
selector: stableSelector,
|
|
174
|
+
html,
|
|
175
|
+
summary: 'This paragraph is entirely bold and rendered at a heading-like size.',
|
|
176
|
+
hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a paragraph to look like one.',
|
|
177
|
+
i18n: {
|
|
178
|
+
summaryKey: 'pAsHeading_summary_cantTell',
|
|
179
|
+
hintKey: 'pAsHeading_hint_cantTell',
|
|
180
|
+
params: { fontSizePx: String(fontSizePx) }
|
|
181
|
+
},
|
|
182
|
+
data: {
|
|
183
|
+
details: { reasonCode: 'BOLD_LARGE_PARAGRAPH', fontSizePx }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
: {
|
|
187
|
+
selector: stableSelector,
|
|
188
|
+
html,
|
|
189
|
+
summary: 'This block of text is entirely bold and rendered at a heading-like size.',
|
|
190
|
+
hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a <div> to look like one.',
|
|
191
|
+
i18n: {
|
|
192
|
+
summaryKey: 'pAsHeading_summary_cantTell_div',
|
|
193
|
+
hintKey: 'pAsHeading_hint_cantTell_div',
|
|
194
|
+
params: { fontSizePx: String(fontSizePx) }
|
|
195
|
+
},
|
|
196
|
+
data: {
|
|
197
|
+
details: { reasonCode: 'BOLD_LARGE_DIV', fontSizePx }
|
|
198
|
+
}
|
|
199
|
+
};
|
|
155
200
|
|
|
156
201
|
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
157
202
|
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* @sc 2.4.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to a run over a whole document whose <title> resolves to
|
|
13
|
-
* non-empty text
|
|
13
|
+
* non-empty text. The title element is the first HTML <title> anywhere in
|
|
14
|
+
* the document, as for document.title, so one the parser left in <body>
|
|
15
|
+
* counts; a missing or empty title is page-title-present's
|
|
14
16
|
* failure, not a pattern to review. A run narrowed by contextSelector or
|
|
15
17
|
* by engineOptions.fragment is notApplicable, as is a title matching none
|
|
16
18
|
* of the patterns below.
|
|
@@ -78,7 +80,24 @@ function runInPage(ctx) {
|
|
|
78
80
|
const occurrences = [];
|
|
79
81
|
let applicableCount = 1;
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
// The document's title element, found as page-title-present finds it: the
|
|
84
|
+
// first HTML-namespace <title> anywhere in the document, since a <title>
|
|
85
|
+
// the parser leaves in <body> is still what document.title reads. An inline
|
|
86
|
+
// <svg><title> is not the page title.
|
|
87
|
+
const HTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
88
|
+
let titleEl = null;
|
|
89
|
+
for (const t of Array.from(document.getElementsByTagName('title'))) {
|
|
90
|
+
if (!t.namespaceURI || t.namespaceURI === HTML_NS) {
|
|
91
|
+
titleEl = t;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Kept as the stable selector for the usual place; a <title> elsewhere gets
|
|
96
|
+
// the selector the engine builds for the node.
|
|
97
|
+
const titleSelector =
|
|
98
|
+
titleEl && titleEl.parentElement && titleEl.parentElement.localName === 'head'
|
|
99
|
+
? 'head > title'
|
|
100
|
+
: undefined;
|
|
82
101
|
const rawTitle = document.title || '';
|
|
83
102
|
const titleText = rawTitle.replace(/\s+/g, ' ').trim();
|
|
84
103
|
const titleLc = titleText.toLowerCase();
|
|
@@ -176,7 +195,7 @@ function runInPage(ctx) {
|
|
|
176
195
|
};
|
|
177
196
|
|
|
178
197
|
const occBase = {
|
|
179
|
-
selector:
|
|
198
|
+
selector: titleSelector,
|
|
180
199
|
html: '',
|
|
181
200
|
summary:
|
|
182
201
|
'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
|
|
@@ -244,18 +263,68 @@ function runInPage(ctx) {
|
|
|
244
263
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
245
264
|
}
|
|
246
265
|
|
|
247
|
-
const GENERIC_TITLES =
|
|
266
|
+
const GENERIC_TITLES = {
|
|
267
|
+
en: new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']),
|
|
268
|
+
de: new Set(['startseite', 'willkommen', 'unbenannt', 'ohne titel', 'seite', 'dokument']),
|
|
269
|
+
es: new Set([
|
|
270
|
+
'inicio',
|
|
271
|
+
'página de inicio',
|
|
272
|
+
'bienvenido',
|
|
273
|
+
'bienvenida',
|
|
274
|
+
'sin título',
|
|
275
|
+
'página',
|
|
276
|
+
'documento'
|
|
277
|
+
]),
|
|
278
|
+
fr: new Set(['accueil', "page d'accueil", 'bienvenue', 'sans titre', 'page', 'document']),
|
|
279
|
+
ja: new Set(['ホーム', 'トップページ', 'トップ', 'ようこそ', '無題', 'ページ', 'ドキュメント'])
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// The page-specific half of "Brand | Home" in languages other than
|
|
283
|
+
// English. English keeps its word-boundary patterns below.
|
|
284
|
+
const TEMPLATE_TOKENS = {
|
|
285
|
+
de: ['startseite', 'willkommen'],
|
|
286
|
+
es: ['inicio', 'página de inicio', 'bienvenido', 'bienvenida'],
|
|
287
|
+
fr: ['accueil', "page d'accueil", 'bienvenue'],
|
|
288
|
+
ja: ['ホーム', 'トップページ', 'トップ', 'ようこそ']
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const htmlEl = document.documentElement;
|
|
292
|
+
const pageLang =
|
|
293
|
+
htmlEl && htmlEl.getAttribute && htmlEl.getAttribute('lang')
|
|
294
|
+
? htmlEl.getAttribute('lang').trim().split('-')[0].toLowerCase()
|
|
295
|
+
: '';
|
|
296
|
+
const titleNorm = titleLc.normalize('NFKC').replace(/[\u2018\u2019]/g, "'");
|
|
297
|
+
|
|
298
|
+
// Chinese, Japanese and Korean characters each carry roughly a word, so
|
|
299
|
+
// they count double: 「お問い合わせ」 is a full title in six characters.
|
|
300
|
+
function effectiveLength(s) {
|
|
301
|
+
let n = 0;
|
|
302
|
+
for (const ch of s)
|
|
303
|
+
n += /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]/.test(ch) ? 2 : 1;
|
|
304
|
+
return n;
|
|
305
|
+
}
|
|
248
306
|
|
|
249
307
|
// Conservative signals:
|
|
250
308
|
// - very short title (likely non-descriptive)
|
|
251
309
|
// - title is one of a small set of generic titles
|
|
252
|
-
const isVeryShort = titleText.length > 0 && titleText
|
|
253
|
-
const isGeneric =
|
|
310
|
+
const isVeryShort = titleText.length > 0 && effectiveLength(titleText) < 8;
|
|
311
|
+
const isGeneric =
|
|
312
|
+
GENERIC_TITLES.en.has(titleNorm) ||
|
|
313
|
+
!!(pageLang && GENERIC_TITLES[pageLang] && GENERIC_TITLES[pageLang].has(titleNorm));
|
|
314
|
+
|
|
315
|
+
function isLocalTemplate(title) {
|
|
316
|
+
const tokens = TEMPLATE_TOKENS[pageLang];
|
|
317
|
+
if (!tokens) return false;
|
|
318
|
+
const parts = title.split(/\s*(?:\||-|—|:)\s*/).filter(Boolean);
|
|
319
|
+
if (parts.length < 2) return false;
|
|
320
|
+
return tokens.includes(parts[0]) || tokens.includes(parts[parts.length - 1]);
|
|
321
|
+
}
|
|
254
322
|
|
|
255
323
|
// Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
|
|
256
324
|
const templateLike =
|
|
257
325
|
/\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
|
|
258
|
-
/.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText)
|
|
326
|
+
/.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText) ||
|
|
327
|
+
isLocalTemplate(titleNorm);
|
|
259
328
|
|
|
260
329
|
if (isGeneric || isVeryShort || templateLike) {
|
|
261
330
|
const reasonCode = isGeneric
|
|
@@ -271,7 +340,7 @@ function runInPage(ctx) {
|
|
|
271
340
|
? 'pageTitlePatterns_summary_cantTell_veryShort'
|
|
272
341
|
: 'pageTitlePatterns_summary_cantTell_templateLike';
|
|
273
342
|
const occBase = {
|
|
274
|
-
selector:
|
|
343
|
+
selector: titleSelector,
|
|
275
344
|
html: '',
|
|
276
345
|
summary:
|
|
277
346
|
'The page title may not be descriptive enough to identify the page topic or purpose.',
|