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,301 @@
|
|
|
1
|
+
export const structureRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'heading-order',
|
|
4
|
+
wcag: '1.3.1',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'moderate',
|
|
7
|
+
description: 'Heading levels must not be skipped (e.g. h1 → h3 skips h2)',
|
|
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 headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
|
|
30
|
+
const violations = [];
|
|
31
|
+
let prevLevel = 0;
|
|
32
|
+
for (const h of headings) {
|
|
33
|
+
const level = parseInt(h.tagName[1]);
|
|
34
|
+
if (prevLevel > 0 && level > prevLevel + 1) {
|
|
35
|
+
violations.push({ selector: getCssPath(h), html: h.outerHTML.slice(0, 200) });
|
|
36
|
+
}
|
|
37
|
+
prevLevel = level;
|
|
38
|
+
}
|
|
39
|
+
return violations;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'page-title',
|
|
44
|
+
wcag: '2.4.2',
|
|
45
|
+
level: 'A',
|
|
46
|
+
impact: 'serious',
|
|
47
|
+
description: 'Page must have a non-empty <title> element',
|
|
48
|
+
check: () => {
|
|
49
|
+
const title = document.querySelector('title');
|
|
50
|
+
if (!title || !title.textContent?.trim()) {
|
|
51
|
+
return [{ selector: 'head', html: '<title> missing or empty' }];
|
|
52
|
+
}
|
|
53
|
+
return [];
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'landmark-one-main',
|
|
58
|
+
wcag: '2.4.1',
|
|
59
|
+
level: 'A',
|
|
60
|
+
impact: 'moderate',
|
|
61
|
+
description: 'Page must have exactly one <main> landmark',
|
|
62
|
+
check: () => {
|
|
63
|
+
const mains = Array.from(document.querySelectorAll('main, [role="main"]'));
|
|
64
|
+
if (mains.length === 0)
|
|
65
|
+
return [{ selector: 'body', html: 'No <main> landmark found' }];
|
|
66
|
+
if (mains.length > 1)
|
|
67
|
+
return mains.slice(1).map((el) => ({ selector: el.tagName.toLowerCase(), html: el.outerHTML.slice(0, 200) }));
|
|
68
|
+
return [];
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'list-structure',
|
|
73
|
+
wcag: '1.3.1',
|
|
74
|
+
level: 'A',
|
|
75
|
+
impact: 'moderate',
|
|
76
|
+
description: '<li> elements must be contained within <ul> or <ol>',
|
|
77
|
+
check: () => {
|
|
78
|
+
const getCssPath = (el) => {
|
|
79
|
+
if (el.id)
|
|
80
|
+
return '#' + el.id;
|
|
81
|
+
const parts = [];
|
|
82
|
+
let node = el;
|
|
83
|
+
while (node && node.tagName !== 'BODY') {
|
|
84
|
+
const parent = node.parentElement;
|
|
85
|
+
if (!parent)
|
|
86
|
+
break;
|
|
87
|
+
const tag = node.tagName.toLowerCase();
|
|
88
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
89
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
90
|
+
if (parent.id) {
|
|
91
|
+
parts.unshift('#' + parent.id);
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
node = parent;
|
|
95
|
+
}
|
|
96
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
97
|
+
};
|
|
98
|
+
const items = Array.from(document.querySelectorAll('li'));
|
|
99
|
+
return items
|
|
100
|
+
.filter((li) => !li.closest('ul, ol'))
|
|
101
|
+
.map((li) => ({ selector: getCssPath(li), html: li.outerHTML.slice(0, 200) }));
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'region-landmark',
|
|
106
|
+
wcag: '1.3.1',
|
|
107
|
+
level: 'A',
|
|
108
|
+
impact: 'moderate',
|
|
109
|
+
description: 'Page sections must use appropriate landmark roles (header, nav, main, footer)',
|
|
110
|
+
check: () => {
|
|
111
|
+
const hasHeader = document.querySelector('header, [role="banner"]') !== null;
|
|
112
|
+
const hasNav = document.querySelector('nav, [role="navigation"]') !== null;
|
|
113
|
+
const hasMain = document.querySelector('main, [role="main"]') !== null;
|
|
114
|
+
const violations = [];
|
|
115
|
+
if (!hasHeader)
|
|
116
|
+
violations.push({ selector: 'body', html: 'No <header> or role="banner" found' });
|
|
117
|
+
if (!hasNav)
|
|
118
|
+
violations.push({ selector: 'body', html: 'No <nav> or role="navigation" found' });
|
|
119
|
+
if (!hasMain)
|
|
120
|
+
violations.push({ selector: 'body', html: 'No <main> or role="main" found' });
|
|
121
|
+
return violations;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'duplicate-id',
|
|
126
|
+
wcag: '4.1.1',
|
|
127
|
+
level: 'A',
|
|
128
|
+
impact: 'critical',
|
|
129
|
+
description: 'IDs must be unique — duplicate IDs break ARIA label associations',
|
|
130
|
+
check: () => {
|
|
131
|
+
const getCssPath = (el) => {
|
|
132
|
+
if (el.id)
|
|
133
|
+
return '#' + el.id;
|
|
134
|
+
const parts = [];
|
|
135
|
+
let node = el;
|
|
136
|
+
while (node && node.tagName !== 'BODY') {
|
|
137
|
+
const parent = node.parentElement;
|
|
138
|
+
if (!parent)
|
|
139
|
+
break;
|
|
140
|
+
const tag = node.tagName.toLowerCase();
|
|
141
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
142
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
143
|
+
if (parent.id) {
|
|
144
|
+
parts.unshift('#' + parent.id);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
node = parent;
|
|
148
|
+
}
|
|
149
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
150
|
+
};
|
|
151
|
+
const allEls = Array.from(document.querySelectorAll('[id]'));
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
const dupes = new Set();
|
|
154
|
+
for (const el of allEls) {
|
|
155
|
+
const id = el.getAttribute('id') ?? '';
|
|
156
|
+
if (id && seen.has(id))
|
|
157
|
+
dupes.add(id);
|
|
158
|
+
else if (id)
|
|
159
|
+
seen.add(id);
|
|
160
|
+
}
|
|
161
|
+
if (dupes.size === 0)
|
|
162
|
+
return [];
|
|
163
|
+
const results = [];
|
|
164
|
+
for (const id of dupes) {
|
|
165
|
+
const matches = Array.from(document.querySelectorAll(`[id="${id}"]`));
|
|
166
|
+
for (const el of matches) {
|
|
167
|
+
results.push({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return results;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'frame-title',
|
|
175
|
+
wcag: '2.4.1',
|
|
176
|
+
level: 'A',
|
|
177
|
+
impact: 'serious',
|
|
178
|
+
description: '<iframe> elements must have a non-empty title attribute',
|
|
179
|
+
check: () => {
|
|
180
|
+
const getCssPath = (el) => {
|
|
181
|
+
if (el.id)
|
|
182
|
+
return '#' + el.id;
|
|
183
|
+
const parts = [];
|
|
184
|
+
let node = el;
|
|
185
|
+
while (node && node.tagName !== 'BODY') {
|
|
186
|
+
const parent = node.parentElement;
|
|
187
|
+
if (!parent)
|
|
188
|
+
break;
|
|
189
|
+
const tag = node.tagName.toLowerCase();
|
|
190
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
191
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
192
|
+
if (parent.id) {
|
|
193
|
+
parts.unshift('#' + parent.id);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
node = parent;
|
|
197
|
+
}
|
|
198
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
199
|
+
};
|
|
200
|
+
const frames = Array.from(document.querySelectorAll('iframe, frame'));
|
|
201
|
+
return frames
|
|
202
|
+
.filter((el) => {
|
|
203
|
+
const title = el.getAttribute('title')?.trim() ?? '';
|
|
204
|
+
const ariaLabel = el.getAttribute('aria-label')?.trim() ?? '';
|
|
205
|
+
return !title && !ariaLabel;
|
|
206
|
+
})
|
|
207
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'meta-viewport',
|
|
212
|
+
wcag: '1.4.4',
|
|
213
|
+
level: 'AA',
|
|
214
|
+
impact: 'critical',
|
|
215
|
+
description: 'Viewport meta must not prevent users from zooming (user-scalable=no or maximum-scale < 2)',
|
|
216
|
+
check: () => {
|
|
217
|
+
const metas = Array.from(document.querySelectorAll('meta[name="viewport"]'));
|
|
218
|
+
return metas
|
|
219
|
+
.filter((meta) => {
|
|
220
|
+
const content = meta.getAttribute('content') ?? '';
|
|
221
|
+
const disablesZoom = /user-scalable\s*=\s*no/i.test(content);
|
|
222
|
+
const maxScaleMatch = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
|
|
223
|
+
const maxScale = maxScaleMatch ? parseFloat(maxScaleMatch[1]) : null;
|
|
224
|
+
return disablesZoom || (maxScale !== null && maxScale < 2);
|
|
225
|
+
})
|
|
226
|
+
.map((meta) => ({ selector: 'meta[name="viewport"]', html: meta.outerHTML }));
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'marquee',
|
|
231
|
+
wcag: '2.2.2',
|
|
232
|
+
level: 'A',
|
|
233
|
+
impact: 'serious',
|
|
234
|
+
description: '<marquee> elements cause accessibility issues — content moves automatically with no pause control',
|
|
235
|
+
check: () => {
|
|
236
|
+
const getCssPath = (el) => {
|
|
237
|
+
if (el.id)
|
|
238
|
+
return '#' + el.id;
|
|
239
|
+
const parts = [];
|
|
240
|
+
let node = el;
|
|
241
|
+
while (node && node.tagName !== 'BODY') {
|
|
242
|
+
const parent = node.parentElement;
|
|
243
|
+
if (!parent)
|
|
244
|
+
break;
|
|
245
|
+
const tag = node.tagName.toLowerCase();
|
|
246
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
247
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
248
|
+
if (parent.id) {
|
|
249
|
+
parts.unshift('#' + parent.id);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
node = parent;
|
|
253
|
+
}
|
|
254
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
255
|
+
};
|
|
256
|
+
return Array.from(document.querySelectorAll('marquee'))
|
|
257
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: 'p-as-heading',
|
|
262
|
+
wcag: '1.3.1',
|
|
263
|
+
level: 'A',
|
|
264
|
+
impact: 'moderate',
|
|
265
|
+
description: '<p> elements styled to look like headings should use an actual heading element',
|
|
266
|
+
check: () => {
|
|
267
|
+
const getCssPath = (el) => {
|
|
268
|
+
if (el.id)
|
|
269
|
+
return '#' + el.id;
|
|
270
|
+
const parts = [];
|
|
271
|
+
let node = el;
|
|
272
|
+
while (node && node.tagName !== 'BODY') {
|
|
273
|
+
const parent = node.parentElement;
|
|
274
|
+
if (!parent)
|
|
275
|
+
break;
|
|
276
|
+
const tag = node.tagName.toLowerCase();
|
|
277
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
278
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
279
|
+
if (parent.id) {
|
|
280
|
+
parts.unshift('#' + parent.id);
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
node = parent;
|
|
284
|
+
}
|
|
285
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
286
|
+
};
|
|
287
|
+
const paras = Array.from(document.querySelectorAll('p'));
|
|
288
|
+
return paras
|
|
289
|
+
.filter((p) => {
|
|
290
|
+
if (!p.textContent?.trim())
|
|
291
|
+
return false;
|
|
292
|
+
const style = window.getComputedStyle(p);
|
|
293
|
+
const fontSize = parseFloat(style.fontSize);
|
|
294
|
+
const fontWeight = parseInt(style.fontWeight) || 400;
|
|
295
|
+
// Flag <p> that is visually heading-sized (>= 18px) and bold, or >= 24px
|
|
296
|
+
return (fontSize >= 24) || (fontSize >= 18 && fontWeight >= 700);
|
|
297
|
+
})
|
|
298
|
+
.map((p) => ({ selector: getCssPath(p), html: p.outerHTML.slice(0, 200) }));
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
];
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
export const tableRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'table-headers',
|
|
4
|
+
wcag: '1.3.1',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'serious',
|
|
7
|
+
description: 'Data tables must use <th> or role="columnheader"/"rowheader" to identify headers',
|
|
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 tables = Array.from(document.querySelectorAll('table'));
|
|
30
|
+
return tables
|
|
31
|
+
.filter((table) => {
|
|
32
|
+
const role = table.getAttribute('role');
|
|
33
|
+
if (role === 'presentation' || role === 'none')
|
|
34
|
+
return false;
|
|
35
|
+
const hasDataCells = table.querySelector('td') !== null;
|
|
36
|
+
if (!hasDataCells)
|
|
37
|
+
return false;
|
|
38
|
+
const hasHeaders = table.querySelector('th') !== null ||
|
|
39
|
+
table.querySelector('[scope]') !== null ||
|
|
40
|
+
table.querySelector('[role="columnheader"], [role="rowheader"]') !== null;
|
|
41
|
+
return !hasHeaders;
|
|
42
|
+
})
|
|
43
|
+
.map((table) => ({ selector: getCssPath(table), html: table.outerHTML.slice(0, 200) }));
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'table-scope-valid',
|
|
48
|
+
wcag: '1.3.1',
|
|
49
|
+
level: 'A',
|
|
50
|
+
impact: 'moderate',
|
|
51
|
+
description: 'The scope attribute on <th> must have a valid value: col, row, colgroup, or rowgroup',
|
|
52
|
+
check: () => {
|
|
53
|
+
const getCssPath = (el) => {
|
|
54
|
+
if (el.id)
|
|
55
|
+
return '#' + el.id;
|
|
56
|
+
const parts = [];
|
|
57
|
+
let node = el;
|
|
58
|
+
while (node && node.tagName !== 'BODY') {
|
|
59
|
+
const parent = node.parentElement;
|
|
60
|
+
if (!parent)
|
|
61
|
+
break;
|
|
62
|
+
const tag = node.tagName.toLowerCase();
|
|
63
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
64
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
65
|
+
if (parent.id) {
|
|
66
|
+
parts.unshift('#' + parent.id);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
node = parent;
|
|
70
|
+
}
|
|
71
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
72
|
+
};
|
|
73
|
+
const validScopes = new Set(['col', 'row', 'colgroup', 'rowgroup']);
|
|
74
|
+
const ths = Array.from(document.querySelectorAll('th[scope]'));
|
|
75
|
+
return ths
|
|
76
|
+
.filter((th) => !validScopes.has(th.getAttribute('scope') ?? ''))
|
|
77
|
+
.map((th) => ({ selector: getCssPath(th), html: th.outerHTML.slice(0, 200) }));
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'td-headers-attr',
|
|
82
|
+
wcag: '1.3.1',
|
|
83
|
+
level: 'A',
|
|
84
|
+
impact: 'serious',
|
|
85
|
+
description: 'Table cells using the headers attribute must reference valid, non-empty <th> IDs',
|
|
86
|
+
check: () => {
|
|
87
|
+
const getCssPath = (el) => {
|
|
88
|
+
if (el.id)
|
|
89
|
+
return '#' + el.id;
|
|
90
|
+
const parts = [];
|
|
91
|
+
let node = el;
|
|
92
|
+
while (node && node.tagName !== 'BODY') {
|
|
93
|
+
const parent = node.parentElement;
|
|
94
|
+
if (!parent)
|
|
95
|
+
break;
|
|
96
|
+
const tag = node.tagName.toLowerCase();
|
|
97
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
98
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
99
|
+
if (parent.id) {
|
|
100
|
+
parts.unshift('#' + parent.id);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
node = parent;
|
|
104
|
+
}
|
|
105
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
106
|
+
};
|
|
107
|
+
const cells = Array.from(document.querySelectorAll('td[headers], th[headers]'));
|
|
108
|
+
return cells
|
|
109
|
+
.filter((cell) => {
|
|
110
|
+
const ids = (cell.getAttribute('headers') ?? '').trim().split(/\s+/).filter(Boolean);
|
|
111
|
+
if (ids.length === 0)
|
|
112
|
+
return false;
|
|
113
|
+
return ids.some((id) => {
|
|
114
|
+
const target = document.getElementById(id);
|
|
115
|
+
return !target || target.tagName.toLowerCase() !== 'th' || !target.textContent?.trim();
|
|
116
|
+
});
|
|
117
|
+
})
|
|
118
|
+
.map((cell) => ({ selector: getCssPath(cell), html: cell.outerHTML.slice(0, 200) }));
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'table-duplicate-name',
|
|
123
|
+
wcag: '1.3.1',
|
|
124
|
+
level: 'A',
|
|
125
|
+
impact: 'moderate',
|
|
126
|
+
description: 'Table summary and caption must not be identical',
|
|
127
|
+
check: () => {
|
|
128
|
+
const getCssPath = (el) => {
|
|
129
|
+
if (el.id)
|
|
130
|
+
return '#' + el.id;
|
|
131
|
+
const parts = [];
|
|
132
|
+
let node = el;
|
|
133
|
+
while (node && node.tagName !== 'BODY') {
|
|
134
|
+
const parent = node.parentElement;
|
|
135
|
+
if (!parent)
|
|
136
|
+
break;
|
|
137
|
+
const tag = node.tagName.toLowerCase();
|
|
138
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
139
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
140
|
+
if (parent.id) {
|
|
141
|
+
parts.unshift('#' + parent.id);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
node = parent;
|
|
145
|
+
}
|
|
146
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
147
|
+
};
|
|
148
|
+
const tables = Array.from(document.querySelectorAll('table[summary]'));
|
|
149
|
+
return tables
|
|
150
|
+
.filter((table) => {
|
|
151
|
+
const summary = (table.getAttribute('summary') ?? '').trim().toLowerCase();
|
|
152
|
+
const caption = table.querySelector('caption');
|
|
153
|
+
const captionText = (caption?.textContent ?? '').trim().toLowerCase();
|
|
154
|
+
return summary && captionText && summary === captionText;
|
|
155
|
+
})
|
|
156
|
+
.map((table) => ({ selector: getCssPath(table), html: table.outerHTML.slice(0, 200) }));
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
];
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export const textAlternativeRules = [
|
|
2
|
+
{
|
|
3
|
+
id: 'img-alt',
|
|
4
|
+
wcag: '1.1.1',
|
|
5
|
+
level: 'A',
|
|
6
|
+
impact: 'critical',
|
|
7
|
+
description: 'Images must have an alt attribute',
|
|
8
|
+
check: () => {
|
|
9
|
+
const images = Array.from(document.querySelectorAll('img'));
|
|
10
|
+
return images
|
|
11
|
+
.filter((img) => !img.hasAttribute('alt'))
|
|
12
|
+
.map((img) => ({
|
|
13
|
+
selector: img.id ? `#${img.id}` : `img[src="${img.getAttribute('src')}"]`,
|
|
14
|
+
html: img.outerHTML.slice(0, 200),
|
|
15
|
+
}));
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'input-image-alt',
|
|
20
|
+
wcag: '1.1.1',
|
|
21
|
+
level: 'A',
|
|
22
|
+
impact: 'critical',
|
|
23
|
+
description: '<input type="image"> must have an alt attribute',
|
|
24
|
+
check: () => {
|
|
25
|
+
const inputs = Array.from(document.querySelectorAll('input[type="image"]'));
|
|
26
|
+
return inputs
|
|
27
|
+
.filter((el) => !el.hasAttribute('alt'))
|
|
28
|
+
.map((el) => ({
|
|
29
|
+
selector: el.id ? `#${el.id}` : `input[type="image"]`,
|
|
30
|
+
html: el.outerHTML.slice(0, 200),
|
|
31
|
+
}));
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'svg-title',
|
|
36
|
+
wcag: '1.1.1',
|
|
37
|
+
level: 'A',
|
|
38
|
+
impact: 'serious',
|
|
39
|
+
description: 'Inline SVGs must have a <title> or aria-label for screen readers',
|
|
40
|
+
check: () => {
|
|
41
|
+
const svgs = Array.from(document.querySelectorAll('svg'));
|
|
42
|
+
return svgs
|
|
43
|
+
.filter((svg) => {
|
|
44
|
+
const hasTitle = svg.querySelector('title') !== null;
|
|
45
|
+
const hasAriaLabel = svg.hasAttribute('aria-label') || svg.hasAttribute('aria-labelledby');
|
|
46
|
+
const isDecorative = svg.getAttribute('aria-hidden') === 'true';
|
|
47
|
+
return !hasTitle && !hasAriaLabel && !isDecorative;
|
|
48
|
+
})
|
|
49
|
+
.map((svg) => {
|
|
50
|
+
const getCssPath = (el) => {
|
|
51
|
+
if (el.id)
|
|
52
|
+
return '#' + el.id;
|
|
53
|
+
const parts = [];
|
|
54
|
+
let node = el;
|
|
55
|
+
while (node && node.tagName !== 'BODY') {
|
|
56
|
+
const parent = node.parentElement;
|
|
57
|
+
if (!parent)
|
|
58
|
+
break;
|
|
59
|
+
const tag = node.tagName.toLowerCase();
|
|
60
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
61
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
62
|
+
if (parent.id) {
|
|
63
|
+
parts.unshift('#' + parent.id);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
node = parent;
|
|
67
|
+
}
|
|
68
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
69
|
+
};
|
|
70
|
+
return { selector: getCssPath(svg), html: svg.outerHTML.slice(0, 200) };
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'object-alt',
|
|
76
|
+
wcag: '1.1.1',
|
|
77
|
+
level: 'A',
|
|
78
|
+
impact: 'serious',
|
|
79
|
+
description: '<object> elements must have fallback text content',
|
|
80
|
+
check: () => {
|
|
81
|
+
const objects = Array.from(document.querySelectorAll('object'));
|
|
82
|
+
return objects
|
|
83
|
+
.filter((obj) => !obj.textContent?.trim())
|
|
84
|
+
.map((obj) => {
|
|
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
|
+
return { selector: getCssPath(obj), html: obj.outerHTML.slice(0, 200) };
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'role-img-alt',
|
|
111
|
+
wcag: '1.1.1',
|
|
112
|
+
level: 'A',
|
|
113
|
+
impact: 'serious',
|
|
114
|
+
description: 'Elements with role="img" must have an accessible name',
|
|
115
|
+
check: () => {
|
|
116
|
+
const getCssPath = (el) => {
|
|
117
|
+
if (el.id)
|
|
118
|
+
return '#' + el.id;
|
|
119
|
+
const parts = [];
|
|
120
|
+
let node = el;
|
|
121
|
+
while (node && node.tagName !== 'BODY') {
|
|
122
|
+
const parent = node.parentElement;
|
|
123
|
+
if (!parent)
|
|
124
|
+
break;
|
|
125
|
+
const tag = node.tagName.toLowerCase();
|
|
126
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
127
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
128
|
+
if (parent.id) {
|
|
129
|
+
parts.unshift('#' + parent.id);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
node = parent;
|
|
133
|
+
}
|
|
134
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
135
|
+
};
|
|
136
|
+
const els = Array.from(document.querySelectorAll('[role="img"]'));
|
|
137
|
+
return els
|
|
138
|
+
.filter((el) => {
|
|
139
|
+
if (el.getAttribute('aria-hidden') === 'true')
|
|
140
|
+
return false;
|
|
141
|
+
const ariaLabel = el.getAttribute('aria-label')?.trim() ?? '';
|
|
142
|
+
const ariaLabelledby = el.getAttribute('aria-labelledby');
|
|
143
|
+
const labelledEl = ariaLabelledby ? document.getElementById(ariaLabelledby) : null;
|
|
144
|
+
return !ariaLabel && !labelledEl?.textContent?.trim();
|
|
145
|
+
})
|
|
146
|
+
.map((el) => ({ selector: getCssPath(el), html: el.outerHTML.slice(0, 200) }));
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'image-redundant-alt',
|
|
151
|
+
wcag: '1.1.1',
|
|
152
|
+
level: 'A',
|
|
153
|
+
impact: 'minor',
|
|
154
|
+
description: 'Image alt text must not duplicate text that is already visible nearby',
|
|
155
|
+
check: () => {
|
|
156
|
+
const getCssPath = (el) => {
|
|
157
|
+
if (el.id)
|
|
158
|
+
return '#' + el.id;
|
|
159
|
+
const parts = [];
|
|
160
|
+
let node = el;
|
|
161
|
+
while (node && node.tagName !== 'BODY') {
|
|
162
|
+
const parent = node.parentElement;
|
|
163
|
+
if (!parent)
|
|
164
|
+
break;
|
|
165
|
+
const tag = node.tagName.toLowerCase();
|
|
166
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
|
|
167
|
+
parts.unshift(sibs.length === 1 ? tag : tag + ':nth-of-type(' + (sibs.indexOf(node) + 1) + ')');
|
|
168
|
+
if (parent.id) {
|
|
169
|
+
parts.unshift('#' + parent.id);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
node = parent;
|
|
173
|
+
}
|
|
174
|
+
return parts.join(' > ') || el.tagName.toLowerCase();
|
|
175
|
+
};
|
|
176
|
+
const images = Array.from(document.querySelectorAll('img[alt]'));
|
|
177
|
+
return images
|
|
178
|
+
.filter((img) => {
|
|
179
|
+
const alt = (img.getAttribute('alt') ?? '').trim().toLowerCase();
|
|
180
|
+
if (!alt)
|
|
181
|
+
return false;
|
|
182
|
+
const parent = img.parentElement;
|
|
183
|
+
if (!parent)
|
|
184
|
+
return false;
|
|
185
|
+
// Get sibling/adjacent text, excluding the img alt itself
|
|
186
|
+
const siblingText = Array.from(parent.childNodes)
|
|
187
|
+
.filter((n) => n !== img && n.nodeType === Node.TEXT_NODE)
|
|
188
|
+
.map((n) => n.textContent ?? '')
|
|
189
|
+
.join(' ')
|
|
190
|
+
.trim()
|
|
191
|
+
.toLowerCase();
|
|
192
|
+
const adjacentLabel = parent.querySelector('figcaption, caption');
|
|
193
|
+
const captionText = (adjacentLabel?.textContent ?? '').trim().toLowerCase();
|
|
194
|
+
return siblingText.includes(alt) || captionText.includes(alt);
|
|
195
|
+
})
|
|
196
|
+
.map((img) => ({
|
|
197
|
+
selector: getCssPath(img),
|
|
198
|
+
html: img.outerHTML.slice(0, 200),
|
|
199
|
+
}));
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|