wcag-a11y 0.1.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/README.md +44 -0
- package/dist/ai/gemini.js +57 -0
- package/dist/ai/index.js +12 -0
- package/dist/ai/ollama.js +55 -0
- package/dist/ai/prompt.js +20 -0
- package/dist/ai/types.js +1 -0
- package/dist/cli.js +67 -0
- package/dist/config.js +32 -0
- package/dist/crawler.js +54 -0
- package/dist/demo.js +89 -0
- package/dist/engine/index.js +56 -0
- package/dist/engine/rules/aria.js +293 -0
- package/dist/engine/rules/color-contrast.js +114 -0
- package/dist/engine/rules/forms.js +255 -0
- package/dist/engine/rules/keyboard.js +229 -0
- package/dist/engine/rules/language.js +31 -0
- package/dist/engine/rules/links.js +166 -0
- package/dist/engine/rules/media.js +104 -0
- package/dist/engine/rules/structure.js +301 -0
- package/dist/engine/rules/tables.js +159 -0
- package/dist/engine/rules/text-alternatives.js +202 -0
- package/dist/engine/types.js +1 -0
- package/dist/reporter/markdown.js +42 -0
- package/dist/reporter/terminal.js +55 -0
- package/package.json +33 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
export const keyboardRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'no-positive-tabindex',
|
|
4
|
+
wcag: '2.4.3',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
description: 'tabindex values greater than 0 disrupt the natural tab order',
|
|
8
|
+
check: () => {
|
|
9
|
+
const getCssPath = (el) => {
|
|
10
|
+
if (el.id)
|
|
11
|
+
return '#' + el.id;
|
|
12
|
+
const parts = [];
|
|
13
|
+
let node = el;
|
|
14
|
+
while (node && node.tagName !== 'BODY') {
|
|
15
|
+
const parent = node.parentElement;
|
|
16
|
+
if (!parent)
|
|
17
|
+
break;
|
|
18
|
+
const tag = node.tagName.toLowerCase();
|
|
19
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
20
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
21
|
+
if (parent.id) {
|
|
22
|
+
parts.unshift('#' + parent.id);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
node = parent;
|
|
26
|
+
}
|
|
27
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
28
|
+
};
|
|
29
|
+
const els = Array.from(document.querySelectorAll('[tabindex]'));
|
|
30
|
+
return els
|
|
31
|
+
.filter((el) => parseInt(el.getAttribute('tabindex') ?? '0') > 0)
|
|
32
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'interactive-not-focusable',
|
|
37
|
+
wcag: '2.1.1',
|
|
38
|
+
level: 'A',
|
|
39
|
+
impact: 'critical',
|
|
40
|
+
description: 'Non-semantic elements with click handlers must have a role and be keyboard focusable',
|
|
41
|
+
check: () => {
|
|
42
|
+
const getCssPath = (el) => {
|
|
43
|
+
if (el.id)
|
|
44
|
+
return '#' + el.id;
|
|
45
|
+
const parts = [];
|
|
46
|
+
let node = el;
|
|
47
|
+
while (node && node.tagName !== 'BODY') {
|
|
48
|
+
const parent = node.parentElement;
|
|
49
|
+
if (!parent)
|
|
50
|
+
break;
|
|
51
|
+
const tag = node.tagName.toLowerCase();
|
|
52
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
53
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
54
|
+
if (parent.id) {
|
|
55
|
+
parts.unshift('#' + parent.id);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
node = parent;
|
|
59
|
+
}
|
|
60
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
61
|
+
};
|
|
62
|
+
const nonInteractiveTags = ['div', 'span', 'li', 'td', 'p'];
|
|
63
|
+
const results = [];
|
|
64
|
+
for (const tag of nonInteractiveTags) {
|
|
65
|
+
const els = Array.from(document.querySelectorAll(`${tag}[onclick]`));
|
|
66
|
+
for (const el of els) {
|
|
67
|
+
const hasRole = el.hasAttribute('role');
|
|
68
|
+
const hasTabIndex = el.hasAttribute('tabindex');
|
|
69
|
+
if (!hasRole || !hasTabIndex) {
|
|
70
|
+
results.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'skip-link',
|
|
79
|
+
wcag: '2.4.1',
|
|
80
|
+
level: 'A',
|
|
81
|
+
impact: 'moderate',
|
|
82
|
+
description: 'Page should have a skip navigation link as the first focusable element',
|
|
83
|
+
check: () => {
|
|
84
|
+
const firstLink = document.querySelector('a[href]');
|
|
85
|
+
if (!firstLink)
|
|
86
|
+
return [{ selector: 'body', html: '<body> — no skip link found' }];
|
|
87
|
+
const href = firstLink.getAttribute('href') ?? '';
|
|
88
|
+
const text = firstLink.textContent?.toLowerCase() ?? '';
|
|
89
|
+
const isSkipLink = href.startsWith('#') && (text.includes('skip') || text.includes('main') || text.includes('content'));
|
|
90
|
+
if (!isSkipLink)
|
|
91
|
+
return [{ selector: 'a:first-of-type', html: firstLink.outerHTML.slice(0, 200) }];
|
|
92
|
+
return [];
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'focus-visible',
|
|
97
|
+
wcag: '2.4.7',
|
|
98
|
+
level: 'AA',
|
|
99
|
+
impact: 'serious',
|
|
100
|
+
description: 'Interactive elements must have a visible focus indicator',
|
|
101
|
+
check: () => {
|
|
102
|
+
const getCssPath = (el) => {
|
|
103
|
+
if (el.id)
|
|
104
|
+
return '#' + el.id;
|
|
105
|
+
const parts = [];
|
|
106
|
+
let node = el;
|
|
107
|
+
while (node && node.tagName !== 'BODY') {
|
|
108
|
+
const parent = node.parentElement;
|
|
109
|
+
if (!parent)
|
|
110
|
+
break;
|
|
111
|
+
const tag = node.tagName.toLowerCase();
|
|
112
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
113
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
114
|
+
if (parent.id) {
|
|
115
|
+
parts.unshift('#' + parent.id);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
node = parent;
|
|
119
|
+
}
|
|
120
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
121
|
+
};
|
|
122
|
+
const interactive = Array.from(document.querySelectorAll('a, button, input, select, textarea, [tabindex="0"]'));
|
|
123
|
+
return interactive
|
|
124
|
+
.filter((el) => {
|
|
125
|
+
const style = window.getComputedStyle(el, ':focus');
|
|
126
|
+
const outline = style.outline;
|
|
127
|
+
const outlineWidth = parseFloat(style.outlineWidth);
|
|
128
|
+
return outline === 'none' || outline === '0px none' || outlineWidth === 0;
|
|
129
|
+
})
|
|
130
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'scrollable-region-focusable',
|
|
135
|
+
wcag: '2.1.1',
|
|
136
|
+
level: 'A',
|
|
137
|
+
impact: 'moderate',
|
|
138
|
+
description: 'Scrollable regions must be accessible by keyboard (tabindex="0" or contain focusable child)',
|
|
139
|
+
check: () => {
|
|
140
|
+
const getCssPath = (el) => {
|
|
141
|
+
if (el.id)
|
|
142
|
+
return '#' + el.id;
|
|
143
|
+
const parts = [];
|
|
144
|
+
let node = el;
|
|
145
|
+
while (node && node.tagName !== 'BODY') {
|
|
146
|
+
const parent = node.parentElement;
|
|
147
|
+
if (!parent)
|
|
148
|
+
break;
|
|
149
|
+
const tag = node.tagName.toLowerCase();
|
|
150
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
151
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
152
|
+
if (parent.id) {
|
|
153
|
+
parts.unshift('#' + parent.id);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
node = parent;
|
|
157
|
+
}
|
|
158
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
159
|
+
};
|
|
160
|
+
const nativelyFocusable = ['a', 'button', 'input', 'select', 'textarea'];
|
|
161
|
+
const results = [];
|
|
162
|
+
const allEls = Array.from(document.querySelectorAll('*'));
|
|
163
|
+
for (const el of allEls) {
|
|
164
|
+
const style = window.getComputedStyle(el);
|
|
165
|
+
const overflow = style.overflow;
|
|
166
|
+
const overflowX = style.overflowX;
|
|
167
|
+
const overflowY = style.overflowY;
|
|
168
|
+
const isScrollable = ['auto', 'scroll'].includes(overflow) || ['auto', 'scroll'].includes(overflowX) || ['auto', 'scroll'].includes(overflowY);
|
|
169
|
+
if (!isScrollable)
|
|
170
|
+
continue;
|
|
171
|
+
const hasActualOverflow = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
|
172
|
+
if (!hasActualOverflow)
|
|
173
|
+
continue;
|
|
174
|
+
if (nativelyFocusable.includes(el.tagName.toLowerCase()))
|
|
175
|
+
continue;
|
|
176
|
+
if (el.hasAttribute('tabindex'))
|
|
177
|
+
continue;
|
|
178
|
+
const hasFocusableChild = el.querySelector('a[href], button, input, select, textarea, [tabindex="0"]') !== null;
|
|
179
|
+
if (!hasFocusableChild) {
|
|
180
|
+
results.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return results;
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'accesskey-unique',
|
|
188
|
+
wcag: '4.1.1',
|
|
189
|
+
level: 'A',
|
|
190
|
+
impact: 'moderate',
|
|
191
|
+
description: 'accesskey attribute values must be unique across the page',
|
|
192
|
+
check: () => {
|
|
193
|
+
const getCssPath = (el) => {
|
|
194
|
+
if (el.id)
|
|
195
|
+
return '#' + el.id;
|
|
196
|
+
const parts = [];
|
|
197
|
+
let node = el;
|
|
198
|
+
while (node && node.tagName !== 'BODY') {
|
|
199
|
+
const parent = node.parentElement;
|
|
200
|
+
if (!parent)
|
|
201
|
+
break;
|
|
202
|
+
const tag = node.tagName.toLowerCase();
|
|
203
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
204
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
205
|
+
if (parent.id) {
|
|
206
|
+
parts.unshift('#' + parent.id);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
node = parent;
|
|
210
|
+
}
|
|
211
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
212
|
+
};
|
|
213
|
+
const els = Array.from(document.querySelectorAll('[accesskey]'));
|
|
214
|
+
const seen = new Map();
|
|
215
|
+
const dupes = new Set();
|
|
216
|
+
for (const el of els) {
|
|
217
|
+
const key = (el.getAttribute('accesskey') ?? '').toLowerCase();
|
|
218
|
+
if (seen.has(key)) {
|
|
219
|
+
dupes.add(seen.get(key));
|
|
220
|
+
dupes.add(el);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
seen.set(key, el);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return Array.from(dupes).map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const languageRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'html-lang',
|
|
4
|
+
wcag: '3.1.1',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
description: 'The <html> element must have a lang attribute',
|
|
8
|
+
check: () => {
|
|
9
|
+
const html = document.documentElement;
|
|
10
|
+
if (!html.hasAttribute('lang') || !html.getAttribute('lang')?.trim()) {
|
|
11
|
+
return [{ selector: 'html', html: html.outerHTML.slice(0, 100) }];
|
|
12
|
+
}
|
|
13
|
+
return [];
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'html-lang-valid',
|
|
18
|
+
wcag: '3.1.1',
|
|
19
|
+
level: 'A',
|
|
20
|
+
impact: 'serious',
|
|
21
|
+
description: 'The lang attribute must be a valid BCP 47 language tag',
|
|
22
|
+
check: () => {
|
|
23
|
+
const lang = document.documentElement.getAttribute('lang') ?? '';
|
|
24
|
+
const valid = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang);
|
|
25
|
+
if (lang && !valid) {
|
|
26
|
+
return [{ selector: 'html', html: `lang="${lang}" is not a valid BCP 47 tag` }];
|
|
27
|
+
}
|
|
28
|
+
return [];
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
];
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export const linkRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'link-name',
|
|
4
|
+
wcag: '2.4.4',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
description: 'Links must have descriptive text that explains their destination or purpose',
|
|
8
|
+
check: () => {
|
|
9
|
+
const getCssPath = (el) => {
|
|
10
|
+
if (el.id)
|
|
11
|
+
return '#' + el.id;
|
|
12
|
+
const parts = [];
|
|
13
|
+
let node = el;
|
|
14
|
+
while (node && node.tagName !== 'BODY') {
|
|
15
|
+
const parent = node.parentElement;
|
|
16
|
+
if (!parent)
|
|
17
|
+
break;
|
|
18
|
+
const tag = node.tagName.toLowerCase();
|
|
19
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
20
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
21
|
+
if (parent.id) {
|
|
22
|
+
parts.unshift('#' + parent.id);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
node = parent;
|
|
26
|
+
}
|
|
27
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
28
|
+
};
|
|
29
|
+
const meaningless = new Set(['click here', 'here', 'read more', 'more', 'learn more', 'this', 'link', 'go', 'continue', 'download', 'click', 'tap']);
|
|
30
|
+
const links = Array.from(document.querySelectorAll('a[href]'));
|
|
31
|
+
return links
|
|
32
|
+
.filter((a) => {
|
|
33
|
+
const text = (a.textContent ?? '').trim().toLowerCase();
|
|
34
|
+
return meaningless.has(text);
|
|
35
|
+
})
|
|
36
|
+
.map((a) => ({ selector: getCssPath(a), html: a.outerHTML.slice(0, 200) }));
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'link-empty',
|
|
41
|
+
wcag: '2.4.4',
|
|
42
|
+
level: 'A',
|
|
43
|
+
impact: 'critical',
|
|
44
|
+
description: 'Links must have non-empty accessible names',
|
|
45
|
+
check: () => {
|
|
46
|
+
const getCssPath = (el) => {
|
|
47
|
+
if (el.id)
|
|
48
|
+
return '#' + el.id;
|
|
49
|
+
const parts = [];
|
|
50
|
+
let node = el;
|
|
51
|
+
while (node && node.tagName !== 'BODY') {
|
|
52
|
+
const parent = node.parentElement;
|
|
53
|
+
if (!parent)
|
|
54
|
+
break;
|
|
55
|
+
const tag = node.tagName.toLowerCase();
|
|
56
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
57
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
58
|
+
if (parent.id) {
|
|
59
|
+
parts.unshift('#' + parent.id);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
node = parent;
|
|
63
|
+
}
|
|
64
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
65
|
+
};
|
|
66
|
+
const links = Array.from(document.querySelectorAll('a[href]'));
|
|
67
|
+
return links
|
|
68
|
+
.filter((a) => {
|
|
69
|
+
const text = a.textContent?.trim() ?? '';
|
|
70
|
+
const ariaLabel = a.getAttribute('aria-label')?.trim() ?? '';
|
|
71
|
+
const ariaLabelledby = a.getAttribute('aria-labelledby');
|
|
72
|
+
const img = a.querySelector('img[alt]');
|
|
73
|
+
return !text && !ariaLabel && !ariaLabelledby && !img;
|
|
74
|
+
})
|
|
75
|
+
.map((a) => ({ selector: getCssPath(a), html: a.outerHTML.slice(0, 200) }));
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'identical-links-different-purpose',
|
|
80
|
+
wcag: '2.4.9',
|
|
81
|
+
level: 'AAA',
|
|
82
|
+
impact: 'minor',
|
|
83
|
+
description: 'Links with identical text must point to the same URL',
|
|
84
|
+
check: () => {
|
|
85
|
+
const getCssPath = (el) => {
|
|
86
|
+
if (el.id)
|
|
87
|
+
return '#' + el.id;
|
|
88
|
+
const parts = [];
|
|
89
|
+
let node = el;
|
|
90
|
+
while (node && node.tagName !== 'BODY') {
|
|
91
|
+
const parent = node.parentElement;
|
|
92
|
+
if (!parent)
|
|
93
|
+
break;
|
|
94
|
+
const tag = node.tagName.toLowerCase();
|
|
95
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
96
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
97
|
+
if (parent.id) {
|
|
98
|
+
parts.unshift('#' + parent.id);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
node = parent;
|
|
102
|
+
}
|
|
103
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
104
|
+
};
|
|
105
|
+
const links = Array.from(document.querySelectorAll('a[href]'));
|
|
106
|
+
const textToHrefs = new Map();
|
|
107
|
+
for (const a of links) {
|
|
108
|
+
const text = (a.getAttribute('aria-label') || a.textContent || '').trim().toLowerCase();
|
|
109
|
+
const href = a.getAttribute('href') ?? '';
|
|
110
|
+
if (!text || !href)
|
|
111
|
+
continue;
|
|
112
|
+
if (!textToHrefs.has(text))
|
|
113
|
+
textToHrefs.set(text, new Set());
|
|
114
|
+
textToHrefs.get(text).add(href);
|
|
115
|
+
}
|
|
116
|
+
return links
|
|
117
|
+
.filter((a) => {
|
|
118
|
+
const text = (a.getAttribute('aria-label') || a.textContent || '').trim().toLowerCase();
|
|
119
|
+
if (!text)
|
|
120
|
+
return false;
|
|
121
|
+
const hrefs = textToHrefs.get(text);
|
|
122
|
+
return hrefs !== undefined && hrefs.size > 1;
|
|
123
|
+
})
|
|
124
|
+
.map((a) => ({ selector: getCssPath(a), html: a.outerHTML.slice(0, 200) }));
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'link-new-window-warn',
|
|
129
|
+
wcag: '3.2.2',
|
|
130
|
+
level: 'A',
|
|
131
|
+
impact: 'moderate',
|
|
132
|
+
description: 'Links that open in a new window or tab must warn users in advance',
|
|
133
|
+
check: () => {
|
|
134
|
+
const getCssPath = (el) => {
|
|
135
|
+
if (el.id)
|
|
136
|
+
return '#' + el.id;
|
|
137
|
+
const parts = [];
|
|
138
|
+
let node = el;
|
|
139
|
+
while (node && node.tagName !== 'BODY') {
|
|
140
|
+
const parent = node.parentElement;
|
|
141
|
+
if (!parent)
|
|
142
|
+
break;
|
|
143
|
+
const tag = node.tagName.toLowerCase();
|
|
144
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
145
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
146
|
+
if (parent.id) {
|
|
147
|
+
parts.unshift('#' + parent.id);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
node = parent;
|
|
151
|
+
}
|
|
152
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
153
|
+
};
|
|
154
|
+
const links = Array.from(document.querySelectorAll('a[target="_blank"], a[target="_new"]'));
|
|
155
|
+
return links
|
|
156
|
+
.filter((a) => {
|
|
157
|
+
const text = (a.textContent ?? '').toLowerCase();
|
|
158
|
+
const ariaLabel = (a.getAttribute('aria-label') ?? '').toLowerCase();
|
|
159
|
+
const title = (a.getAttribute('title') ?? '').toLowerCase();
|
|
160
|
+
const hasWarning = ['new window', 'new tab', 'opens in', 'external'].some((hint) => text.includes(hint) || ariaLabel.includes(hint) || title.includes(hint));
|
|
161
|
+
return !hasWarning;
|
|
162
|
+
})
|
|
163
|
+
.map((a) => ({ selector: getCssPath(a), html: a.outerHTML.slice(0, 200) }));
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const mediaRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'video-captions',
|
|
4
|
+
wcag: '1.2.2',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'critical',
|
|
7
|
+
description: '<video> elements must have a caption track',
|
|
8
|
+
check: () => {
|
|
9
|
+
const getCssPath = (el) => {
|
|
10
|
+
if (el.id)
|
|
11
|
+
return '#' + el.id;
|
|
12
|
+
const parts = [];
|
|
13
|
+
let node = el;
|
|
14
|
+
while (node && node.tagName !== 'BODY') {
|
|
15
|
+
const parent = node.parentElement;
|
|
16
|
+
if (!parent)
|
|
17
|
+
break;
|
|
18
|
+
const tag = node.tagName.toLowerCase();
|
|
19
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
20
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
21
|
+
if (parent.id) {
|
|
22
|
+
parts.unshift('#' + parent.id);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
node = parent;
|
|
26
|
+
}
|
|
27
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
28
|
+
};
|
|
29
|
+
const videos = Array.from(document.querySelectorAll('video'));
|
|
30
|
+
return videos
|
|
31
|
+
.filter((v) => !v.querySelector('track[kind="captions"], track[kind="subtitles"]'))
|
|
32
|
+
.map((v) => ({ selector: getCssPath(v), html: v.outerHTML.slice(0, 200) }));
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'audio-description',
|
|
37
|
+
wcag: '1.2.3',
|
|
38
|
+
level: 'A',
|
|
39
|
+
impact: 'serious',
|
|
40
|
+
description: '<video> elements should have an audio description track',
|
|
41
|
+
check: () => {
|
|
42
|
+
const getCssPath = (el) => {
|
|
43
|
+
if (el.id)
|
|
44
|
+
return '#' + el.id;
|
|
45
|
+
const parts = [];
|
|
46
|
+
let node = el;
|
|
47
|
+
while (node && node.tagName !== 'BODY') {
|
|
48
|
+
const parent = node.parentElement;
|
|
49
|
+
if (!parent)
|
|
50
|
+
break;
|
|
51
|
+
const tag = node.tagName.toLowerCase();
|
|
52
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
53
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
54
|
+
if (parent.id) {
|
|
55
|
+
parts.unshift('#' + parent.id);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
node = parent;
|
|
59
|
+
}
|
|
60
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
61
|
+
};
|
|
62
|
+
const videos = Array.from(document.querySelectorAll('video'));
|
|
63
|
+
return videos
|
|
64
|
+
.filter((v) => !v.querySelector('track[kind="descriptions"]'))
|
|
65
|
+
.map((v) => ({ selector: getCssPath(v), html: v.outerHTML.slice(0, 200) }));
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'audio-transcript',
|
|
70
|
+
wcag: '1.2.1',
|
|
71
|
+
level: 'A',
|
|
72
|
+
impact: 'serious',
|
|
73
|
+
description: '<audio> elements should have a linked transcript',
|
|
74
|
+
check: () => {
|
|
75
|
+
const getCssPath = (el) => {
|
|
76
|
+
if (el.id)
|
|
77
|
+
return '#' + el.id;
|
|
78
|
+
const parts = [];
|
|
79
|
+
let node = el;
|
|
80
|
+
while (node && node.tagName !== 'BODY') {
|
|
81
|
+
const parent = node.parentElement;
|
|
82
|
+
if (!parent)
|
|
83
|
+
break;
|
|
84
|
+
const tag = node.tagName.toLowerCase();
|
|
85
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
86
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
87
|
+
if (parent.id) {
|
|
88
|
+
parts.unshift('#' + parent.id);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
node = parent;
|
|
92
|
+
}
|
|
93
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
94
|
+
};
|
|
95
|
+
const audios = Array.from(document.querySelectorAll('audio'));
|
|
96
|
+
return audios
|
|
97
|
+
.filter((a) => {
|
|
98
|
+
const describedBy = a.getAttribute('aria-describedby');
|
|
99
|
+
return !describedBy || !document.getElementById(describedBy);
|
|
100
|
+
})
|
|
101
|
+
.map((a) => ({ selector: getCssPath(a), html: a.outerHTML.slice(0, 200) }));
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
];
|