rpc-agent 0.4.0 → 0.5.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 +40 -0
- package/README.md +255 -1
- package/dist/args.js +101 -31
- package/dist/capture.js +20 -17
- package/dist/check-format.js +120 -0
- package/dist/check-page.js +336 -0
- package/dist/check.js +677 -0
- package/dist/cli.js +32 -1
- package/dist/help.js +25 -1
- package/dist/mcp.js +92 -14
- package/dist/options.js +14 -0
- package/dist/png.js +47 -0
- package/dist/ui.js +106 -0
- package/package.json +2 -2
package/dist/check.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
// Ship-check: a pre-launch audit of one URL. Each device gets the same
|
|
2
|
+
// context `rpc shot` would use; the page loads, settles, idles, scrolls once,
|
|
3
|
+
// and in-page scripts (check-page.ts) collect what the checks need. The
|
|
4
|
+
// findings that come out identical on several devices are merged.
|
|
5
|
+
import { mkdir } from 'node:fs/promises';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
|
|
8
|
+
import { launchBrowser } from './browser.js';
|
|
9
|
+
import { describeFailure, fileStem, newDeviceContext, settle } from './capture.js';
|
|
10
|
+
import { FONTS_JS, LABELS_JS, LAYOUT_SHIFT_INIT_JS, LAYOUT_SHIFTS_JS, META_JS, MOBILE_JS, SCROLL_ONCE_JS, } from './check-page.js';
|
|
11
|
+
import { resolveDevices } from './devices.js';
|
|
12
|
+
import { imageSize } from './png.js';
|
|
13
|
+
/** Report order and headings. */
|
|
14
|
+
export const CHECKS = [
|
|
15
|
+
{ id: 'basics', title: 'Basics' },
|
|
16
|
+
{ id: 'meta', title: 'Meta and social' },
|
|
17
|
+
{ id: 'layout-shift', title: 'Layout shift' },
|
|
18
|
+
{ id: 'labels', title: 'Unlabelled controls' },
|
|
19
|
+
{ id: 'fonts', title: 'Fonts' },
|
|
20
|
+
{ id: 'mobile', title: 'Mobile viewport' },
|
|
21
|
+
];
|
|
22
|
+
/** Bumped when a field changes meaning or goes away; new fields don't bump it. */
|
|
23
|
+
export const REPORT_SCHEMA = 1;
|
|
24
|
+
/** Google's CLS thresholds: good up to 0.1, poor above 0.25. */
|
|
25
|
+
export const CLS_GOOD = 0.1;
|
|
26
|
+
export const CLS_POOR = 0.25;
|
|
27
|
+
/** Totals below this are sub-pixel noise and count as zero. */
|
|
28
|
+
export const CLS_TOLERANCE = 0.001;
|
|
29
|
+
/** Fixed idle after load and settle, before the scroll. */
|
|
30
|
+
export const IDLE_MS = 500;
|
|
31
|
+
export const MIN_TAP = 44;
|
|
32
|
+
export async function runCheck(opts, hooks = {}) {
|
|
33
|
+
const devices = resolveDevices(opts.devices);
|
|
34
|
+
const auth = hooks.auth ?? resolveAuth(opts.url, opts.auth ?? {});
|
|
35
|
+
if (auth.summary)
|
|
36
|
+
hooks.onAuth?.(auth.summary);
|
|
37
|
+
if (opts.shots)
|
|
38
|
+
await mkdir(resolve(opts.outDir), { recursive: true });
|
|
39
|
+
const browser = hooks.browser ?? (await launchBrowser());
|
|
40
|
+
const cache = { probes: new Map() };
|
|
41
|
+
const results = [];
|
|
42
|
+
const found = [];
|
|
43
|
+
let ogImage;
|
|
44
|
+
try {
|
|
45
|
+
for (const device of devices) {
|
|
46
|
+
hooks.onDeviceStart?.(device);
|
|
47
|
+
const result = {
|
|
48
|
+
device: device.id,
|
|
49
|
+
name: device.name,
|
|
50
|
+
viewport: { width: device.width, height: device.height },
|
|
51
|
+
dpr: device.dpr,
|
|
52
|
+
mobile: isPhone(device),
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const out = await checkDevice(browser, device, opts, auth, cache, result);
|
|
56
|
+
for (const f of out.findings)
|
|
57
|
+
found.push({ device: device.id, finding: f });
|
|
58
|
+
ogImage ??= out.ogImage;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const failure = describeFailure(err, opts, auth);
|
|
62
|
+
Object.assign(result, failure);
|
|
63
|
+
found.push({
|
|
64
|
+
device: device.id,
|
|
65
|
+
finding: {
|
|
66
|
+
id: failure.code === 'AUTH_REQUIRED' ? 'auth-required' : 'page-load-failed',
|
|
67
|
+
check: 'basics',
|
|
68
|
+
severity: 'error',
|
|
69
|
+
message: failure.error,
|
|
70
|
+
fix: failure.hint ?? 'Check that the URL loads in a browser, then run the check again.',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
hooks.onDevice?.(result);
|
|
75
|
+
results.push(result);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (!hooks.browser)
|
|
80
|
+
await browser.close();
|
|
81
|
+
}
|
|
82
|
+
const findings = mergeFindings(found).map(f => scrub(f, auth.secrets));
|
|
83
|
+
return {
|
|
84
|
+
schema: REPORT_SCHEMA,
|
|
85
|
+
url: opts.url,
|
|
86
|
+
devices: results,
|
|
87
|
+
summary: summarize(findings),
|
|
88
|
+
findings,
|
|
89
|
+
...(ogImage ? { ogImage } : {}),
|
|
90
|
+
...(auth.summary ? { auth: auth.summary } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function isPhone(device) {
|
|
94
|
+
return device.category === 'apple-phone' || device.category === 'android-phone';
|
|
95
|
+
}
|
|
96
|
+
async function checkDevice(browser, device, opts, auth, cache, result) {
|
|
97
|
+
const context = await newDeviceContext(browser, device, opts, auth);
|
|
98
|
+
const uncaught = new Set();
|
|
99
|
+
const consoleErrors = new Set();
|
|
100
|
+
const mixed = new Map();
|
|
101
|
+
const fonts = new Map();
|
|
102
|
+
try {
|
|
103
|
+
const page = await context.newPage();
|
|
104
|
+
await applyHeaders(context, page, auth);
|
|
105
|
+
await page.addInitScript(LAYOUT_SHIFT_INIT_JS);
|
|
106
|
+
page.on('pageerror', e => uncaught.add(e.message.split('\n')[0] ?? e.message));
|
|
107
|
+
page.on('console', m => {
|
|
108
|
+
const text = m.text();
|
|
109
|
+
if (/Mixed Content/i.test(text)) {
|
|
110
|
+
const url = /requested an insecure [^']*'(http:[^']+)'/i.exec(text)?.[1];
|
|
111
|
+
if (url)
|
|
112
|
+
mixed.set(url, /upgraded/i.test(text) ? 'upgraded' : 'blocked');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (m.type() === 'error')
|
|
116
|
+
consoleErrors.add(text);
|
|
117
|
+
});
|
|
118
|
+
page.on('response', r => {
|
|
119
|
+
if (r.request().resourceType() === 'font')
|
|
120
|
+
fonts.set(r.url(), r.status());
|
|
121
|
+
});
|
|
122
|
+
page.on('request', r => {
|
|
123
|
+
if (r.url().startsWith('http:') && page.url().startsWith('https:') && !mixed.has(r.url()))
|
|
124
|
+
mixed.set(r.url(), 'loaded');
|
|
125
|
+
});
|
|
126
|
+
const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
127
|
+
result.status = response?.status() ?? null;
|
|
128
|
+
await settle(page, opts.timeoutMs);
|
|
129
|
+
const checkLogin = () => {
|
|
130
|
+
if (redirectedToLogin(opts.url, page.url()))
|
|
131
|
+
throw new AuthRequiredError(opts.url, page.url(), result.status ?? null);
|
|
132
|
+
};
|
|
133
|
+
checkLogin();
|
|
134
|
+
if (opts.waitFor) {
|
|
135
|
+
try {
|
|
136
|
+
await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
checkLogin();
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Deterministic CLS window: settled, a fixed idle, one scroll down and back.
|
|
144
|
+
await page.waitForTimeout(IDLE_MS);
|
|
145
|
+
await page.evaluate(SCROLL_ONCE_JS);
|
|
146
|
+
if (opts.waitMs > 0)
|
|
147
|
+
await page.waitForTimeout(opts.waitMs);
|
|
148
|
+
checkLogin();
|
|
149
|
+
result.finalUrl = page.url();
|
|
150
|
+
result.title = await page.title();
|
|
151
|
+
const findings = [];
|
|
152
|
+
findings.push(...basicsFindings({ status: result.status, uncaught: [...uncaught], consoleErrors: [...consoleErrors], mixed }));
|
|
153
|
+
const meta = (await page.evaluate(META_JS));
|
|
154
|
+
const og = meta.og.image ? await probeOgImage(context, meta.og.image, meta.baseUrl, cache) : undefined;
|
|
155
|
+
const faviconOk = meta.icons.length > 0 ? true : await probeFavicon(context, page.url(), cache);
|
|
156
|
+
findings.push(...metaFindings(meta, { og, faviconOk }));
|
|
157
|
+
const shifts = (await page.evaluate(LAYOUT_SHIFTS_JS));
|
|
158
|
+
const score = layoutShiftScore(shifts);
|
|
159
|
+
result.cls = score.cls;
|
|
160
|
+
result.layoutShifts = shifts.filter(s => !s.hadRecentInput).length;
|
|
161
|
+
const shift = layoutShiftFindings(score);
|
|
162
|
+
if (opts.shots)
|
|
163
|
+
await attachShots(page, shift, opts, device, 'layout-shift');
|
|
164
|
+
findings.push(...shift);
|
|
165
|
+
findings.push(...labelFindings((await page.evaluate(LABELS_JS))));
|
|
166
|
+
const fontData = (await page.evaluate(FONTS_JS));
|
|
167
|
+
for (const href of fontData.unreadable)
|
|
168
|
+
fontData.faces.push(...(await fetchFaces(context, href, cache)));
|
|
169
|
+
findings.push(...fontFindings(fontData, fonts));
|
|
170
|
+
if (result.mobile) {
|
|
171
|
+
const mobile = mobileFindings((await page.evaluate(MOBILE_JS)));
|
|
172
|
+
if (opts.shots)
|
|
173
|
+
await attachShots(page, mobile, opts, device, 'horizontal-overflow');
|
|
174
|
+
findings.push(...mobile);
|
|
175
|
+
}
|
|
176
|
+
return { findings, ...(og ? { ogImage: og } : {}) };
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await context.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** PNGs of the elements behind one finding id, written next to `rpc shot` output. */
|
|
183
|
+
async function attachShots(page, findings, opts, device, id) {
|
|
184
|
+
const target = findings.find(f => f.id === id || f.id.startsWith(`${id}-`));
|
|
185
|
+
if (!target?.items)
|
|
186
|
+
return;
|
|
187
|
+
const base = fileStem(opts.url, device.id);
|
|
188
|
+
let n = 0;
|
|
189
|
+
for (const item of target.items) {
|
|
190
|
+
if (!item.selector)
|
|
191
|
+
continue;
|
|
192
|
+
const path = resolve(opts.outDir, `${base}-check-${id}-${++n}.png`);
|
|
193
|
+
try {
|
|
194
|
+
await page.locator(item.selector).first().screenshot({ path, animations: 'disabled', timeout: 3000 });
|
|
195
|
+
item.screenshot = path;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Gone, hidden or zero-sized by now: nothing to show.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// ------------------------------------------------------------------ fetches
|
|
203
|
+
function probe(context, url, cache) {
|
|
204
|
+
const cached = cache.probes.get(url);
|
|
205
|
+
if (cached)
|
|
206
|
+
return cached;
|
|
207
|
+
const p = context.request
|
|
208
|
+
.get(url, { timeout: 10_000, failOnStatusCode: false, maxRedirects: 5 })
|
|
209
|
+
.then(async (r) => ({
|
|
210
|
+
status: r.status(),
|
|
211
|
+
contentType: (r.headers()['content-type'] ?? '').split(';')[0].trim(),
|
|
212
|
+
...(r.ok() ? { body: await r.body() } : {}),
|
|
213
|
+
}))
|
|
214
|
+
.catch((err) => ({ error: (err.message.split('\n')[0] ?? err.message).replace(/^apiRequestContext\.get: /, '') }));
|
|
215
|
+
cache.probes.set(url, p);
|
|
216
|
+
return p;
|
|
217
|
+
}
|
|
218
|
+
async function probeOgImage(context, raw, base, cache) {
|
|
219
|
+
let url;
|
|
220
|
+
try {
|
|
221
|
+
url = new URL(raw, base).href;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return { url: raw, error: 'not a valid URL' };
|
|
225
|
+
}
|
|
226
|
+
if (!/^https?:/.test(url))
|
|
227
|
+
return { url };
|
|
228
|
+
const r = await probe(context, url, cache);
|
|
229
|
+
const info = { url };
|
|
230
|
+
if (r.error)
|
|
231
|
+
info.error = r.error;
|
|
232
|
+
if (r.status !== undefined)
|
|
233
|
+
info.status = r.status;
|
|
234
|
+
if (r.contentType)
|
|
235
|
+
info.contentType = r.contentType;
|
|
236
|
+
const size = r.body ? imageSize(r.body) : undefined;
|
|
237
|
+
if (size)
|
|
238
|
+
Object.assign(info, size);
|
|
239
|
+
return info;
|
|
240
|
+
}
|
|
241
|
+
async function probeFavicon(context, pageUrl, cache) {
|
|
242
|
+
const u = new URL(pageUrl);
|
|
243
|
+
if (!/^https?:$/.test(u.protocol))
|
|
244
|
+
return false;
|
|
245
|
+
const r = await probe(context, `${u.origin}/favicon.ico`, cache);
|
|
246
|
+
return r.status !== undefined && r.status < 400 && /^image\//.test(r.contentType ?? '');
|
|
247
|
+
}
|
|
248
|
+
/** @font-face rules from a stylesheet the page can't read (cross-origin), parsed from its text. */
|
|
249
|
+
async function fetchFaces(context, href, cache) {
|
|
250
|
+
const r = await probe(context, href, cache);
|
|
251
|
+
return r.body ? parseFontFaces(r.body.toString('utf8'), href) : [];
|
|
252
|
+
}
|
|
253
|
+
export function parseFontFaces(css, base) {
|
|
254
|
+
const out = [];
|
|
255
|
+
for (const m of css.matchAll(/@font-face\s*{([^}]*)}/gi)) {
|
|
256
|
+
const body = m[1];
|
|
257
|
+
const prop = (name) => new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, 'i').exec(body)?.[1]?.trim() ?? '';
|
|
258
|
+
const urls = [];
|
|
259
|
+
for (const u of prop('src').matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)) {
|
|
260
|
+
try {
|
|
261
|
+
urls.push(new URL(u[2], base).href);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Unparseable url(): skip it.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
out.push({ family: prop('font-family').replace(/^["']|["']$/g, ''), display: prop('font-display'), urls, sheet: base });
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
// ------------------------------------------------------------------ basics
|
|
272
|
+
export function basicsFindings(d) {
|
|
273
|
+
const out = [];
|
|
274
|
+
if (d.status != null && d.status >= 400) {
|
|
275
|
+
out.push({
|
|
276
|
+
id: 'http-status',
|
|
277
|
+
check: 'basics',
|
|
278
|
+
severity: 'error',
|
|
279
|
+
message: `The page answered HTTP ${d.status}`,
|
|
280
|
+
fix: 'Serve the page with a 200. If it should be a 404 page, check that you meant to audit it.',
|
|
281
|
+
value: d.status,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (d.mixed.size) {
|
|
285
|
+
const blocked = [...d.mixed].filter(([, how]) => how !== 'upgraded');
|
|
286
|
+
out.push({
|
|
287
|
+
id: 'mixed-content',
|
|
288
|
+
check: 'basics',
|
|
289
|
+
severity: blocked.length ? 'error' : 'warn',
|
|
290
|
+
message: `${plural(d.mixed.size, 'http:// resource')} on an https page${blocked.length ? '' : ' (the browser upgraded them)'}`,
|
|
291
|
+
fix: 'Load every resource over https://, or use protocol-relative or relative URLs.',
|
|
292
|
+
items: [...d.mixed].slice(0, 10).map(([url, how]) => ({ url, text: how })),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (d.uncaught.length) {
|
|
296
|
+
out.push({
|
|
297
|
+
id: 'uncaught-exception',
|
|
298
|
+
check: 'basics',
|
|
299
|
+
severity: 'error',
|
|
300
|
+
message: `${plural(d.uncaught.length, 'uncaught exception')} during load`,
|
|
301
|
+
fix: 'Open the page with the browser console open and fix the first exception; later ones often follow from it.',
|
|
302
|
+
items: d.uncaught.slice(0, 3).map(text => ({ text })),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (d.consoleErrors.length) {
|
|
306
|
+
out.push({
|
|
307
|
+
id: 'console-errors',
|
|
308
|
+
check: 'basics',
|
|
309
|
+
severity: 'warn',
|
|
310
|
+
message: `${plural(d.consoleErrors.length, 'console error')} during load`,
|
|
311
|
+
fix: 'Fix or silence the errors logged while the page loads (failed requests, hydration warnings, missing env vars).',
|
|
312
|
+
items: d.consoleErrors.slice(0, 3).map(text => ({ text })),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
const OG_W = 1200;
|
|
318
|
+
const OG_H = 630;
|
|
319
|
+
export function metaFindings(m, probes) {
|
|
320
|
+
const out = [];
|
|
321
|
+
const add = (id, severity, message, fix, extra = {}) => out.push({ id, check: 'meta', severity, message, fix, ...extra });
|
|
322
|
+
const title = (m.title ?? '').trim();
|
|
323
|
+
if (!title)
|
|
324
|
+
add('title-missing', 'error', 'No <title>', 'Add a <title> that names the page and the site, about 50 to 60 characters.');
|
|
325
|
+
else if (title.length > 60) {
|
|
326
|
+
add('title-long', 'warn', `<title> is ${title.length} characters; search results cut it at about 60`, 'Shorten the title to 60 characters or fewer, key words first.', {
|
|
327
|
+
value: title.length,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
const desc = (m.description ?? '').trim();
|
|
331
|
+
if (!desc)
|
|
332
|
+
add('description-missing', 'warn', 'No meta description', 'Add <meta name="description" content="…"> with one or two sentences, up to about 160 characters.');
|
|
333
|
+
else if (desc.length > 160) {
|
|
334
|
+
add('description-long', 'info', `Meta description is ${desc.length} characters; search results cut it at about 160`, 'Trim the description to 160 characters or fewer.', {
|
|
335
|
+
value: desc.length,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (!m.canonical)
|
|
339
|
+
add('canonical-missing', 'warn', 'No canonical link', 'Add <link rel="canonical" href="https://…"> with the absolute URL this page should be indexed under.');
|
|
340
|
+
if (!(m.lang ?? '').trim()) {
|
|
341
|
+
add('lang-missing', 'error', '<html> has no lang attribute', 'Set the page language, e.g. <html lang="en">, so screen readers pronounce it correctly.', {
|
|
342
|
+
selector: 'html',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (!m.viewport) {
|
|
346
|
+
add('viewport-missing', 'error', 'No viewport meta tag', 'Add <meta name="viewport" content="width=device-width, initial-scale=1">.');
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const v = m.viewport.toLowerCase().replace(/\s+/g, '');
|
|
350
|
+
const max = /maximum-scale=([\d.]+)/.exec(v)?.[1];
|
|
351
|
+
if (/user-scalable=(no|0)(,|;|$)/.test(v) || (max !== undefined && Number(max) < 2)) {
|
|
352
|
+
add('viewport-no-zoom', 'error', `The viewport blocks pinch zoom (${m.viewport.trim()})`, 'Remove user-scalable=no and maximum-scale from the viewport meta; people need to zoom.', {
|
|
353
|
+
selector: 'meta[name="viewport"]',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!m.icons.length && !probes.faviconOk) {
|
|
358
|
+
add('favicon-missing', 'warn', 'No favicon: no <link rel="icon"> and /favicon.ico does not load', 'Add <link rel="icon" href="/favicon.svg"> (or .ico/.png) to the <head>.');
|
|
359
|
+
}
|
|
360
|
+
if (!m.appleTouchIcon) {
|
|
361
|
+
add('apple-touch-icon-missing', 'info', 'No apple-touch-icon', 'Add <link rel="apple-touch-icon" href="/apple-touch-icon.png"> (180×180 PNG) for iOS home screens.');
|
|
362
|
+
}
|
|
363
|
+
for (const [key, label] of [
|
|
364
|
+
['title', 'og:title'],
|
|
365
|
+
['description', 'og:description'],
|
|
366
|
+
['image', 'og:image'],
|
|
367
|
+
['url', 'og:url'],
|
|
368
|
+
]) {
|
|
369
|
+
if (!(m.og[key] ?? '').trim()) {
|
|
370
|
+
add(`og-${key}-missing`, 'warn', `No ${label}`, `Add <meta property="${label}" content="…">; link previews in Slack, iMessage and social apps use it.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!(m.twitterCard ?? '').trim()) {
|
|
374
|
+
add('twitter-card-missing', 'warn', 'No twitter:card', 'Add <meta name="twitter:card" content="summary_large_image"> so X shows a large image preview.');
|
|
375
|
+
}
|
|
376
|
+
const og = probes.og;
|
|
377
|
+
if (m.og.image && og) {
|
|
378
|
+
if (!/^https?:\/\//i.test(m.og.image.trim())) {
|
|
379
|
+
add('og-image-relative', 'warn', `og:image is a relative URL (${m.og.image.trim()})`, 'Use an absolute https:// URL; most scrapers do not resolve relative ones.', { url: og.url });
|
|
380
|
+
}
|
|
381
|
+
if (og.error || (og.status !== undefined && og.status >= 400)) {
|
|
382
|
+
add('og-image-broken', 'error', `og:image does not load (${og.error ?? `HTTP ${og.status}`})`, 'Point og:image at an image URL that returns 200 to an anonymous request.', { url: og.url });
|
|
383
|
+
}
|
|
384
|
+
else if (og.contentType && !og.contentType.startsWith('image/')) {
|
|
385
|
+
add('og-image-not-image', 'error', `og:image is served as ${og.contentType}, not an image`, 'Point og:image at the image file itself (PNG or JPEG), not a page.', { url: og.url });
|
|
386
|
+
}
|
|
387
|
+
else if (og.width && og.height) {
|
|
388
|
+
const size = `${og.width}×${og.height}`;
|
|
389
|
+
if (og.width < OG_W / 2 || og.height < OG_H / 2) {
|
|
390
|
+
add('og-image-small', 'warn', `og:image is ${size}; large previews need at least 600×315`, `Export the image at ${OG_W}×${OG_H}.`, { url: og.url, value: og.width });
|
|
391
|
+
}
|
|
392
|
+
else if (Math.abs(og.width / og.height - OG_W / OG_H) > 0.05) {
|
|
393
|
+
add('og-image-ratio', 'info', `og:image is ${size}, not the 1.91:1 shape previews crop to (${OG_W}×${OG_H})`, `Export the image at ${OG_W}×${OG_H} so previews don't crop it.`, {
|
|
394
|
+
url: og.url,
|
|
395
|
+
value: og.width,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!(m.themeColor ?? '').trim()) {
|
|
401
|
+
out.push({
|
|
402
|
+
id: 'theme-color-missing',
|
|
403
|
+
check: 'basics',
|
|
404
|
+
severity: 'info',
|
|
405
|
+
message: 'No theme-color',
|
|
406
|
+
fix: 'Add <meta name="theme-color" content="#…"> to tint the mobile browser bar to match the page.',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
const round = (n, places = 4) => Math.round(n * 10 ** places) / 10 ** places;
|
|
412
|
+
/**
|
|
413
|
+
* CLS as Chrome reports it: the largest session window (shifts less than 1 s
|
|
414
|
+
* apart, at most 5 s long), input-driven shifts excluded. Each shift's score
|
|
415
|
+
* is shared among its sources by the area they moved through.
|
|
416
|
+
*/
|
|
417
|
+
export function layoutShiftScore(shifts) {
|
|
418
|
+
const counted = shifts.filter(s => !s.hadRecentInput).sort((a, b) => a.startTime - b.startTime);
|
|
419
|
+
let best = 0;
|
|
420
|
+
let bestWindow = [];
|
|
421
|
+
let window = [];
|
|
422
|
+
let sum = 0;
|
|
423
|
+
for (const s of counted) {
|
|
424
|
+
const first = window[0];
|
|
425
|
+
const last = window[window.length - 1];
|
|
426
|
+
if (first && last && s.startTime - last.startTime < 1000 && s.startTime - first.startTime < 5000) {
|
|
427
|
+
window.push(s);
|
|
428
|
+
sum += s.value;
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
window = [s];
|
|
432
|
+
sum = s.value;
|
|
433
|
+
}
|
|
434
|
+
if (sum > best) {
|
|
435
|
+
best = sum;
|
|
436
|
+
bestWindow = [...window];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (best < CLS_TOLERANCE)
|
|
440
|
+
return { cls: 0, sources: [] };
|
|
441
|
+
const area = (r) => (r ? r.width * r.height : 0);
|
|
442
|
+
const by = new Map();
|
|
443
|
+
for (const s of bestWindow) {
|
|
444
|
+
const srcs = s.sources.filter(x => x.selector);
|
|
445
|
+
const total = srcs.reduce((n, x) => n + area(x.previousRect) + area(x.currentRect), 0);
|
|
446
|
+
for (const x of srcs) {
|
|
447
|
+
const share = total > 0 ? (area(x.previousRect) + area(x.currentRect)) / total : 1 / srcs.length;
|
|
448
|
+
by.set(x.selector, (by.get(x.selector) ?? 0) + s.value * share);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const sources = [...by]
|
|
452
|
+
.map(([selector, score]) => ({ selector, score: round(score) }))
|
|
453
|
+
.sort((a, b) => b.score - a.score);
|
|
454
|
+
return { cls: round(best), sources };
|
|
455
|
+
}
|
|
456
|
+
export function layoutShiftFindings(score) {
|
|
457
|
+
if (score.cls === 0)
|
|
458
|
+
return [];
|
|
459
|
+
const severity = score.cls > CLS_POOR ? 'error' : score.cls > CLS_GOOD ? 'warn' : 'info';
|
|
460
|
+
const verdict = severity === 'error' ? 'poor' : severity === 'warn' ? 'needs improvement' : 'good, but not zero';
|
|
461
|
+
const top = score.sources.slice(0, 3);
|
|
462
|
+
return [
|
|
463
|
+
{
|
|
464
|
+
id: 'layout-shift',
|
|
465
|
+
check: 'layout-shift',
|
|
466
|
+
severity,
|
|
467
|
+
message: `CLS ${score.cls.toFixed(3)} (${verdict}; good is ${CLS_GOOD} or less)`,
|
|
468
|
+
fix: 'Reserve space for what loads late: width/height or aspect-ratio on images and embeds, min-height on async sections, and no content inserted above what is on screen.',
|
|
469
|
+
value: score.cls,
|
|
470
|
+
...(top[0] ? { selector: top[0].selector } : {}),
|
|
471
|
+
items: top.map(s => ({ selector: s.selector, text: `${s.score.toFixed(3)} of the shift`, value: s.score })),
|
|
472
|
+
},
|
|
473
|
+
];
|
|
474
|
+
}
|
|
475
|
+
export function labelFindings(d) {
|
|
476
|
+
const out = [];
|
|
477
|
+
const group = (id, severity, message, fix, items) => {
|
|
478
|
+
if (!items.length)
|
|
479
|
+
return;
|
|
480
|
+
out.push({ id, check: 'labels', severity, message, fix, selector: items[0].selector, items: items.slice(0, 20).map(({ selector, text }) => ({ selector, text })) });
|
|
481
|
+
};
|
|
482
|
+
const buttons = d.controls.filter(c => c.kind === 'button');
|
|
483
|
+
const links = d.controls.filter(c => c.kind === 'link');
|
|
484
|
+
group('button-no-name', 'error', `${plural(buttons.length, 'button')} with no accessible name`, 'Give icon-only buttons an aria-label (or visually hidden text), e.g. <button aria-label="Open menu">.', buttons);
|
|
485
|
+
group('link-no-name', 'error', `${plural(links.length, 'link')} with no accessible name`, 'Give icon-only links an aria-label, or alt text on the image inside them.', links);
|
|
486
|
+
group('img-no-alt', 'error', `${plural(d.images.length, 'image')} without an alt attribute`, 'Add alt text that says what the image shows, or alt="" if it is decorative.', d.images);
|
|
487
|
+
const bare = d.fields.filter(f => !f.placeholder);
|
|
488
|
+
const placeholderOnly = d.fields.filter(f => f.placeholder);
|
|
489
|
+
group('field-no-label', 'error', `${plural(bare.length, 'form field')} without a label`, 'Add a <label for="…">, wrap the field in a <label>, or set aria-label.', bare);
|
|
490
|
+
group('field-placeholder-only', 'warn', `${plural(placeholderOnly.length, 'form field')} labelled only by a placeholder`, 'Add a visible <label>; the placeholder disappears as soon as someone types.', placeholderOnly);
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
/** `downloaded`: font URL -> HTTP status, from the page's responses. */
|
|
494
|
+
export function fontFindings(d, downloaded) {
|
|
495
|
+
const out = [];
|
|
496
|
+
const above = new Set(d.aboveFold);
|
|
497
|
+
const loaded = new Set(d.loaded);
|
|
498
|
+
const fileName = (u) => decodeURIComponent(new URL(u).pathname.split('/').pop() || u);
|
|
499
|
+
// Faces that actually served a file, or whose family has a loaded face.
|
|
500
|
+
// local()-only faces (next/font's size-adjusted fallbacks) never block text.
|
|
501
|
+
const used = d.faces.filter(f => f.urls.length > 0 && (f.urls.some(u => downloaded.has(u)) || loaded.has(f.family.toLowerCase())));
|
|
502
|
+
const families = new Map();
|
|
503
|
+
for (const f of used)
|
|
504
|
+
families.set(f.family, [...(families.get(f.family) ?? []), f]);
|
|
505
|
+
for (const [family, faces] of families) {
|
|
506
|
+
const blocking = faces.filter(f => !f.display || f.display === 'auto' || f.display === 'block');
|
|
507
|
+
if (!blocking.length)
|
|
508
|
+
continue;
|
|
509
|
+
const missing = blocking.some(f => !f.display || f.display === 'auto');
|
|
510
|
+
const isAbove = above.has(family.toLowerCase());
|
|
511
|
+
const files = blocking.flatMap(f => f.urls.filter(u => downloaded.has(u)));
|
|
512
|
+
out.push({
|
|
513
|
+
id: missing ? 'font-display-missing' : 'font-display-block',
|
|
514
|
+
check: 'fonts',
|
|
515
|
+
severity: 'warn',
|
|
516
|
+
message: `@font-face "${family}" ${missing ? 'has no font-display' : 'uses font-display: block'}` +
|
|
517
|
+
(isAbove ? '; it sets text above the fold, which stays invisible until the font loads (FOIT)' : ''),
|
|
518
|
+
fix: 'Add font-display: swap (or optional) to the @font-face rule. With Google Fonts, add &display=swap to the URL; next/font sets it for you.',
|
|
519
|
+
url: blocking[0].sheet,
|
|
520
|
+
items: [...new Set(files.length ? files : blocking.flatMap(f => f.urls))].slice(0, 5).map(url => ({ url })),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const preloads = new Map(d.preloads.map(p => [p.href, p]));
|
|
524
|
+
for (const [url, status] of downloaded) {
|
|
525
|
+
if (status >= 400) {
|
|
526
|
+
out.push({
|
|
527
|
+
id: 'font-failed',
|
|
528
|
+
check: 'fonts',
|
|
529
|
+
severity: 'error',
|
|
530
|
+
message: `Font file failed to load (HTTP ${status}): ${fileName(url)}`,
|
|
531
|
+
fix: 'Fix the font URL in the @font-face src, or deploy the file.',
|
|
532
|
+
url,
|
|
533
|
+
});
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const face = d.faces.find(f => f.urls.includes(url));
|
|
537
|
+
if (!face || !above.has(face.family.toLowerCase()))
|
|
538
|
+
continue;
|
|
539
|
+
const pre = preloads.get(url);
|
|
540
|
+
if (!pre) {
|
|
541
|
+
out.push({
|
|
542
|
+
id: 'font-not-preloaded',
|
|
543
|
+
check: 'fonts',
|
|
544
|
+
severity: 'warn',
|
|
545
|
+
message: `"${face.family}" sets text above the fold but ${fileName(url)} is not preloaded`,
|
|
546
|
+
fix: `Add <link rel="preload" href="${url}" as="font" type="font/woff2" crossorigin> to the <head>.`,
|
|
547
|
+
url,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
for (const p of d.preloads) {
|
|
552
|
+
if (!p.crossorigin) {
|
|
553
|
+
out.push({
|
|
554
|
+
id: 'font-preload-no-crossorigin',
|
|
555
|
+
check: 'fonts',
|
|
556
|
+
severity: 'warn',
|
|
557
|
+
message: `Font preload without crossorigin: ${fileName(p.href)} is downloaded twice`,
|
|
558
|
+
fix: 'Add the crossorigin attribute to the <link rel="preload" as="font">; fonts are always fetched in CORS mode.',
|
|
559
|
+
url: p.href,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (!d.faces.some(f => f.urls.includes(p.href))) {
|
|
563
|
+
out.push({
|
|
564
|
+
id: 'font-preload-unused',
|
|
565
|
+
check: 'fonts',
|
|
566
|
+
severity: 'info',
|
|
567
|
+
message: `Preloaded font ${fileName(p.href)} is not used by any @font-face rule`,
|
|
568
|
+
fix: 'Remove the preload, or make its href match the URL in the @font-face src exactly.',
|
|
569
|
+
url: p.href,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return out;
|
|
574
|
+
}
|
|
575
|
+
export function mobileFindings(d) {
|
|
576
|
+
const out = [];
|
|
577
|
+
if (d.scrollWidth > d.viewportWidth + 1) {
|
|
578
|
+
out.push({
|
|
579
|
+
id: 'horizontal-overflow',
|
|
580
|
+
check: 'mobile',
|
|
581
|
+
severity: 'error',
|
|
582
|
+
message: `The page scrolls sideways: ${d.scrollWidth}px wide in a ${d.viewportWidth}px viewport`,
|
|
583
|
+
fix: 'Find the element that sticks out (fixed widths, 100vw with a scrollbar, wide tables or code blocks, negative margins) and constrain it with max-width: 100% or overflow-x: auto on its wrapper.',
|
|
584
|
+
value: d.scrollWidth - d.viewportWidth,
|
|
585
|
+
...(d.overflow[0] ? { selector: d.overflow[0].selector } : {}),
|
|
586
|
+
items: d.overflow.map(({ selector, text, value }) => ({ selector, text, value })),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
if (d.vh.length) {
|
|
590
|
+
out.push({
|
|
591
|
+
id: 'vh-units',
|
|
592
|
+
check: 'mobile',
|
|
593
|
+
severity: 'warn',
|
|
594
|
+
message: `${plural(d.vh.length, 'element')} sized with 100vh; on phones that is taller than the visible area when the browser bars show`,
|
|
595
|
+
fix: 'Use 100dvh (follows the browser bars) or 100svh (the smallest viewport). Keep 100vh first as a fallback: height: 100vh; height: 100dvh.',
|
|
596
|
+
selector: d.vh[0].selector,
|
|
597
|
+
items: d.vh.slice(0, 10).map(({ selector, text, value }) => ({ selector, text, value })),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (d.smallTargets.length) {
|
|
601
|
+
out.push({
|
|
602
|
+
id: 'tap-target-small',
|
|
603
|
+
check: 'mobile',
|
|
604
|
+
severity: 'warn',
|
|
605
|
+
message: `${d.smallTargets.length} of ${d.tapTargets} tap targets are smaller than ${MIN_TAP}×${MIN_TAP}px`,
|
|
606
|
+
fix: `Make each control at least ${MIN_TAP}×${MIN_TAP} CSS px: padding, min-height/min-width, or a larger hit area with a ::before inset.`,
|
|
607
|
+
selector: d.smallTargets[0].selector,
|
|
608
|
+
items: d.smallTargets.slice(0, 15).map(({ selector, text, value }) => ({ selector, text, value })),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (d.zoom.length) {
|
|
612
|
+
out.push({
|
|
613
|
+
id: 'input-zoom',
|
|
614
|
+
check: 'mobile',
|
|
615
|
+
severity: 'warn',
|
|
616
|
+
message: `${plural(d.zoom.length, 'text field')} under 16px; iOS Safari zooms the page when one gets focus`,
|
|
617
|
+
fix: 'Set font-size: 16px (1rem) or more on inputs, selects and textareas, at least on small screens.',
|
|
618
|
+
selector: d.zoom[0].selector,
|
|
619
|
+
items: d.zoom.slice(0, 10).map(({ selector, text, value }) => ({ selector, text, value })),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
// ------------------------------------------------------------------ report
|
|
625
|
+
const SEVERITY_ORDER = ['error', 'warn', 'info'];
|
|
626
|
+
/** One finding per id/message/selector/url, listing every device it came up on, in report order. */
|
|
627
|
+
export function mergeFindings(found) {
|
|
628
|
+
const merged = new Map();
|
|
629
|
+
for (const { device, finding } of found) {
|
|
630
|
+
const key = [finding.id, finding.message, finding.selector ?? '', finding.url ?? ''].join('\n');
|
|
631
|
+
const existing = merged.get(key);
|
|
632
|
+
if (existing) {
|
|
633
|
+
if (!existing.devices.includes(device))
|
|
634
|
+
existing.devices.push(device);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const { id, check, severity, message, fix, ...rest } = finding;
|
|
638
|
+
merged.set(key, { id, check, severity, message, fix, devices: [device], ...rest });
|
|
639
|
+
}
|
|
640
|
+
const checkIndex = (c) => CHECKS.findIndex(x => x.id === c);
|
|
641
|
+
return [...merged.values()]
|
|
642
|
+
.map((f, i) => ({ f, i }))
|
|
643
|
+
.sort((a, b) => checkIndex(a.f.check) - checkIndex(b.f.check) ||
|
|
644
|
+
SEVERITY_ORDER.indexOf(a.f.severity) - SEVERITY_ORDER.indexOf(b.f.severity) ||
|
|
645
|
+
a.i - b.i)
|
|
646
|
+
.map(x => x.f);
|
|
647
|
+
}
|
|
648
|
+
export function summarize(findings) {
|
|
649
|
+
const n = (s) => findings.filter(f => f.severity === s).length;
|
|
650
|
+
return { errors: n('error'), warnings: n('warn'), info: n('info') };
|
|
651
|
+
}
|
|
652
|
+
/** 1 when there are errors, or warnings with `--fail-on warn`; else 0. */
|
|
653
|
+
export function checkExitCode(report, failOn) {
|
|
654
|
+
if (report.summary.errors > 0)
|
|
655
|
+
return 1;
|
|
656
|
+
if (failOn === 'warn' && report.summary.warnings > 0)
|
|
657
|
+
return 1;
|
|
658
|
+
return 0;
|
|
659
|
+
}
|
|
660
|
+
/** `2 errors, 5 warnings, 3 info` (zero counts included, so the line always has the same shape). */
|
|
661
|
+
export function summaryLine(s) {
|
|
662
|
+
return `${plural(s.errors, 'error')}, ${plural(s.warnings, 'warning')}, ${s.info} info`;
|
|
663
|
+
}
|
|
664
|
+
function plural(n, word) {
|
|
665
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
666
|
+
}
|
|
667
|
+
function scrub(f, secrets) {
|
|
668
|
+
if (!secrets.length)
|
|
669
|
+
return f;
|
|
670
|
+
const r = (s) => redact(s, secrets);
|
|
671
|
+
return {
|
|
672
|
+
...f,
|
|
673
|
+
message: r(f.message),
|
|
674
|
+
...(f.url ? { url: r(f.url) } : {}),
|
|
675
|
+
...(f.items ? { items: f.items.map(i => ({ ...i, ...(i.text ? { text: r(i.text) } : {}), ...(i.url ? { url: r(i.url) } : {}) })) } : {}),
|
|
676
|
+
};
|
|
677
|
+
}
|