samey-cli 0.0.0-stage → 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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/ai/local-model.d.ts +26 -0
- package/dist/ai/local-model.js +91 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.js +250 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +166 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/render/crawl.d.ts +34 -0
- package/dist/render/crawl.js +337 -0
- package/dist/render/extract.d.ts +8 -0
- package/dist/render/extract.js +441 -0
- package/dist/render/interact.d.ts +8 -0
- package/dist/render/interact.js +76 -0
- package/dist/render/network.d.ts +33 -0
- package/dist/render/network.js +92 -0
- package/dist/render/robots.d.ts +8 -0
- package/dist/render/robots.js +61 -0
- package/dist/render/server.d.ts +15 -0
- package/dist/render/server.js +174 -0
- package/dist/report/html.d.ts +2 -0
- package/dist/report/html.js +368 -0
- package/dist/report/prompt.d.ts +12 -0
- package/dist/report/prompt.js +62 -0
- package/dist/report/terminal.d.ts +6 -0
- package/dist/report/terminal.js +73 -0
- package/dist/rules/content.d.ts +8 -0
- package/dist/rules/content.js +227 -0
- package/dist/rules/functional.d.ts +17 -0
- package/dist/rules/functional.js +467 -0
- package/dist/rules/helpers.d.ts +11 -0
- package/dist/rules/helpers.js +29 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.js +100 -0
- package/dist/rules/normalize.d.ts +18 -0
- package/dist/rules/normalize.js +114 -0
- package/dist/rules/static.d.ts +9 -0
- package/dist/rules/static.js +314 -0
- package/dist/rules/types.d.ts +39 -0
- package/dist/rules/types.js +16 -0
- package/dist/rules/visual.d.ts +15 -0
- package/dist/rules/visual.js +463 -0
- package/dist/score.d.ts +31 -0
- package/dist/score.js +66 -0
- package/dist/security/fetch.d.ts +26 -0
- package/dist/security/fetch.js +86 -0
- package/dist/security/net-policy.d.ts +46 -0
- package/dist/security/net-policy.js +178 -0
- package/dist/static/scan.d.ts +15 -0
- package/dist/static/scan.js +191 -0
- package/dist/types.d.ts +316 -0
- package/dist/types.js +1 -0
- package/dist/util/color.d.ts +25 -0
- package/dist/util/color.js +180 -0
- package/dist/util/text.d.ts +22 -0
- package/dist/util/text.js +106 -0
- package/package.json +69 -4
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { truncate } from '../util/text.js';
|
|
2
|
+
import { el, pagesWith, plural, uniqueRoutes } from './helpers.js';
|
|
3
|
+
export const pageLoadFailed = {
|
|
4
|
+
id: 'page-load-failed',
|
|
5
|
+
title: 'Page failed to load',
|
|
6
|
+
category: 'functional',
|
|
7
|
+
needs: 'rendered',
|
|
8
|
+
verified: true,
|
|
9
|
+
penalty: 80,
|
|
10
|
+
description: 'A page timed out or errored during navigation.',
|
|
11
|
+
run({ crawl }) {
|
|
12
|
+
return (crawl?.pages ?? [])
|
|
13
|
+
.filter((p) => p.error && !p.desktop)
|
|
14
|
+
.map((p) => ({
|
|
15
|
+
title: `Page failed to load: ${p.path}`,
|
|
16
|
+
severity: 'critical',
|
|
17
|
+
confidence: 0.9,
|
|
18
|
+
description: `Navigating to ${p.url} failed: ${p.error}`,
|
|
19
|
+
recommendation: 'Open the page locally and check the server log. If it is slow rather than broken, raise --timeout.',
|
|
20
|
+
evidence: [{ type: 'network', route: p.path, detail: p.error }],
|
|
21
|
+
routes: [p.path],
|
|
22
|
+
}));
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const brokenLinks = {
|
|
26
|
+
id: 'broken-internal-links',
|
|
27
|
+
title: 'Broken internal links',
|
|
28
|
+
category: 'functional',
|
|
29
|
+
needs: 'rendered',
|
|
30
|
+
verified: true,
|
|
31
|
+
penalty: 50,
|
|
32
|
+
description: 'Internal links that return an error status or fail to load.',
|
|
33
|
+
run({ crawl }) {
|
|
34
|
+
const links = (crawl?.brokenLinks ?? []).filter((l) => l.from !== '(route hint)');
|
|
35
|
+
if (!links.length)
|
|
36
|
+
return [];
|
|
37
|
+
const ev = links.map((l) => ({
|
|
38
|
+
type: 'network',
|
|
39
|
+
route: l.from.startsWith('/') ? l.from : undefined,
|
|
40
|
+
excerpt: l.url,
|
|
41
|
+
detail: l.status ? `HTTP ${l.status}` : (l.error ?? 'request failed'),
|
|
42
|
+
}));
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
severity: 'high',
|
|
46
|
+
confidence: 0.9,
|
|
47
|
+
description: `${plural(links.length, 'internal link')} lead to pages that do not load.`,
|
|
48
|
+
recommendation: 'Fix the target route or update the link. Remove navigation entries for pages that do not exist yet.',
|
|
49
|
+
evidence: ev,
|
|
50
|
+
routes: uniqueRoutes(ev),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
export const consoleErrors = {
|
|
56
|
+
id: 'console-errors',
|
|
57
|
+
title: 'JavaScript errors in the console',
|
|
58
|
+
category: 'functional',
|
|
59
|
+
needs: 'rendered',
|
|
60
|
+
verified: true,
|
|
61
|
+
penalty: 45,
|
|
62
|
+
description: 'Uncaught exceptions and console.error output during page load.',
|
|
63
|
+
run({ crawl }) {
|
|
64
|
+
const ev = [];
|
|
65
|
+
for (const p of crawl?.pages ?? [])
|
|
66
|
+
for (const e of p.consoleErrors.slice(0, 5))
|
|
67
|
+
ev.push({ type: 'console', route: p.path, excerpt: truncate(e, 220) });
|
|
68
|
+
if (!ev.length)
|
|
69
|
+
return [];
|
|
70
|
+
const uncaught = ev.filter((e) => e.excerpt?.startsWith('Uncaught')).length;
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
severity: uncaught ? 'high' : 'medium',
|
|
74
|
+
confidence: 0.9,
|
|
75
|
+
description: `${plural(ev.length, 'console error')} on ${plural(uniqueRoutes(ev).length, 'page')}${uncaught ? `, including ${uncaught} uncaught exception${uncaught === 1 ? '' : 's'}` : ''}.`,
|
|
76
|
+
recommendation: 'Open the browser console on each listed page and fix the errors; uncaught exceptions often mean a feature silently does not work.',
|
|
77
|
+
evidence: ev,
|
|
78
|
+
routes: uniqueRoutes(ev),
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
export const failedRequests = {
|
|
84
|
+
id: 'failed-requests',
|
|
85
|
+
title: 'Failed asset requests',
|
|
86
|
+
category: 'functional',
|
|
87
|
+
needs: 'rendered',
|
|
88
|
+
verified: true,
|
|
89
|
+
description: 'Scripts, styles, images, or API calls that returned errors or failed.',
|
|
90
|
+
run({ crawl }) {
|
|
91
|
+
const ev = [];
|
|
92
|
+
let critical = 0;
|
|
93
|
+
for (const p of crawl?.pages ?? []) {
|
|
94
|
+
for (const r of p.failedRequests) {
|
|
95
|
+
if (r.resourceType === 'document' && r.url.replace(/\/$/, '') === p.url.replace(/\/$/, ''))
|
|
96
|
+
continue; // reported as a broken page
|
|
97
|
+
if (/favicon\.ico$/.test(r.url))
|
|
98
|
+
continue;
|
|
99
|
+
if (['script', 'stylesheet', 'document'].includes(r.resourceType))
|
|
100
|
+
critical++;
|
|
101
|
+
ev.push({ type: 'network', route: p.path, excerpt: truncate(r.url, 140), detail: `${r.resourceType}: ${r.status ? `HTTP ${r.status}` : r.failure}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!ev.length)
|
|
105
|
+
return [];
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
severity: critical ? 'high' : 'medium',
|
|
109
|
+
confidence: 0.9,
|
|
110
|
+
description: `${plural(ev.length, 'request')} failed while loading pages${critical ? `, ${critical} of them scripts, styles, or documents` : ''}.`,
|
|
111
|
+
recommendation: 'Fix the asset paths or remove references to files that do not exist.',
|
|
112
|
+
evidence: ev,
|
|
113
|
+
routes: uniqueRoutes(ev),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
export const deadLinks = {
|
|
119
|
+
id: 'placeholder-links',
|
|
120
|
+
title: 'Links that go nowhere',
|
|
121
|
+
category: 'functional',
|
|
122
|
+
needs: 'rendered',
|
|
123
|
+
verified: true,
|
|
124
|
+
description: 'Links with href="#", an empty href, or javascript: URLs.',
|
|
125
|
+
run({ crawl }) {
|
|
126
|
+
const ev = [];
|
|
127
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
128
|
+
for (const l of facts.links) {
|
|
129
|
+
const h = l.href.trim();
|
|
130
|
+
if (h === '#' || h === '' || /^javascript:(?:void\(0\);?|;)?$/i.test(h))
|
|
131
|
+
ev.push(el(page, 'desktop', l, l.text || '(no text)', `href="${h}"`));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!ev.length)
|
|
135
|
+
return [];
|
|
136
|
+
return [
|
|
137
|
+
{
|
|
138
|
+
severity: ev.length >= 5 ? 'high' : 'medium',
|
|
139
|
+
confidence: 0.9,
|
|
140
|
+
description: `${plural(ev.length, 'link')} point to "#" or nowhere. These look clickable and do nothing, which is common in generated navigation and footers.`,
|
|
141
|
+
recommendation: 'Point each link at a real page, turn it into a button if it triggers an action, or remove it.',
|
|
142
|
+
evidence: ev,
|
|
143
|
+
routes: uniqueRoutes(ev),
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
export const unresponsiveButtons = {
|
|
149
|
+
id: 'unresponsive-buttons',
|
|
150
|
+
title: 'Buttons that do nothing',
|
|
151
|
+
category: 'functional',
|
|
152
|
+
needs: 'rendered',
|
|
153
|
+
verified: false,
|
|
154
|
+
description: 'Safe-to-click buttons that produced no navigation, DOM change, or error when clicked.',
|
|
155
|
+
run({ crawl }) {
|
|
156
|
+
const ev = [];
|
|
157
|
+
const errors = [];
|
|
158
|
+
for (const p of crawl?.pages ?? []) {
|
|
159
|
+
for (const i of p.interactions) {
|
|
160
|
+
if (i.outcome === 'no-effect')
|
|
161
|
+
ev.push({ type: 'dom', route: p.path, viewport: 'desktop', selector: i.selector, rect: i.rect, excerpt: i.text });
|
|
162
|
+
if (i.outcome === 'error')
|
|
163
|
+
errors.push({ type: 'dom', route: p.path, viewport: 'desktop', selector: i.selector, rect: i.rect, excerpt: i.text, detail: i.detail });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const out = [];
|
|
167
|
+
if (ev.length)
|
|
168
|
+
out.push({
|
|
169
|
+
severity: 'medium',
|
|
170
|
+
confidence: 0.6,
|
|
171
|
+
description: `${plural(ev.length, 'button')} produced no visible change when clicked (no navigation, no DOM update). Hover-only or CSS-only effects can cause false positives, so confirm by hand.`,
|
|
172
|
+
recommendation: 'Wire the button to its action, or remove it. If it is a placeholder for a future feature, hide it until the feature exists.',
|
|
173
|
+
evidence: ev,
|
|
174
|
+
routes: uniqueRoutes(ev),
|
|
175
|
+
});
|
|
176
|
+
if (errors.length)
|
|
177
|
+
out.push({
|
|
178
|
+
title: 'Buttons that throw errors',
|
|
179
|
+
severity: 'high',
|
|
180
|
+
confidence: 0.9,
|
|
181
|
+
description: `${plural(errors.length, 'button')} threw a JavaScript error when clicked.`,
|
|
182
|
+
recommendation: 'Fix the click handler; the error message is shown with each item.',
|
|
183
|
+
evidence: errors,
|
|
184
|
+
routes: uniqueRoutes(errors),
|
|
185
|
+
});
|
|
186
|
+
return out;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
export const emptyControls = {
|
|
190
|
+
id: 'empty-controls',
|
|
191
|
+
title: 'Buttons and links without an accessible name',
|
|
192
|
+
category: 'accessibility',
|
|
193
|
+
needs: 'rendered',
|
|
194
|
+
verified: true,
|
|
195
|
+
description: 'Interactive elements with no text, aria-label, or title.',
|
|
196
|
+
run({ crawl }) {
|
|
197
|
+
const ev = [];
|
|
198
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
199
|
+
for (const c of facts.controls)
|
|
200
|
+
if (!c.name.trim())
|
|
201
|
+
ev.push(el(page, 'desktop', c, `<${c.tag}>`));
|
|
202
|
+
for (const l of facts.links)
|
|
203
|
+
if (!l.text.trim() && !facts.controls.some((c) => c.selector === l.selector))
|
|
204
|
+
ev.push(el(page, 'desktop', l, `<a href="${truncate(l.href, 60)}">`));
|
|
205
|
+
}
|
|
206
|
+
if (!ev.length)
|
|
207
|
+
return [];
|
|
208
|
+
return [
|
|
209
|
+
{
|
|
210
|
+
severity: 'high',
|
|
211
|
+
confidence: 0.9,
|
|
212
|
+
description: `${plural(ev.length, 'interactive element')} have no accessible name. Screen readers announce them as just "button" or "link". Icon-only buttons are the usual cause.`,
|
|
213
|
+
recommendation: 'Add an aria-label to icon-only buttons and links, or include visually hidden text.',
|
|
214
|
+
evidence: ev,
|
|
215
|
+
routes: uniqueRoutes(ev),
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
export const brokenImages = {
|
|
221
|
+
id: 'broken-images',
|
|
222
|
+
title: 'Broken images',
|
|
223
|
+
category: 'functional',
|
|
224
|
+
needs: 'rendered',
|
|
225
|
+
verified: true,
|
|
226
|
+
description: 'Images that failed to load.',
|
|
227
|
+
run({ crawl }) {
|
|
228
|
+
const ev = [];
|
|
229
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop'))
|
|
230
|
+
for (const i of facts.images)
|
|
231
|
+
if (i.broken)
|
|
232
|
+
ev.push(el(page, 'desktop', i, truncate(i.src, 120)));
|
|
233
|
+
if (!ev.length)
|
|
234
|
+
return [];
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
severity: 'medium',
|
|
238
|
+
confidence: 0.9,
|
|
239
|
+
description: `${plural(ev.length, 'image')} failed to load.`,
|
|
240
|
+
recommendation: 'Fix the image paths or remove the images.',
|
|
241
|
+
evidence: ev,
|
|
242
|
+
routes: uniqueRoutes(ev),
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
export const missingAlt = {
|
|
248
|
+
id: 'missing-alt',
|
|
249
|
+
title: 'Images without alt text',
|
|
250
|
+
category: 'accessibility',
|
|
251
|
+
needs: 'rendered',
|
|
252
|
+
verified: true,
|
|
253
|
+
description: 'Images with no alt attribute.',
|
|
254
|
+
run({ crawl }) {
|
|
255
|
+
const ev = [];
|
|
256
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop'))
|
|
257
|
+
for (const i of facts.images)
|
|
258
|
+
if (i.alt === null && !i.decorative)
|
|
259
|
+
ev.push(el(page, 'desktop', i, truncate(i.src, 120)));
|
|
260
|
+
if (!ev.length)
|
|
261
|
+
return [];
|
|
262
|
+
return [
|
|
263
|
+
{
|
|
264
|
+
severity: 'medium',
|
|
265
|
+
confidence: 0.9,
|
|
266
|
+
description: `${plural(ev.length, 'image')} have no alt attribute.`,
|
|
267
|
+
recommendation: 'Describe informative images in alt text; use alt="" for purely decorative ones.',
|
|
268
|
+
evidence: ev,
|
|
269
|
+
routes: uniqueRoutes(ev),
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
export const unlabeledInputs = {
|
|
275
|
+
id: 'unlabeled-inputs',
|
|
276
|
+
title: 'Form fields without labels',
|
|
277
|
+
category: 'accessibility',
|
|
278
|
+
needs: 'rendered',
|
|
279
|
+
verified: true,
|
|
280
|
+
description: 'Inputs with no associated label or aria-label. Placeholders are not labels.',
|
|
281
|
+
run({ crawl }) {
|
|
282
|
+
const ev = [];
|
|
283
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop'))
|
|
284
|
+
for (const i of facts.unlabeledInputs)
|
|
285
|
+
ev.push(el(page, 'desktop', i, `<${i.type}>`));
|
|
286
|
+
if (!ev.length)
|
|
287
|
+
return [];
|
|
288
|
+
return [
|
|
289
|
+
{
|
|
290
|
+
severity: 'medium',
|
|
291
|
+
confidence: 0.9,
|
|
292
|
+
description: `${plural(ev.length, 'form field')} have no label. Placeholder text disappears on input and is not announced reliably.`,
|
|
293
|
+
recommendation: 'Add a visible <label for=...> to each field, or aria-label where a visible label truly does not fit.',
|
|
294
|
+
evidence: ev,
|
|
295
|
+
routes: uniqueRoutes(ev),
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
export const headingStructure = {
|
|
301
|
+
id: 'heading-structure',
|
|
302
|
+
title: 'Heading structure problems',
|
|
303
|
+
category: 'accessibility',
|
|
304
|
+
needs: 'rendered',
|
|
305
|
+
verified: true,
|
|
306
|
+
description: 'Pages with no h1, several h1s, or skipped heading levels.',
|
|
307
|
+
run({ crawl }) {
|
|
308
|
+
const ev = [];
|
|
309
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
310
|
+
const h1 = facts.headings.filter((h) => h.level === 1);
|
|
311
|
+
if (h1.length === 0)
|
|
312
|
+
ev.push({ type: 'metric', route: page.path, detail: 'no h1' });
|
|
313
|
+
if (h1.length > 1)
|
|
314
|
+
ev.push({ type: 'metric', route: page.path, detail: `${h1.length} h1 elements` });
|
|
315
|
+
let prev = 0;
|
|
316
|
+
for (const h of facts.headings) {
|
|
317
|
+
if (prev && h.level > prev + 1) {
|
|
318
|
+
ev.push(el(page, 'desktop', h, truncate(h.text, 60), `h${prev} followed by h${h.level}`));
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
prev = h.level;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (!ev.length)
|
|
325
|
+
return [];
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
severity: 'low',
|
|
329
|
+
confidence: 0.9,
|
|
330
|
+
description: `${plural(uniqueRoutes(ev).length, 'page')} have heading structure problems. Headings are how assistive technology users skim a page.`,
|
|
331
|
+
recommendation: 'Use exactly one h1 per page and do not skip levels; style headings with CSS instead of picking tags for their size.',
|
|
332
|
+
evidence: ev,
|
|
333
|
+
routes: uniqueRoutes(ev),
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
export const missingMetadata = {
|
|
339
|
+
id: 'missing-metadata',
|
|
340
|
+
title: 'Missing page metadata',
|
|
341
|
+
category: 'technical',
|
|
342
|
+
needs: 'rendered',
|
|
343
|
+
verified: true,
|
|
344
|
+
penalty: 25,
|
|
345
|
+
description: 'Missing <title>, meta description, or html lang attribute.',
|
|
346
|
+
run({ crawl }) {
|
|
347
|
+
const ev = [];
|
|
348
|
+
const titles = new Map();
|
|
349
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
350
|
+
if (!facts.title.trim())
|
|
351
|
+
ev.push({ type: 'metric', route: page.path, detail: 'no <title>' });
|
|
352
|
+
else
|
|
353
|
+
titles.set(facts.title, [...(titles.get(facts.title) ?? []), page.path]);
|
|
354
|
+
if (!facts.metaDescription)
|
|
355
|
+
ev.push({ type: 'metric', route: page.path, detail: 'no meta description' });
|
|
356
|
+
if (!facts.lang)
|
|
357
|
+
ev.push({ type: 'metric', route: page.path, detail: 'no lang attribute on <html>' });
|
|
358
|
+
}
|
|
359
|
+
for (const [t, pages] of titles)
|
|
360
|
+
if (pages.length >= 3)
|
|
361
|
+
ev.push({ type: 'metric', route: pages[0], detail: `${pages.length} pages share the title "${truncate(t, 60)}"` });
|
|
362
|
+
if (!ev.length)
|
|
363
|
+
return [];
|
|
364
|
+
return [
|
|
365
|
+
{
|
|
366
|
+
severity: 'low',
|
|
367
|
+
confidence: 0.9,
|
|
368
|
+
description: `${plural(ev.length, 'metadata gap')} across ${plural(uniqueRoutes(ev).length, 'page')}.`,
|
|
369
|
+
recommendation: 'Give each page a unique title and description, and set <html lang>.',
|
|
370
|
+
evidence: ev,
|
|
371
|
+
routes: uniqueRoutes(ev),
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
export const horizontalOverflow = {
|
|
377
|
+
id: 'horizontal-overflow',
|
|
378
|
+
title: 'Horizontal scrolling on mobile',
|
|
379
|
+
category: 'functional',
|
|
380
|
+
needs: 'rendered',
|
|
381
|
+
verified: true,
|
|
382
|
+
description: 'Content wider than the viewport, which causes sideways scrolling.',
|
|
383
|
+
run({ crawl }) {
|
|
384
|
+
const ev = [];
|
|
385
|
+
for (const vp of ['mobile', 'desktop']) {
|
|
386
|
+
for (const { page, facts } of pagesWith(crawl, vp)) {
|
|
387
|
+
if (facts.scrollWidth <= facts.width + 1)
|
|
388
|
+
continue;
|
|
389
|
+
const offenders = facts.overflowing.slice(0, 3);
|
|
390
|
+
if (!offenders.length)
|
|
391
|
+
ev.push({ type: 'metric', route: page.path, viewport: vp, detail: `page is ${facts.scrollWidth}px wide in a ${facts.width}px viewport` });
|
|
392
|
+
for (const o of offenders)
|
|
393
|
+
ev.push(el(page, vp, o, undefined, `extends to ${o.right}px in a ${facts.width}px viewport`));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (!ev.length)
|
|
397
|
+
return [];
|
|
398
|
+
const mobile = ev.filter((e) => e.viewport === 'mobile');
|
|
399
|
+
return [
|
|
400
|
+
{
|
|
401
|
+
severity: 'high',
|
|
402
|
+
confidence: 0.9,
|
|
403
|
+
description: `${plural(uniqueRoutes(ev).length, 'page')} scroll sideways${mobile.length ? ' on mobile' : ''}. Elements are wider than the screen.`,
|
|
404
|
+
recommendation: 'Find the listed elements and constrain them: max-width: 100%, flex-wrap, min-width: 0 on flex children, or overflow-x: auto on wide tables and code blocks.',
|
|
405
|
+
evidence: ev,
|
|
406
|
+
routes: uniqueRoutes(ev),
|
|
407
|
+
},
|
|
408
|
+
];
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
export const smallTargets = {
|
|
412
|
+
id: 'small-touch-targets',
|
|
413
|
+
title: 'Touch targets too small',
|
|
414
|
+
category: 'accessibility',
|
|
415
|
+
needs: 'rendered',
|
|
416
|
+
verified: true,
|
|
417
|
+
description: 'Buttons and links smaller than 24x24 CSS pixels on mobile.',
|
|
418
|
+
run({ crawl }) {
|
|
419
|
+
const ev = [];
|
|
420
|
+
for (const { page, facts } of pagesWith(crawl, 'mobile'))
|
|
421
|
+
for (const t of facts.smallTargets)
|
|
422
|
+
ev.push(el(page, 'mobile', t, t.text || undefined, `${t.w}x${t.h}px`));
|
|
423
|
+
if (!ev.length)
|
|
424
|
+
return [];
|
|
425
|
+
return [
|
|
426
|
+
{
|
|
427
|
+
severity: ev.length >= 6 ? 'medium' : 'low',
|
|
428
|
+
confidence: 0.9,
|
|
429
|
+
description: `${plural(ev.length, 'control')} are smaller than 24x24px on mobile (WCAG 2.5.8 minimum).`,
|
|
430
|
+
recommendation: 'Increase padding on small controls so the tappable area is at least 24px, ideally 44px.',
|
|
431
|
+
evidence: ev,
|
|
432
|
+
routes: uniqueRoutes(ev),
|
|
433
|
+
},
|
|
434
|
+
];
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
export const tinyText = {
|
|
438
|
+
id: 'tiny-text',
|
|
439
|
+
title: 'Text too small to read on mobile',
|
|
440
|
+
category: 'accessibility',
|
|
441
|
+
needs: 'rendered',
|
|
442
|
+
verified: true,
|
|
443
|
+
penalty: 25,
|
|
444
|
+
description: 'Text set below 12px on mobile.',
|
|
445
|
+
run({ crawl }) {
|
|
446
|
+
const ev = [];
|
|
447
|
+
for (const { page, facts } of pagesWith(crawl, 'mobile'))
|
|
448
|
+
for (const t of facts.tinyText)
|
|
449
|
+
ev.push(el(page, 'mobile', t, truncate(t.text, 60), `${t.size}px`));
|
|
450
|
+
if (ev.length < 3)
|
|
451
|
+
return [];
|
|
452
|
+
return [
|
|
453
|
+
{
|
|
454
|
+
severity: 'low',
|
|
455
|
+
confidence: 0.9,
|
|
456
|
+
description: `${plural(ev.length, 'text element')} are smaller than 12px on mobile.`,
|
|
457
|
+
recommendation: 'Keep body text at 16px and secondary text at 13 to 14px or larger on small screens.',
|
|
458
|
+
evidence: ev,
|
|
459
|
+
routes: uniqueRoutes(ev),
|
|
460
|
+
},
|
|
461
|
+
];
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
export const functionalRules = [
|
|
465
|
+
pageLoadFailed, brokenLinks, consoleErrors, failedRequests, deadLinks, unresponsiveButtons, emptyControls, brokenImages,
|
|
466
|
+
missingAlt, unlabeledInputs, headingStructure, missingMetadata, horizontalOverflow, smallTargets, tinyText,
|
|
467
|
+
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CrawlResult, ElementRef, Evidence, PageFacts, ViewportFacts, ViewportName } from '../types.js';
|
|
2
|
+
export declare function pagesWith(crawl: CrawlResult | undefined, vp: ViewportName): {
|
|
3
|
+
page: PageFacts;
|
|
4
|
+
facts: ViewportFacts;
|
|
5
|
+
}[];
|
|
6
|
+
export declare function el(page: PageFacts, vp: ViewportName, ref: ElementRef, excerpt?: string, detail?: string): Evidence;
|
|
7
|
+
export declare function plural(n: number, word: string, pluralWord?: string): string;
|
|
8
|
+
export declare function uniqueRoutes(evidence: Evidence[]): string[];
|
|
9
|
+
/** True if the element sits inside a header/nav/footer section of the page. */
|
|
10
|
+
export declare function inChrome(facts: ViewportFacts, ref: ElementRef): boolean;
|
|
11
|
+
export declare function topEntries(rec: Record<string, number>, n: number): [string, number][];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function pagesWith(crawl, vp) {
|
|
2
|
+
if (!crawl)
|
|
3
|
+
return [];
|
|
4
|
+
const out = [];
|
|
5
|
+
for (const page of crawl.pages) {
|
|
6
|
+
const facts = page[vp];
|
|
7
|
+
if (facts)
|
|
8
|
+
out.push({ page, facts });
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
export function el(page, vp, ref, excerpt, detail) {
|
|
13
|
+
return { type: 'dom', route: page.path, viewport: vp, selector: ref.selector, rect: ref.rect, excerpt, detail };
|
|
14
|
+
}
|
|
15
|
+
export function plural(n, word, pluralWord = word + 's') {
|
|
16
|
+
return `${n} ${n === 1 ? word : pluralWord}`;
|
|
17
|
+
}
|
|
18
|
+
export function uniqueRoutes(evidence) {
|
|
19
|
+
return [...new Set(evidence.map((e) => e.route).filter((p) => !!p))];
|
|
20
|
+
}
|
|
21
|
+
/** True if the element sits inside a header/nav/footer section of the page. */
|
|
22
|
+
export function inChrome(facts, ref) {
|
|
23
|
+
return facts.sections.some((s) => s.isChrome && ref.rect.y >= s.rect.y - 1 && ref.rect.y + ref.rect.height <= s.rect.y + s.rect.height + 1);
|
|
24
|
+
}
|
|
25
|
+
export function topEntries(rec, n) {
|
|
26
|
+
return Object.entries(rec)
|
|
27
|
+
.sort((a, b) => b[1] - a[1])
|
|
28
|
+
.slice(0, n);
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Finding } from '../types.js';
|
|
2
|
+
import { type Rule, type RuleContext } from './types.js';
|
|
3
|
+
export declare const allRules: Rule[];
|
|
4
|
+
export declare const ruleVersions: () => Record<string, number>;
|
|
5
|
+
export declare const rulePenalties: () => Record<string, number | undefined>;
|
|
6
|
+
export interface RuleRun {
|
|
7
|
+
findings: Finding[];
|
|
8
|
+
errors: {
|
|
9
|
+
ruleId: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}[];
|
|
12
|
+
}
|
|
13
|
+
export declare function runRules(ctx: RuleContext, ignore?: string[]): RuleRun;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { locateText } from '../static/scan.js';
|
|
2
|
+
import { contentRules } from './content.js';
|
|
3
|
+
import { functionalRules } from './functional.js';
|
|
4
|
+
import { normalizeFindings, validateDraft } from './normalize.js';
|
|
5
|
+
import { staticRules } from './static.js';
|
|
6
|
+
import { toDraft } from './types.js';
|
|
7
|
+
import { visualRules } from './visual.js';
|
|
8
|
+
export const allRules = [...visualRules, ...contentRules, ...functionalRules, ...staticRules];
|
|
9
|
+
export const ruleVersions = () => Object.fromEntries(allRules.map((r) => [r.id, r.version ?? 1]));
|
|
10
|
+
export const rulePenalties = () => Object.fromEntries(allRules.map((r) => [r.id, r.penalty]));
|
|
11
|
+
/** Rendered rules whose element evidence carries visible text worth tracing back to source. */
|
|
12
|
+
const TRACEABLE = new Set(['stock-copy', 'placeholder-content', 'duplicate-copy', 'unverified-claims', 'emoji-icons', 'generic-link-text']);
|
|
13
|
+
export function runRules(ctx, ignore = []) {
|
|
14
|
+
const drafts = [];
|
|
15
|
+
const errors = [];
|
|
16
|
+
const ignored = new Set(ignore);
|
|
17
|
+
const deferred = [];
|
|
18
|
+
for (const rule of allRules) {
|
|
19
|
+
if (ignored.has(rule.id) || (rule.mergeInto && ignored.has(rule.mergeInto)))
|
|
20
|
+
continue;
|
|
21
|
+
if (rule.needs === 'rendered' && !ctx.crawl)
|
|
22
|
+
continue;
|
|
23
|
+
if (rule.needs === 'static' && !ctx.repo)
|
|
24
|
+
continue;
|
|
25
|
+
let inputs;
|
|
26
|
+
try {
|
|
27
|
+
inputs = rule.run(ctx);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
errors.push({ ruleId: rule.id, message: err.message });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const input of inputs) {
|
|
34
|
+
const d = toDraft(rule, input, rule.needs === 'static' ? 'static' : 'rendered');
|
|
35
|
+
if (rule.mergeInto)
|
|
36
|
+
deferred.push({ rule, draft: d });
|
|
37
|
+
else
|
|
38
|
+
drafts.push(d);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Attach source locations to rendered text findings when the exact text is found in the repo.
|
|
42
|
+
if (ctx.repo) {
|
|
43
|
+
for (const d of drafts) {
|
|
44
|
+
if (!TRACEABLE.has(d.ruleId))
|
|
45
|
+
continue;
|
|
46
|
+
for (const e of d.evidence) {
|
|
47
|
+
if (!e.excerpt || e.file)
|
|
48
|
+
continue;
|
|
49
|
+
const text = e.excerpt.replace(/^…/, '').replace(/…$/, '');
|
|
50
|
+
const loc = locateText(ctx.repo.files, text, 1, e.route)[0];
|
|
51
|
+
if (loc) {
|
|
52
|
+
e.file = loc.file;
|
|
53
|
+
e.line = loc.line;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Fold static mirrors into their rendered counterpart when it fired; otherwise keep them standalone.
|
|
59
|
+
for (const { rule, draft } of deferred) {
|
|
60
|
+
const target = drafts.find((f) => f.ruleId === rule.mergeInto);
|
|
61
|
+
if (target) {
|
|
62
|
+
const have = new Set(target.evidence.filter((e) => e.file).map((e) => `${e.file}:${e.line}`));
|
|
63
|
+
const extra = draft.evidence.filter((e) => !have.has(`${e.file}:${e.line}`)).slice(0, 8);
|
|
64
|
+
target.evidence.push(...extra);
|
|
65
|
+
if (extra.length)
|
|
66
|
+
target.description += ` Source locations: ${draft.evidence.length} matching line${draft.evidence.length === 1 ? '' : 's'} in the repository.`;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
drafts.push(draft);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Screenshot references for element evidence, so each item points at an image of where it is.
|
|
73
|
+
if (ctx.crawl) {
|
|
74
|
+
const shots = new Map();
|
|
75
|
+
for (const p of ctx.crawl.pages)
|
|
76
|
+
for (const vp of ['desktop', 'mobile']) {
|
|
77
|
+
const file = p[vp]?.screenshot;
|
|
78
|
+
if (file)
|
|
79
|
+
shots.set(`${p.path}|${vp}`, `shots/${file}`);
|
|
80
|
+
}
|
|
81
|
+
for (const d of drafts)
|
|
82
|
+
for (const e of d.evidence) {
|
|
83
|
+
if (e.route && e.viewport && !e.screenshotPath)
|
|
84
|
+
e.screenshotPath = shots.get(`${e.route}|${e.viewport}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// A rule bug (say, confidence out of range) drops that one finding, not the whole audit.
|
|
88
|
+
const valid = drafts.filter((d) => {
|
|
89
|
+
try {
|
|
90
|
+
validateDraft(d);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
errors.push({ ruleId: d.ruleId, message: err.message });
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const findings = normalizeFindings(valid, ctx.repo ? new Set(ctx.repo.files.map((f) => f.path)) : undefined);
|
|
99
|
+
return { findings, errors };
|
|
100
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Evidence, Finding } from '../types.js';
|
|
2
|
+
import type { DraftFinding } from './types.js';
|
|
3
|
+
export declare function redact(text: string): string;
|
|
4
|
+
/** A source path is kept only if it is relative, inside the project, and in the scanned file set. */
|
|
5
|
+
export declare function safeSourcePath(file: string, known?: Set<string>): string | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Identical evidence on several routes (the same footer link on every page) is one problem
|
|
8
|
+
* in one shared component. Keep the first occurrence and list the other routes.
|
|
9
|
+
*/
|
|
10
|
+
export declare function collapseEvidence(evidence: Evidence[]): Evidence[];
|
|
11
|
+
/** Stable hash of rule identity and evidence identity, independent of routes and order. */
|
|
12
|
+
export declare function fingerprint(ruleId: string, evidence: Evidence[]): string;
|
|
13
|
+
export declare function validateDraft(d: DraftFinding): void;
|
|
14
|
+
/**
|
|
15
|
+
* Validates drafts, cleans evidence, assigns fingerprints and ids, and merges findings that
|
|
16
|
+
* share a fingerprint (same rule, same evidence), keeping the higher severity and confidence.
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalizeFindings(drafts: DraftFinding[], knownFiles?: Set<string>): Finding[];
|