webperf-snippets 0.2.0 → 0.4.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 (73) hide show
  1. package/README.md +142 -14
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/CLS.js +29 -15
  4. package/snippets/CoreWebVitals/INP.js +14 -7
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
  9. package/snippets/CoreWebVitals/LCP.js +4 -2
  10. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
  11. package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
  12. package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
  13. package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
  14. package/snippets/Interaction/Interactions.js +16 -5
  15. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
  16. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
  17. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
  18. package/snippets/Interaction/Long-Animation-Frames.js +31 -2
  19. package/snippets/Interaction/LongTask.js +37 -5
  20. package/snippets/Interaction/Scroll-Performance.js +40 -0
  21. package/snippets/Interaction/WORKFLOWS.md +9 -0
  22. package/snippets/Loading/Back-Forward-Cache.js +174 -381
  23. package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
  24. package/snippets/Loading/Cache-Strategy-Analysis.js +141 -43
  25. package/snippets/Loading/Compression-Audit.js +172 -0
  26. package/snippets/Loading/Content-Visibility.js +8 -3
  27. package/snippets/Loading/Critical-CSS-Detection.js +8 -6
  28. package/snippets/Loading/Event-Processing-Time.js +15 -2
  29. package/snippets/Loading/FCP.js +39 -5
  30. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
  31. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
  32. package/snippets/Loading/First-And-Third-Party-Script-Info.js +58 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +46 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +53 -4
  35. package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
  36. package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
  37. package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
  38. package/snippets/Loading/Priority-Hints-Audit.js +4 -1
  39. package/snippets/Loading/Resource-Hints-Validation.js +44 -9
  40. package/snippets/Loading/Resource-Hints.js +29 -8
  41. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
  42. package/snippets/Loading/Script-Loading.js +72 -24
  43. package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
  44. package/snippets/Loading/Service-Worker-Analysis.js +229 -53
  45. package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
  46. package/snippets/Loading/TTFB-Resources.js +86 -18
  47. package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
  48. package/snippets/Loading/TTFB.js +3 -2
  49. package/snippets/Loading/Third-Party-Impact-by-Domain.js +244 -0
  50. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
  51. package/snippets/Loading/WORKFLOWS.md +47 -1
  52. package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
  53. package/snippets/Media/Image-Element-Audit.js +74 -11
  54. package/snippets/Media/Oversized-Images.js +217 -0
  55. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
  56. package/snippets/Media/WORKFLOWS.md +22 -0
  57. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
  58. package/snippets/SCHEMA.md +1179 -0
  59. package/snippets/_shared/helpers.js +61 -0
  60. package/src/bin.js +72 -45
  61. package/src/crux.js +84 -0
  62. package/src/decision-tree.js +55 -7
  63. package/src/interactions.js +13 -3
  64. package/src/load-snippet.js +1 -1
  65. package/src/report-to.js +46 -0
  66. package/src/reporters/human.js +43 -5
  67. package/src/reporters/markdown.js +27 -1
  68. package/src/runner.js +75 -21
  69. package/src/snippet-names.js +89 -0
  70. package/src/tracking.js +6 -0
  71. package/src/workflows/audit.js +17 -0
  72. package/src/workflows/interaction.js +30 -0
  73. package/src/workflows/loading.js +9 -0
