@fulldotdev/scan 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 +79 -0
- package/bin/fullscan.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +239 -0
- package/dist/engine/analysis.d.ts +2 -0
- package/dist/engine/analysis.js +747 -0
- package/dist/engine/browser-inspection.d.ts +143 -0
- package/dist/engine/browser-inspection.js +567 -0
- package/dist/engine/browser.d.ts +22 -0
- package/dist/engine/browser.js +629 -0
- package/dist/engine/collect.d.ts +6 -0
- package/dist/engine/collect.js +359 -0
- package/dist/engine/crawl-scope.d.ts +22 -0
- package/dist/engine/crawl-scope.js +145 -0
- package/dist/engine/env.d.ts +1 -0
- package/dist/engine/env.js +3 -0
- package/dist/engine/html.d.ts +307 -0
- package/dist/engine/html.js +645 -0
- package/dist/engine/language.d.ts +13 -0
- package/dist/engine/language.js +75 -0
- package/dist/engine/lighthouse-evidence.d.ts +36 -0
- package/dist/engine/lighthouse-evidence.js +69 -0
- package/dist/engine/lighthouse.d.ts +4 -0
- package/dist/engine/lighthouse.js +284 -0
- package/dist/engine/log.d.ts +1 -0
- package/dist/engine/log.js +4 -0
- package/dist/engine/network.d.ts +53 -0
- package/dist/engine/network.js +296 -0
- package/dist/engine/proxy.d.ts +8 -0
- package/dist/engine/proxy.js +95 -0
- package/dist/engine/run.d.ts +38 -0
- package/dist/engine/run.js +202 -0
- package/dist/engine/select.d.ts +6 -0
- package/dist/engine/select.js +38 -0
- package/dist/engine/site.d.ts +186 -0
- package/dist/engine/site.js +758 -0
- package/dist/engine/srcset.d.ts +1 -0
- package/dist/engine/srcset.js +31 -0
- package/dist/engine/state.d.ts +30 -0
- package/dist/engine/state.js +198 -0
- package/dist/engine/structured-data.d.ts +83 -0
- package/dist/engine/structured-data.js +331 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +63 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/report/build.d.ts +377 -0
- package/dist/report/build.js +2263 -0
- package/dist/report/evidence.d.ts +58 -0
- package/dist/report/evidence.js +192 -0
- package/dist/report/rules.d.ts +41 -0
- package/dist/report/rules.js +301 -0
- package/package.json +52 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
export async function observeRendering(page) {
|
|
2
|
+
await page.evaluateOnNewDocument(() => {
|
|
3
|
+
const state = { lcp: null, shifts: [], fontEvents: [] };
|
|
4
|
+
window.__monitorRendering = state;
|
|
5
|
+
const describe = (el) => el
|
|
6
|
+
? {
|
|
7
|
+
tag: el.tagName,
|
|
8
|
+
id: el.id,
|
|
9
|
+
selector: el.id
|
|
10
|
+
? `#${CSS.escape(el.id)}`
|
|
11
|
+
: el.tagName.toLowerCase() +
|
|
12
|
+
[...el.classList].map((c) => `.${CSS.escape(c)}`).join(""),
|
|
13
|
+
snippet: el.outerHTML.slice(0, 1500),
|
|
14
|
+
}
|
|
15
|
+
: null;
|
|
16
|
+
new PerformanceObserver((list) => {
|
|
17
|
+
for (const e of list.getEntries()) {
|
|
18
|
+
state.lcpElement = e.element;
|
|
19
|
+
state.lcp = {
|
|
20
|
+
time: e.renderTime || e.startTime,
|
|
21
|
+
size: e.size,
|
|
22
|
+
url: e.url,
|
|
23
|
+
element: describe(e.element),
|
|
24
|
+
loading: e.element?.getAttribute("loading"),
|
|
25
|
+
fetchpriority: e.element?.getAttribute("fetchpriority") ?? "auto",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
29
|
+
new PerformanceObserver((list) => {
|
|
30
|
+
for (const e of list.getEntries())
|
|
31
|
+
if (!e.hadRecentInput && state.shifts.length < 100)
|
|
32
|
+
state.shifts.push({
|
|
33
|
+
time: e.startTime,
|
|
34
|
+
value: e.value,
|
|
35
|
+
fontsLoading: document.fonts.status === "loading",
|
|
36
|
+
sources: (e.sources ?? []).map((s) => ({
|
|
37
|
+
element: describe(s.node),
|
|
38
|
+
previousRect: {
|
|
39
|
+
x: s.previousRect.x,
|
|
40
|
+
y: s.previousRect.y,
|
|
41
|
+
width: s.previousRect.width,
|
|
42
|
+
height: s.previousRect.height,
|
|
43
|
+
},
|
|
44
|
+
currentRect: {
|
|
45
|
+
x: s.currentRect.x,
|
|
46
|
+
y: s.currentRect.y,
|
|
47
|
+
width: s.currentRect.width,
|
|
48
|
+
height: s.currentRect.height,
|
|
49
|
+
},
|
|
50
|
+
fontFamily: s.node instanceof Element
|
|
51
|
+
? getComputedStyle(s.node).fontFamily
|
|
52
|
+
: null,
|
|
53
|
+
})),
|
|
54
|
+
});
|
|
55
|
+
}).observe({ type: "layout-shift", buffered: true });
|
|
56
|
+
for (const name of ["loading", "loadingdone", "loadingerror"])
|
|
57
|
+
document.fonts.addEventListener(name, () => state.fontEvents.push({ type: name, time: performance.now() }));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function stableLayout(page) {
|
|
61
|
+
return page.evaluate(async () => {
|
|
62
|
+
const started = performance.now();
|
|
63
|
+
let fontsReady = false;
|
|
64
|
+
await Promise.race([
|
|
65
|
+
document.fonts.ready.then(() => {
|
|
66
|
+
fontsReady = true;
|
|
67
|
+
}),
|
|
68
|
+
new Promise((r) => setTimeout(r, 4000)),
|
|
69
|
+
]);
|
|
70
|
+
let last = "", equalSince = performance.now(), stable = false;
|
|
71
|
+
let samples = 0;
|
|
72
|
+
while (performance.now() - started < 6000) {
|
|
73
|
+
const signature = [
|
|
74
|
+
document.documentElement.scrollWidth,
|
|
75
|
+
document.documentElement.scrollHeight,
|
|
76
|
+
...[...document.querySelectorAll("body *")]
|
|
77
|
+
.slice(0, 2000)
|
|
78
|
+
.flatMap((el) => {
|
|
79
|
+
const r = el.getBoundingClientRect();
|
|
80
|
+
return [
|
|
81
|
+
Math.round(r.x),
|
|
82
|
+
Math.round(r.y),
|
|
83
|
+
Math.round(r.width),
|
|
84
|
+
Math.round(r.height),
|
|
85
|
+
];
|
|
86
|
+
}),
|
|
87
|
+
].join(",");
|
|
88
|
+
samples++;
|
|
89
|
+
if (signature !== last) {
|
|
90
|
+
last = signature;
|
|
91
|
+
equalSince = performance.now();
|
|
92
|
+
}
|
|
93
|
+
else if (performance.now() - equalSince >= 500) {
|
|
94
|
+
stable = true;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
fontsReady,
|
|
101
|
+
stable,
|
|
102
|
+
samples,
|
|
103
|
+
waitedMs: Math.round(performance.now() - started),
|
|
104
|
+
elementLimit: 2000,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export async function inspectOverflow(page) {
|
|
109
|
+
const readiness = await stableLayout(page);
|
|
110
|
+
const data = await page.evaluate(() => {
|
|
111
|
+
const width = document.documentElement.clientWidth;
|
|
112
|
+
const documentWidth = document.documentElement.scrollWidth;
|
|
113
|
+
const clipped = [], elements = [];
|
|
114
|
+
for (const el of document.querySelectorAll("body *")) {
|
|
115
|
+
const r = el.getBoundingClientRect(), style = getComputedStyle(el);
|
|
116
|
+
if (!r.width ||
|
|
117
|
+
!r.height ||
|
|
118
|
+
style.visibility === "hidden" ||
|
|
119
|
+
style.display === "none" ||
|
|
120
|
+
(r.left >= -1 && r.right <= width + 1))
|
|
121
|
+
continue;
|
|
122
|
+
let left = r.left, right = r.right;
|
|
123
|
+
let clipAncestor = null;
|
|
124
|
+
for (let p = el.parentElement; p && p !== document.documentElement; p = p.parentElement) {
|
|
125
|
+
const s = getComputedStyle(p);
|
|
126
|
+
if (/^(hidden|clip|auto|scroll)$/.test(s.overflowX) ||
|
|
127
|
+
/paint|strict|content/.test(s.contain)) {
|
|
128
|
+
const b = p.getBoundingClientRect();
|
|
129
|
+
left = Math.max(left, b.left + p.clientLeft);
|
|
130
|
+
right = Math.min(right, b.left + p.clientLeft + p.clientWidth);
|
|
131
|
+
clipAncestor = p.id || p.className || p.tagName;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const item = {
|
|
135
|
+
tag: el.tagName,
|
|
136
|
+
id: el.id,
|
|
137
|
+
className: el.getAttribute("class"),
|
|
138
|
+
left: r.left,
|
|
139
|
+
right: r.right,
|
|
140
|
+
width: r.width,
|
|
141
|
+
visibleLeft: left,
|
|
142
|
+
visibleRight: right,
|
|
143
|
+
clipAncestor,
|
|
144
|
+
};
|
|
145
|
+
if (right <= left || (left >= -1 && right <= width + 1))
|
|
146
|
+
clipped.push(item);
|
|
147
|
+
else
|
|
148
|
+
elements.push(item);
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
width,
|
|
152
|
+
documentWidth,
|
|
153
|
+
bodyWidth: document.body?.scrollWidth,
|
|
154
|
+
documentOverflow: documentWidth > width + 1,
|
|
155
|
+
elements: elements.slice(0, 100),
|
|
156
|
+
clipped: clipped.slice(0, 100),
|
|
157
|
+
elementCount: elements.length,
|
|
158
|
+
clippedCount: clipped.length,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
...data,
|
|
163
|
+
readiness,
|
|
164
|
+
conclusive: readiness.fontsReady && readiness.stable,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export async function inspectRendering(page, cdp, html, responses) {
|
|
168
|
+
const readiness = await stableLayout(page);
|
|
169
|
+
const evidence = await page.evaluate((initialHtml) => {
|
|
170
|
+
const source = new DOMParser().parseFromString(initialHtml, "text/html");
|
|
171
|
+
const base = new URL(source.querySelector("base[href]")?.getAttribute("href") ?? location.href, location.href).href;
|
|
172
|
+
const absolute = (s) => {
|
|
173
|
+
try {
|
|
174
|
+
return new URL(s, base).href;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const candidates = (srcset) => srcset
|
|
181
|
+
.split(/,\s*/)
|
|
182
|
+
.filter((s) => s.trim())
|
|
183
|
+
.map((s) => {
|
|
184
|
+
const [url, descriptor = "1x"] = s.trim().split(/\s+/);
|
|
185
|
+
return {
|
|
186
|
+
url: absolute(url),
|
|
187
|
+
descriptor,
|
|
188
|
+
width: descriptor.endsWith("w")
|
|
189
|
+
? Number.parseFloat(descriptor)
|
|
190
|
+
: null,
|
|
191
|
+
density: descriptor.endsWith("x")
|
|
192
|
+
? Number.parseFloat(descriptor)
|
|
193
|
+
: null,
|
|
194
|
+
};
|
|
195
|
+
})
|
|
196
|
+
.filter((c) => c.url && !c.url.startsWith("data:"));
|
|
197
|
+
const declared = new Set([...source.querySelectorAll("img,source,link[rel=preload]")].flatMap((el) => [
|
|
198
|
+
el.getAttribute("src"),
|
|
199
|
+
el.getAttribute("href"),
|
|
200
|
+
...candidates(el.getAttribute("srcset") ?? el.getAttribute("imagesrcset") ?? "").map((c) => c.url),
|
|
201
|
+
]
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.map((s) => absolute(s))));
|
|
204
|
+
const state = window.__monitorRendering ?? {
|
|
205
|
+
lcp: null,
|
|
206
|
+
shifts: [],
|
|
207
|
+
fontEvents: [],
|
|
208
|
+
};
|
|
209
|
+
const resources = performance.getEntriesByType("resource");
|
|
210
|
+
const images = [...document.images].slice(0, 500).map((img) => {
|
|
211
|
+
const r = img.getBoundingClientRect(), style = getComputedStyle(img);
|
|
212
|
+
let left = Math.max(r.left, 0), right = Math.min(r.right, innerWidth), top = Math.max(r.top, 0), bottom = Math.min(r.bottom, innerHeight);
|
|
213
|
+
let hidden = style.visibility === "hidden" ||
|
|
214
|
+
style.display === "none" ||
|
|
215
|
+
Number(style.opacity) === 0;
|
|
216
|
+
for (let p = img.parentElement; p; p = p.parentElement) {
|
|
217
|
+
const s = getComputedStyle(p), b = p.getBoundingClientRect();
|
|
218
|
+
hidden ||=
|
|
219
|
+
s.visibility === "hidden" ||
|
|
220
|
+
s.display === "none" ||
|
|
221
|
+
Number(s.opacity) === 0;
|
|
222
|
+
if (/hidden|clip|auto|scroll/.test(s.overflowX)) {
|
|
223
|
+
left = Math.max(left, b.left);
|
|
224
|
+
right = Math.min(right, b.right);
|
|
225
|
+
}
|
|
226
|
+
if (/hidden|clip|auto|scroll/.test(s.overflowY)) {
|
|
227
|
+
top = Math.max(top, b.top);
|
|
228
|
+
bottom = Math.min(bottom, b.bottom);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const sources = [
|
|
232
|
+
...(img.closest("picture")?.querySelectorAll("source") ?? []),
|
|
233
|
+
].map((s) => ({
|
|
234
|
+
srcset: s.srcset,
|
|
235
|
+
sizes: s.sizes,
|
|
236
|
+
media: s.media,
|
|
237
|
+
type: s.type,
|
|
238
|
+
matches: !s.media || matchMedia(s.media).matches,
|
|
239
|
+
}));
|
|
240
|
+
const activeSource = sources.find((s) => s.matches &&
|
|
241
|
+
candidates(s.srcset).some((c) => c.url === img.currentSrc));
|
|
242
|
+
const choices = candidates(activeSource?.srcset ?? img.srcset);
|
|
243
|
+
const selected = choices.find((c) => c.url === img.currentSrc);
|
|
244
|
+
const selectedWidth = selected?.width ?? img.naturalWidth * (selected?.density ?? 1);
|
|
245
|
+
const requiredWidth = Math.ceil(r.width * devicePixelRatio);
|
|
246
|
+
const resource = resources.find((e) => e.name === img.currentSrc);
|
|
247
|
+
const visible = !hidden && right > left && bottom > top;
|
|
248
|
+
return {
|
|
249
|
+
selector: img.id
|
|
250
|
+
? `#${CSS.escape(img.id)}`
|
|
251
|
+
: `img:nth-of-type(${[...img.parentElement.children].filter((e) => e.tagName === "IMG").indexOf(img) + 1})`,
|
|
252
|
+
snippet: img.outerHTML.slice(0, 1500),
|
|
253
|
+
currentSrc: img.currentSrc,
|
|
254
|
+
src: img.src,
|
|
255
|
+
srcset: img.srcset,
|
|
256
|
+
sizes: img.sizes,
|
|
257
|
+
sources,
|
|
258
|
+
choices,
|
|
259
|
+
loading: img.getAttribute("loading") ?? "auto",
|
|
260
|
+
fetchpriority: img.fetchPriority,
|
|
261
|
+
rendered: {
|
|
262
|
+
width: r.width,
|
|
263
|
+
height: r.height,
|
|
264
|
+
top: r.top,
|
|
265
|
+
left: r.left,
|
|
266
|
+
},
|
|
267
|
+
dpr: devicePixelRatio,
|
|
268
|
+
naturalWidth: img.naturalWidth,
|
|
269
|
+
selectedWidth,
|
|
270
|
+
requiredWidth,
|
|
271
|
+
widthEvidence: selected?.width
|
|
272
|
+
? "srcset-width-descriptor"
|
|
273
|
+
: "natural-width-and-density",
|
|
274
|
+
oversizeRatio: requiredWidth && selectedWidth ? selectedWidth / requiredWidth : null,
|
|
275
|
+
missingIntermediate: requiredWidth > 0 &&
|
|
276
|
+
selectedWidth > requiredWidth * 1.2 &&
|
|
277
|
+
!choices.some((c) => c.width &&
|
|
278
|
+
c.width >= requiredWidth &&
|
|
279
|
+
c.width <= requiredWidth * 1.2),
|
|
280
|
+
visible,
|
|
281
|
+
position: visible
|
|
282
|
+
? "first-viewport"
|
|
283
|
+
: r.top >= innerHeight
|
|
284
|
+
? "below-viewport"
|
|
285
|
+
: "offscreen-or-clipped",
|
|
286
|
+
loaded: !!resource || (img.complete && img.naturalWidth > 0),
|
|
287
|
+
inInitialHtml: declared.has(img.currentSrc),
|
|
288
|
+
bytes: resource?.encodedBodySize || null,
|
|
289
|
+
transferBytes: resource?.transferSize || null,
|
|
290
|
+
isLcp: state.lcpElement === img,
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
const faces = [], unreadable = [];
|
|
294
|
+
const walk = (rules, stylesheet) => {
|
|
295
|
+
for (const rule of rules) {
|
|
296
|
+
if (rule instanceof CSSFontFaceRule)
|
|
297
|
+
faces.push({
|
|
298
|
+
stylesheet,
|
|
299
|
+
family: rule.style.getPropertyValue("font-family"),
|
|
300
|
+
weight: rule.style.getPropertyValue("font-weight"),
|
|
301
|
+
unicodeRange: rule.style.getPropertyValue("unicode-range") || "U+0-10FFFF",
|
|
302
|
+
src: rule.style.getPropertyValue("src"),
|
|
303
|
+
display: rule.style.getPropertyValue("font-display"),
|
|
304
|
+
sizeAdjust: rule.style.getPropertyValue("size-adjust"),
|
|
305
|
+
ascentOverride: rule.style.getPropertyValue("ascent-override"),
|
|
306
|
+
descentOverride: rule.style.getPropertyValue("descent-override"),
|
|
307
|
+
lineGapOverride: rule.style.getPropertyValue("line-gap-override"),
|
|
308
|
+
});
|
|
309
|
+
else if ("cssRules" in rule)
|
|
310
|
+
walk(rule.cssRules, stylesheet);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
for (const sheet of document.styleSheets) {
|
|
314
|
+
try {
|
|
315
|
+
walk(sheet.cssRules, sheet.href ?? location.href);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
unreadable.push(sheet.href ?? "inline");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const samples = [...document.querySelectorAll("body *")]
|
|
322
|
+
.filter((el) => [...el.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent?.trim()) && el.getBoundingClientRect().width > 0)
|
|
323
|
+
.slice(0, 80)
|
|
324
|
+
.map((el) => {
|
|
325
|
+
const s = getComputedStyle(el), text = el.textContent?.trim().slice(0, 120) ?? "";
|
|
326
|
+
const canvas = document.createElement("canvas"), ctx = canvas.getContext("2d");
|
|
327
|
+
ctx.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
|
|
328
|
+
const loadedWidth = ctx.measureText(text).width;
|
|
329
|
+
const fallback = s.fontFamily.split(",").slice(1).join(",") || "serif";
|
|
330
|
+
ctx.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${fallback}`;
|
|
331
|
+
const fallbackWidth = ctx.measureText(text).width;
|
|
332
|
+
return {
|
|
333
|
+
tag: el.tagName,
|
|
334
|
+
text,
|
|
335
|
+
family: s.fontFamily,
|
|
336
|
+
weight: s.fontWeight,
|
|
337
|
+
fontSize: s.fontSize,
|
|
338
|
+
lineHeight: s.lineHeight,
|
|
339
|
+
fallback,
|
|
340
|
+
loadedWidth,
|
|
341
|
+
fallbackWidth,
|
|
342
|
+
widthDifference: loadedWidth - fallbackWidth,
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
const preloads = [
|
|
346
|
+
...document.querySelectorAll('link[rel~="preload"][as="font"],link[rel~="preload"][type^="font/"]'),
|
|
347
|
+
].map((link) => ({
|
|
348
|
+
url: link.href,
|
|
349
|
+
as: link.as,
|
|
350
|
+
type: link.type,
|
|
351
|
+
crossorigin: link.getAttribute("crossorigin"),
|
|
352
|
+
media: link.media,
|
|
353
|
+
active: !link.media || matchMedia(link.media).matches,
|
|
354
|
+
requested: resources.some((r) => r.name === link.href),
|
|
355
|
+
declaredFace: faces.some((f) => [...f.src.matchAll(/url\(["']?([^"')]+)["']?\)/g)].some((m) => new URL(m[1], f.stylesheet).href === link.href)),
|
|
356
|
+
}));
|
|
357
|
+
return {
|
|
358
|
+
viewport: {
|
|
359
|
+
width: innerWidth,
|
|
360
|
+
height: innerHeight,
|
|
361
|
+
dpr: devicePixelRatio,
|
|
362
|
+
},
|
|
363
|
+
lcp: state.lcp
|
|
364
|
+
? {
|
|
365
|
+
...state.lcp,
|
|
366
|
+
inInitialHtml: state.lcp.url ? declared.has(state.lcp.url) : null,
|
|
367
|
+
}
|
|
368
|
+
: null,
|
|
369
|
+
images,
|
|
370
|
+
fonts: {
|
|
371
|
+
faces,
|
|
372
|
+
unreadableStylesheets: unreadable,
|
|
373
|
+
samples,
|
|
374
|
+
preloads,
|
|
375
|
+
events: state.fontEvents,
|
|
376
|
+
shifts: state.shifts.map((s) => ({
|
|
377
|
+
...s,
|
|
378
|
+
fontTimingCorrelation: state.fontEvents.some((e) => e.type === "loadingdone" && Math.abs(e.time - s.time) < 150),
|
|
379
|
+
})),
|
|
380
|
+
scope: "80 text samples; fallback canvas widths, not a reflow simulation. Shift timing is correlation, not proof of font causation.",
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}, html);
|
|
384
|
+
// The protocol reports fonts actually used for glyphs, including fallback fonts.
|
|
385
|
+
await cdp.send("DOM.enable");
|
|
386
|
+
await cdp.send("CSS.enable");
|
|
387
|
+
const doc = await cdp.send("DOM.getDocument", { depth: 0 });
|
|
388
|
+
const nodes = await cdp.send("DOM.querySelectorAll", {
|
|
389
|
+
nodeId: doc.root.nodeId,
|
|
390
|
+
selector: "body *",
|
|
391
|
+
});
|
|
392
|
+
const used = [];
|
|
393
|
+
for (const nodeId of nodes.nodeIds.slice(0, 300)) {
|
|
394
|
+
const usage = await cdp
|
|
395
|
+
.send("CSS.getPlatformFontsForNode", { nodeId })
|
|
396
|
+
.catch(() => null);
|
|
397
|
+
if (usage?.fonts.length) {
|
|
398
|
+
const computed = await cdp
|
|
399
|
+
.send("CSS.getComputedStyleForNode", { nodeId })
|
|
400
|
+
.catch(() => null);
|
|
401
|
+
used.push({
|
|
402
|
+
nodeId,
|
|
403
|
+
fonts: usage.fonts,
|
|
404
|
+
weight: computed?.computedStyle.find((s) => s.name === "font-weight")
|
|
405
|
+
?.value ?? null,
|
|
406
|
+
family: computed?.computedStyle.find((s) => s.name === "font-family")
|
|
407
|
+
?.value ?? null,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const cleanFamily = (family) => family.replace(/["']/g, "").trim().toLowerCase();
|
|
412
|
+
const loadedFaces = await page.evaluate(() => [...document.fonts].map((f) => ({
|
|
413
|
+
family: f.family,
|
|
414
|
+
unicodeRange: f.unicodeRange,
|
|
415
|
+
weight: f.weight,
|
|
416
|
+
status: f.status,
|
|
417
|
+
})));
|
|
418
|
+
const weightMatches = (declared, weight) => {
|
|
419
|
+
const numeric = (s) => s === "normal" ? 400 : s === "bold" ? 700 : Number(s);
|
|
420
|
+
const bounds = (declared || "400").split(/\s+/).map(numeric);
|
|
421
|
+
const value = numeric(weight);
|
|
422
|
+
return value >= bounds[0] && value <= (bounds[1] ?? bounds[0]);
|
|
423
|
+
};
|
|
424
|
+
const preloads = evidence.fonts.preloads.map((preload) => {
|
|
425
|
+
const faces = evidence.fonts.faces.filter((f) => [...f.src.matchAll(/url\(["']?([^"')]+)["']?\)/g)].some((m) => new URL(m[1], f.stylesheet).href === preload.url));
|
|
426
|
+
const usedFace = faces.some((f) => loadedFaces.some((loaded) => loaded.status === "loaded" &&
|
|
427
|
+
cleanFamily(loaded.family) === cleanFamily(f.family) &&
|
|
428
|
+
loaded.weight === (f.weight || "normal") &&
|
|
429
|
+
loaded.unicodeRange.toUpperCase() === f.unicodeRange.toUpperCase()) &&
|
|
430
|
+
used.some((node) => weightMatches(f.weight, node.weight ?? "400") &&
|
|
431
|
+
node.fonts.some((font) => font.isCustomFont &&
|
|
432
|
+
(cleanFamily(font.familyName) === cleanFamily(f.family) ||
|
|
433
|
+
cleanFamily((node.family ?? "").split(",")[0]) ===
|
|
434
|
+
cleanFamily(f.family)))));
|
|
435
|
+
const response = responses.find((r) => r.url === preload.url);
|
|
436
|
+
return {
|
|
437
|
+
...preload,
|
|
438
|
+
status: response?.status ?? null,
|
|
439
|
+
used: faces.length ? usedFace : null,
|
|
440
|
+
usageEvidence: "Loaded FontFace plus rendered glyph family and computed weight; unicode subset file attribution is not available",
|
|
441
|
+
valid: preload.as === "font" &&
|
|
442
|
+
(preload.crossorigin === "" || preload.crossorigin === "anonymous") &&
|
|
443
|
+
(!preload.type ||
|
|
444
|
+
/^(font\/(woff2?|otf|ttf)|application\/font-woff)$/.test(preload.type)),
|
|
445
|
+
coverage: faces.length
|
|
446
|
+
? "observed-faces-and-glyphs"
|
|
447
|
+
: "face-not-observed",
|
|
448
|
+
};
|
|
449
|
+
});
|
|
450
|
+
const imageEvidence = evidence.images.map((img) => {
|
|
451
|
+
const response = responses.find((r) => r.url === img.currentSrc);
|
|
452
|
+
return {
|
|
453
|
+
...img,
|
|
454
|
+
mimeType: response?.mimeType ?? null,
|
|
455
|
+
bytes: response?.encodedBodySize ?? img.bytes,
|
|
456
|
+
transferBytes: response?.transferBytes ?? img.transferBytes,
|
|
457
|
+
compression: {
|
|
458
|
+
contentEncoding: response?.contentEncoding ?? null,
|
|
459
|
+
note: "Encoded bytes and Lighthouse image-delivery savings; format alone is not a failure.",
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
return {
|
|
464
|
+
...evidence,
|
|
465
|
+
images: imageEvidence,
|
|
466
|
+
readiness,
|
|
467
|
+
fonts: {
|
|
468
|
+
...evidence.fonts,
|
|
469
|
+
used,
|
|
470
|
+
loadedFaces,
|
|
471
|
+
preloads,
|
|
472
|
+
platformNodeLimit: 300,
|
|
473
|
+
},
|
|
474
|
+
findings: [
|
|
475
|
+
...imageEvidence.flatMap((img) => [
|
|
476
|
+
...(img.isLcp && img.loading === "lazy"
|
|
477
|
+
? [
|
|
478
|
+
{
|
|
479
|
+
code: "lcp-lazy",
|
|
480
|
+
message: "Measured LCP image is lazy loaded",
|
|
481
|
+
},
|
|
482
|
+
]
|
|
483
|
+
: []),
|
|
484
|
+
...(img.isLcp && img.fetchpriority !== "high"
|
|
485
|
+
? [
|
|
486
|
+
{
|
|
487
|
+
code: "lcp-priority",
|
|
488
|
+
message: "Measured LCP image has no high fetch priority",
|
|
489
|
+
},
|
|
490
|
+
]
|
|
491
|
+
: []),
|
|
492
|
+
...(img.isLcp && !img.inInitialHtml
|
|
493
|
+
? [
|
|
494
|
+
{
|
|
495
|
+
code: "lcp-discovery",
|
|
496
|
+
message: "Measured LCP image is not declared in the initial HTML",
|
|
497
|
+
},
|
|
498
|
+
]
|
|
499
|
+
: []),
|
|
500
|
+
...(img.missingIntermediate &&
|
|
501
|
+
(img.bytes ?? 0) >= 20000 &&
|
|
502
|
+
img.mimeType !== "image/svg+xml"
|
|
503
|
+
? [
|
|
504
|
+
{
|
|
505
|
+
code: "image-intermediate-size",
|
|
506
|
+
message: `${img.selectedWidth}px selected for ${img.requiredWidth}px needed, including DPR`,
|
|
507
|
+
},
|
|
508
|
+
]
|
|
509
|
+
: []),
|
|
510
|
+
...(img.visible && img.loading === "lazy"
|
|
511
|
+
? [
|
|
512
|
+
{
|
|
513
|
+
code: "visible-image-lazy",
|
|
514
|
+
message: "Visible first-viewport image is lazy loaded",
|
|
515
|
+
},
|
|
516
|
+
]
|
|
517
|
+
: []),
|
|
518
|
+
...(!img.visible && img.loaded && img.loading !== "lazy"
|
|
519
|
+
? [
|
|
520
|
+
{
|
|
521
|
+
code: "offscreen-image-eager",
|
|
522
|
+
message: `${img.position} image loaded eagerly`,
|
|
523
|
+
},
|
|
524
|
+
]
|
|
525
|
+
: []),
|
|
526
|
+
...(!img.visible && img.fetchpriority === "high"
|
|
527
|
+
? [
|
|
528
|
+
{
|
|
529
|
+
code: "offscreen-image-priority",
|
|
530
|
+
message: "Offscreen image competes at high priority",
|
|
531
|
+
},
|
|
532
|
+
]
|
|
533
|
+
: []),
|
|
534
|
+
].map((f) => ({ ...f, element: img.snippet, file: img.currentSrc }))),
|
|
535
|
+
...(imageEvidence.filter((i) => i.fetchpriority === "high").length > 1
|
|
536
|
+
? [
|
|
537
|
+
{
|
|
538
|
+
code: "competing-image-priorities",
|
|
539
|
+
message: "Multiple images request high fetch priority",
|
|
540
|
+
file: null,
|
|
541
|
+
element: null,
|
|
542
|
+
},
|
|
543
|
+
]
|
|
544
|
+
: []),
|
|
545
|
+
...evidence.fonts.faces
|
|
546
|
+
.filter((f) => /\.(otf|ttf)(?:["')?#]|$)/i.test(f.src))
|
|
547
|
+
.map((f) => ({
|
|
548
|
+
code: "font-format",
|
|
549
|
+
message: "Font source uses OTF/TTF",
|
|
550
|
+
file: f.src,
|
|
551
|
+
element: null,
|
|
552
|
+
})),
|
|
553
|
+
...preloads
|
|
554
|
+
.filter((p) => p.active &&
|
|
555
|
+
(!p.valid ||
|
|
556
|
+
!p.requested ||
|
|
557
|
+
p.used === false ||
|
|
558
|
+
(p.status !== null && p.status >= 400)))
|
|
559
|
+
.map((p) => ({
|
|
560
|
+
code: "font-preload",
|
|
561
|
+
message: "Font preload is invalid, failed, not requested, or unused by observed glyphs",
|
|
562
|
+
file: p.url,
|
|
563
|
+
element: null,
|
|
564
|
+
})),
|
|
565
|
+
],
|
|
566
|
+
};
|
|
567
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import lighthouse from "lighthouse";
|
|
2
|
+
import { HttpClient } from "./network.js";
|
|
3
|
+
import { type JobResult, type Scan } from "./types.js";
|
|
4
|
+
export type BrowserSession = Awaited<ReturnType<typeof openBrowserSession>>;
|
|
5
|
+
export declare function openBrowserSession(): Promise<{
|
|
6
|
+
proxy: {
|
|
7
|
+
port: number;
|
|
8
|
+
blocked: {
|
|
9
|
+
target: string;
|
|
10
|
+
error: string;
|
|
11
|
+
}[];
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
lighthouse: typeof lighthouse;
|
|
15
|
+
browser: import("puppeteer").Browser;
|
|
16
|
+
args: string[];
|
|
17
|
+
chromeVersion: string;
|
|
18
|
+
readonly connected: boolean;
|
|
19
|
+
close: () => Promise<void>;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function screenshotSampled(url: string, rotation: number, home: string): boolean;
|
|
22
|
+
export declare function collectBrowser(scan: Scan, url: string, http: HttpClient, session: BrowserSession): Promise<JobResult>;
|