webperf-snippets 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 (67) hide show
  1. package/README.md +139 -0
  2. package/package.json +54 -0
  3. package/snippets/CoreWebVitals/CLS.js +88 -0
  4. package/snippets/CoreWebVitals/INP.js +432 -0
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
  9. package/snippets/CoreWebVitals/LCP.js +135 -0
  10. package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
  12. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
  15. package/snippets/Interaction/Interactions.js +220 -0
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
  19. package/snippets/Interaction/Long-Animation-Frames.js +298 -0
  20. package/snippets/Interaction/LongTask.js +203 -0
  21. package/snippets/Interaction/Scroll-Performance.js +359 -0
  22. package/snippets/Interaction/WORKFLOWS.md +205 -0
  23. package/snippets/Loading/Back-Forward-Cache.js +473 -0
  24. package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
  25. package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
  26. package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
  27. package/snippets/Loading/Content-Visibility.js +352 -0
  28. package/snippets/Loading/Critical-CSS-Detection.js +329 -0
  29. package/snippets/Loading/Event-Processing-Time.js +179 -0
  30. package/snippets/Loading/FCP.js +101 -0
  31. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
  32. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
  33. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
  34. package/snippets/Loading/Find-render-blocking-resources.js +161 -0
  35. package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
  36. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
  37. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
  38. package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
  39. package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
  40. package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
  41. package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
  42. package/snippets/Loading/Priority-Hints-Audit.js +336 -0
  43. package/snippets/Loading/Resource-Hints-Validation.js +503 -0
  44. package/snippets/Loading/Resource-Hints.js +400 -0
  45. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
  46. package/snippets/Loading/Script-Loading.js +332 -0
  47. package/snippets/Loading/Service-Worker-Analysis.js +192 -0
  48. package/snippets/Loading/TTFB-Resources.js +115 -0
  49. package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
  50. package/snippets/Loading/TTFB.js +37 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
  52. package/snippets/Loading/WORKFLOWS.md +163 -0
  53. package/snippets/Media/Image-Element-Audit.js +350 -0
  54. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
  55. package/snippets/Media/Video-Element-Audit.js +282 -0
  56. package/snippets/Media/WORKFLOWS.md +190 -0
  57. package/snippets/README.md +98 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
  59. package/snippets/Resources/WORKFLOWS.md +173 -0
  60. package/src/bin.js +229 -0
  61. package/src/decision-tree.js +19 -0
  62. package/src/load-snippet.js +23 -0
  63. package/src/reporters/human.js +179 -0
  64. package/src/reporters/json.js +3 -0
  65. package/src/runner.js +61 -0
  66. package/src/workflows/audit.js +21 -0
  67. package/src/workflows/cwv.js +10 -0
