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