@@ -0,0 +1,332 @@
1
+ // Webfont Usage Analyzer
2
+ // https://webperf-snippets.nucliweb.net
3
+ //
4
+ // Cross-checks every @font-face known to document.fonts against the text that renders on the
5
+ // page: faces loaded but not used, faces declared but never loaded, unicode-range subsets,
6
+ // font-display, and the bytes the font files cost.
7
+ // Idea and original script: https://github.com/paulcalvano/webfont-usage-analyzer
8
+
9
+ (() => {
10
+ const SCRIPT = "Webfont-Usage-Analyzer";
11
+ const MAX_ITEMS = 50;
12
+ const MAX_ELEMENTS = 20000;
13
+ const MAX_CODEPOINTS = 20000;
14
+ const BYTES_WARNING = 300 * 1024;
15
+ const FONT_FILE = /\.(woff2?|ttf|otf|eot)(\?|#|$)/i;
16
+
17
+ if (typeof FontFace === "undefined" || !document.fonts) {
18
+ return {
19
+ script: SCRIPT,
20
+ status: "unsupported",
21
+ message: "The CSS Font Loading API (document.fonts) is not available in this browser",
22
+ };
23
+ }
24
+
25
+ const stripQuotes = (s) => String(s).replace(/["']/g, "").trim();
26
+ const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
27
+
28
+ // --- Face metadata -------------------------------------------------------------------
29
+
30
+ // "normal" is 400 and "bold" is 700; a variable range such as "100 900" is kept as a range
31
+ function parseWeight(value) {
32
+ const v = String(value).trim().toLowerCase();
33
+ if (v === "normal") return [400, 400];
34
+ if (v === "bold") return [700, 700];
35
+ const nums = v.split(/\s+/).map(Number).filter((n) => !Number.isNaN(n));
36
+ if (nums.length === 0) return [400, 400];
37
+ return [Math.min(...nums), Math.max(...nums)];
38
+ }
39
+
40
+ // "U+0-FF, U+131, U+4??" as [[lo, hi], ...]
41
+ function parseRanges(value) {
42
+ const ranges = [];
43
+ for (const token of String(value).split(",")) {
44
+ const m = token.trim().match(/^U\+([0-9a-f?]+)(?:-([0-9a-f]+))?$/i);
45
+ if (!m) continue;
46
+ if (m[1].includes("?")) {
47
+ ranges.push([parseInt(m[1].replace(/\?/g, "0"), 16), parseInt(m[1].replace(/\?/g, "f"), 16)]);
48
+ } else {
49
+ const lo = parseInt(m[1], 16);
50
+ ranges.push([lo, m[2] ? parseInt(m[2], 16) : lo]);
51
+ }
52
+ }
53
+ return ranges;
54
+ }
55
+
56
+ const isDefaultRange = (value) => !value || /^U\+0-10FFFF$/i.test(value.trim());
57
+ const rangeCovers = (ranges, cp) => ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
58
+ const styleKind = (s) => (String(s).trim().toLowerCase() === "normal" ? "normal" : "slanted");
59
+
60
+ // --- Font file URLs, from the @font-face rules ---------------------------------------
61
+
62
+ let unreadableStylesheets = 0;
63
+ const ruleSources = new Map(); // "family|weight|style|range" -> [absolute urls]
64
+ const faceKey = (family, weight, style, range) =>
65
+ [stripQuotes(family).toLowerCase(), String(weight).trim() || "normal", String(style).trim() || "normal", isDefaultRange(range) ? "" : parseRanges(range).map((r) => r.join("-")).join(",")].join("|");
66
+
67
+ function collectRules(rules, baseHref, depth) {
68
+ if (depth > 6) return;
69
+ for (const rule of rules) {
70
+ if (rule instanceof window.CSSFontFaceRule) {
71
+ const s = rule.style;
72
+ const urls = [...s.getPropertyValue("src").matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/g)]
73
+ .map((m) => {
74
+ try {
75
+ return new URL(m[2], baseHref).href;
76
+ } catch {
77
+ return null;
78
+ }
79
+ })
80
+ .filter((u) => u && !u.startsWith("data:"));
81
+ const key = faceKey(s.getPropertyValue("font-family"), s.getPropertyValue("font-weight"), s.getPropertyValue("font-style"), s.getPropertyValue("unicode-range"));
82
+ ruleSources.set(key, [...(ruleSources.get(key) ?? []), ...urls]);
83
+ } else if (rule.styleSheet) {
84
+ readSheet(rule.styleSheet, depth + 1);
85
+ } else if (rule.cssRules) {
86
+ collectRules(rule.cssRules, baseHref, depth + 1);
87
+ }
88
+ }
89
+ }
90
+
91
+ function readSheet(sheet, depth) {
92
+ try {
93
+ collectRules(sheet.cssRules, sheet.href || location.href, depth);
94
+ } catch {
95
+ unreadableStylesheets++; // cross-origin stylesheet without CORS
96
+ }
97
+ }
98
+
99
+ Array.from(document.styleSheets).forEach((sheet) => readSheet(sheet, 0));
100
+
101
+ // --- Bytes, from Resource Timing -----------------------------------------------------
102
+
103
+ const knownUrls = new Set([...ruleSources.values()].flat());
104
+ const fontEntries = new Map(); // url -> encodedBodySize
105
+ for (const e of performance.getEntriesByType("resource")) {
106
+ if (FONT_FILE.test(e.name) || knownUrls.has(e.name)) {
107
+ fontEntries.set(e.name, Math.max(fontEntries.get(e.name) ?? 0, e.encodedBodySize || 0));
108
+ }
109
+ }
110
+ const totalFontBytes = [...fontEntries.values()].reduce((sum, b) => sum + b, 0);
111
+ // Cross-origin files without Timing-Allow-Origin report a size of 0
112
+ const bytesReadable = [...fontEntries.values()].every((b) => b > 0);
113
+
114
+ // --- Text that renders, grouped by font stack, weight and style ----------------------
115
+
116
+ const groups = new Map(); // key -> { families, weight, style, codepoints }
117
+ const HIDDEN_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "TITLE", "META", "LINK"]);
118
+
119
+ function addText(el, style, text) {
120
+ const clean = text.replace(/\s+/g, "");
121
+ if (!clean) return;
122
+ const key = `${style.fontFamily}|${style.fontWeight}|${style.fontStyle}`;
123
+ let g = groups.get(key);
124
+ if (!g) {
125
+ const families = [...style.fontFamily.matchAll(/"([^"]+)"|'([^']+)'|([^,]+)/g)].map((m) => stripQuotes(m[1] ?? m[2] ?? m[3]).toLowerCase());
126
+ g = { families, weight: Number(style.fontWeight) || 400, style: style.fontStyle, codepoints: new Set() };
127
+ groups.set(key, g);
128
+ }
129
+ for (const ch of clean) {
130
+ if (g.codepoints.size >= MAX_CODEPOINTS) break;
131
+ g.codepoints.add(ch.codePointAt(0));
132
+ }
133
+ }
134
+
135
+ // Computed content of ::before/::after: "\"\\e900\"" -> the icon glyph
136
+ const decodeContent = (content) => {
137
+ if (!content || content === "none" || content === "normal") return "";
138
+ return content
139
+ .replace(/^["']|["']$/g, "")
140
+ .replace(/\\([0-9a-f]{1,6})\s?/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
141
+ .replace(/\\(.)/g, "$1");
142
+ };
143
+
144
+ const elements = [document.body, ...document.body.querySelectorAll("*")];
145
+ const scanned = Math.min(elements.length, MAX_ELEMENTS);
146
+ for (let i = 0; i < scanned; i++) {
147
+ const el = elements[i];
148
+ if (HIDDEN_TAGS.has(el.tagName) || (el.checkVisibility && !el.checkVisibility())) continue;
149
+ const style = getComputedStyle(el);
150
+ let own = "";
151
+ for (const node of el.childNodes) if (node.nodeType === Node.TEXT_NODE) own += node.nodeValue;
152
+ addText(el, style, own);
153
+ for (const pseudo of ["::before", "::after"]) {
154
+ const ps = getComputedStyle(el, pseudo);
155
+ addText(el, ps, decodeContent(ps.content));
156
+ }
157
+ }
158
+
159
+ // --- Faces ---------------------------------------------------------------------------
160
+
161
+ const faces = Array.from(document.fonts).map((f) => {
162
+ const family = stripQuotes(f.family);
163
+ const range = isDefaultRange(f.unicodeRange) ? "" : f.unicodeRange.trim();
164
+ const urls = ruleSources.get(faceKey(f.family, f.weight, f.style, f.unicodeRange)) ?? [];
165
+ const url = urls.find((u) => fontEntries.has(u)) ?? urls[0] ?? "";
166
+ return {
167
+ family,
168
+ weight: String(f.weight),
169
+ style: String(f.style),
170
+ display: f.display || "auto",
171
+ unicodeRange: range,
172
+ status: f.status,
173
+ used: false,
174
+ bytes: url && fontEntries.has(url) ? fontEntries.get(url) : 0,
175
+ url,
176
+ _weightRange: parseWeight(f.weight),
177
+ _ranges: range ? parseRanges(range) : null,
178
+ };
179
+ });
180
+
181
+ // A face is used when the text that renders with its family reaches it: the style matches
182
+ // (or the family has no face in that style), the text has a character inside its
183
+ // unicode-range, and it is the closest weight, since browsers pick the nearest weight.
184
+ const weightDistance = (face, w) =>
185
+ w >= face._weightRange[0] && w <= face._weightRange[1] ? 0 : Math.min(Math.abs(w - face._weightRange[0]), Math.abs(w - face._weightRange[1]));
186
+ const covers = (face, codepoints) => !face._ranges || [...codepoints].some((cp) => rangeCovers(face._ranges, cp));
187
+
188
+ for (const g of groups.values()) {
189
+ for (const family of g.families) {
190
+ const ofFamily = faces.filter((f) => f.family.toLowerCase() === family);
191
+ if (ofFamily.length === 0) continue;
192
+ const sameStyle = ofFamily.filter((f) => styleKind(f.style) === styleKind(g.style));
193
+ const candidates = (sameStyle.length ? sameStyle : ofFamily).filter((f) => covers(f, g.codepoints));
194
+ if (candidates.length === 0) continue;
195
+ const best = Math.min(...candidates.map((f) => weightDistance(f, g.weight)));
196
+ candidates.filter((f) => weightDistance(f, g.weight) === best).forEach((f) => (f.used = true));
197
+ }
198
+ }
199
+
200
+ // --- Classification ------------------------------------------------------------------
201
+
202
+ // Split fonts declare long ranges; keep reported text short
203
+ const shortRange = (r) => (r.length > 60 ? `${r.slice(0, 57)}...` : r);
204
+ const label = (f) => `${f.family} ${f.weight} ${f.style}${f.unicodeRange ? ` (${shortRange(f.unicodeRange)})` : ""}`;
205
+ const loaded = faces.filter((f) => f.status === "loaded");
206
+ const loadedUnused = loaded.filter((f) => !f.used);
207
+ const neverLoaded = faces.filter((f) => f.status === "unloaded");
208
+ const failed = faces.filter((f) => f.status === "error");
209
+ const neverLoadedSubsets = neverLoaded.filter((f) => f.unicodeRange);
210
+ const neverLoadedWhole = neverLoaded.filter((f) => !f.unicodeRange);
211
+ const blockingDisplay = faces.filter((f) => (f.status === "loaded" || f.status === "loading") && (f.display === "auto" || f.display === "block"));
212
+ const loadedUnusedBytes = loadedUnused.reduce((sum, f) => sum + f.bytes, 0);
213
+
214
+ const bySubsetFamily = new Map();
215
+ for (const f of faces.filter((x) => x.unicodeRange)) {
216
+ const entry = bySubsetFamily.get(f.family) ?? { family: f.family, subsets: 0, loadedSubsets: 0 };
217
+ entry.subsets++;
218
+ if (f.status === "loaded") entry.loadedSubsets++;
219
+ bySubsetFamily.set(f.family, entry);
220
+ }
221
+ const unicodeRangeFamilies = [...bySubsetFamily.values()].filter((e) => e.subsets > 1).slice(0, 20);
222
+
223
+ const displayCounts = {};
224
+ faces.forEach((f) => (displayCounts[f.display] = (displayCounts[f.display] ?? 0) + 1));
225
+
226
+ const names = (list) => {
227
+ // A stylesheet can declare the same face twice
228
+ const unique = [...new Set(list.map(label))];
229
+ const shown = unique.slice(0, 8).join(", ");
230
+ return unique.length > 8 ? `${shown} and ${unique.length - 8} more` : shown;
231
+ };
232
+
233
+ const issues = [
234
+ ...failed.map((f) => ({ severity: "error", message: `Font failed to load: ${label(f)}` })),
235
+ ...loadedUnused.map((f) => ({
236
+ severity: "warning",
237
+ message: `Loaded but not used by any rendered element: ${label(f)}${f.bytes ? `, ${kb(f.bytes)} downloaded for nothing` : ""}`,
238
+ })),
239
+ ];
240
+ if (blockingDisplay.length > 0) {
241
+ issues.push({
242
+ severity: "warning",
243
+ message: `font-display is auto or block on ${blockingDisplay.length} face(s), so text can stay invisible while they load. Use swap or optional: ${names(blockingDisplay)}`,
244
+ });
245
+ }
246
+ if (totalFontBytes > BYTES_WARNING) {
247
+ issues.push({ severity: "warning", message: `Web fonts weigh ${kb(totalFontBytes)} in total (over ${kb(BYTES_WARNING)}). Subset, use WOFF2 or drop unused weights` });
248
+ }
249
+ if (neverLoadedWhole.length > 0) {
250
+ issues.push({ severity: "info", message: `Declared but never loaded (${neverLoadedWhole.length}); nothing on this page needed them: ${names(neverLoadedWhole)}` });
251
+ }
252
+ if (neverLoadedSubsets.length > 0) {
253
+ issues.push({ severity: "info", message: `unicode-range subsets this page did not need (${neverLoadedSubsets.length}), which is the intended behavior of a split font: ${names(neverLoadedSubsets)}` });
254
+ }
255
+ if (!bytesReadable) {
256
+ issues.push({ severity: "info", message: "Some font files are cross-origin without Timing-Allow-Origin, so their size reads as 0 and the byte totals are a lower bound" });
257
+ }
258
+ if (unreadableStylesheets > 0) {
259
+ issues.push({ severity: "info", message: `${unreadableStylesheets} cross-origin stylesheet(s) could not be read, so their font files cannot be matched to faces` });
260
+ }
261
+ if (scanned < elements.length) {
262
+ issues.push({ severity: "info", message: `Only the first ${MAX_ELEMENTS} elements were scanned for text; some faces may be reported as unused` });
263
+ }
264
+
265
+ // --- Console output ------------------------------------------------------------------
266
+
267
+ console.group("%cWebfont Usage Analyzer", "font-weight: bold; font-size: 14px;");
268
+ console.log(`Faces: ${faces.length} | loaded: ${loaded.length} | used: ${faces.filter((f) => f.used).length} | loaded but unused: ${loadedUnused.length} | never loaded: ${neverLoaded.length}`);
269
+ console.log(`Font files: ${fontEntries.size} | total: ${kb(totalFontBytes)}${bytesReadable ? "" : " (lower bound, cross-origin sizes hidden)"}`);
270
+ if (faces.length > 0) {
271
+ console.table(
272
+ faces.map((f) => ({
273
+ Family: f.family,
274
+ Weight: f.weight,
275
+ Style: f.style,
276
+ Display: f.display,
277
+ "unicode-range": shortRange(f.unicodeRange) || "all",
278
+ Status: f.status,
279
+ Used: f.used ? "yes" : "no",
280
+ Size: f.bytes ? kb(f.bytes) : "-",
281
+ }))
282
+ );
283
+ } else {
284
+ console.log("No @font-face rules or FontFace objects on this page.");
285
+ }
286
+ issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
287
+ console.groupEnd();
288
+
289
+ // --- Return value --------------------------------------------------------------------
290
+
291
+ // Problems first, so the 50-item cap never hides them
292
+ const rank = (f) => (f.status === "error" ? 0 : f.status === "loaded" && !f.used ? 1 : f.status === "loaded" ? 2 : 3);
293
+ const items = faces
294
+ .map((f, index) => ({ f, index }))
295
+ .sort((a, b) => rank(a.f) - rank(b.f) || a.index - b.index)
296
+ .slice(0, MAX_ITEMS)
297
+ .map(({ f }) => ({
298
+ family: f.family,
299
+ weight: f.weight,
300
+ style: f.style,
301
+ display: f.display,
302
+ unicodeRange: shortRange(f.unicodeRange),
303
+ status: f.status,
304
+ used: f.used,
305
+ bytes: f.bytes,
306
+ url: f.url,
307
+ }));
308
+
309
+ return {
310
+ script: SCRIPT,
311
+ status: "ok",
312
+ count: faces.length,
313
+ corsLimitedAnalysis: !bytesReadable,
314
+ details: {
315
+ loadedCount: loaded.length,
316
+ usedCount: loaded.filter((f) => f.used).length,
317
+ loadedUnusedCount: loadedUnused.length,
318
+ loadedUnusedBytes,
319
+ neverLoadedCount: neverLoaded.length,
320
+ errorCount: failed.length,
321
+ fontFileCount: fontEntries.size,
322
+ totalFontBytes,
323
+ bytesReadable,
324
+ fontDisplay: displayCounts,
325
+ unicodeRangeFamilies,
326
+ unreadableStylesheets,
327
+ elementsScanned: scanned,
328
+ },
329
+ items,
330
+ issues,
331
+ };
332
+ })();
@@ -14,7 +14,8 @@
14
14
  );
15
15
  }
16
16
 
17
- function findLcpCandidate(imgs) {
17
+ // Fallback when the browser cannot report the LCP element: the largest image in the viewport
18
+ function estimateLcpCandidate(imgs) {
18
19
  let candidate = null;
19
20
  let maxArea = 0;
20
21
  imgs.filter(isInViewport).forEach((img) => {
@@ -28,6 +29,40 @@
28
29
  return candidate;
29
30
  }
30
31
 
32
+ // Chrome only exposes largest-contentful-paint entries through a PerformanceObserver
33
+ function getLastLcpEntry() {
34
+ return new Promise((resolve) => {
35
+ const entries = [];
36
+ const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
37
+ observer.observe({ type: "largest-contentful-paint", buffered: true });
38
+ setTimeout(() => {
39
+ observer.disconnect();
40
+ resolve(entries.at(-1) ?? null);
41
+ }, 100);
42
+ });
43
+ }
44
+
45
+ // The <img> the browser reports as the Largest Contentful Paint, or null when the LCP element is
46
+ // not one of the page's images (text, a CSS background, a video). Estimates by area when the
47
+ // browser reports no LCP.
48
+ async function findLcpCandidate(imgs) {
49
+ if (!PerformanceObserver.supportedEntryTypes?.includes("largest-contentful-paint")) {
50
+ return { candidate: estimateLcpCandidate(imgs), nonImageTag: null };
51
+ }
52
+ const entry = await getLastLcpEntry();
53
+ if (!entry) return { candidate: estimateLcpCandidate(imgs), nonImageTag: null };
54
+ const el = entry.element;
55
+ if (el?.tagName === "IMG") {
56
+ return imgs.includes(el) ? { candidate: el, nonImageTag: null } : { candidate: null, nonImageTag: "IMG" };
57
+ }
58
+ // The element left the DOM after painting: find its image by URL
59
+ if (!el && entry.url) {
60
+ const byUrl = imgs.find((img) => (img.currentSrc || img.src) === entry.url) ?? null;
61
+ return { candidate: byUrl, nonImageTag: byUrl ? null : "unknown" };
62
+ }
63
+ return { candidate: null, nonImageTag: el ? el.tagName : "unknown" };
64
+ }
65
+
31
66
  function detectFormat(url) {
32
67
  if (!url) return "unknown";
33
68
  const lower = url.toLowerCase();
@@ -60,6 +95,10 @@
60
95
  return ["avif", "webp", "jxl", "auto (cdn)", "auto (cdn?)"].includes(format);
61
96
  }
62
97
 
98
+ // Images whose format could not be read from the response (fetch blocked by CORS) and was
99
+ // guessed from the URL instead
100
+ let formatGuessedCount = 0;
101
+
63
102
  async function fetchFormat(url) {
64
103
  if (!url) return detectFormat(url);
65
104
  try {
@@ -72,7 +111,9 @@
72
111
  if (ct.includes("gif")) return "gif";
73
112
  if (ct.includes("svg")) return "svg";
74
113
  if (ct.includes("jpeg")) return "jpg";
75
- } catch {}
114
+ } catch {
115
+ formatGuessedCount++;
116
+ }
76
117
  return detectFormat(url);
77
118
  }
78
119
 
@@ -111,10 +152,10 @@
111
152
 
112
153
  if (images.length === 0) {
113
154
  console.log("No <img> elements found on this page.");
114
- return { script: "Image-Element-Audit", status: "ok", count: 0, items: [], issues: [] };
155
+ return { script: "Image-Element-Audit", status: "ok", count: 0, corsLimitedAnalysis: false, items: [], issues: [] };
115
156
  }
116
157
 
117
- const lcpCandidate = findLcpCandidate(images);
158
+ const { candidate: lcpCandidate, nonImageTag } = await findLcpCandidate(images);
118
159
  const imagePreloads = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
119
160
 
120
161
  // Fetch actual formats from Content-Type headers in parallel.
@@ -207,6 +248,10 @@
207
248
  );
208
249
 
209
250
  // LCP candidate
251
+ if (nonImageTag) {
252
+ console.log("");
253
+ console.log(`%cℹ️ The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP.`, "color: #3b82f6;");
254
+ }
210
255
  if (lcpCandidate) {
211
256
  const lcp = audited.find((r) => r.isLcp);
212
257
  const fpOk = lcp.fetchpriority === "high";
@@ -305,12 +350,25 @@
305
350
  console.groupEnd();
306
351
 
307
352
  const lcpData = lcpCandidate ? audited.find((r) => r.isLcp) : null;
353
+ // The list keeps the 50 images to look at first: the LCP image, then the most errors, warnings and notes, then those in the viewport; count and details cover all of them
354
+ const MAX_ITEMS = 50;
355
+ const countBy = (r, severity) => r.issues.filter((i) => i.s === severity).length;
356
+ const rankedImages = [...audited].sort(
357
+ (a, b) =>
358
+ Number(b.isLcp) - Number(a.isLcp) ||
359
+ countBy(b, "error") - countBy(a, "error") ||
360
+ countBy(b, "warning") - countBy(a, "warning") ||
361
+ b.issues.length - a.issues.length ||
362
+ Number(b.inViewport) - Number(a.inViewport)
363
+ );
308
364
  return {
309
365
  script: "Image-Element-Audit",
310
366
  status: "ok",
311
367
  count: images.length,
368
+ corsLimitedAnalysis: formatGuessedCount > 0,
312
369
  details: {
313
370
  totalImages: images.length,
371
+ formatGuessedCount,
314
372
  inViewport: audited.filter((r) => r.inViewport).length,
315
373
  offViewport: audited.filter((r) => !r.inViewport).length,
316
374
  totalErrors,
@@ -326,7 +384,7 @@
326
384
  }
327
385
  : null,
328
386
  },
329
- items: audited.map((r) => ({
387
+ items: rankedImages.slice(0, MAX_ITEMS).map((r) => ({
330
388
  url: r.src,
331
389
  format: r.format,
332
390
  inViewport: r.inViewport,
@@ -340,11 +398,16 @@
340
398
  inPicture: r.inPicture,
341
399
  issues: r.issues.map((i) => ({ severity: i.s, message: i.msg })),
342
400
  })),
343
- issues: audited.flatMap((r) =>
344
- r.issues.map((i) => ({
345
- severity: i.s,
346
- message: `${shortSrc(r.src) || "(no src)"}: ${i.msg}`,
347
- }))
348
- ),
401
+ issues: [
402
+ ...(nonImageTag
403
+ ? [{ severity: "info", message: `The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP` }]
404
+ : []),
405
+ ...audited.flatMap((r) =>
406
+ r.issues.map((i) => ({
407
+ severity: i.s,
408
+ message: `${shortSrc(r.src) || "(no src)"}: ${i.msg}`,
409
+ }))
410
+ ),
411
+ ],
349
412
  };
350
413
  })();