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,62 @@
|
|
|
1
|
+
/** Shared by the HTML report's copy buttons, fix-prompt.md, and `samey prompt`. */
|
|
2
|
+
export const PROMPT_HEADER = 'Fix the following UI issues reported by samey. Make focused changes, keep unrelated code as is, and re-run samey afterwards to verify.\n\n';
|
|
3
|
+
/** One finding as plain text an AI coding agent can act on. */
|
|
4
|
+
export function fixPrompt(f) {
|
|
5
|
+
const ev = f.evidence
|
|
6
|
+
.slice(0, 10)
|
|
7
|
+
.map((e) => {
|
|
8
|
+
const bits = [
|
|
9
|
+
e.route && `route ${e.route}${e.viewport ? ` (${e.viewport})` : ''}${e.alsoOn?.length ? `, also on ${e.alsoOn.join(', ')}` : ''}`,
|
|
10
|
+
e.file && `${e.file}${e.line ? `:${e.line}` : ''}`,
|
|
11
|
+
e.selector && `selector \`${e.selector}\``,
|
|
12
|
+
e.excerpt && `"${e.excerpt}"`,
|
|
13
|
+
e.detail,
|
|
14
|
+
].filter(Boolean);
|
|
15
|
+
return `- ${bits.join(', ')}`;
|
|
16
|
+
})
|
|
17
|
+
.join('\n');
|
|
18
|
+
return `[${f.ruleId}] ${f.title}${f.verified ? '' : ' (design observation, use judgment)'}\n${f.description}\n${ev ? `Evidence:\n${ev}\n` : ''}Fix: ${f.recommendation}`;
|
|
19
|
+
}
|
|
20
|
+
function rerunCommand(report) {
|
|
21
|
+
if (report.target.url && report.target.path)
|
|
22
|
+
return `npx samey-cli audit . --url ${report.target.url}`;
|
|
23
|
+
if (report.target.url)
|
|
24
|
+
return `npx samey-cli audit --url ${report.target.url}`;
|
|
25
|
+
return 'npx samey-cli audit .';
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The whole audit as one prompt: measured defects first, then design observations the agent
|
|
29
|
+
* should weigh rather than apply blindly. Info-level and advisory model findings are left out.
|
|
30
|
+
*/
|
|
31
|
+
export function fullPrompt(report, opts = {}) {
|
|
32
|
+
const actionable = report.findings.filter((f) => !f.advisory && f.severity !== 'info');
|
|
33
|
+
const defects = actionable.filter((f) => f.verified);
|
|
34
|
+
const observations = opts.onlyDefects ? [] : actionable.filter((f) => !f.verified);
|
|
35
|
+
if (!defects.length && !observations.length)
|
|
36
|
+
return null;
|
|
37
|
+
const context = [];
|
|
38
|
+
if (report.target.url)
|
|
39
|
+
context.push(`Site: ${report.target.url}`);
|
|
40
|
+
if (report.repo?.frameworks.length)
|
|
41
|
+
context.push(`Stack: ${report.repo.frameworks.join(', ')}`);
|
|
42
|
+
if (report.score !== null)
|
|
43
|
+
context.push(`Current slop score: ${report.score}/100 (lower is better)`);
|
|
44
|
+
const out = [PROMPT_HEADER.trimEnd(), ''];
|
|
45
|
+
if (context.length)
|
|
46
|
+
out.push(context.join('\n'), '');
|
|
47
|
+
out.push('Work through the list in order. Each item names the page, the CSS selector, and the source file and line when samey could trace it. ' +
|
|
48
|
+
'Where a fix needs content only a person can provide (real testimonials, real numbers, a brand colour), ask instead of inventing it.', '');
|
|
49
|
+
let n = 0;
|
|
50
|
+
if (defects.length) {
|
|
51
|
+
out.push('## Defects (measured, fix these)', '');
|
|
52
|
+
for (const f of defects)
|
|
53
|
+
out.push(`${++n}. ${fixPrompt(f)}`, '');
|
|
54
|
+
}
|
|
55
|
+
if (observations.length) {
|
|
56
|
+
out.push('## Design observations (heuristics, use judgment)', '');
|
|
57
|
+
for (const f of observations)
|
|
58
|
+
out.push(`${++n}. ${fixPrompt(f)}`, '');
|
|
59
|
+
}
|
|
60
|
+
out.push(`When you are done, run \`${rerunCommand(report)}\` and confirm these findings are gone.`);
|
|
61
|
+
return out.join('\n') + '\n';
|
|
62
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { CATEGORY_LABELS } from '../score.js';
|
|
4
|
+
const SEV_COLOR = {
|
|
5
|
+
critical: (s) => pc.bold(pc.red(s)),
|
|
6
|
+
high: pc.red,
|
|
7
|
+
medium: pc.yellow,
|
|
8
|
+
low: pc.dim,
|
|
9
|
+
info: pc.dim,
|
|
10
|
+
};
|
|
11
|
+
export function summarize(report, files) {
|
|
12
|
+
const out = [];
|
|
13
|
+
const scored = report.findings.filter((f) => !f.advisory);
|
|
14
|
+
const cov = report.coverage;
|
|
15
|
+
out.push('');
|
|
16
|
+
if (report.score === null)
|
|
17
|
+
out.push(` ${pc.bold('Slop score n/a')} ${pc.dim('nothing could be measured')}`);
|
|
18
|
+
else
|
|
19
|
+
out.push(` ${pc.bold(`Slop score ${report.score}`)}${pc.dim('/100, lower is better')}${cov.rendered ? '' : pc.dim(' (static analysis only)')}`);
|
|
20
|
+
out.push('');
|
|
21
|
+
for (const c of report.categories) {
|
|
22
|
+
const label = CATEGORY_LABELS[c.category].padEnd(28);
|
|
23
|
+
if (c.score === null) {
|
|
24
|
+
out.push(pc.dim(` ${label} not measured`));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const filled = Math.round(c.score / 5);
|
|
28
|
+
const bar = '█'.repeat(filled) + pc.dim('░'.repeat(20 - filled));
|
|
29
|
+
out.push(` ${label} ${bar} ${String(Math.round(c.score)).padStart(3)}`);
|
|
30
|
+
}
|
|
31
|
+
out.push('');
|
|
32
|
+
out.push(pc.dim(` ${cov.pagesAudited} page${cov.pagesAudited === 1 ? '' : 's'} rendered` +
|
|
33
|
+
(report.repo ? `, ${report.repo.fileCount} source files scanned` : '') +
|
|
34
|
+
(cov.routeLimitHit ? `, route limit reached` : '') +
|
|
35
|
+
(cov.blockedRequests.length ? `, ${cov.blockedRequests.length} requests blocked by network policy` : '')));
|
|
36
|
+
if (cov.renderedNote)
|
|
37
|
+
out.push(pc.yellow(` ${cov.renderedNote}`));
|
|
38
|
+
if (scored.length) {
|
|
39
|
+
out.push('');
|
|
40
|
+
out.push(pc.bold(' Fix first'));
|
|
41
|
+
for (const f of scored.slice(0, 5)) {
|
|
42
|
+
const where = f.evidence.find((e) => e.file) ?? f.evidence.find((e) => e.route);
|
|
43
|
+
const loc = where?.file ? `${where.file}${where.line ? `:${where.line}` : ''}` : where?.route ?? '';
|
|
44
|
+
out.push(` ${SEV_COLOR[f.severity](f.severity.padEnd(8))} ${f.title}${f.verified ? '' : pc.dim(' (observation)')}${loc ? pc.dim(` ${loc}`) : ''}`);
|
|
45
|
+
}
|
|
46
|
+
if (scored.length > 5)
|
|
47
|
+
out.push(pc.dim(` …and ${scored.length - 5} more in the report`));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
out.push('');
|
|
51
|
+
out.push(' No findings.');
|
|
52
|
+
}
|
|
53
|
+
out.push('');
|
|
54
|
+
const rel = (p) => {
|
|
55
|
+
const r = path.relative(process.cwd(), p);
|
|
56
|
+
return !r || r.startsWith('..') ? p : r;
|
|
57
|
+
};
|
|
58
|
+
if (files.html)
|
|
59
|
+
out.push(` Report ${pc.underline(rel(files.html))}`);
|
|
60
|
+
if (files.json)
|
|
61
|
+
out.push(` JSON ${rel(files.json)}`);
|
|
62
|
+
if (files.prompt) {
|
|
63
|
+
out.push(` Prompt ${rel(files.prompt)}`);
|
|
64
|
+
// Spell out the exact command, since `samey prompt` looks in <path>/.samey unless told otherwise.
|
|
65
|
+
const dir = path.dirname(files.prompt);
|
|
66
|
+
const base = report.target.path ?? process.cwd();
|
|
67
|
+
const fromCwd = path.relative(process.cwd(), base);
|
|
68
|
+
const arg = dir !== path.join(base, '.samey') ? ` --out ${rel(dir)}` : !fromCwd ? '' : fromCwd.startsWith('..') ? ` ${base}` : ` ${fromCwd}`;
|
|
69
|
+
out.push(pc.dim(` Paste it into Claude Code, Cursor or Codex, or copy it: npx samey-cli prompt${arg}${process.platform === 'darwin' ? ' | pbcopy' : ''}`));
|
|
70
|
+
}
|
|
71
|
+
out.push('');
|
|
72
|
+
return out.join('\n');
|
|
73
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Rule } from './types.js';
|
|
2
|
+
export declare const stockCopy: Rule;
|
|
3
|
+
export declare const placeholderContent: Rule;
|
|
4
|
+
export declare const duplicateCopy: Rule;
|
|
5
|
+
export declare const unverifiedClaims: Rule;
|
|
6
|
+
export declare const anonymousTestimonials: Rule;
|
|
7
|
+
export declare const genericLinkText: Rule;
|
|
8
|
+
export declare const contentRules: Rule[];
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { ATTRIBUTION, findClaims, findPlaceholders, findStockPhrases, GENERIC_LINK_TEXT, snippetAround, truncate } from '../util/text.js';
|
|
2
|
+
import { el, inChrome, pagesWith, plural, uniqueRoutes } from './helpers.js';
|
|
3
|
+
const PLACEHOLDER_IMG = /(?:via\.placeholder\.com|placehold\.(?:co|it)|placekitten|picsum\.photos|dummyimage\.com|fakeimg\.pl|source\.unsplash\.com\/random|placeholder\.(?:png|jpe?g|svg|webp))/i;
|
|
4
|
+
export const stockCopy = {
|
|
5
|
+
id: 'stock-copy',
|
|
6
|
+
title: 'Interchangeable marketing copy',
|
|
7
|
+
category: 'content',
|
|
8
|
+
needs: 'rendered',
|
|
9
|
+
verified: false,
|
|
10
|
+
description: 'Headlines and body text built from stock phrases that could describe any product.',
|
|
11
|
+
run({ crawl }) {
|
|
12
|
+
const ev = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
let inHeadings = 0;
|
|
15
|
+
let inH1 = false;
|
|
16
|
+
const phrases = new Map();
|
|
17
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
18
|
+
for (const b of facts.textBlocks) {
|
|
19
|
+
if (!/^(?:h[1-6]|p|li|button|a|dd|figcaption|blockquote|span|div)$/.test(b.tag))
|
|
20
|
+
continue;
|
|
21
|
+
if (seen.has(b.text))
|
|
22
|
+
continue;
|
|
23
|
+
const matches = findStockPhrases(b.text);
|
|
24
|
+
if (!matches.length)
|
|
25
|
+
continue;
|
|
26
|
+
seen.add(b.text);
|
|
27
|
+
for (const m of matches)
|
|
28
|
+
phrases.set(m.phrase, (phrases.get(m.phrase) ?? 0) + 1);
|
|
29
|
+
if (/^h[1-6]$/.test(b.tag))
|
|
30
|
+
inHeadings++;
|
|
31
|
+
if (b.tag === 'h1')
|
|
32
|
+
inH1 = true;
|
|
33
|
+
ev.push(el(page, 'desktop', b, snippetAround(b.text, matches[0].index), matches.map((m) => `"${m.phrase}"`).join(', ')));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (ev.length < 2 && !inH1)
|
|
37
|
+
return [];
|
|
38
|
+
const top = [...phrases].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([p]) => `"${p}"`);
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
severity: inH1 || inHeadings >= 3 || ev.length >= 6 ? 'medium' : 'low',
|
|
42
|
+
confidence: 0.9,
|
|
43
|
+
description: `${plural(ev.length, 'text block')} lean on stock phrases (${top.join(', ')})${inHeadings ? `, ${inHeadings} of them in headings` : ''}. Copy like this could be pasted onto any product and says nothing about this one.`,
|
|
44
|
+
recommendation: 'Rewrite headings to state what the product does, for whom, and the concrete result. Replace adjectives with specifics: numbers you can back up, named features, a real example.',
|
|
45
|
+
evidence: ev.sort((a, b) => (b.detail?.length ?? 0) - (a.detail?.length ?? 0)),
|
|
46
|
+
routes: uniqueRoutes(ev),
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
export const placeholderContent = {
|
|
52
|
+
id: 'placeholder-content',
|
|
53
|
+
title: 'Placeholder content shipped',
|
|
54
|
+
category: 'content',
|
|
55
|
+
needs: 'rendered',
|
|
56
|
+
verified: true,
|
|
57
|
+
description: 'Lorem ipsum, sample names and companies, example domains, and placeholder images.',
|
|
58
|
+
run({ crawl }) {
|
|
59
|
+
const ev = [];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
62
|
+
for (const b of facts.textBlocks) {
|
|
63
|
+
if (seen.has(b.text))
|
|
64
|
+
continue;
|
|
65
|
+
const m = findPlaceholders(b.text);
|
|
66
|
+
if (!m.length)
|
|
67
|
+
continue;
|
|
68
|
+
seen.add(b.text);
|
|
69
|
+
ev.push(el(page, 'desktop', b, snippetAround(b.text, m[0].index), m.map((x) => x.phrase).join(', ')));
|
|
70
|
+
}
|
|
71
|
+
for (const img of facts.images) {
|
|
72
|
+
if (PLACEHOLDER_IMG.test(img.src))
|
|
73
|
+
ev.push(el(page, 'desktop', img, truncate(img.src, 100), 'placeholder image service'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!ev.length)
|
|
77
|
+
return [];
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
severity: ev.length >= 3 ? 'high' : 'medium',
|
|
81
|
+
confidence: 0.9,
|
|
82
|
+
description: `Found ${plural(ev.length, 'piece')} of placeholder content. Visitors notice this immediately and it signals the site is unfinished.`,
|
|
83
|
+
recommendation: 'Replace every placeholder with real content, or remove the section until the content exists.',
|
|
84
|
+
evidence: ev,
|
|
85
|
+
routes: uniqueRoutes(ev),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
export const duplicateCopy = {
|
|
91
|
+
id: 'duplicate-copy',
|
|
92
|
+
title: 'Repeated paragraphs',
|
|
93
|
+
category: 'content',
|
|
94
|
+
needs: 'rendered',
|
|
95
|
+
verified: true,
|
|
96
|
+
description: 'The same paragraph text repeated across the site outside header and footer.',
|
|
97
|
+
run({ crawl }) {
|
|
98
|
+
const byText = new Map();
|
|
99
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
100
|
+
for (const b of facts.textBlocks) {
|
|
101
|
+
if (b.tag !== 'p' && b.tag !== 'li' && b.tag !== 'dd')
|
|
102
|
+
continue;
|
|
103
|
+
if (b.text.length < 60 || inChrome(facts, b))
|
|
104
|
+
continue;
|
|
105
|
+
if (!byText.has(b.text))
|
|
106
|
+
byText.set(b.text, []);
|
|
107
|
+
byText.get(b.text).push(el(page, 'desktop', b, truncate(b.text, 100)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const dupes = [...byText].filter(([, ev]) => ev.length >= 2);
|
|
111
|
+
if (!dupes.length)
|
|
112
|
+
return [];
|
|
113
|
+
const ev = dupes.slice(0, 6).flatMap(([, e]) => e.slice(0, 2));
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
severity: dupes.length >= 3 ? 'medium' : 'low',
|
|
117
|
+
confidence: 0.9,
|
|
118
|
+
description: `${plural(dupes.length, 'paragraph')} appear more than once across the site. Repeated copy suggests sections were duplicated rather than written.`,
|
|
119
|
+
recommendation: 'Write copy specific to each place it appears, or remove the duplicate.',
|
|
120
|
+
evidence: ev,
|
|
121
|
+
routes: uniqueRoutes(ev),
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
export const unverifiedClaims = {
|
|
127
|
+
id: 'unverified-claims',
|
|
128
|
+
title: 'Unsupported credibility claims',
|
|
129
|
+
category: 'content',
|
|
130
|
+
needs: 'rendered',
|
|
131
|
+
verified: false,
|
|
132
|
+
penalty: 20,
|
|
133
|
+
description: 'User counts, ratings, and performance percentages stated without a source.',
|
|
134
|
+
run({ crawl }) {
|
|
135
|
+
const ev = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
138
|
+
for (const b of facts.textBlocks) {
|
|
139
|
+
if (b.text.length > 300 || seen.has(b.text))
|
|
140
|
+
continue;
|
|
141
|
+
const m = findClaims(b.text);
|
|
142
|
+
if (!m.length)
|
|
143
|
+
continue;
|
|
144
|
+
seen.add(b.text);
|
|
145
|
+
ev.push(el(page, 'desktop', b, snippetAround(b.text, m[0].index), m.map((x) => x.phrase).join(', ')));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!ev.length)
|
|
149
|
+
return [];
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
severity: ev.length >= 4 ? 'medium' : 'low',
|
|
153
|
+
confidence: 0.35,
|
|
154
|
+
description: `${plural(ev.length, 'claim')} such as user counts, ratings, or percentages appear without a visible source. If these are real, they are fine; generated sites often invent them.`,
|
|
155
|
+
recommendation: 'Keep only numbers you can substantiate, and link or footnote the source. Remove invented social proof before launch.',
|
|
156
|
+
evidence: ev,
|
|
157
|
+
routes: uniqueRoutes(ev),
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
export const anonymousTestimonials = {
|
|
163
|
+
id: 'anonymous-testimonials',
|
|
164
|
+
title: 'Testimonials without verifiable attribution',
|
|
165
|
+
category: 'content',
|
|
166
|
+
needs: 'rendered',
|
|
167
|
+
verified: false,
|
|
168
|
+
penalty: 20,
|
|
169
|
+
description: 'Testimonials attributed to a name and title with no photo, link, or company.',
|
|
170
|
+
run({ crawl }) {
|
|
171
|
+
const ev = [];
|
|
172
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
173
|
+
for (const s of facts.sections) {
|
|
174
|
+
if (s.isChrome)
|
|
175
|
+
continue;
|
|
176
|
+
const isTestimonials = s.hasBlockquote || /testimonial|what (?:our|people|customers)|loved by|customers say/i.test(s.heading ?? '');
|
|
177
|
+
if (!isTestimonials || !ATTRIBUTION.test(s.text))
|
|
178
|
+
continue;
|
|
179
|
+
if (s.imageCount > 0)
|
|
180
|
+
continue;
|
|
181
|
+
ev.push(el(page, 'desktop', s, truncate(s.text.match(ATTRIBUTION)?.[0] ?? s.heading ?? '', 80), 'quote with name and title only'));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!ev.length)
|
|
185
|
+
return [];
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
severity: 'low',
|
|
189
|
+
confidence: 0.35,
|
|
190
|
+
description: `${plural(ev.length, 'testimonial section')} attribute quotes to a name and job title with no photo, company link, or source. Readers increasingly assume these are invented.`,
|
|
191
|
+
recommendation: 'Use real testimonials with permission, a photo or company logo, and a link where possible. If you have none yet, remove the section.',
|
|
192
|
+
evidence: ev,
|
|
193
|
+
routes: uniqueRoutes(ev),
|
|
194
|
+
},
|
|
195
|
+
];
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
export const genericLinkText = {
|
|
199
|
+
id: 'generic-link-text',
|
|
200
|
+
title: 'Vague link and button labels',
|
|
201
|
+
category: 'accessibility',
|
|
202
|
+
needs: 'rendered',
|
|
203
|
+
verified: true,
|
|
204
|
+
penalty: 25,
|
|
205
|
+
description: 'Several links labelled "Learn more", "Read more", or "Click here".',
|
|
206
|
+
run({ crawl }) {
|
|
207
|
+
const ev = [];
|
|
208
|
+
for (const { page, facts } of pagesWith(crawl, 'desktop')) {
|
|
209
|
+
for (const l of facts.links)
|
|
210
|
+
if (GENERIC_LINK_TEXT.test(l.text.trim()))
|
|
211
|
+
ev.push(el(page, 'desktop', l, l.text));
|
|
212
|
+
}
|
|
213
|
+
if (ev.length < 3)
|
|
214
|
+
return [];
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
severity: 'low',
|
|
218
|
+
confidence: 0.9,
|
|
219
|
+
description: `${plural(ev.length, 'link')} use labels like "Learn more" that do not say where they go. Screen reader users navigating by links hear a list of identical labels.`,
|
|
220
|
+
recommendation: 'Name the destination: "Read the pricing FAQ", "See how imports work".',
|
|
221
|
+
evidence: ev,
|
|
222
|
+
routes: uniqueRoutes(ev),
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
export const contentRules = [stockCopy, placeholderContent, duplicateCopy, unverifiedClaims, anonymousTestimonials, genericLinkText];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Rule } from './types.js';
|
|
2
|
+
export declare const pageLoadFailed: Rule;
|
|
3
|
+
export declare const brokenLinks: Rule;
|
|
4
|
+
export declare const consoleErrors: Rule;
|
|
5
|
+
export declare const failedRequests: Rule;
|
|
6
|
+
export declare const deadLinks: Rule;
|
|
7
|
+
export declare const unresponsiveButtons: Rule;
|
|
8
|
+
export declare const emptyControls: Rule;
|
|
9
|
+
export declare const brokenImages: Rule;
|
|
10
|
+
export declare const missingAlt: Rule;
|
|
11
|
+
export declare const unlabeledInputs: Rule;
|
|
12
|
+
export declare const headingStructure: Rule;
|
|
13
|
+
export declare const missingMetadata: Rule;
|
|
14
|
+
export declare const horizontalOverflow: Rule;
|
|
15
|
+
export declare const smallTargets: Rule;
|
|
16
|
+
export declare const tinyText: Rule;
|
|
17
|
+
export declare const functionalRules: Rule[];
|