webperf-snippets 0.2.0 → 0.3.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 (65) hide show
  1. package/README.md +120 -5
  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 +52 -30
  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 +40 -20
  33. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
  34. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -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 +15 -0
  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 +55 -25
  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 +59 -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 +216 -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 +22 -3
  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 +1176 -0
  59. package/snippets/_shared/helpers.js +44 -0
  60. package/src/bin.js +55 -3
  61. package/src/crux.js +84 -0
  62. package/src/report-to.js +46 -0
  63. package/src/reporters/human.js +20 -1
  64. package/src/reporters/markdown.js +22 -1
  65. package/src/runner.js +4 -2
@@ -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
+ })();
@@ -60,6 +60,10 @@
60
60
  return ["avif", "webp", "jxl", "auto (cdn)", "auto (cdn?)"].includes(format);
61
61
  }
62
62
 
63
+ // Images whose format could not be read from the response (fetch blocked by CORS) and was
64
+ // guessed from the URL instead
65
+ let formatGuessedCount = 0;
66
+
63
67
  async function fetchFormat(url) {
64
68
  if (!url) return detectFormat(url);
65
69
  try {
@@ -72,7 +76,9 @@
72
76
  if (ct.includes("gif")) return "gif";
73
77
  if (ct.includes("svg")) return "svg";
74
78
  if (ct.includes("jpeg")) return "jpg";
75
- } catch {}
79
+ } catch {
80
+ formatGuessedCount++;
81
+ }
76
82
  return detectFormat(url);
77
83
  }
78
84
 
@@ -111,7 +117,7 @@
111
117
 
112
118
  if (images.length === 0) {
113
119
  console.log("No <img> elements found on this page.");
114
- return { script: "Image-Element-Audit", status: "ok", count: 0, items: [], issues: [] };
120
+ return { script: "Image-Element-Audit", status: "ok", count: 0, corsLimitedAnalysis: false, items: [], issues: [] };
115
121
  }
116
122
 
117
123
  const lcpCandidate = findLcpCandidate(images);
@@ -305,12 +311,25 @@
305
311
  console.groupEnd();
306
312
 
307
313
  const lcpData = lcpCandidate ? audited.find((r) => r.isLcp) : null;
314
+ // 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
315
+ const MAX_ITEMS = 50;
316
+ const countBy = (r, severity) => r.issues.filter((i) => i.s === severity).length;
317
+ const rankedImages = [...audited].sort(
318
+ (a, b) =>
319
+ Number(b.isLcp) - Number(a.isLcp) ||
320
+ countBy(b, "error") - countBy(a, "error") ||
321
+ countBy(b, "warning") - countBy(a, "warning") ||
322
+ b.issues.length - a.issues.length ||
323
+ Number(b.inViewport) - Number(a.inViewport)
324
+ );
308
325
  return {
309
326
  script: "Image-Element-Audit",
310
327
  status: "ok",
311
328
  count: images.length,
329
+ corsLimitedAnalysis: formatGuessedCount > 0,
312
330
  details: {
313
331
  totalImages: images.length,
332
+ formatGuessedCount,
314
333
  inViewport: audited.filter((r) => r.inViewport).length,
315
334
  offViewport: audited.filter((r) => !r.inViewport).length,
316
335
  totalErrors,
@@ -326,7 +345,7 @@
326
345
  }
327
346
  : null,
328
347
  },