@@ -0,0 +1,350 @@
1
+ // Image Element Audit
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (async () => {
5
+ function isInViewport(el) {
6
+ const rect = el.getBoundingClientRect();
7
+ return (
8
+ rect.top < window.innerHeight &&
9
+ rect.bottom > 0 &&
10
+ rect.left < window.innerWidth &&
11
+ rect.right > 0 &&
12
+ rect.width > 0 &&
13
+ rect.height > 0
14
+ );
15
+ }
16
+
17
+ function findLcpCandidate(imgs) {
18
+ let candidate = null;
19
+ let maxArea = 0;
20
+ imgs.filter(isInViewport).forEach((img) => {
21
+ const { width, height } = img.getBoundingClientRect();
22
+ const area = width * height;
23
+ if (area > maxArea) {
24
+ maxArea = area;
25
+ candidate = img;
26
+ }
27
+ });
28
+ return candidate;
29
+ }
30
+
31
+ function detectFormat(url) {
32
+ if (!url) return "unknown";
33
+ const lower = url.toLowerCase();
34
+ const path = lower.split("?")[0];
35
+ const query = lower.includes("?") ? lower.split("?")[1] : "";
36
+
37
+ // Cloudinary path transforms: /f_auto/, /f_avif,q_auto/, etc.
38
+ const cloudinaryMatch = path.match(/\/f_(auto|avif|webp|jxl|png|jpg|jpeg|gif|svg)[,/]/);
39
+ if (cloudinaryMatch) {
40
+ const fmt = cloudinaryMatch[1];
41
+ return fmt === "auto" ? "auto (cdn)" : fmt === "jpeg" ? "jpg" : fmt;
42
+ }
43
+
44
+ // Query string params: ?fm=webp (imgix), ?format=avif (generic)
45
+ const qMatch = query.match(/(?:^|&)(?:fm|format)=(avif|webp|jxl|png|jpg|jpeg|gif|svg)(?:&|$)/);
46
+ if (qMatch) return qMatch[1] === "jpeg" ? "jpg" : qMatch[1];
47
+
48
+ // File extension in path
49
+ const extMatch = path.match(/\.(avif|webp|jxl|png|gif|svg|jpg|jpeg)(?:[?#]|$)/);
50
+ if (extMatch) return extMatch[1] === "jpeg" ? "jpg" : extMatch[1];
51
+
52
+ // No extension at all — likely a DAM or CDN serving format via Accept header
53
+ const lastSegment = path.split("/").pop() || "";
54
+ if (!lastSegment.includes(".")) return "auto (cdn?)";
55
+
56
+ return "unknown";
57
+ }
58
+
59
+ function isModernFormat(format) {
60
+ return ["avif", "webp", "jxl", "auto (cdn)", "auto (cdn?)"].includes(format);
61
+ }
62
+
63
+ async function fetchFormat(url) {
64
+ if (!url) return detectFormat(url);
65
+ try {
66
+ const res = await fetch(url, { cache: "force-cache" });
67
+ const ct = res.headers.get("content-type")?.split(";")[0]?.trim() || "";
68
+ if (ct.includes("avif")) return "avif";
69
+ if (ct.includes("webp")) return "webp";
70
+ if (ct.includes("jxl")) return "jxl";
71
+ if (ct.includes("png")) return "png";
72
+ if (ct.includes("gif")) return "gif";
73
+ if (ct.includes("svg")) return "svg";
74
+ if (ct.includes("jpeg")) return "jpg";
75
+ } catch {}
76
+ return detectFormat(url);
77
+ }
78
+
79
+ function shortSrc(url) {
80
+ if (!url) return "";
81
+ return url.split("/").pop()?.split("?")[0]?.slice(0, 40) || url.slice(-40);
82
+ }
83
+
84
+ function normalizeUrl(url) {
85
+ try {
86
+ return new URL(url, location.origin).href;
87
+ } catch {
88
+ return url;
89
+ }
90
+ }
91
+
92
+ function findPreloadForLcp(img, preloads) {
93
+ const src = normalizeUrl(img.currentSrc || img.src || "");
94
+ return (
95
+ preloads.find((link) => {
96
+ const href = link.getAttribute("href");
97
+ const imagesrcset = link.getAttribute("imagesrcset") || "";
98
+ if (href && normalizeUrl(href) === src) return true;
99
+ if (imagesrcset) {
100
+ return imagesrcset
101
+ .split(",")
102
+ .map((s) => normalizeUrl(s.trim().split(/\s+/)[0]))
103
+ .includes(src);
104
+ }
105
+ return false;
106
+ }) ?? null
107
+ );
108
+ }
109
+
110
+ const images = Array.from(document.querySelectorAll("img"));
111
+
112
+ if (images.length === 0) {
113
+ console.log("No <img> elements found on this page.");
114
+ return { script: "Image-Element-Audit", status: "ok", count: 0, items: [], issues: [] };
115
+ }
116
+
117
+ const lcpCandidate = findLcpCandidate(images);
118
+ const imagePreloads = Array.from(document.querySelectorAll('link[rel="preload"][as="image"]'));
119
+
120
+ // Fetch actual formats from Content-Type headers in parallel.
121
+ // Uses the browser cache (force-cache) so already-loaded images
122
+ // don't trigger new network requests. Falls back to URL detection
123
+ // if the server does not expose CORS headers.
124
+ const formats = await Promise.all(images.map((img) => fetchFormat(img.currentSrc || img.src)));
125
+
126
+ const audited = images.map((img, i) => {
127
+ const inViewport = isInViewport(img);
128
+ const isLcp = img === lcpCandidate;
129
+ const rect = img.getBoundingClientRect();
130
+ const src = img.currentSrc || img.src || "";
131
+ const format = formats[i];
132
+ const inPicture = img.parentElement?.tagName === "PICTURE";
133
+
134
+ const loading = img.getAttribute("loading");
135
+ const decoding = img.getAttribute("decoding");
136
+ const fetchpriority = img.getAttribute("fetchpriority");
137
+ const hasDimensions = img.hasAttribute("width") && img.hasAttribute("height");
138
+
139
+ const issues = [];
140
+ let lcpPreload = null;
141
+
142
+ if (isLcp) {
143
+ if (fetchpriority !== "high")
144
+ issues.push({ s: "error", msg: 'Add fetchpriority="high" to the LCP image' });
145
+ if (loading === "lazy")
146
+ issues.push({ s: "error", msg: 'Remove loading="lazy" from the LCP image' });
147
+ if (decoding !== "sync")
148
+ issues.push({ s: "warning", msg: 'Consider decoding="sync" for the LCP image' });
149
+ lcpPreload = findPreloadForLcp(img, imagePreloads);
150
+ if (!lcpPreload)
151
+ issues.push({ s: "warning", msg: 'LCP image has no <link rel="preload" as="image">' });
152
+ else if (lcpPreload.getAttribute("fetchpriority") !== "high")
153
+ issues.push({ s: "info", msg: 'LCP image preload is missing fetchpriority="high"' });
154
+ } else if (inViewport) {
155
+ if (loading === "lazy")
156
+ issues.push({ s: "warning", msg: 'Remove loading="lazy" (image is above the fold)' });
157
+ } else {
158
+ if (loading !== "lazy")
159
+ issues.push({ s: "warning", msg: 'Add loading="lazy" (image is off-viewport)' });
160
+ if (fetchpriority === "high")
161
+ issues.push({ s: "error", msg: 'Remove fetchpriority="high" (image is off-viewport)' });
162
+ }
163
+
164
+ if (loading === "lazy" && fetchpriority === "high")
165
+ issues.push({ s: "error", msg: 'Conflict: loading="lazy" + fetchpriority="high"' });
166
+
167
+ if (!hasDimensions)
168
+ issues.push({ s: "warning", msg: "Missing width/height attributes (CLS risk)" });
169
+
170
+ if (!isModernFormat(format) && !inPicture && format !== "svg")
171
+ issues.push({ s: "info", msg: "No modern format detected (WebP / AVIF / JXL)" });
172
+
173
+ return {
174
+ img,
175
+ inViewport,
176
+ isLcp,
177
+ src,
178
+ format,
179
+ loading: loading ?? "(not set)",
180
+ decoding: decoding ?? "(not set)",
181
+ fetchpriority: fetchpriority ?? "(not set)",
182
+ hasDimensions,
183
+ hasSrcset: img.hasAttribute("srcset"),
184
+ hasSizes: img.hasAttribute("sizes"),
185
+ inPicture,
186
+ dimensions: `${Math.round(rect.width)}×${Math.round(rect.height)}`,
187
+ lcpPreload,
188
+ issues,
189
+ };
190
+ });
191
+
192
+ const withIssues = audited.filter((r) => r.issues.length > 0);
193
+ const totalErrors = audited.flatMap((r) => r.issues.filter((i) => i.s === "error")).length;
194
+ const totalWarnings = audited.flatMap((r) => r.issues.filter((i) => i.s === "warning")).length;
195
+ const totalInfos = audited.flatMap((r) => r.issues.filter((i) => i.s === "info")).length;
196
+
197
+ console.group("%c🖼️ Image Element Audit", "font-weight: bold; font-size: 14px;");
198
+
199
+ // Summary
200
+ console.log("");
201
+ console.log("%cSummary", "font-weight: bold;");
202
+ console.log(` Total images : ${images.length}`);
203
+ console.log(` In viewport : ${audited.filter((r) => r.inViewport).length}`);
204
+ console.log(` Off viewport : ${audited.filter((r) => !r.inViewport).length}`);
205
+ console.log(
206
+ ` Issues : ${totalErrors} errors · ${totalWarnings} warnings · ${totalInfos} info`,
207
+ );
208
+
209
+ // LCP candidate
210
+ if (lcpCandidate) {
211
+ const lcp = audited.find((r) => r.isLcp);
212
+ const fpOk = lcp.fetchpriority === "high";
213
+ const preloadStatus = lcp.lcpPreload
214
+ ? lcp.lcpPreload.getAttribute("fetchpriority") === "high"
215
+ ? "✅ found (fetchpriority=high)"
216
+ : "⚠️ found (no fetchpriority=high)"
217
+ : "⚠️ not found";
218
+ console.log("");
219
+ console.log("%cLCP Candidate", "font-weight: bold;");
220
+ console.log(` fetchpriority : ${fpOk ? "✅" : "⚠️"} ${lcp.fetchpriority}`);
221
+ console.log(` decoding : ${lcp.decoding}`);
222
+ console.log(` loading : ${lcp.loading}`);
223
+ console.log(` format : ${lcp.format}`);
224
+ console.log(` preload : ${preloadStatus}`);
225
+ console.log(` dimensions : ${lcp.dimensions}`);
226
+ console.log(" Element:", lcpCandidate);
227
+ if (lcp.lcpPreload) console.log(" Preload:", lcp.lcpPreload);
228
+ }
229
+
230
+ // Full table
231
+ console.log("");
232
+ console.group(`%c📋 All Images (${images.length})`, "font-weight: bold;");
233
+ console.table(
234
+ audited.map((r) => ({
235
+ src: shortSrc(r.src),
236
+ format: r.format,
237
+ viewport: r.inViewport ? "✓" : "",
238
+ LCP: r.isLcp ? "⭐" : "",
239
+ loading: r.loading,
240
+ decoding: r.decoding,
241
+ fetchpriority: r.fetchpriority,
242
+ srcset: r.hasSrcset ? "✓" : "",
243
+ sizes: r.hasSizes ? "✓" : "",
244
+ "in <picture>": r.inPicture ? "✓" : "",
245
+ "w/h": r.hasDimensions ? "✓" : "⚠️",
246
+ issues:
247
+ r.issues.length === 0
248
+ ? "✅"
249
+ : r.issues
250
+ .map((i) => (i.s === "error" ? "🔴" : i.s === "warning" ? "⚠️" : "ℹ️"))
251
+ .join(" "),
252
+ })),
253
+ );
254
+ console.groupEnd();
255
+
256
+ // Issues detail
257
+ if (withIssues.length > 0) {
258
+ console.log("");
259
+ console.group(
260
+ `%c⚠️ Issues Detail (${totalErrors} errors · ${totalWarnings} warnings · ${totalInfos} info)`,
261
+ "color: #ef4444; font-weight: bold;",
262
+ );
263
+
264
+ withIssues.forEach((r) => {
265
+ const hasError = r.issues.some((i) => i.s === "error");
266
+ const icon = hasError ? "🔴" : "⚠️";
267
+ console.log("");
268
+ console.log(`%c${icon} ${shortSrc(r.src) || "(no src)"}`, "font-weight: bold;");
269
+ r.issues.forEach((issue) => {
270
+ const prefix = issue.s === "error" ? " 🔴" : issue.s === "warning" ? " ⚠️" : " ℹ️";
271
+ console.log(`${prefix} ${issue.msg}`);
272
+ });
273
+ console.log(" Element:", r.img);
274
+ });
275
+
276
+ console.groupEnd();
277
+ } else {
278
+ console.log("");
279
+ console.log("%c✅ No issues found.", "color: #22c55e; font-weight: bold;");
280
+ }
281
+
282
+ // Quick reference
283
+ console.log("");
284
+ console.group("%c📝 Quick Reference", "color: #3b82f6; font-weight: bold;");
285
+ console.log("");
286
+ console.log("%c ⭐ LCP image + preload:", "color: #22c55e;");
287
+ console.log(
288
+ '%c <link rel="preload" as="image" href="hero.avif" fetchpriority="high">\n <img src="hero.avif" fetchpriority="high" decoding="sync" width="1200" height="630" alt="...">',
289
+ "font-family: monospace;",
290
+ );
291
+ console.log("");
292
+ console.log("%c ✅ Below-fold image:", "color: #22c55e;");
293
+ console.log(
294
+ '%c <img src="content.avif" loading="lazy" decoding="async" width="800" height="600" alt="...">',
295
+ "font-family: monospace;",
296
+ );
297
+ console.log("");
298
+ console.log("%c ✅ Picture with modern format fallback chain:", "color: #22c55e;");
299
+ console.log(
300
+ '%c <picture>\n <source type="image/jxl" srcset="img.jxl">\n <source type="image/avif" srcset="img.avif">\n <source type="image/webp" srcset="img.webp">\n <img src="img.jpg" loading="lazy" width="800" height="600" alt="...">\n </picture>',
301
+ "font-family: monospace;",
302
+ );
303
+ console.groupEnd();
304
+
305
+ console.groupEnd();
306
+
307
+ const lcpData = lcpCandidate ? audited.find((r) => r.isLcp) : null;
308
+ return {
309
+ script: "Image-Element-Audit",
310
+ status: "ok",
311
+ count: images.length,
312
+ details: {
313
+ totalImages: images.length,
314
+ inViewport: audited.filter((r) => r.inViewport).length,
315
+ offViewport: audited.filter((r) => !r.inViewport).length,
316
+ totalErrors,
317
+ totalWarnings,
318
+ totalInfos,
319
+ lcpCandidate: lcpData
320
+ ? {
321
+ selector: shortSrc(lcpData.src),
322
+ format: lcpData.format,
323
+ fetchpriority: lcpData.fetchpriority,
324
+ loading: lcpData.loading,
325
+ preloaded: !!lcpData.lcpPreload,
326
+ }
327
+ : null,
328
+ },
329
+ items: audited.map((r) => ({
330
+ url: r.src,
331
+ format: r.format,
332
+ inViewport: r.inViewport,
333
+ isLCP: r.isLcp,
334
+ loading: r.loading,
335
+ decoding: r.decoding,
336
+ fetchpriority: r.fetchpriority,
337
+ hasDimensions: r.hasDimensions,
338
+ hasSrcset: r.hasSrcset,
339
+ hasSizes: r.hasSizes,
340
+ inPicture: r.inPicture,
341
+ issues: r.issues.map((i) => ({ severity: i.s, message: i.msg })),
342
+ })),
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
+ ),
349
+ };
350
+ })();
@@ -0,0 +1,225 @@
1
+ // SVG Embedded Bitmap Analysis
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ void (async () => {
5
+ function formatSize(bytes) {
6
+ if (!bytes || bytes === 0) return "—";
7
+ if (bytes < 1024) return `${bytes} B`;
8
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
9
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
10
+ }
11
+
12
+ function shortName(url) {
13
+ try {
14
+ const path = new URL(url).pathname;
15
+ const name = path.split("/").pop() || path;
16
+ return name.length > 45 ? "…" + name.slice(-42) : name;
17
+ } catch {
18
+ return url.slice(-45);
19
+ }
20
+ }
21
+
22
+ function compressionFromEntry(entry) {
23
+ if (entry.transferSize === 0 && entry.encodedBodySize === 0) return "cached";
24
+ if (entry.encodedBodySize === 0) return "unknown";
25
+ if (entry.encodedBodySize < entry.decodedBodySize) return "compressed";
26
+ return "none";
27
+ }
28
+
29
+ function findEmbeddedBitmaps(svgText) {
30
+ const found = [];
31
+
32
+ // Inline base64-encoded bitmaps via data URIs
33
+ const dataRe = /data:image\/(png|jpe?g|gif|webp|avif|bmp|tiff?|ico);base64,([A-Za-z0-9+/=]*)/gi;
34
+ let m;
35
+ while ((m = dataRe.exec(svgText)) !== null) {
36
+ found.push({
37
+ kind: "inline",
38
+ format: m[1].replace("jpeg", "jpg"),
39
+ estimatedBytes: Math.floor((m[2].length * 3) / 4),
40
+ });
41
+ }
42
+
43
+ // External bitmap URLs referenced via <image href> or xlink:href
44
+ const hrefRe = /(?:xlink:)?href=["']([^"']*\.(?:png|jpe?g|gif|webp|avif|bmp|tiff?|ico))["']/gi;
45
+ while ((m = hrefRe.exec(svgText)) !== null) {
46
+ found.push({
47
+ kind: "external",
48
+ format: m[1].split(".").pop().toLowerCase().replace("jpeg", "jpg"),
49
+ url: m[1],
50
+ });
51
+ }
52
+
53
+ return found;
54
+ }
55
+
56
+ // ── External SVG files (Performance API) ──────────────────────────────
57
+ const svgEntries = performance
58
+ .getEntriesByType("resource")
59
+ .filter((e) => e.name.split("?")[0].toLowerCase().endsWith(".svg"));
60
+
61
+ const externalResults = await Promise.all(
62
+ svgEntries.map(async (entry) => {
63
+ let compression = compressionFromEntry(entry);
64
+ let bitmaps = [];
65
+
66
+ try {
67
+ const res = await fetch(entry.name, { cache: "force-cache" });
68
+ const ce = res.headers.get("content-encoding");
69
+ if (ce) compression = ce; // gzip | br | zstd | deflate
70
+ bitmaps = findEmbeddedBitmaps(await res.text());
71
+ } catch {
72
+ // CORS or network error — Performance API data used as fallback
73
+ }
74
+
75
+ const transferSize = entry.transferSize > 0 ? entry.transferSize : entry.encodedBodySize;
76
+
77
+ return {
78
+ name: shortName(entry.name),
79
+ url: entry.name,
80
+ transferSize,
81
+ compression,
82
+ bitmaps,
83
+ };
84
+ }),
85
+ );
86
+
87
+ // ── Inline <svg> elements (DOM scan) ────────────────────────────────
88
+ const inlineSvgs = Array.from(document.querySelectorAll("svg"));
89
+ const inlineSvgTotal = inlineSvgs.length;
90
+ const svgsWithUse = inlineSvgs.filter((svg) => svg.querySelector("use")).length;
91
+ const standaloneInlineSvgs = inlineSvgTotal - svgsWithUse;
92
+
93
+ const inlineResults = Array.from(document.querySelectorAll("svg"))
94
+ .map((svg, i) => {
95
+ const html = svg.outerHTML;
96
+ const bitmaps = findEmbeddedBitmaps(html);
97
+ if (!bitmaps.length) return null;
98
+ return {
99
+ name: svg.id ? `#${svg.id}` : `inline-svg[${i + 1}]`,
100
+ transferSize: new Blob([html]).size,
101
+ compression: "N/A",
102
+ bitmaps,
103
+ };
104
+ })
105
+ .filter(Boolean);
106
+
107
+ // ── Output ───────────────────────────────────────────────────────────
108
+ const withBitmaps = [...externalResults.filter((r) => r.bitmaps.length > 0), ...inlineResults];
109
+
110
+ if (svgEntries.length === 0 && inlineSvgTotal === 0) {
111
+ console.log("No SVG resources found on this page.");
112
+ return { script: "SVG-Embedded-Bitmap-Analysis", status: "ok", count: 0, items: [], issues: [] };
113
+ }
114
+
115
+ console.group("%c🖼️ SVG Embedded Bitmap Analysis", "font-weight: bold; font-size: 14px;");
116
+
117
+ console.log("");
118
+ console.log("%cSummary", "font-weight: bold;");
119
+ console.log(` External SVG files : ${svgEntries.length}`);
120
+ const inlineLabel =
121
+ svgsWithUse > 0
122
+ ? `${inlineSvgTotal} (${svgsWithUse} using <use>, ${standaloneInlineSvgs} standalone)`
123
+ : `${inlineSvgTotal}`;
124
+ console.log(` Inline <svg> elements : ${inlineLabel}`);
125
+ console.log(
126
+ ` SVGs with bitmaps : ${withBitmaps.length}${withBitmaps.length > 0 ? " ⚠️" : " ✅"}`,
127
+ );
128
+
129
+ if (externalResults.length > 0) {
130
+ console.log("");
131
+ console.group(`%c📋 External SVG Resources (${externalResults.length})`, "font-weight: bold;");
132
+ console.table(
133
+ externalResults.map((r) => ({
134
+ name: r.name,
135
+ size: formatSize(r.transferSize),
136
+ compression: r.compression,
137
+ "embedded bitmap": r.bitmaps.length ? `⚠️ ${r.bitmaps.length} found` : "✅ none",
138
+ })),
139
+ );
140
+ console.groupEnd();
141
+ }
142
+
143
+ if (withBitmaps.length > 0) {
144
+ console.log("");
145
+ console.group(
146
+ `%c⚠️ SVGs with Embedded Bitmaps (${withBitmaps.length})`,
147
+ "color: #f59e0b; font-weight: bold;",
148
+ );
149
+
150
+ withBitmaps.forEach((r) => {
151
+ console.log("");
152
+ console.log(`%c📄 ${r.name}`, "font-weight: bold;");
153
+ console.log(` SVG size : ${formatSize(r.transferSize)}`);
154
+ console.log(` Compression : ${r.compression}`);
155
+ r.bitmaps.forEach((b) => {
156
+ if (b.kind === "inline") {
157
+ console.log(` 🖼️ inline ${b.format.toUpperCase()} — ~${formatSize(b.estimatedBytes)}`);
158
+ } else {
159
+ console.log(` 🔗 external ${b.format.toUpperCase()} — ${b.url}`);
160
+ }
161
+ });
162
+ });
163
+
164
+ console.groupEnd();
165
+
166
+ console.log("");
167
+ console.group("%c💡 Recommendations", "color: #3b82f6; font-weight: bold;");
168
+ console.log("");
169
+ console.log(" Embedded bitmaps inflate SVG size (~33% overhead for base64),");
170
+ console.log(" block format negotiation, and prevent independent caching.");
171
+ console.log("");
172
+ console.log(" ✅ Extract the bitmap and serve it as a separate resource");
173
+ console.log(" ✅ Convert the bitmap to a modern format (AVIF or WebP)");
174
+ console.log(' ✅ Reference it from the SVG: <image href="image.avif" width="…" height="…"/>');
175
+ console.log(" ✅ Both files are then cached and optimised independently");
176
+ console.groupEnd();
177
+ } else {
178
+ console.log("");
179
+ console.log(
180
+ "%c✅ No embedded bitmaps found in SVG resources.",
181
+ "color: #22c55e; font-weight: bold;",
182
+ );
183
+ }
184
+
185
+ if (standaloneInlineSvgs >= 5) {
186
+ console.log("");
187
+ console.group("%c💡 SVG Sprite Opportunity", "color: #3b82f6; font-weight: bold;");
188
+ console.log("");
189
+ console.log(` ${standaloneInlineSvgs} standalone inline <svg> elements detected.`);
190
+ console.log(" Each one duplicates markup in the HTML and cannot be cached independently.");
191
+ console.log(" Consider an SVG sprite: define each icon once as a <symbol> and");
192
+ console.log(' reference it anywhere with <use href="#icon-id">.');
193
+ console.log("");
194
+ console.log('%c <!-- Sprite (once, hidden) -->', "font-family: monospace; color: #6b7280;");
195
+ console.log(
196
+ '%c <svg hidden>\n <symbol id="icon-arrow" viewBox="0 0 24 24">…</symbol>\n </svg>',
197
+ "font-family: monospace;",
198
+ );
199
+ console.log('%c <!-- Usage (anywhere, N times) -->', "font-family: monospace; color: #6b7280;");
200
+ console.log(
201
+ '%c <svg aria-hidden="true"><use href="#icon-arrow"/></svg>',
202
+ "font-family: monospace;",
203
+ );
204
+ console.groupEnd();
205
+ }
206
+
207
+ console.groupEnd();
208
+
209
+ return {
210
+ script: "SVG-Embedded-Bitmap-Analysis",
211
+ status: "ok",
212
+ count: withBitmaps.length,
213
+ items: withBitmaps.map((r) => ({
214
+ url: r.url || r.name,
215
+ name: r.name,
216
+ hasBitmap: true,
217
+ bitmapCount: r.bitmaps.length,
218
+ bitmapTypes: r.bitmaps.map((b) => b.format).join(", "),
219
+ sizeBytes: r.transferSize,
220
+ })),
221
+ issues: withBitmaps.length > 0
222
+ ? [{ severity: "warning", message: `${withBitmaps.length} SVG file(s) contain embedded bitmaps` }]
223
+ : [],
224
+ };
225
+ })();