@setsquare/review-sdk 0.0.1 → 0.1.1
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/NOTICE +3 -2
- package/README.md +287 -3
- package/dist/autoscan.d.ts +23 -1
- package/dist/autoscan.d.ts.map +1 -1
- package/dist/autoscan.js +220 -15
- package/dist/autoscan.js.map +1 -1
- package/dist/brand.d.ts +51 -2
- package/dist/brand.d.ts.map +1 -1
- package/dist/brand.js +560 -5
- package/dist/brand.js.map +1 -1
- package/dist/checks/context-change.d.ts +96 -0
- package/dist/checks/context-change.d.ts.map +1 -0
- package/dist/checks/context-change.js +655 -0
- package/dist/checks/context-change.js.map +1 -0
- package/dist/checks/focus.d.ts +154 -0
- package/dist/checks/focus.d.ts.map +1 -0
- package/dist/checks/focus.js +1123 -0
- package/dist/checks/focus.js.map +1 -0
- package/dist/checks/heuristics.d.ts +117 -0
- package/dist/checks/heuristics.d.ts.map +1 -0
- package/dist/checks/heuristics.js +961 -0
- package/dist/checks/heuristics.js.map +1 -0
- package/dist/checks/keyboard.d.ts +106 -0
- package/dist/checks/keyboard.d.ts.map +1 -0
- package/dist/checks/keyboard.js +452 -0
- package/dist/checks/keyboard.js.map +1 -0
- package/dist/checks/reflow.d.ts +75 -0
- package/dist/checks/reflow.d.ts.map +1 -0
- package/dist/checks/reflow.js +285 -0
- package/dist/checks/reflow.js.map +1 -0
- package/dist/checks/rule-pack.d.ts +1 -1
- package/dist/checks/rule-pack.d.ts.map +1 -1
- package/dist/checks/rule-pack.js +350 -45
- package/dist/checks/rule-pack.js.map +1 -1
- package/dist/checks/shortcuts.d.ts +71 -0
- package/dist/checks/shortcuts.d.ts.map +1 -0
- package/dist/checks/shortcuts.js +196 -0
- package/dist/checks/shortcuts.js.map +1 -0
- package/dist/checks/versions.d.ts.map +1 -1
- package/dist/checks/versions.js +33 -1
- package/dist/checks/versions.js.map +1 -1
- package/dist/contract.d.ts +32 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/covers.d.ts +56 -0
- package/dist/covers.d.ts.map +1 -0
- package/dist/covers.js +135 -0
- package/dist/covers.js.map +1 -0
- package/dist/dedupe.d.ts +23 -0
- package/dist/dedupe.d.ts.map +1 -1
- package/dist/dedupe.js +96 -2
- package/dist/dedupe.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +144 -0
- package/dist/document.js.map +1 -1
- package/dist/envelope.d.ts +8 -0
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +106 -16
- package/dist/envelope.js.map +1 -1
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +33 -4
- package/dist/fixture.js.map +1 -1
- package/dist/html-report.d.ts +24 -7
- package/dist/html-report.d.ts.map +1 -1
- package/dist/html-report.js +608 -136
- package/dist/html-report.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts.map +1 -1
- package/dist/insights.js +9 -12
- package/dist/insights.js.map +1 -1
- package/dist/links.d.ts +43 -0
- package/dist/links.d.ts.map +1 -1
- package/dist/links.js +43 -0
- package/dist/links.js.map +1 -1
- package/dist/observe.d.ts +8 -0
- package/dist/observe.d.ts.map +1 -1
- package/dist/observe.js +45 -1
- package/dist/observe.js.map +1 -1
- package/dist/options.d.ts +53 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +3 -0
- package/dist/options.js.map +1 -1
- package/dist/reading-level.d.ts +22 -1
- package/dist/reading-level.d.ts.map +1 -1
- package/dist/reading-level.js +122 -19
- package/dist/reading-level.js.map +1 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +31 -15
- package/dist/reporter.js.map +1 -1
- package/dist/scan.d.ts +36 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +16 -2
- package/dist/scan.js.map +1 -1
- package/dist/screens.d.ts +55 -4
- package/dist/screens.d.ts.map +1 -1
- package/dist/screens.js +243 -43
- package/dist/screens.js.map +1 -1
- package/dist/settle.d.ts +35 -0
- package/dist/settle.d.ts.map +1 -1
- package/dist/settle.js +43 -2
- package/dist/settle.js.map +1 -1
- package/dist/summary.d.ts +35 -0
- package/dist/summary.d.ts.map +1 -1
- package/dist/summary.js +248 -28
- package/dist/summary.js.map +1 -1
- package/dist/unscannable-reasons.d.ts +30 -0
- package/dist/unscannable-reasons.d.ts.map +1 -0
- package/dist/unscannable-reasons.js +53 -0
- package/dist/unscannable-reasons.js.map +1 -0
- package/dist/viewports.d.ts +105 -0
- package/dist/viewports.d.ts.map +1 -0
- package/dist/viewports.js +134 -0
- package/dist/viewports.js.map +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import { ABBREVIATIONS_HELP_URL, CAPTIONS_PRERECORDED_HELP_URL, HEADINGS_AND_LABELS_HELP_URL, LOCATION_HELP_URL, MEANINGFUL_SEQUENCE_HELP_URL, SECTION_HEADINGS_HELP_URL, SENSORY_CHARACTERISTICS_HELP_URL, } from '../links.js';
|
|
2
|
+
import { sanitiseSelectors } from '../sanitise.js';
|
|
3
|
+
/**
|
|
4
|
+
* Seven heuristics for criteria no rule can decide.
|
|
5
|
+
*
|
|
6
|
+
* Every one follows the shape `reading-level.ts` established, and following it
|
|
7
|
+
* is the point of grouping them:
|
|
8
|
+
*
|
|
9
|
+
* 1. **A judgement is `incomplete`; a measurement is a `violation`.** This
|
|
10
|
+
* rule used to read "never a violation, always incomplete", and it was
|
|
11
|
+
* too broad. It is right for the heuristics that read English or guess
|
|
12
|
+
* intent — 1.3.2, 1.3.3, 2.4.6, 3.1.4 — where a customer who disproves
|
|
13
|
+
* one would stop trusting all of them, including the rules that are not
|
|
14
|
+
* heuristics at all.
|
|
15
|
+
*
|
|
16
|
+
* It was wrong for the ones that do arithmetic or look for a mechanism.
|
|
17
|
+
* 2.4.8 names four sufficient techniques and every one of them is a thing
|
|
18
|
+
* in the DOM; a page carrying none of them has a fact reported about it,
|
|
19
|
+
* not an opinion. Those use `fail()` rather than `note()`, at `minor`
|
|
20
|
+
* where the measure is a correlate and `moderate` where it is the thing
|
|
21
|
+
* the SC names. The caveat stays in the message either way — the change
|
|
22
|
+
* is which column it is answered in, and "needs a person", repeated
|
|
23
|
+
* across every page of a site, is a column nobody ever works through.
|
|
24
|
+
* 2. **Fire only above a margin**, and the margin is argued in a comment
|
|
25
|
+
* rather than chosen. Every one of these is a judgement about where the
|
|
26
|
+
* signal stops being noise, and a number nobody defended is a number
|
|
27
|
+
* somebody will quietly change.
|
|
28
|
+
* 3. **Say what would resolve it.** "This might be a problem" is not a
|
|
29
|
+
* finding; "add a `<track kind="captions">` or confirm the media has no
|
|
30
|
+
* speech" is.
|
|
31
|
+
* 4. **Language-gated where the heuristic is language-specific**, and silent
|
|
32
|
+
* rather than guessing otherwise. Three of these read English words. A
|
|
33
|
+
* page in Finnish gets nothing from them, and a phrase list applied to a
|
|
34
|
+
* language it was not written for manufactures findings.
|
|
35
|
+
*
|
|
36
|
+
* **Individually switchable**, because a customer who disagrees with one of
|
|
37
|
+
* these must not have to turn off the lot. They are read-only and can never
|
|
38
|
+
* fail a build, so they are on by default.
|
|
39
|
+
*
|
|
40
|
+
* The seven share ONE page evaluate. They are all DOM reads, and seven round
|
|
41
|
+
* trips per scan for seven cheap questions would be the wrong trade.
|
|
42
|
+
*/
|
|
43
|
+
export const SETSQUARE_HEURISTICS_VERSION = 'setsquare-heuristics@3';
|
|
44
|
+
export const HEURISTIC_RULE_IDS = {
|
|
45
|
+
meaningfulSequence: 'setsquare-meaningful-sequence',
|
|
46
|
+
sensoryCharacteristics: 'setsquare-sensory-characteristics',
|
|
47
|
+
headingsAndLabels: 'setsquare-headings-and-labels',
|
|
48
|
+
sectionHeadings: 'setsquare-section-headings',
|
|
49
|
+
abbreviations: 'setsquare-abbreviations',
|
|
50
|
+
location: 'setsquare-location',
|
|
51
|
+
captions: 'setsquare-captions',
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The margins, together, because they are the part of this file most likely to
|
|
55
|
+
* be argued with and they should be arguable in one place.
|
|
56
|
+
*/
|
|
57
|
+
const MARGINS = {
|
|
58
|
+
/**
|
|
59
|
+
* Three re-ordered items, not one. A single `order` on a flex child is a
|
|
60
|
+
* routine layout tweak (a logo before a nav on mobile); three or more is a
|
|
61
|
+
* layout whose reading order has been deliberately rearranged, which is when
|
|
62
|
+
* DOM order and visual order start to disagree in ways a screen-reader user
|
|
63
|
+
* notices.
|
|
64
|
+
*/
|
|
65
|
+
reorderedItems: 3,
|
|
66
|
+
/**
|
|
67
|
+
* Two thousand characters. A run of prose that long with no heading is past
|
|
68
|
+
* what anyone scans by ear — a screen-reader user navigating by heading has
|
|
69
|
+
* nothing to navigate to. Below it, headings are a style choice.
|
|
70
|
+
*/
|
|
71
|
+
unheadedChars: 2_000,
|
|
72
|
+
/**
|
|
73
|
+
* Three distinct all-caps tokens that have no expansion and no `<abbr>`. One
|
|
74
|
+
* or two are almost always a brand or a currency; three starts to look like
|
|
75
|
+
* jargon.
|
|
76
|
+
*
|
|
77
|
+
* The margin does less work than it used to, because the check now excludes
|
|
78
|
+
* anything the page expands in its own prose and anything already carrying
|
|
79
|
+
* an `<abbr>` — so what reaches here is already the residue rather than
|
|
80
|
+
* every capitalised token on the page.
|
|
81
|
+
*/
|
|
82
|
+
abbreviations: 3,
|
|
83
|
+
/**
|
|
84
|
+
* Two generic labels, or one empty heading. An empty heading is
|
|
85
|
+
* unambiguous — there is nothing to read — so it needs no margin. "More" or
|
|
86
|
+
* "Section" appearing once is usually fine in context; twice on one page and
|
|
87
|
+
* the headings have stopped describing anything.
|
|
88
|
+
*/
|
|
89
|
+
genericLabels: 2,
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The level A time-based-media criteria a page with no media does not engage.
|
|
93
|
+
*
|
|
94
|
+
* Only level A here: a finding carries one level, and the AA and AAA media
|
|
95
|
+
* criteria (1.2.4, 1.2.5, 1.2.7–1.2.9) need their own row rather than being
|
|
96
|
+
* smuggled under an A label. `captionsHigherLevels` below emits those.
|
|
97
|
+
*/
|
|
98
|
+
const MEDIA_CRITERIA_A = ['1.2.1', '1.2.2', '1.2.3'];
|
|
99
|
+
const MEDIA_CRITERIA_AA = ['1.2.4', '1.2.5'];
|
|
100
|
+
const MEDIA_CRITERIA_AAA = ['1.2.6', '1.2.7', '1.2.8', '1.2.9'];
|
|
101
|
+
/** English-only heuristics. See rule 4 above. */
|
|
102
|
+
const ENGLISH_ONLY = new Set([
|
|
103
|
+
'sensoryCharacteristics',
|
|
104
|
+
'headingsAndLabels',
|
|
105
|
+
'abbreviations',
|
|
106
|
+
]);
|
|
107
|
+
/**
|
|
108
|
+
* Read every signal in one pass. Returns undefined on any failure — a page we
|
|
109
|
+
* could not read produces no heuristic findings rather than empty ones.
|
|
110
|
+
*/
|
|
111
|
+
export async function readHeuristicSignals(page) {
|
|
112
|
+
try {
|
|
113
|
+
return await page.evaluate(collectSignals);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The seven verdicts, as a pure function of the signals.
|
|
121
|
+
*
|
|
122
|
+
* Separated from the reading for the reason `focus.ts` records at length: the
|
|
123
|
+
* unit lane cannot see a computed style, and the half of a check that decides
|
|
124
|
+
* what to CLAIM is the half where the honesty rules live.
|
|
125
|
+
*/
|
|
126
|
+
/**
|
|
127
|
+
* Heuristics whose finding is "this page carries none of the mechanisms".
|
|
128
|
+
*
|
|
129
|
+
* A claim about what is ABSENT is only as good as the page being finished, and
|
|
130
|
+
* these two read the whole document to make it: 2.4.8 asks whether anything —
|
|
131
|
+
* breadcrumb, `aria-current`, site map, `rel` link — says where you are, and
|
|
132
|
+
* 3.1.4 asks whether anything expands an abbreviation. Every other heuristic
|
|
133
|
+
* here reasons about content it can SEE, and a half-rendered page gives it less
|
|
134
|
+
* to look at rather than a different answer.
|
|
135
|
+
*
|
|
136
|
+
* Found by friction record run 4: the 2.4.8 cue fired on one run and not the
|
|
137
|
+
* next, on identical code, because an SPA's router had or had not painted its
|
|
138
|
+
* active state by the time the page was scanned. This file's own first rule is
|
|
139
|
+
* that a heuristic fires only above a margin, and one that flips on render
|
|
140
|
+
* timing does not clear it — so on a page we already know never went quiet, the
|
|
141
|
+
* honest answer is silence rather than a finding we cannot reproduce.
|
|
142
|
+
*/
|
|
143
|
+
const PAGE_ABSENCE_CLAIMS = new Set(['location', 'abbreviations']);
|
|
144
|
+
export function heuristicEvidence(signals, target, switches = {},
|
|
145
|
+
/**
|
|
146
|
+
* Had the page met the quiet definition when these signals were read?
|
|
147
|
+
*
|
|
148
|
+
* Defaults to true, so nothing is suppressed unless a caller can actually say
|
|
149
|
+
* otherwise — an explicit scan suppresses nothing, because the customer chose
|
|
150
|
+
* the moment and it is not ours to second-guess.
|
|
151
|
+
*/
|
|
152
|
+
pageWentQuiet = true) {
|
|
153
|
+
const english = signals.lang.toLowerCase().startsWith('en');
|
|
154
|
+
const enabled = (id) => {
|
|
155
|
+
if (switches[id] === false)
|
|
156
|
+
return false;
|
|
157
|
+
// Silent, not a pass: the page never finished, so we did not look at the
|
|
158
|
+
// whole of it, and "we saw no mechanism" would be a claim about our timing.
|
|
159
|
+
if (!pageWentQuiet && PAGE_ABSENCE_CLAIMS.has(id))
|
|
160
|
+
return false;
|
|
161
|
+
// Silent rather than guessing: a phrase list applied to a language it was
|
|
162
|
+
// not written for manufactures findings, and a missing `lang` is already
|
|
163
|
+
// an axe violation (3.1.1) rather than this check's problem to solve.
|
|
164
|
+
if (ENGLISH_ONLY.has(id) && !english)
|
|
165
|
+
return false;
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
const evidence = [];
|
|
169
|
+
const add = (id, build) => {
|
|
170
|
+
if (!enabled(id))
|
|
171
|
+
return;
|
|
172
|
+
const result = build();
|
|
173
|
+
if (result)
|
|
174
|
+
evidence.push(result);
|
|
175
|
+
};
|
|
176
|
+
add('meaningfulSequence', () => meaningfulSequence(signals));
|
|
177
|
+
add('sensoryCharacteristics', () => sensoryCharacteristics(signals));
|
|
178
|
+
add('headingsAndLabels', () => headingsAndLabels(signals));
|
|
179
|
+
add('captions', () => captions(signals));
|
|
180
|
+
add('captions', () => mediaLevel(signals, MEDIA_CRITERIA_AA, 'AA'));
|
|
181
|
+
if (target === 'AAA') {
|
|
182
|
+
add('captions', () => mediaLevel(signals, MEDIA_CRITERIA_AAA, 'AAA'));
|
|
183
|
+
add('sectionHeadings', () => sectionHeadings(signals));
|
|
184
|
+
add('abbreviations', () => abbreviations(signals));
|
|
185
|
+
add('location', () => locationCue(signals));
|
|
186
|
+
}
|
|
187
|
+
return evidence;
|
|
188
|
+
}
|
|
189
|
+
function note(ruleId, criteria, level, helpUrl, failureSummary, selectors) {
|
|
190
|
+
const finding = {
|
|
191
|
+
ruleId,
|
|
192
|
+
// A judgement, so it asks. `fail()` is the sibling for the ones that
|
|
193
|
+
// measure. See rule 1 at the top of this file.
|
|
194
|
+
status: 'incomplete',
|
|
195
|
+
impact: 'moderate',
|
|
196
|
+
wcag: { criteria, level, versionTags: [] },
|
|
197
|
+
helpUrl,
|
|
198
|
+
evidence: { failureSummary },
|
|
199
|
+
targets: { nodeCount: Math.max(selectors.length, 1), ...sanitiseSelectors(selectors) },
|
|
200
|
+
};
|
|
201
|
+
return { finding };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The same finding as `note()`, reported as a failure.
|
|
205
|
+
*
|
|
206
|
+
* Separate function rather than a flag on `note()`, so that the choice is
|
|
207
|
+
* visible at every call site: reading a check, you can see in one word whether
|
|
208
|
+
* it decides or asks, and a check that quietly changed from one to the other
|
|
209
|
+
* shows up in a diff as what it is.
|
|
210
|
+
*/
|
|
211
|
+
function fail(ruleId, criteria, level, helpUrl, impact, failureSummary, selectors) {
|
|
212
|
+
const finding = {
|
|
213
|
+
ruleId,
|
|
214
|
+
status: 'violation',
|
|
215
|
+
impact,
|
|
216
|
+
wcag: { criteria, level, versionTags: [] },
|
|
217
|
+
helpUrl,
|
|
218
|
+
evidence: { failureSummary },
|
|
219
|
+
targets: { nodeCount: Math.max(selectors.length, 1), ...sanitiseSelectors(selectors) },
|
|
220
|
+
};
|
|
221
|
+
return { finding };
|
|
222
|
+
}
|
|
223
|
+
/** 1.3.2 Meaningful Sequence (A). */
|
|
224
|
+
function meaningfulSequence(signals) {
|
|
225
|
+
if (signals.reordered.length < MARGINS.reorderedItems)
|
|
226
|
+
return undefined;
|
|
227
|
+
return note(HEURISTIC_RULE_IDS.meaningfulSequence, ['1.3.2'], 'A', MEANINGFUL_SEQUENCE_HELP_URL, `${signals.reordered.length} elements are moved by CSS \`order\` or a reversed flex or grid ` +
|
|
228
|
+
`container, so what a sighted reader sees is not the order a screen reader announces. ` +
|
|
229
|
+
`Whether that changes the MEANING is a judgement about the content, which is why this is ` +
|
|
230
|
+
`not reported as a failure. Resolve it by reading the page with the stylesheet off, or by ` +
|
|
231
|
+
`putting the DOM in the order the page reads in.`, signals.reordered);
|
|
232
|
+
}
|
|
233
|
+
/** 1.3.3 Sensory Characteristics (A). */
|
|
234
|
+
/**
|
|
235
|
+
* 1.3.3 Sensory Characteristics (A) — a failure, because "solely" is readable.
|
|
236
|
+
*
|
|
237
|
+
* The extraction above applies the criterion's own two conditions: the sentence
|
|
238
|
+
* is an instruction, and the sensory phrase is the only thing in it identifying
|
|
239
|
+
* the target. A sentence failing either test never reaches here. So what is
|
|
240
|
+
* left is an instruction nobody can follow without seeing the layout, which is
|
|
241
|
+
* the criterion rather than a suspicion about it.
|
|
242
|
+
*/
|
|
243
|
+
function sensoryCharacteristics(signals) {
|
|
244
|
+
if (signals.sensory.length === 0)
|
|
245
|
+
return undefined;
|
|
246
|
+
const first = signals.sensory[0];
|
|
247
|
+
return fail(HEURISTIC_RULE_IDS.sensoryCharacteristics, ['1.3.3'], 'A', SENSORY_CHARACTERISTICS_HELP_URL, 'moderate', `An instruction identifies its target by "${first.phrase}" and by nothing else — ` +
|
|
248
|
+
`"${first.sentence}". ${signals.sensory.length} found. The sentence reads as an ` +
|
|
249
|
+
`instruction and carries no name, no quoted label and no code for the thing it is ` +
|
|
250
|
+
`pointing at, so somebody who cannot see the layout cannot follow it; a sentence that ` +
|
|
251
|
+
`names its target, or that describes rather than instructs, is not reported. Resolve it ` +
|
|
252
|
+
`by adding the name: "the Save button on the right" rather than "the button on the right".`, signals.sensory.map((row) => row.selector));
|
|
253
|
+
}
|
|
254
|
+
/** 2.4.6 Headings and Labels (AA). */
|
|
255
|
+
function headingsAndLabels(signals) {
|
|
256
|
+
const empty = signals.weakHeadings.filter((row) => row.why === 'empty');
|
|
257
|
+
const rest = signals.weakHeadings.filter((row) => row.why !== 'empty');
|
|
258
|
+
if (empty.length === 0 && rest.length < MARGINS.genericLabels)
|
|
259
|
+
return undefined;
|
|
260
|
+
const rows = [...empty, ...rest];
|
|
261
|
+
const first = rows[0];
|
|
262
|
+
return note(HEURISTIC_RULE_IDS.headingsAndLabels, ['2.4.6'], 'AA', HEADINGS_AND_LABELS_HELP_URL, `${rows.length} heading(s) or label(s) do not describe what they head: ${first.selector} is ` +
|
|
263
|
+
`${first.why}. 2.4.6 asks that they be descriptive, which is a judgement about the content ` +
|
|
264
|
+
`they introduce — so this points, and a person decides. Resolve it by naming the thing: ` +
|
|
265
|
+
`"Billing address" rather than "Section", "Read the release notes" rather than "More".`, rows.map((row) => row.selector));
|
|
266
|
+
}
|
|
267
|
+
/** 2.4.10 Section Headings (AAA). */
|
|
268
|
+
/**
|
|
269
|
+
* 2.4.10 Section Headings (AAA) — two answers, and only one of them used to
|
|
270
|
+
* exist.
|
|
271
|
+
*
|
|
272
|
+
* **A declared section with no name is a failure.** `<section>`, `<article>`
|
|
273
|
+
* and `role="region"` are the author stating that the content divides into
|
|
274
|
+
* sections. Once that is stated, "where content is organized into sections"
|
|
275
|
+
* is not a judgement any more and the criterion applies directly. This was
|
|
276
|
+
* not checked at all; the whole criterion rested on the character count below.
|
|
277
|
+
*
|
|
278
|
+
* **A long unheaded run is a warning.** Nothing in the SC names a character
|
|
279
|
+
* count — 2,000 is ours — so this is a correlate and reports at `minor` with
|
|
280
|
+
* that said in the message. It is still reported rather than asked, because a
|
|
281
|
+
* question repeated on every long page of a site is one nobody answers.
|
|
282
|
+
*/
|
|
283
|
+
function sectionHeadings(signals) {
|
|
284
|
+
if (signals.unheadedSections.length > 0) {
|
|
285
|
+
return fail(HEURISTIC_RULE_IDS.sectionHeadings, ['2.4.10'], 'AAA', SECTION_HEADINGS_HELP_URL, 'moderate', `${signals.unheadedSections.length} element(s) declare themselves a section — ` +
|
|
286
|
+
`\`<section>\`, \`<article>\` or \`role="region"\` — and carry no heading and no ` +
|
|
287
|
+
`\`aria-label\`. The markup has already divided this content into sections, so the ` +
|
|
288
|
+
`judgement 2.4.10 normally turns on does not arise here: the sections exist and are ` +
|
|
289
|
+
`unnamed. Resolve it with a heading inside each, or an \`aria-label\` where the ` +
|
|
290
|
+
`section genuinely has no visible title — or use a \`<div>\`, if it was never a ` +
|
|
291
|
+
`section.`, signals.unheadedSections);
|
|
292
|
+
}
|
|
293
|
+
if (signals.unheadedChars < MARGINS.unheadedChars)
|
|
294
|
+
return undefined;
|
|
295
|
+
return fail(HEURISTIC_RULE_IDS.sectionHeadings, ['2.4.10'], 'AAA', SECTION_HEADINGS_HELP_URL, 'minor', `A run of roughly ${signals.unheadedChars} characters carries no heading, so somebody ` +
|
|
296
|
+
`navigating by heading has nothing to navigate to across it. Reported as a warning ` +
|
|
297
|
+
`rather than a plain failure because the character count is ours, not the SC's: 2.4.10 ` +
|
|
298
|
+
`applies where content divides into sections, and a single continuous argument ` +
|
|
299
|
+
`legitimately has none. Resolve it by adding headings where the subject changes, or by ` +
|
|
300
|
+
`recording that this page is one section.`, ['main']);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 3.1.4 Abbreviations (AAA) — a warning, because the mechanism half is exact.
|
|
304
|
+
*
|
|
305
|
+
* The criterion asks for A MECHANISM, and every mechanism it accepts is a thing
|
|
306
|
+
* in the page: an `<abbr>`, an expansion in the prose, or a glossary. All three
|
|
307
|
+
* are now looked for, so "this token has no mechanism" is a fact rather than a
|
|
308
|
+
* suspicion. The check used to decline on the grounds that "3.1.4 also accepts
|
|
309
|
+
* a glossary this check cannot see", which was the 2.4.8 excuse one criterion
|
|
310
|
+
* along.
|
|
311
|
+
*
|
|
312
|
+
* What stays a correlate is the other half: an all-capital token STANDS IN for
|
|
313
|
+
* an abbreviation, and a page can capitalise something that is neither an
|
|
314
|
+
* abbreviation nor an acronym. That is why this is `minor` and why the tokens
|
|
315
|
+
* are named in the message — an author who thinks it is wrong can see in one
|
|
316
|
+
* line which words we meant, which is a different conversation from being told
|
|
317
|
+
* a criterion needs a person.
|
|
318
|
+
*/
|
|
319
|
+
function abbreviations(signals) {
|
|
320
|
+
if (signals.abbreviations.length < MARGINS.abbreviations)
|
|
321
|
+
return undefined;
|
|
322
|
+
// A glossary answers the criterion for every abbreviation at once, which is
|
|
323
|
+
// exactly why the SC lists it: it is one mechanism covering the page.
|
|
324
|
+
if (signals.hasGlossary)
|
|
325
|
+
return undefined;
|
|
326
|
+
return fail(HEURISTIC_RULE_IDS.abbreviations, ['3.1.4'], 'AAA', ABBREVIATIONS_HELP_URL, 'minor', `${signals.abbreviations.length} all-capital token(s) appear with no expansion anywhere in ` +
|
|
327
|
+
`the page's text, no \`<abbr>\` of their own, and no glossary linked from the page: ` +
|
|
328
|
+
`${signals.abbreviations.slice(0, 6).join(', ')}. Tokens the prose expands — ` +
|
|
329
|
+
`"Web Content Accessibility Guidelines (WCAG)" — and tokens already wrapped in ` +
|
|
330
|
+
`\`<abbr>\` are excluded, so these are the ones with no mechanism at all. Reported as a ` +
|
|
331
|
+
`warning rather than a plain failure because an all-capital token is a correlate for an ` +
|
|
332
|
+
`abbreviation and a page may capitalise something that is neither — the tokens are named ` +
|
|
333
|
+
`above so you can see in one line whether we have that wrong. This is usually a small ` +
|
|
334
|
+
`fix. Resolve it with one \`<abbr title="…">\` anywhere on the page, which clears that ` +
|
|
335
|
+
`token for the whole page — or, once, with a glossary linked from a shared footer, which ` +
|
|
336
|
+
`clears every token on every page including the pages not written yet.`, ['body']);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* 2.4.8 Location (AAA) — a failure, because every mechanism is visible.
|
|
340
|
+
*
|
|
341
|
+
* The SC's sufficient techniques are a breadcrumb, a site map, the current
|
|
342
|
+
* item marked in a navigation bar, and `rel` links stating the page's place in
|
|
343
|
+
* a collection. All four are things in the DOM, and the signal extraction now
|
|
344
|
+
* looks for all four rather than two. A page that is inside a set of pages and
|
|
345
|
+
* carries none of them does not make its location available — that is a
|
|
346
|
+
* statement about the markup, not an opinion about it.
|
|
347
|
+
*
|
|
348
|
+
* The one judgement left is whether the page is part of a set, and `hasNav`
|
|
349
|
+
* stands in for it: a page with navigation to other pages is in a set, and the
|
|
350
|
+
* front door of a site does not need to say where you are.
|
|
351
|
+
*/
|
|
352
|
+
function locationCue(signals) {
|
|
353
|
+
const { hasNav, hasBreadcrumb, hasCurrent, hasSiteMap, hasRelLink, isRoot } = signals.location;
|
|
354
|
+
if (!hasNav || isRoot)
|
|
355
|
+
return undefined;
|
|
356
|
+
if (hasBreadcrumb || hasCurrent || hasSiteMap || hasRelLink)
|
|
357
|
+
return undefined;
|
|
358
|
+
return fail(HEURISTIC_RULE_IDS.location, ['2.4.8'], 'AAA', LOCATION_HELP_URL, 'moderate', `This page has navigation to other pages but carries none of the mechanisms 2.4.8 is met ` +
|
|
359
|
+
`by: no breadcrumb trail, no \`aria-current\` on any navigation item, no link to a site ` +
|
|
360
|
+
`map, and no \`rel\` stating where the page sits in a collection. Somebody arriving here ` +
|
|
361
|
+
`from a search result has nothing telling them where they are. Resolve it with ` +
|
|
362
|
+
`\`aria-current="page"\` on the navigation item for this page, which is usually already ` +
|
|
363
|
+
`styled as the active one — the styling is the part that is generally there and the ` +
|
|
364
|
+
`attribute is the part that is generally missing.`, ['nav']);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The time-based-media criteria: 1.2.1 through 1.2.9.
|
|
368
|
+
*
|
|
369
|
+
* Two answers, and the second one was missing for long enough to matter.
|
|
370
|
+
*
|
|
371
|
+
* **A page with media and no caption track** is the finding: whether captions
|
|
372
|
+
* are correct is not something any check can judge, whether there are none is,
|
|
373
|
+
* and "no automated result" was a much weaker answer than the one available.
|
|
374
|
+
*
|
|
375
|
+
* **A page with no media at all** is INAPPLICABLE, not silence. The page
|
|
376
|
+
* contains nothing these criteria regulate, we looked, and that is a real
|
|
377
|
+
* observation — the same distinction as "scanned and found nothing" versus
|
|
378
|
+
* "never scanned", which this repository cares about everywhere else. Without
|
|
379
|
+
* it, seven criteria read "no automated result" on a site that has never had a
|
|
380
|
+
* video on it, which is the least useful true thing we could have said.
|
|
381
|
+
*
|
|
382
|
+
* An iframe, object or embed stops the claim. An embedded player is media we
|
|
383
|
+
* cannot see, and inapplicability asserted over a thing we did not look inside
|
|
384
|
+
* is exactly the confident wrong answer this file's discipline exists to
|
|
385
|
+
* prevent.
|
|
386
|
+
*/
|
|
387
|
+
function captions(signals) {
|
|
388
|
+
if (signals.uncaptioned.length === 0) {
|
|
389
|
+
if (signals.media.elements > 0 || signals.media.frames > 0)
|
|
390
|
+
return undefined;
|
|
391
|
+
return {
|
|
392
|
+
inapplicable: {
|
|
393
|
+
ruleId: HEURISTIC_RULE_IDS.captions,
|
|
394
|
+
wcag: { criteria: [...MEDIA_CRITERIA_A], level: 'A', versionTags: [] },
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
return note(HEURISTIC_RULE_IDS.captions, ['1.2.1', '1.2.2'], 'A', CAPTIONS_PRERECORDED_HELP_URL, `${signals.uncaptioned.length} media element(s) carry no \`<track kind="captions">\` or ` +
|
|
399
|
+
`\`kind="subtitles">\`. Whether captions are CORRECT is not something any check can ` +
|
|
400
|
+
`judge; whether there are none is, and there are none here. Media with no speech and no ` +
|
|
401
|
+
`meaningful sound needs no captions, which is the case a person confirms. Resolve it by ` +
|
|
402
|
+
`adding a caption track, or by confirming the media is silent.`, signals.uncaptioned);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The AA and AAA media criteria, for a page with no media.
|
|
406
|
+
*
|
|
407
|
+
* A separate row per level because a finding carries ONE level, and reporting
|
|
408
|
+
* 1.2.5 under a level A label would misstate what was assessed. Emits nothing
|
|
409
|
+
* where media exists: whether an audio description is adequate is a judgement
|
|
410
|
+
* no check makes, so there is nothing to say beyond the caption-track finding
|
|
411
|
+
* above.
|
|
412
|
+
*/
|
|
413
|
+
function mediaLevel(signals, criteria, level) {
|
|
414
|
+
if (signals.media.elements > 0 || signals.media.frames > 0)
|
|
415
|
+
return undefined;
|
|
416
|
+
return {
|
|
417
|
+
inapplicable: {
|
|
418
|
+
ruleId: `${HEURISTIC_RULE_IDS.captions}-${level.toLowerCase()}`,
|
|
419
|
+
wcag: { criteria: [...criteria], level, versionTags: [] },
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The in-page half. One evaluate, seven questions, no writes.
|
|
425
|
+
*
|
|
426
|
+
* Kept as a named function passed to `evaluate` rather than a source string so
|
|
427
|
+
* it typechecks; it must not close over anything in this module.
|
|
428
|
+
*/
|
|
429
|
+
function collectSignals() {
|
|
430
|
+
const step = (el) => {
|
|
431
|
+
const tag = el.tagName.toLowerCase();
|
|
432
|
+
if (el.id)
|
|
433
|
+
return `${tag}#${el.id}`;
|
|
434
|
+
if (el.classList.length > 0)
|
|
435
|
+
return `${tag}.${el.classList[0]}`;
|
|
436
|
+
const parent = el.parentElement;
|
|
437
|
+
if (!parent)
|
|
438
|
+
return tag;
|
|
439
|
+
return `${tag}:nth-child(${Array.prototype.indexOf.call(parent.children, el) + 1})`;
|
|
440
|
+
};
|
|
441
|
+
const describe = (el) => {
|
|
442
|
+
const parts = [];
|
|
443
|
+
let node = el;
|
|
444
|
+
for (let depth = 0; node && depth < 3; depth += 1) {
|
|
445
|
+
parts.unshift(step(node));
|
|
446
|
+
if (node.id || node.classList.length > 0)
|
|
447
|
+
break;
|
|
448
|
+
node = node.parentElement;
|
|
449
|
+
if (!node || node === document.body)
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
return parts.join(' > ');
|
|
453
|
+
};
|
|
454
|
+
const textOf = (el) => (el.innerText ?? el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
455
|
+
/* --- 1.3.2: DOM order versus visual order --- */
|
|
456
|
+
const reordered = [];
|
|
457
|
+
for (const el of Array.from(document.body.querySelectorAll('*')).slice(0, 2000)) {
|
|
458
|
+
if (reordered.length >= 10)
|
|
459
|
+
break;
|
|
460
|
+
const cs = window.getComputedStyle(el);
|
|
461
|
+
const order = cs.order;
|
|
462
|
+
if (order && order !== '0' && order !== 'normal') {
|
|
463
|
+
reordered.push(describe(el));
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (cs.flexDirection === 'row-reverse' || cs.flexDirection === 'column-reverse') {
|
|
467
|
+
reordered.push(describe(el));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/* --- 1.3.3: instructions relying on shape, size or position alone --- */
|
|
471
|
+
const SENSORY = [
|
|
472
|
+
'the button on the right',
|
|
473
|
+
'the button on the left',
|
|
474
|
+
'on the right',
|
|
475
|
+
'on the left',
|
|
476
|
+
'the round button',
|
|
477
|
+
'the round icon',
|
|
478
|
+
'the square icon',
|
|
479
|
+
'click below',
|
|
480
|
+
'see below',
|
|
481
|
+
'the box above',
|
|
482
|
+
'the link above',
|
|
483
|
+
'the green button',
|
|
484
|
+
'the red button',
|
|
485
|
+
'in the top right',
|
|
486
|
+
'in the top left',
|
|
487
|
+
'the icon shaped like',
|
|
488
|
+
];
|
|
489
|
+
/*
|
|
490
|
+
* The two tests the SC actually states, applied to the SENTENCE.
|
|
491
|
+
*
|
|
492
|
+
* 1.3.3 is about instructions that rely SOLELY on a sensory characteristic.
|
|
493
|
+
* The check used to find the phrase and stop, then say "that is only a
|
|
494
|
+
* failure if the instruction cannot be followed without seeing the layout,
|
|
495
|
+
* which depends on what else the sentence says, so a person decides" — while
|
|
496
|
+
* its own next line told the author the answer: *"the Save button on the
|
|
497
|
+
* right" rather than "the button on the right"*. What the sentence says is
|
|
498
|
+
* readable, and the resolution it printed is the test it declined to run.
|
|
499
|
+
*
|
|
500
|
+
* So, per phrase found:
|
|
501
|
+
*
|
|
502
|
+
* 1. Is the sentence an INSTRUCTION? The criterion governs instructions for
|
|
503
|
+
* understanding and operating content, not description. "Review, on the
|
|
504
|
+
* left, runs now" is a statement about a product, and an imperative is
|
|
505
|
+
* how an instruction reads in English.
|
|
506
|
+
* 2. Does it rely SOLELY on the sensory characteristic? A quoted label, a
|
|
507
|
+
* capitalised name that is not the first word, or a `code`/`kbd` in the
|
|
508
|
+
* sentence is another way to identify the thing — and the SC's word is
|
|
509
|
+
* "solely".
|
|
510
|
+
*
|
|
511
|
+
* Both true is a failure. Either false is silence, not needs-review: a
|
|
512
|
+
* description is out of scope and a named control satisfies the criterion.
|
|
513
|
+
* Still English-only, which is why the whole group is language-gated.
|
|
514
|
+
*/
|
|
515
|
+
const IMPERATIVES = 'click|select|press|choose|tap|use|see|open|enter|type|find|look|go|pick|drag|drop|tick|check|scroll|follow|read|expand|collapse|copy|paste|set|add|remove|switch|toggle';
|
|
516
|
+
/*
|
|
517
|
+
* An imperative opens a sentence, follows a comma, or follows a connective.
|
|
518
|
+
*
|
|
519
|
+
* The position is the whole test: it is what separates "press the button on
|
|
520
|
+
* the right" from "the column on the right is what that looks like". A bare
|
|
521
|
+
* word-boundary match on the verb list would catch every descriptive
|
|
522
|
+
* sentence containing "use" or "see", which is how a heuristic earns its
|
|
523
|
+
* reputation. "To continue, press …" is why the comma is in here — the first
|
|
524
|
+
* version had only sentence-start and connectives, and missed the commonest
|
|
525
|
+
* shape an instruction takes.
|
|
526
|
+
*/
|
|
527
|
+
const INSTRUCTION = new RegExp(`(^|[.;:!?,—]\\s*|\\b(?:to|then|and|please|now|first|next|you can|you must)\\s+)(?:${IMPERATIVES})\\b`, 'i');
|
|
528
|
+
/*
|
|
529
|
+
* Is there another way to identify the thing? A quoted label, or a
|
|
530
|
+
* capitalised word that is not the sentence's first word — "press the Save
|
|
531
|
+
* button on the right" names its target and "press the button on the right"
|
|
532
|
+
* does not.
|
|
533
|
+
*
|
|
534
|
+
* Written as a word scan rather than one regex because the regex version was
|
|
535
|
+
* wrong in a way that read as correct: `\b[a-z]` requires the lowercase
|
|
536
|
+
* character to START a word, so "the Save" never matched — the `e` it needed
|
|
537
|
+
* is the end of "the". Every named control was reported as unnamed.
|
|
538
|
+
*
|
|
539
|
+
* It errs toward silence: a proper noun anywhere in the sentence counts, so
|
|
540
|
+
* "click the button on the right to open Settings" is not reported even
|
|
541
|
+
* though Settings is the destination rather than the control. On a heuristic
|
|
542
|
+
* that is the right direction to be wrong in.
|
|
543
|
+
*/
|
|
544
|
+
const named = (sentence) => {
|
|
545
|
+
if (/["“'‘][^"”'’]{2,}["”'’]/.test(sentence))
|
|
546
|
+
return true;
|
|
547
|
+
const words = sentence.split(/\s+/).slice(1);
|
|
548
|
+
return words.some((word) => /^[A-Z][a-zA-Z]/.test(word.replace(/^[^A-Za-z]+/, '')));
|
|
549
|
+
};
|
|
550
|
+
const sensory = [];
|
|
551
|
+
for (const el of Array.from(document.querySelectorAll('p, li, label, td, div')).slice(0, 800)) {
|
|
552
|
+
if (sensory.length >= 10)
|
|
553
|
+
break;
|
|
554
|
+
// Leaf-ish only, so a wrapper does not report its children's sentence.
|
|
555
|
+
if (el.querySelector('p, li, label, td'))
|
|
556
|
+
continue;
|
|
557
|
+
const full = textOf(el);
|
|
558
|
+
if (full.length === 0 || full.length > 400)
|
|
559
|
+
continue;
|
|
560
|
+
const lower = full.toLowerCase();
|
|
561
|
+
const phrase = SENSORY.find((candidate) => lower.includes(candidate));
|
|
562
|
+
if (!phrase)
|
|
563
|
+
continue;
|
|
564
|
+
// The sentence carrying the phrase, not the whole paragraph: a name three
|
|
565
|
+
// sentences away is not what identifies this control.
|
|
566
|
+
const sentence = full.split(/(?<=[.!?])\s+/).find((part) => part.toLowerCase().includes(phrase)) ?? full;
|
|
567
|
+
if (!INSTRUCTION.test(sentence))
|
|
568
|
+
continue;
|
|
569
|
+
if (named(sentence) || el.querySelector('code, kbd'))
|
|
570
|
+
continue;
|
|
571
|
+
sensory.push({ selector: describe(el), phrase, sentence: sentence.slice(0, 160) });
|
|
572
|
+
}
|
|
573
|
+
/* --- 2.4.6: headings and labels that say nothing --- */
|
|
574
|
+
const GENERIC = new Set([
|
|
575
|
+
'more',
|
|
576
|
+
'section',
|
|
577
|
+
'input',
|
|
578
|
+
'field',
|
|
579
|
+
'details',
|
|
580
|
+
'info',
|
|
581
|
+
'information',
|
|
582
|
+
'text',
|
|
583
|
+
'title',
|
|
584
|
+
'label',
|
|
585
|
+
'heading',
|
|
586
|
+
'untitled',
|
|
587
|
+
'read more',
|
|
588
|
+
'learn more',
|
|
589
|
+
'click here',
|
|
590
|
+
]);
|
|
591
|
+
const weakHeadings = [];
|
|
592
|
+
const seenHeading = new Map();
|
|
593
|
+
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6, legend, label'));
|
|
594
|
+
for (const el of headings.slice(0, 200)) {
|
|
595
|
+
if (weakHeadings.length >= 10)
|
|
596
|
+
break;
|
|
597
|
+
const text = textOf(el);
|
|
598
|
+
if (text.length === 0) {
|
|
599
|
+
weakHeadings.push({ selector: describe(el), why: 'empty' });
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const lower = text.toLowerCase();
|
|
603
|
+
if (GENERIC.has(lower)) {
|
|
604
|
+
weakHeadings.push({ selector: describe(el), why: `generic ("${text}")` });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const key = `${el.tagName}:${lower}`;
|
|
608
|
+
const count = (seenHeading.get(key) ?? 0) + 1;
|
|
609
|
+
seenHeading.set(key, count);
|
|
610
|
+
if (count === 2)
|
|
611
|
+
weakHeadings.push({ selector: describe(el), why: `a duplicate of "${text}"` });
|
|
612
|
+
}
|
|
613
|
+
/* --- 2.4.10: sections the author declared and did not head --- */
|
|
614
|
+
/*
|
|
615
|
+
* The half this check was not doing at all, and the only half that needs no
|
|
616
|
+
* judgement. `<section>` and `role="region"` exist to say "this content is a
|
|
617
|
+
* section" — the author has already made the division the SC asks about. So
|
|
618
|
+
* "content is organized into sections" is not in doubt for these, and the
|
|
619
|
+
* criterion's requirement lands squarely: the section gets a heading.
|
|
620
|
+
*
|
|
621
|
+
* `<article>` counts too, for the same reason. `<nav>`, `<aside>`, `<form>`
|
|
622
|
+
* and `<header>`/`<footer>` do not: they are landmarks with their own naming
|
|
623
|
+
* conventions, and a nav without a visible heading is normal markup rather
|
|
624
|
+
* than an unheaded section of content.
|
|
625
|
+
*
|
|
626
|
+
* An `aria-label` or `aria-labelledby` satisfies it as well as a heading
|
|
627
|
+
* does. The requirement is that the section is named, not that the name is
|
|
628
|
+
* an `<h2>`.
|
|
629
|
+
*/
|
|
630
|
+
const unheadedSections = [];
|
|
631
|
+
for (const el of Array.from(document.querySelectorAll('section, article, [role="region"]'))) {
|
|
632
|
+
if (el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby'))
|
|
633
|
+
continue;
|
|
634
|
+
if (el.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]'))
|
|
635
|
+
continue;
|
|
636
|
+
if (unheadedSections.length < 20)
|
|
637
|
+
unheadedSections.push(describe(el));
|
|
638
|
+
}
|
|
639
|
+
/* --- 2.4.10: the longest run of text with no heading --- */
|
|
640
|
+
const main = document.querySelector('main, [role="main"]') ?? document.body;
|
|
641
|
+
let unheadedChars = 0;
|
|
642
|
+
let run = 0;
|
|
643
|
+
/*
|
|
644
|
+
* UNLABELLED prose, which is not the same as everything with text in it.
|
|
645
|
+
*
|
|
646
|
+
* The run used to count `td` and `dd`, and both are wrong for the same
|
|
647
|
+
* reason: they already carry a label. A `<td>` is named by its column and
|
|
648
|
+
* row headers, a `<dd>` by its `<dt>`, and a screen reader announces both —
|
|
649
|
+
* so that content is organised and labelled, which is the shape 2.4.10 asks
|
|
650
|
+
* for rather than a breach of it.
|
|
651
|
+
*
|
|
652
|
+
* What it cost: on this repository's own site three of the four findings
|
|
653
|
+
* were this. The pricing capability table and the evidence page's surface
|
|
654
|
+
* table each read as two and a half thousand characters of unheaded prose,
|
|
655
|
+
* and the evidence page's definition list of criteria read as another three
|
|
656
|
+
* thousand. None of them wants a heading between its rows.
|
|
657
|
+
*
|
|
658
|
+
* `reading-level.ts` already drops tables for the same reason, which is the
|
|
659
|
+
* tell that this was one mistake made twice in different units. Excluded by
|
|
660
|
+
* ancestor rather than by tag, because the text sits in a `<p>` inside the
|
|
661
|
+
* cell as often as in the cell itself.
|
|
662
|
+
*
|
|
663
|
+
* What is left is `p` and `li`: running prose with nothing naming it but the
|
|
664
|
+
* heading above it, which is exactly what the criterion is about.
|
|
665
|
+
*/
|
|
666
|
+
const labelled = (el) => el.closest('table, [role="table"], [role="grid"], dl, [role="term"], [role="definition"]') !==
|
|
667
|
+
null;
|
|
668
|
+
for (const el of Array.from(main.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li'))) {
|
|
669
|
+
if (/^H[1-6]$/.test(el.tagName)) {
|
|
670
|
+
unheadedChars = Math.max(unheadedChars, run);
|
|
671
|
+
run = 0;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (labelled(el))
|
|
675
|
+
continue;
|
|
676
|
+
run += textOf(el).length;
|
|
677
|
+
}
|
|
678
|
+
unheadedChars = Math.max(unheadedChars, run);
|
|
679
|
+
/* --- 3.1.4: abbreviations with no expansion and no <abbr> --- */
|
|
680
|
+
const abbreviations = [];
|
|
681
|
+
{
|
|
682
|
+
/*
|
|
683
|
+
* AUTHORED text, not rendered text.
|
|
684
|
+
*
|
|
685
|
+
* `innerText` returns what CSS painted, so an eyebrow set in
|
|
686
|
+
* `text-transform: uppercase` comes back as an abbreviation. On this
|
|
687
|
+
* repository's own site that reported THE, GAP, REVIEW and US as
|
|
688
|
+
* abbreviations needing expansion — ordinary words the stylesheet had
|
|
689
|
+
* shouted. `textContent` is what somebody typed.
|
|
690
|
+
*/
|
|
691
|
+
/*
|
|
692
|
+
* Text nodes joined with a SPACE, not `main.textContent`.
|
|
693
|
+
*
|
|
694
|
+
* `textContent` concatenates across element boundaries with no separator,
|
|
695
|
+
* so it invents tokens that are not on the page. On this repository's own
|
|
696
|
+
* docs a heading ending "…at AAA rather than AA" ran into a paragraph
|
|
697
|
+
* starting "AA is the default…" and the check reported `AAAA` — a string
|
|
698
|
+
* nobody wrote, in a finding telling the author to expand it. The same
|
|
699
|
+
* glue turned an ordinal into `02Assess`, which destroys the word boundary
|
|
700
|
+
* the token regex depends on.
|
|
701
|
+
*
|
|
702
|
+
* Still authored text rather than `innerText`: the point of reading
|
|
703
|
+
* `textContent` at all is that CSS `text-transform` must not manufacture
|
|
704
|
+
* an abbreviation out of a shouted ordinary word.
|
|
705
|
+
*/
|
|
706
|
+
const walker = document.createTreeWalker(main, NodeFilter.SHOW_TEXT);
|
|
707
|
+
const pieces = [];
|
|
708
|
+
let length = 0;
|
|
709
|
+
for (let node = walker.nextNode(); node && length < 20_000; node = walker.nextNode()) {
|
|
710
|
+
/*
|
|
711
|
+
* Not code. 3.1.4 is about the page's TEXT, and a token inside a
|
|
712
|
+
* snippet is an identifier the reader is meant to type verbatim — you
|
|
713
|
+
* cannot expand it on first use and it would be wrong to.
|
|
714
|
+
*
|
|
715
|
+
* Found on our own docs, where the locale `en-GB` in a config example
|
|
716
|
+
* was reported as the abbreviation `GB`, and the fix it asked for would
|
|
717
|
+
* have broken the snippet.
|
|
718
|
+
*/
|
|
719
|
+
if (node.parentElement?.closest('code, pre, kbd, samp'))
|
|
720
|
+
continue;
|
|
721
|
+
const piece = (node.nodeValue ?? '').replace(/\s+/g, ' ').trim();
|
|
722
|
+
if (!piece)
|
|
723
|
+
continue;
|
|
724
|
+
pieces.push(piece);
|
|
725
|
+
length += piece.length + 1;
|
|
726
|
+
}
|
|
727
|
+
const authored = pieces.join(' ').slice(0, 20_000);
|
|
728
|
+
/** Tokens already carrying an `<abbr>`, which need nothing further. */
|
|
729
|
+
const marked = new Set();
|
|
730
|
+
for (const el of Array.from(document.querySelectorAll('abbr'))) {
|
|
731
|
+
const token = (el.textContent ?? '').trim();
|
|
732
|
+
if (token)
|
|
733
|
+
marked.add(token);
|
|
734
|
+
}
|
|
735
|
+
/*
|
|
736
|
+
* Ordinary English words somebody wrote in capitals, which are not
|
|
737
|
+
* abbreviations however emphatic they are. Reported `TO` and `AN` on this
|
|
738
|
+
* repository's own evidence page, in a finding asking us to expand them.
|
|
739
|
+
*
|
|
740
|
+
* Short and closed on purpose: it holds the function words and the few
|
|
741
|
+
* content words that turn up in a shouted sentence, and nothing that could
|
|
742
|
+
* plausibly also be an acronym. `IT` is deliberately absent — it is the
|
|
743
|
+
* department at least as often as it is the pronoun, and the wrong call
|
|
744
|
+
* there is a missed finding rather than a manufactured one.
|
|
745
|
+
*/
|
|
746
|
+
const WORDS = new Set([
|
|
747
|
+
'THE',
|
|
748
|
+
'AND',
|
|
749
|
+
'BUT',
|
|
750
|
+
'FOR',
|
|
751
|
+
'NOR',
|
|
752
|
+
'YET',
|
|
753
|
+
'SO',
|
|
754
|
+
'OR',
|
|
755
|
+
'AS',
|
|
756
|
+
'AT',
|
|
757
|
+
'BY',
|
|
758
|
+
'IN',
|
|
759
|
+
'OF',
|
|
760
|
+
'ON',
|
|
761
|
+
'TO',
|
|
762
|
+
'UP',
|
|
763
|
+
'AN',
|
|
764
|
+
'A',
|
|
765
|
+
'IS',
|
|
766
|
+
'ARE',
|
|
767
|
+
'WAS',
|
|
768
|
+
'WERE',
|
|
769
|
+
'BE',
|
|
770
|
+
'BEEN',
|
|
771
|
+
'DO',
|
|
772
|
+
'DOES',
|
|
773
|
+
'DID',
|
|
774
|
+
'HAS',
|
|
775
|
+
'HAVE',
|
|
776
|
+
'HAD',
|
|
777
|
+
'CAN',
|
|
778
|
+
'WILL',
|
|
779
|
+
'NOT',
|
|
780
|
+
'NO',
|
|
781
|
+
'ALL',
|
|
782
|
+
'ANY',
|
|
783
|
+
'ONE',
|
|
784
|
+
'TWO',
|
|
785
|
+
'YOU',
|
|
786
|
+
'YOUR',
|
|
787
|
+
'WE',
|
|
788
|
+
'OUR',
|
|
789
|
+
'US',
|
|
790
|
+
'THIS',
|
|
791
|
+
'THAT',
|
|
792
|
+
'THEM',
|
|
793
|
+
'THEY',
|
|
794
|
+
'WHAT',
|
|
795
|
+
'WHEN',
|
|
796
|
+
'WHY',
|
|
797
|
+
'HOW',
|
|
798
|
+
'WHO',
|
|
799
|
+
'IF',
|
|
800
|
+
'THEN',
|
|
801
|
+
'ELSE',
|
|
802
|
+
'FROM',
|
|
803
|
+
'WITH',
|
|
804
|
+
'INTO',
|
|
805
|
+
'OVER',
|
|
806
|
+
'ONLY',
|
|
807
|
+
'EVERY',
|
|
808
|
+
'NEVER',
|
|
809
|
+
'NOW',
|
|
810
|
+
'NEW',
|
|
811
|
+
'OLD',
|
|
812
|
+
'SAME',
|
|
813
|
+
'MORE',
|
|
814
|
+
'MOST',
|
|
815
|
+
'LESS',
|
|
816
|
+
'EACH',
|
|
817
|
+
'BOTH',
|
|
818
|
+
'SOME',
|
|
819
|
+
'SUCH',
|
|
820
|
+
'HERE',
|
|
821
|
+
'THERE',
|
|
822
|
+
'WHERE',
|
|
823
|
+
'WHICH',
|
|
824
|
+
'WHILE',
|
|
825
|
+
'ABOUT',
|
|
826
|
+
'AFTER',
|
|
827
|
+
'AGAIN',
|
|
828
|
+
'STILL',
|
|
829
|
+
]);
|
|
830
|
+
/*
|
|
831
|
+
* Not a run of capitals inside an identifier.
|
|
832
|
+
*
|
|
833
|
+
* `en-GB` is one token — a language tag — and the word-boundary regex cuts
|
|
834
|
+
* `GB` out of it. Expanding that is not what 3.1.4 asks for and the
|
|
835
|
+
* "resolution" would corrupt the value. Same for a dotted or slashed
|
|
836
|
+
* identifier. Found on our own home page, where a consent manifest figure
|
|
837
|
+
* lists `locales en-GB, cy, fr`.
|
|
838
|
+
*/
|
|
839
|
+
const tokens = [...authored.matchAll(/\b[A-Z]{2,6}\b/g)].filter((match) => {
|
|
840
|
+
const before = authored[match.index - 1] ?? ' ';
|
|
841
|
+
const after = authored[match.index + match[0].length] ?? ' ';
|
|
842
|
+
return !'-_/.'.includes(before) && !'-_/'.includes(after);
|
|
843
|
+
});
|
|
844
|
+
const counted = new Set();
|
|
845
|
+
for (const [token] of tokens) {
|
|
846
|
+
// Roman numerals are not abbreviations, and `I` is not either.
|
|
847
|
+
if (/^[IVXLCDM]+$/.test(token))
|
|
848
|
+
continue;
|
|
849
|
+
if (WORDS.has(token))
|
|
850
|
+
continue;
|
|
851
|
+
if (counted.has(token))
|
|
852
|
+
continue;
|
|
853
|
+
counted.add(token);
|
|
854
|
+
// Already explained: an `<abbr>` anywhere for THIS token. The first
|
|
855
|
+
// version asked whether the page had ANY `<abbr>` at all, so one
|
|
856
|
+
// expanded abbreviation silenced the check for every other one.
|
|
857
|
+
if (marked.has(token))
|
|
858
|
+
continue;
|
|
859
|
+
/*
|
|
860
|
+
* Expanded in the prose is a mechanism too, and the commonest one:
|
|
861
|
+
* "Web Content Accessibility Guidelines (WCAG)" or "WCAG (Web Content
|
|
862
|
+
* Accessibility Guidelines)". Detected rather than ignored, because
|
|
863
|
+
* reporting a term the page defines in its first sentence is how a
|
|
864
|
+
* check gets switched off.
|
|
865
|
+
*/
|
|
866
|
+
const expanded = new RegExp('(\\([^)]*\\b' + token + '\\b[^)]*\\)|\\b' + token + '\\b\\s*\\([^)]{4,}\\))').test(authored);
|
|
867
|
+
if (expanded)
|
|
868
|
+
continue;
|
|
869
|
+
if (abbreviations.length < 12)
|
|
870
|
+
abbreviations.push(token);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/*
|
|
874
|
+
* --- 3.1.4: a glossary, which is a mechanism like any other ---
|
|
875
|
+
*
|
|
876
|
+
* The check used to report needs-review partly on the grounds that "3.1.4
|
|
877
|
+
* also accepts a glossary this check cannot see". It can see one. That is
|
|
878
|
+
* the same excuse 2.4.8 was making about site maps, one criterion along:
|
|
879
|
+
* naming a sufficient technique and then declining to look for it.
|
|
880
|
+
*
|
|
881
|
+
* G62 is a glossary; a link to one is how a page carries it. Matched on the
|
|
882
|
+
* link rather than on any `<dl>` in the page, because a definition list is
|
|
883
|
+
* the markup for a glossary and also the markup for a dozen other things —
|
|
884
|
+
* this repository's own evidence page has three of them and none is a
|
|
885
|
+
* glossary. A false silence here switches the criterion off completely,
|
|
886
|
+
* which is worse than a finding somebody dismisses.
|
|
887
|
+
*/
|
|
888
|
+
const hasGlossary = Array.from(document.querySelectorAll('a[href]')).some((a) => {
|
|
889
|
+
const href = (a.getAttribute('href') ?? '').toLowerCase();
|
|
890
|
+
const text = (a.textContent ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
891
|
+
return /glossary|definitions/.test(href) || /^(a |the )?(glossary|definitions)$/.test(text);
|
|
892
|
+
});
|
|
893
|
+
/* --- 2.4.8: does anything say where you are? --- */
|
|
894
|
+
/*
|
|
895
|
+
* Every sufficient technique the SC lists, not two of them.
|
|
896
|
+
*
|
|
897
|
+
* This used to look for a breadcrumb and an `aria-current`, find neither,
|
|
898
|
+
* and report needs-review on the grounds that "2.4.8 can also be met by a
|
|
899
|
+
* site map or by the page's own heading structure, neither of which this
|
|
900
|
+
* check can judge". A site map is a link and is perfectly visible; `rel`
|
|
901
|
+
* links are an attribute. Naming two mechanisms, failing to look for the
|
|
902
|
+
* rest, and calling the gap a judgement is the pattern this file keeps
|
|
903
|
+
* getting caught by.
|
|
904
|
+
*
|
|
905
|
+
* G65 breadcrumb · G63 site map · G128 current item in a nav bar ·
|
|
906
|
+
* H59 link rel. What survives as a genuine judgement is only whether a page
|
|
907
|
+
* is part of a set at all, which `hasNav` stands in for.
|
|
908
|
+
*/
|
|
909
|
+
const navs = Array.from(document.querySelectorAll('nav, [role="navigation"]'));
|
|
910
|
+
const hasBreadcrumb = document.querySelector('[aria-label*="readcrumb"], .breadcrumb, .breadcrumbs, ol.breadcrumb') !== null;
|
|
911
|
+
const hasCurrent = navs.some((nav) => nav.querySelector('[aria-current]') !== null);
|
|
912
|
+
/*
|
|
913
|
+
* A site map, by href or by link text. Both, because a site links to its
|
|
914
|
+
* map as `/sitemap` far more often than it labels it, and labels it
|
|
915
|
+
* "Site map" far more often than it uses a recognisable href.
|
|
916
|
+
*/
|
|
917
|
+
const hasSiteMap = Array.from(document.querySelectorAll('a[href]')).some((a) => {
|
|
918
|
+
const href = (a.getAttribute('href') ?? '').toLowerCase();
|
|
919
|
+
const text = (a.textContent ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
920
|
+
return /sitemap|site-map/.test(href) || /^site\s?map$/.test(text);
|
|
921
|
+
});
|
|
922
|
+
/*
|
|
923
|
+
* H59: `rel` stating the page's place in a collection. On `<link>` in the
|
|
924
|
+
* head, and on `<a>` — `rel="up"` on a parent link is the same statement.
|
|
925
|
+
*/
|
|
926
|
+
const REL = /\b(index|contents|up|start|prev|previous|next)\b/i;
|
|
927
|
+
const hasRelLink = Array.from(document.querySelectorAll('link[rel], a[rel]')).some((el) => REL.test(el.getAttribute('rel') ?? ''));
|
|
928
|
+
const path = window.location.pathname.replace(/\/+$/, '');
|
|
929
|
+
/* --- 1.2.x: media with no caption track --- */
|
|
930
|
+
const mediaElements = Array.from(document.querySelectorAll('video, audio'));
|
|
931
|
+
const uncaptioned = [];
|
|
932
|
+
for (const media of mediaElements.slice(0, 20)) {
|
|
933
|
+
const track = media.querySelector('track[kind="captions"], track[kind="subtitles"]');
|
|
934
|
+
if (!track)
|
|
935
|
+
uncaptioned.push(describe(media));
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
lang: document.documentElement.getAttribute('lang') ?? '',
|
|
939
|
+
reordered,
|
|
940
|
+
sensory,
|
|
941
|
+
weakHeadings,
|
|
942
|
+
unheadedSections,
|
|
943
|
+
unheadedChars,
|
|
944
|
+
abbreviations,
|
|
945
|
+
hasGlossary,
|
|
946
|
+
location: {
|
|
947
|
+
hasNav: navs.length > 0,
|
|
948
|
+
hasBreadcrumb,
|
|
949
|
+
hasCurrent,
|
|
950
|
+
hasSiteMap,
|
|
951
|
+
hasRelLink,
|
|
952
|
+
isRoot: path === '' || path === '/index.html',
|
|
953
|
+
},
|
|
954
|
+
uncaptioned,
|
|
955
|
+
media: {
|
|
956
|
+
elements: mediaElements.length,
|
|
957
|
+
frames: document.querySelectorAll('iframe, object, embed').length,
|
|
958
|
+
},
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
//# sourceMappingURL=heuristics.js.map
|