329
- items: audited.map((r) => ({
348
+ items: rankedImages.slice(0, MAX_ITEMS).map((r) => ({
330
349
  url: r.src,
331
350
  format: r.format,
332
351
  inViewport: r.inViewport,
@@ -0,0 +1,217 @@
1
+ // Oversized Images
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (async () => {
5
+ const OVERSIZED_RATIO = 1.5; // file has at least 1.5x the pixels it needs on each axis
6
+ const UNDERSIZED_RATIO = 0.9; // file has less than 90% of the pixels it needs
7
+ const SEVERE_OVERSIZED = 2;
8
+ const SEVERE_UNDERSIZED = 0.75;
9
+ const MAX_ITEMS = 50;
10
+ const LOAD_TIMEOUT_MS = 3000;
11
+
12
+ const dpr = window.devicePixelRatio || 1;
13
+
14
+ const round2 = (n) => Math.round(n * 100) / 100;
15
+ const px = (v) => parseFloat(v) || 0;
16
+
17
+ const shortUrl = (url) => (url.startsWith("data:") ? url.slice(0, 60) : url);
18
+ const fileName = (url) => url.split("?")[0].split("/").pop() || url;
19
+
20
+ function selectorOf(el) {
21
+ if (el.id) return `img#${el.id}`;
22
+ const cls = Array.from(el.classList).slice(0, 2);
23
+ return cls.length ? `img.${cls.join(".")}` : "img";
24
+ }
25
+
26
+ function isVector(url) {
27
+ return /^data:image\/svg/i.test(url) || /\.svg([?#]|$)/i.test(url);
28
+ }
29
+
30
+ // The size of the file in pixels. HTMLImageElement.naturalWidth is corrected by the srcset
31
+ // density (a 400px file picked as "2x" reports 200), so a fresh Image, which has no srcset,
32
+ // gives the real pixels. The file comes from the browser cache.
33
+ function loadPixels(url) {
34
+ return new Promise((resolve) => {
35
+ const probe = new Image();
36
+ const timer = setTimeout(() => resolve(null), LOAD_TIMEOUT_MS);
37
+ probe.onload = () => {
38
+ clearTimeout(timer);
39
+ resolve({ width: probe.naturalWidth, height: probe.naturalHeight });
40
+ };
41
+ probe.onerror = () => {
42
+ clearTimeout(timer);
43
+ resolve(null);
44
+ };
45
+ probe.src = url;
46
+ });
47
+ }
48
+
49
+ // Bytes of each image URL; null when the browser hides them (cross-origin without
50
+ // Timing-Allow-Origin).
51
+ const transferByUrl = new Map();
52
+ performance.getEntriesByType("resource").forEach((e) => {
53
+ if (e.initiatorType !== "img" && e.initiatorType !== "css" && e.initiatorType !== "other") return;
54
+ const bytes = e.transferSize > 0 ? e.transferSize : e.encodedBodySize > 0 ? e.encodedBodySize : null;
55
+ if (bytes !== null) transferByUrl.set(e.name, bytes);
56
+ });
57
+
58
+ // Scale from file pixels to CSS pixels on each axis, following object-fit.
59
+ function scaleOf(img, boxW, boxH, fileW, fileH) {
60
+ const fit = getComputedStyle(img).objectFit;
61
+ const sx = boxW / fileW;
62
+ const sy = boxH / fileH;
63
+ const contain = Math.min(sx, sy);
64
+ const none = img.naturalWidth / fileW; // CSS pixels per file pixel, density-corrected
65
+ if (fit === "cover") return { sx: Math.max(sx, sy), sy: Math.max(sx, sy) };
66
+ if (fit === "contain") return { sx: contain, sy: contain };
67
+ if (fit === "none") return { sx: none, sy: none };
68
+ if (fit === "scale-down") {
69
+ const s = Math.min(contain, none);
70
+ return { sx: s, sy: s };
71
+ }
72
+ return { sx, sy }; // fill
73
+ }
74
+
75
+ const images = Array.from(document.querySelectorAll("img"));
76
+ let skippedHidden = 0;
77
+ let skippedNotLoaded = 0;
78
+ let skippedVector = 0;
79
+
80
+ const candidates = [];
81
+ images.forEach((img) => {
82
+ const rect = img.getBoundingClientRect();
83
+ if (rect.width === 0 || rect.height === 0) return void skippedHidden++;
84
+ const url = img.currentSrc || img.src || "";
85
+ if (!img.complete || img.naturalWidth === 0 || !url) return void skippedNotLoaded++;
86
+ if (isVector(url)) return void skippedVector++;
87
+ candidates.push({ img, rect, url });
88
+ });
89
+
90
+ const pixelsByUrl = new Map();
91
+ await Promise.all(
92
+ [...new Set(candidates.map((c) => c.url))].map(async (url) => {
93
+ pixelsByUrl.set(url, await loadPixels(url));
94
+ }),
95
+ );
96
+
97
+ const flagged = [];
98
+ let checked = 0;
99
+ let wastedPixels = 0;
100
+ const wastedBytesByUrl = new Map();
101
+
102
+ candidates.forEach(({ img, rect, url }) => {
103
+ const file = pixelsByUrl.get(url);
104
+ if (!file || !file.width || !file.height) return void skippedNotLoaded++;
105
+ checked++;
106
+
107
+ const style = getComputedStyle(img);
108
+ const boxW = rect.width - px(style.paddingLeft) - px(style.paddingRight) - px(style.borderLeftWidth) - px(style.borderRightWidth);
109
+ const boxH = rect.height - px(style.paddingTop) - px(style.paddingBottom) - px(style.borderTopWidth) - px(style.borderBottomWidth);
110
+ if (boxW <= 0 || boxH <= 0) return;
111
+
112
+ const { sx, sy } = scaleOf(img, boxW, boxH, file.width, file.height);
113
+ const neededWidth = Math.round(file.width * sx * dpr);
114
+ const neededHeight = Math.round(file.height * sy * dpr);
115
+ // Pixels of the file per device pixel drawn, on the tighter axis
116
+ const ratio = Math.min(1 / (sx * dpr), 1 / (sy * dpr));
117
+
118
+ let verdict = null;
119
+ if (ratio >= OVERSIZED_RATIO) verdict = "oversized";
120
+ else if (ratio < UNDERSIZED_RATIO) verdict = "undersized";
121
+ if (!verdict) return;
122
+
123
+ const filePixels = file.width * file.height;
124
+ const wasted = verdict === "oversized" ? Math.max(0, filePixels - neededWidth * neededHeight) : 0;
125
+ const transferBytes = transferByUrl.get(url) ?? null;
126
+ const wastedBytes = transferBytes === null ? null : Math.round(transferBytes * (wasted / filePixels));
127
+
128
+ wastedPixels += wasted;
129
+ if (wastedBytes !== null) wastedBytesByUrl.set(url, Math.max(wastedBytesByUrl.get(url) ?? 0, wastedBytes));
130
+
131
+ flagged.push({
132
+ selector: selectorOf(img),
133
+ url: shortUrl(url),
134
+ verdict,
135
+ naturalWidth: file.width,
136
+ naturalHeight: file.height,
137
+ renderedWidth: Math.round(boxW),
138
+ renderedHeight: Math.round(boxH),
139
+ neededWidth,
140
+ neededHeight,
141
+ ratio: round2(ratio),
142
+ wastedPixels: wasted,
143
+ transferBytes,
144
+ wastedBytes,
145
+ hasSrcset: img.hasAttribute("srcset"),
146
+ hasSizes: img.hasAttribute("sizes"),
147
+ });
148
+ });
149
+
150
+ // Most waste first; undersized images (no waste) follow, the blurriest first
151
+ flagged.sort((a, b) => b.wastedPixels - a.wastedPixels || a.ratio - b.ratio);
152
+
153
+ const oversized = flagged.filter((f) => f.verdict === "oversized").length;
154
+ const undersized = flagged.length - oversized;
155
+ // An image used several times downloads once, so each URL counts once
156
+ const wastedBytes = [...wastedBytesByUrl.values()].reduce((a, b) => a + b, 0);
157
+
158
+ const issues = flagged.map((f) => {
159
+ const size = `${f.naturalWidth}x${f.naturalHeight}`;
160
+ const shown = `${f.neededWidth}x${f.neededHeight}`;
161
+ if (f.verdict === "oversized") {
162
+ const kb = f.wastedBytes === null ? "" : `, about ${Math.round(f.wastedBytes / 1024)} KB wasted`;
163
+ return {
164
+ severity: f.ratio >= SEVERE_OVERSIZED ? "warning" : "info",
165
+ message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (${f.ratio}x too large${kb})`,
166
+ };
167
+ }
168
+ return {
169
+ severity: f.ratio < SEVERE_UNDERSIZED ? "warning" : "info",
170
+ message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (blurry, needs ${round2(1 / f.ratio)}x more pixels)`,
171
+ };
172
+ });
173
+
174
+ const items = flagged.slice(0, MAX_ITEMS);
175
+
176
+ console.group("%cOversized Images", "font-weight: bold; font-size: 14px;");
177
+ console.log(` Images checked : ${checked} (devicePixelRatio ${dpr})`);
178
+ console.log(` Oversized : ${oversized}`);
179
+ console.log(` Undersized : ${undersized}`);
180
+ console.log(` Wasted pixels : ${wastedPixels.toLocaleString()}`);
181
+ console.log(` Wasted bytes : ${Math.round(wastedBytes / 1024)} KB (estimated)`);
182
+ if (items.length > 0) {
183
+ console.table(
184
+ items.map((f) => ({
185
+ image: fileName(f.url).slice(0, 40),
186
+ verdict: f.verdict,
187
+ file: `${f.naturalWidth}x${f.naturalHeight}`,
188
+ needed: `${f.neededWidth}x${f.neededHeight}`,
189
+ ratio: f.ratio,
190
+ "wasted KB": f.wastedBytes === null ? "n/a" : Math.round(f.wastedBytes / 1024),
191
+ })),
192
+ );
193
+ } else {
194
+ console.log("%cNo oversized or undersized images found.", "color: #22c55e; font-weight: bold;");
195
+ }
196
+ console.groupEnd();
197
+
198
+ return {
199
+ script: "Oversized-Images",
200
+ status: "ok",
201
+ count: flagged.length,
202
+ details: {
203
+ devicePixelRatio: dpr,
204
+ totalImages: images.length,
205
+ imagesChecked: checked,
206
+ oversized,
207
+ undersized,
208
+ wastedPixels,
209
+ wastedBytes,
210
+ skippedHidden,
211
+ skippedNotLoaded,
212
+ skippedVector,
213
+ },
214
+ items,
215
+ issues,
216
+ };
217
+ })();
@@ -1,7 +1,7 @@
1
1
  // SVG Embedded Bitmap Analysis
2
2
  // https://webperf-snippets.nucliweb.net
3
3
 
4
- void (async () => {
4
+ (async () => {
5
5
  function formatSize(bytes) {
6
6
  if (!bytes || bytes === 0) return "—";
7
7
  if (bytes < 1024) return `${bytes} B`;
@@ -20,7 +20,7 @@ void (async () => {
20
20
  }
21
21
 
22
22
  function compressionFromEntry(entry) {
23
- if (entry.transferSize === 0 && entry.encodedBodySize === 0) return "cached";
23
+ // Cross-origin resources without Timing-Allow-Origin report every size as 0
24
24
  if (entry.encodedBodySize === 0) return "unknown";
25
25
  if (entry.encodedBodySize < entry.decodedBodySize) return "compressed";
26
26
  return "none";
@@ -109,7 +109,14 @@ void (async () => {
109
109
 
110
110
  if (svgEntries.length === 0 && inlineSvgTotal === 0) {
111
111
  console.log("No SVG resources found on this page.");
112
- return { script: "SVG-Embedded-Bitmap-Analysis", status: "ok", count: 0, items: [], issues: [] };
112
+ return {
113
+ script: "SVG-Embedded-Bitmap-Analysis",
114
+ status: "ok",
115
+ count: 0,
116
+ details: { externalSvgCount: 0, inlineSvgTotal: 0, svgsWithUse: 0, standaloneInlineSvgs: 0, spriteOpportunity: false },
117
+ items: [],
118
+ issues: [],
119
+ };
113
120
  }
114
121
 
115
122
  console.group("%c🖼️ SVG Embedded Bitmap Analysis", "font-weight: bold; font-size: 14px;");
@@ -210,6 +217,14 @@ void (async () => {
210
217
  script: "SVG-Embedded-Bitmap-Analysis",
211
218
  status: "ok",
212
219
  count: withBitmaps.length,
220
+ details: {
221
+ externalSvgCount: svgEntries.length,
222
+ inlineSvgTotal,
223
+ svgsWithUse,
224
+ standaloneInlineSvgs,
225
+ // Five or more standalone inline SVGs are worth moving into a <symbol> sprite
226
+ spriteOpportunity: standaloneInlineSvgs >= 5,
227
+ },
213
228
  items: withBitmaps.map((r) => ({
214
229
  url: r.url || r.name,
215
230
  name: r.name,