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.
- package/README.md +120 -5
- package/package.json +1 -1
- package/snippets/CoreWebVitals/CLS.js +29 -15
- package/snippets/CoreWebVitals/INP.js +14 -7
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +29 -11
- package/snippets/CoreWebVitals/LCP-Subparts.js +4 -0
- package/snippets/CoreWebVitals/LCP-Trail.js +14 -3
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +20 -2
- package/snippets/CoreWebVitals/LCP.js +4 -2
- package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +9 -2
- package/snippets/Interaction/DOM-Size-and-Depth.js +134 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +33 -7
- package/snippets/Interaction/Input-Latency-Breakdown.js +45 -1
- package/snippets/Interaction/Interactions.js +16 -5
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +44 -13
- package/snippets/Interaction/Long-Animation-Frames-Helpers.js +28 -1
- package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +8 -0
- package/snippets/Interaction/Long-Animation-Frames.js +31 -2
- package/snippets/Interaction/LongTask.js +37 -5
- package/snippets/Interaction/Scroll-Performance.js +40 -0
- package/snippets/Interaction/WORKFLOWS.md +9 -0
- package/snippets/Loading/Back-Forward-Cache.js +174 -381
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +76 -44
- package/snippets/Loading/Cache-Strategy-Analysis.js +52 -30
- package/snippets/Loading/Compression-Audit.js +172 -0
- package/snippets/Loading/Content-Visibility.js +8 -3
- package/snippets/Loading/Critical-CSS-Detection.js +8 -6
- package/snippets/Loading/Event-Processing-Time.js +15 -2
- package/snippets/Loading/FCP.js +39 -5
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +7 -5
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +40 -20
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +21 -13
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +35 -4
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +8 -6
- package/snippets/Loading/Inline-Script-Info-and-Size.js +14 -7
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +23 -10
- package/snippets/Loading/Priority-Hints-Audit.js +4 -1
- package/snippets/Loading/Resource-Hints-Validation.js +15 -0
- package/snippets/Loading/Resource-Hints.js +29 -8
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +77 -8
- package/snippets/Loading/Script-Loading.js +55 -25
- package/snippets/Loading/Server-Timing-Early-Hints.js +126 -0
- package/snippets/Loading/Service-Worker-Analysis.js +229 -53
- package/snippets/Loading/Speculation-Rules-Inspector.js +134 -0
- package/snippets/Loading/TTFB-Resources.js +59 -18
- package/snippets/Loading/TTFB-Sub-Parts.js +9 -2
- package/snippets/Loading/TTFB.js +3 -2
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +216 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +21 -3
- package/snippets/Loading/WORKFLOWS.md +47 -1
- package/snippets/Loading/Webfont-Usage-Analyzer.js +332 -0
- package/snippets/Media/Image-Element-Audit.js +22 -3
- package/snippets/Media/Oversized-Images.js +217 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +18 -3
- package/snippets/Media/WORKFLOWS.md +22 -0
- package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +7 -0
- package/snippets/SCHEMA.md +1176 -0
- package/snippets/_shared/helpers.js +44 -0
- package/src/bin.js +55 -3
- package/src/crux.js +84 -0
- package/src/report-to.js +46 -0
- package/src/reporters/human.js +20 -1
- package/src/reporters/markdown.js +22 -1
- 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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|