@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.
Files changed (55) hide show
  1. package/README.md +79 -0
  2. package/bin/fullscan.js +2 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +239 -0
  5. package/dist/engine/analysis.d.ts +2 -0
  6. package/dist/engine/analysis.js +747 -0
  7. package/dist/engine/browser-inspection.d.ts +143 -0
  8. package/dist/engine/browser-inspection.js +567 -0
  9. package/dist/engine/browser.d.ts +22 -0
  10. package/dist/engine/browser.js +629 -0
  11. package/dist/engine/collect.d.ts +6 -0
  12. package/dist/engine/collect.js +359 -0
  13. package/dist/engine/crawl-scope.d.ts +22 -0
  14. package/dist/engine/crawl-scope.js +145 -0
  15. package/dist/engine/env.d.ts +1 -0
  16. package/dist/engine/env.js +3 -0
  17. package/dist/engine/html.d.ts +307 -0
  18. package/dist/engine/html.js +645 -0
  19. package/dist/engine/language.d.ts +13 -0
  20. package/dist/engine/language.js +75 -0
  21. package/dist/engine/lighthouse-evidence.d.ts +36 -0
  22. package/dist/engine/lighthouse-evidence.js +69 -0
  23. package/dist/engine/lighthouse.d.ts +4 -0
  24. package/dist/engine/lighthouse.js +284 -0
  25. package/dist/engine/log.d.ts +1 -0
  26. package/dist/engine/log.js +4 -0
  27. package/dist/engine/network.d.ts +53 -0
  28. package/dist/engine/network.js +296 -0
  29. package/dist/engine/proxy.d.ts +8 -0
  30. package/dist/engine/proxy.js +95 -0
  31. package/dist/engine/run.d.ts +38 -0
  32. package/dist/engine/run.js +202 -0
  33. package/dist/engine/select.d.ts +6 -0
  34. package/dist/engine/select.js +38 -0
  35. package/dist/engine/site.d.ts +186 -0
  36. package/dist/engine/site.js +758 -0
  37. package/dist/engine/srcset.d.ts +1 -0
  38. package/dist/engine/srcset.js +31 -0
  39. package/dist/engine/state.d.ts +30 -0
  40. package/dist/engine/state.js +198 -0
  41. package/dist/engine/structured-data.d.ts +83 -0
  42. package/dist/engine/structured-data.js +331 -0
  43. package/dist/engine/types.d.ts +128 -0
  44. package/dist/engine/types.js +63 -0
  45. package/dist/engine.d.ts +1 -0
  46. package/dist/engine.js +1 -0
  47. package/dist/index.d.ts +8 -0
  48. package/dist/index.js +7 -0
  49. package/dist/report/build.d.ts +377 -0
  50. package/dist/report/build.js +2263 -0
  51. package/dist/report/evidence.d.ts +58 -0
  52. package/dist/report/evidence.js +192 -0
  53. package/dist/report/rules.d.ts +41 -0
  54. package/dist/report/rules.js +301 -0
  55. 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>;