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.
- package/README.md +139 -0
- package/package.json +54 -0
- package/snippets/CoreWebVitals/CLS.js +88 -0
- package/snippets/CoreWebVitals/INP.js +432 -0
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
- package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
- package/snippets/CoreWebVitals/LCP.js +135 -0
- package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
- package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
- package/snippets/Interaction/Interactions.js +220 -0
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
- package/snippets/Interaction/Long-Animation-Frames.js +298 -0
- package/snippets/Interaction/LongTask.js +203 -0
- package/snippets/Interaction/Scroll-Performance.js +359 -0
- package/snippets/Interaction/WORKFLOWS.md +205 -0
- package/snippets/Loading/Back-Forward-Cache.js +473 -0
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
- package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
- package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
- package/snippets/Loading/Content-Visibility.js +352 -0
- package/snippets/Loading/Critical-CSS-Detection.js +329 -0
- package/snippets/Loading/Event-Processing-Time.js +179 -0
- package/snippets/Loading/FCP.js +101 -0
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
- package/snippets/Loading/Find-render-blocking-resources.js +161 -0
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
- package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
- package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
- package/snippets/Loading/Priority-Hints-Audit.js +336 -0
- package/snippets/Loading/Resource-Hints-Validation.js +503 -0
- package/snippets/Loading/Resource-Hints.js +400 -0
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
- package/snippets/Loading/Script-Loading.js +332 -0
- package/snippets/Loading/Service-Worker-Analysis.js +192 -0
- package/snippets/Loading/TTFB-Resources.js +115 -0
- package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
- package/snippets/Loading/TTFB.js +37 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
- package/snippets/Loading/WORKFLOWS.md +163 -0
- package/snippets/Media/Image-Element-Audit.js +350 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
- package/snippets/Media/Video-Element-Audit.js +282 -0
- package/snippets/Media/WORKFLOWS.md +190 -0
- package/snippets/README.md +98 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
- package/snippets/Resources/WORKFLOWS.md +173 -0
- package/src/bin.js +229 -0
- package/src/decision-tree.js +19 -0
- package/src/load-snippet.js +23 -0
- package/src/reporters/human.js +179 -0
- package/src/reporters/json.js +3 -0
- package/src/runner.js +61 -0
- package/src/workflows/audit.js +21 -0
- 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
|
+
})();
|