webperf-snippets 1.4.0 → 1.5.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 +5 -5
- package/package.json +1 -1
- package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
- package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
- package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
- package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
- package/snippets/Interaction/Input-Latency-Breakdown.js +2 -0
- package/snippets/Interaction/Interactions.js +2 -0
- package/snippets/Interaction/Scroll-Performance.js +2 -0
- package/snippets/Loading/Back-Forward-Cache.js +7 -0
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
- package/snippets/Loading/Cache-Strategy-Analysis.js +12 -0
- package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
- package/snippets/Loading/Compression-Audit.js +3 -0
- package/snippets/Loading/Content-Visibility.js +2 -2
- package/snippets/Loading/Critical-CSS-Detection.js +6 -0
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
- package/snippets/Loading/Find-render-blocking-resources.js +1 -1
- package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
- package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
- package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
- package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
- package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
- package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
- package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
- package/snippets/Loading/Priority-Hints-Audit.js +10 -0
- package/snippets/Loading/Resource-Hints-Validation.js +3 -3
- package/snippets/Loading/Resource-Hints.js +7 -1
- package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
- package/snippets/Loading/Script-Loading.js +6 -2
- package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
- package/snippets/Loading/Service-Worker-Analysis.js +12 -2
- package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
- package/snippets/Loading/TTFB-Resources.js +1 -1
- package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
- package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
- package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
- package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
- package/snippets/Media/Image-Element-Audit.js +16 -13
- package/snippets/Media/Oversized-Images.js +4 -0
- package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
- package/snippets/Media/Video-Element-Audit.js +13 -2
- package/snippets/SCHEMA.md +425 -3
- package/src/baseline.js +30 -6
- package/src/blocking.js +1 -1
- package/src/mcp/documents.js +41 -21
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ npx webperf-snippets https://example.com --json
|
|
|
73
73
|
The JSON output records how the page was measured in `conditions`, so a later run can tell whether it measured the same way: the viewport, the workflow (or the path of the snippet with `--snippet`), the wait and the version of the CLI.
|
|
74
74
|
|
|
75
75
|
```json
|
|
76
|
-
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.
|
|
76
|
+
"conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "cliVersion": "1.5.0" }
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
Single snippet:
|
|
@@ -158,7 +158,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
|
|
|
158
158
|
"timestamp": "2026-05-06T10:00:00.000Z",
|
|
159
159
|
"navMs": 1240,
|
|
160
160
|
"results": [],
|
|
161
|
-
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.
|
|
161
|
+
"meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.5.0" }
|
|
162
162
|
}
|
|
163
163
|
```
|
|
164
164
|
|
|
@@ -265,7 +265,7 @@ LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of
|
|
|
265
265
|
|
|
266
266
|
`--threshold` changes one part of a threshold and keeps the other: `--threshold LCP=15%` the share, `--threshold LCP=200ms` the minimum, and `--threshold CLS=0.1` the only part of CLS. Repeat it for more metrics.
|
|
267
267
|
|
|
268
|
-
The errors that compare are the ones that decide the exit code: the issues of severity `error` of the steps of the workflow and the snippets that failed. Two errors are the same when they share
|
|
268
|
+
The errors that compare are the ones that decide the exit code: the issues of severity `error` of the steps of the workflow and the snippets that failed. Two errors are the same when they share their [code](https://github.com/nucliweb/webperf-snippets/blob/main/snippets/SCHEMA.md#issue-codes) and the resource they name, whatever their messages say; a resource of the page itself compares by its path and query, so the review app of a pull request, on another host, matches production. A snippet without codes on one side, as in a baseline of an earlier version, compares by message with the figures left out, so "Render-blocking CSS (69.21 KB)…" and "Render-blocking CSS (71.80 KB)…" are one error. An error that only this run has is new, and one that only the baseline had is fixed. Warnings and information do not compare, since they change too easily.
|
|
269
269
|
|
|
270
270
|
The report adds the comparison after the usual one, and `--markdown` a table for the pull request comment. The JSON output carries it in `baseline`:
|
|
271
271
|
|
|
@@ -389,8 +389,8 @@ The server also offers resources, documents a client can attach to the context:
|
|
|
389
389
|
|
|
390
390
|
| Resource | What it has |
|
|
391
391
|
| -------------------------------- | ------------------------------------------------------------------------ |
|
|
392
|
-
| `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result. |
|
|
393
|
-
| `webperf://schema/{snippet}` | The fields one snippet returns, with an example.
|
|
392
|
+
| `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result, the rules of the issue codes. |
|
|
393
|
+
| `webperf://schema/{snippet}` | The fields one snippet returns, with an example, and the codes of its issues. |
|
|
394
394
|
| `webperf://workflows/{category}` | The workflows and the decision tree of a category. |
|
|
395
395
|
|
|
396
396
|
And prompts, templates that chain the tools (in Claude Code, `/mcp__webperf__audit_lcp`): `audit_lcp`, `debug_inp` and `audit_page`, each with a `url` argument.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"mcpName": "io.github.nucliweb/webperf-snippets",
|
|
5
5
|
"description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
|
|
6
6
|
"type": "module",
|
|
@@ -213,23 +213,22 @@
|
|
|
213
213
|
}
|
|
214
214
|
const issuesSync = [];
|
|
215
215
|
if (lowEntropyCount > 0) {
|
|
216
|
-
issuesSync.push({ severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
|
|
216
|
+
issuesSync.push({ code: "low-entropy-images", severity: "warning", message: `${lowEntropyCount} image(s) have low entropy and are LCP-ineligible in Chrome 112+` });
|
|
217
217
|
}
|
|
218
218
|
if (lcpImageSync?.isLowEntropy) {
|
|
219
|
-
issuesSync.push({ severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
|
|
219
|
+
issuesSync.push({ code: "lcp-low-entropy", severity: "error", message: "Current LCP image has low entropy and may be skipped by Chrome" });
|
|
220
220
|
}
|
|
221
221
|
// Entropy applies to the LCP only when it is an <img> whose file size can be read; for text, or an
|
|
222
222
|
// image this snippet cannot read, it says so. The images of the page are still listed.
|
|
223
223
|
const lcpTag = lcpElementSync ? lcpElementSync.tagName.toLowerCase() : null;
|
|
224
224
|
if (lcpTag && !lcpImageSync) {
|
|
225
|
-
issuesSync.push(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
? "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed."
|
|
225
|
+
issuesSync.push(
|
|
226
|
+
lcpTag === "img"
|
|
227
|
+
? { code: "lcp-size-unknown", severity: "info", message: "The LCP <img> has no file size to read, as with a data: URL or a cross-origin image without Timing-Allow-Origin, so its entropy cannot be computed." }
|
|
229
228
|
: lcpUrlSync
|
|
230
|
-
? `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.`
|
|
231
|
-
: `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it
|
|
232
|
-
|
|
229
|
+
? { code: "lcp-not-img", severity: "info", message: `The LCP is the image of a <${lcpTag}>, such as a CSS background or a video poster, and this snippet reads <img> elements only. Run the LCP snippet to measure it.` }
|
|
230
|
+
: { code: "lcp-is-text", severity: "info", message: `The LCP element is <${lcpTag}>, text and not an image, so its entropy does not apply. Run the LCP snippet to measure it.` }
|
|
231
|
+
);
|
|
233
232
|
}
|
|
234
233
|
// The list keeps the 50 most relevant images (the LCP image, then low entropy, then lowest bits per pixel); count and details cover all of them
|
|
235
234
|
const MAX_ITEMS = 50;
|
|
@@ -71,22 +71,22 @@
|
|
|
71
71
|
// The value of a non-video LCP belongs to the LCP snippet; this one only says what the element is
|
|
72
72
|
if (!element || element.tagName !== "VIDEO") {
|
|
73
73
|
const tag = element ? element.tagName.toLowerCase() : null;
|
|
74
|
-
const
|
|
75
|
-
? `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.`
|
|
76
|
-
: "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it.";
|
|
74
|
+
const issue = tag
|
|
75
|
+
? { code: "lcp-not-video", severity: "info", message: `The LCP element is <${tag}>, not a <video>. Run the LCP snippet to measure it, and LCP Subparts to see its phases.` }
|
|
76
|
+
: { code: "lcp-element-removed", severity: "info", message: "The LCP element is no longer in the DOM, so it cannot be checked for a <video>. Run the LCP snippet to measure it." };
|
|
77
77
|
console.log("%cLCP element is not a <video>", "font-weight: bold;");
|
|
78
78
|
console.log("");
|
|
79
79
|
console.log(` Tag : ${tag ? `<${tag}>` : "(element no longer in DOM)"}`);
|
|
80
80
|
if (lcp.url) console.log(` URL : ${lcp.url}`);
|
|
81
81
|
if (element) console.log(" Element :", element);
|
|
82
82
|
console.log("");
|
|
83
|
-
console.log(` ${message}`);
|
|
83
|
+
console.log(` ${issue.message}`);
|
|
84
84
|
console.groupEnd();
|
|
85
85
|
return {
|
|
86
86
|
script: "LCP-Video-Candidate",
|
|
87
87
|
status: "ok",
|
|
88
88
|
details: { isVideo: false, lcpElement: tag, lcpUrl: lcp.url || null },
|
|
89
|
-
issues: [
|
|
89
|
+
issues: [issue],
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -140,34 +140,35 @@
|
|
|
140
140
|
|
|
141
141
|
if (secondsAfterLoad > 10) {
|
|
142
142
|
issues.push({
|
|
143
|
+
code: "late-candidate",
|
|
143
144
|
s: "warning",
|
|
144
145
|
msg: `The LCP candidate rendered ${secondsAfterLoad} s after the page loaded, because the page stayed open without interaction. Reload and run the snippet again to measure the LCP of the load`,
|
|
145
146
|
});
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
if (lcpSource === "first-frame") {
|
|
149
|
-
issues.push({ s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
|
|
150
|
+
issues.push({ code: "first-frame-lcp", s: "info", msg: "LCP is the first video frame: adding a poster gives explicit control over the LCP image" });
|
|
150
151
|
}
|
|
151
152
|
if (lcpSource === "first-frame" && (!autoplay || !muted)) {
|
|
152
|
-
issues.push({ s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
|
|
153
|
+
issues.push({ code: "first-frame-not-autoplay-muted", s: "warning", msg: "First-frame LCP requires autoplay + muted for the browser to render it immediately" });
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
if (posterAttr && !posterPreload) {
|
|
156
|
-
issues.push({ s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
|
|
157
|
+
issues.push({ code: "poster-not-preloaded", s: "warning", msg: 'No <link rel="preload" as="image"> for the poster: the browser discovers it late' });
|
|
157
158
|
} else if (posterPreload && posterPreload.getAttribute("fetchpriority") !== "high") {
|
|
158
|
-
issues.push({ s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
|
|
159
|
+
issues.push({ code: "poster-preload-no-fetchpriority", s: "info", msg: 'Preload found but missing fetchpriority="high", so it may be deprioritised' });
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
if (posterAttr && !isModernFormat && posterFormat !== "unknown") {
|
|
162
|
-
issues.push({ s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
|
|
163
|
+
issues.push({ code: "poster-legacy-format", s: "info", msg: `Poster uses ${posterFormat}; AVIF or WebP would reduce file size and LCP load time` });
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
if (isCrossOrigin) {
|
|
166
|
-
issues.push({ s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
|
|
167
|
+
issues.push({ code: "render-time-hidden", s: "info", msg: "renderTime is 0: the resource is cross-origin and the server does not send Timing-Allow-Origin" });
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
if (!autoplay && preload === "none") {
|
|
170
|
-
issues.push({ s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
|
|
171
|
+
issues.push({ code: "preload-none-delays-poster", s: "warning", msg: 'preload="none" on a non-autoplay video may delay poster image loading in some browsers' });
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
// Summary
|
|
@@ -265,6 +266,6 @@
|
|
|
265
266
|
isCrossOrigin,
|
|
266
267
|
videoAttributes: { autoplay, muted, playsinline, preload },
|
|
267
268
|
},
|
|
268
|
-
issues: issues.map((i) => ({ severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
|
|
269
|
+
issues: issues.map((i) => ({ code: i.code, severity: i.s === "warning" ? "warning" : "info", message: i.msg })),
|
|
269
270
|
};
|
|
270
271
|
})();
|
|
@@ -80,23 +80,27 @@
|
|
|
80
80
|
const issues = [];
|
|
81
81
|
if (totalElements > THRESHOLDS.errorNodes) {
|
|
82
82
|
issues.push({
|
|
83
|
+
code: "too-many-nodes",
|
|
83
84
|
severity: "error",
|
|
84
85
|
message: `${totalElements} nodes exceeds ${THRESHOLDS.errorNodes}; a large DOM slows style recalculation and layout on every interaction`,
|
|
85
86
|
});
|
|
86
87
|
} else if (totalElements > THRESHOLDS.warnNodes) {
|
|
87
88
|
issues.push({
|
|
89
|
+
code: "too-many-nodes",
|
|
88
90
|
severity: "warning",
|
|
89
91
|
message: `${totalElements} nodes exceeds ${THRESHOLDS.warnNodes}; a large DOM increases style recalculation and layout cost`,
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
94
|
if (maxDepth > THRESHOLDS.maxDepth) {
|
|
93
95
|
issues.push({
|
|
96
|
+
code: "too-deep",
|
|
94
97
|
severity: "warning",
|
|
95
98
|
message: `DOM depth of ${maxDepth} exceeds ${THRESHOLDS.maxDepth}; deep trees make style and layout work costlier (deepest: ${selectorFor(deepest)})`,
|
|
96
99
|
});
|
|
97
100
|
}
|
|
98
101
|
if (widest && widest.children > THRESHOLDS.maxChildren) {
|
|
99
102
|
issues.push({
|
|
103
|
+
code: "too-many-children",
|
|
100
104
|
severity: "warning",
|
|
101
105
|
message: `${widestSelector} has ${widest.children} children, above ${THRESHOLDS.maxChildren}; consider virtualizing or paginating the list`,
|
|
102
106
|
});
|
|
@@ -265,8 +265,10 @@
|
|
|
265
265
|
.sort((a, b) => b[1] - a[1])
|
|
266
266
|
.slice(0, 10)
|
|
267
267
|
.map(([property, count]) => ({
|
|
268
|
+
code: "forced-layout",
|
|
268
269
|
severity: count > 5 ? "error" : "warning",
|
|
269
270
|
message: `${property} triggered ${count} forced synchronous layout${count > 1 ? "s" : ""}. Read layout properties before writing to the DOM.`,
|
|
271
|
+
target: property,
|
|
270
272
|
})),
|
|
271
273
|
};
|
|
272
274
|
};
|
|
@@ -259,8 +259,10 @@
|
|
|
259
259
|
const bottleneck = phases.reduce((a, c) => (a.value > c.value ? a : c));
|
|
260
260
|
const rating = valueToRating(p75(b.durations));
|
|
261
261
|
return {
|
|
262
|
+
code: "slow-event-type",
|
|
262
263
|
severity: rating === "poor" ? "error" : "warning",
|
|
263
264
|
message: `${type} P75 is ${Math.round(p75(b.durations))}ms (${rating}). Bottleneck: ${bottleneck.name}. Fix: ${BOTTLENECK_FIXES[bottleneck.name]}.`,
|
|
265
|
+
target: type,
|
|
264
266
|
};
|
|
265
267
|
});
|
|
266
268
|
|
|
@@ -264,8 +264,10 @@
|
|
|
264
264
|
.sort((a, b) => b.duration - a.duration)
|
|
265
265
|
.slice(0, 10)
|
|
266
266
|
.map((i) => ({
|
|
267
|
+
code: "slow-interaction",
|
|
267
268
|
severity: i.rating === "poor" ? "error" : "warning",
|
|
268
269
|
message: `${i.type} interaction took ${Math.round(i.duration)}ms (${i.rating}), above the ${i.rating === "poor" ? "500ms" : "200ms"} threshold`,
|
|
270
|
+
target: i.type,
|
|
269
271
|
})),
|
|
270
272
|
};
|
|
271
273
|
};
|
|
@@ -207,12 +207,14 @@
|
|
|
207
207
|
const issues = [];
|
|
208
208
|
if (nonPassiveListeners.length > 0) {
|
|
209
209
|
issues.push({
|
|
210
|
+
code: "non-passive-listeners",
|
|
210
211
|
severity: "warning",
|
|
211
212
|
message: `${nonPassiveListeners.length} non-passive scroll or touch listener(s). Add { passive: true } so scrolling does not wait for JavaScript.`,
|
|
212
213
|
});
|
|
213
214
|
}
|
|
214
215
|
if (css.willChangeElements.length > 0) {
|
|
215
216
|
issues.push({
|
|
217
|
+
code: "will-change",
|
|
216
218
|
severity: "info",
|
|
217
219
|
message: `will-change is set on ${css.willChangeElements.length} element(s). Remove it from elements that do not animate, since each one keeps a compositor layer.`,
|
|
218
220
|
});
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
: [];
|
|
83
83
|
confirmed.forEach(({ reason, frame }) => {
|
|
84
84
|
issues.push({
|
|
85
|
+
code: 'not-restored',
|
|
85
86
|
reason: `${reason} (${frame})`,
|
|
86
87
|
severity: 'high',
|
|
87
88
|
source: 'browser',
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
// cannot be detected from JavaScript.
|
|
94
95
|
if (window.onunload !== null && !confirmed.some(({ reason }) => reason === 'unload-listener')) {
|
|
95
96
|
issues.push({
|
|
97
|
+
code: 'unload-handler',
|
|
96
98
|
reason: 'window.onunload handler set',
|
|
97
99
|
severity: 'high',
|
|
98
100
|
source: 'detected',
|
|
@@ -108,6 +110,7 @@
|
|
|
108
110
|
const cacheControl = res.headers.get('cache-control') || '';
|
|
109
111
|
if (/no-store/i.test(cacheControl) && !confirmed.some(({ reason }) => reason === 'response-cache-control-no-store')) {
|
|
110
112
|
issues.push({
|
|
113
|
+
code: 'cache-control-no-store',
|
|
111
114
|
reason: 'Cache-Control: no-store on the page response',
|
|
112
115
|
severity: 'medium',
|
|
113
116
|
source: 'detected',
|
|
@@ -123,6 +126,7 @@
|
|
|
123
126
|
const iframeCount = document.querySelectorAll('iframe').length;
|
|
124
127
|
if (iframeCount > 0) {
|
|
125
128
|
issues.push({
|
|
129
|
+
code: 'iframes',
|
|
126
130
|
reason: `${iframeCount} iframe(s) on the page`,
|
|
127
131
|
severity: 'info',
|
|
128
132
|
source: 'note',
|
|
@@ -132,6 +136,7 @@
|
|
|
132
136
|
|
|
133
137
|
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
|
134
138
|
issues.push({
|
|
139
|
+
code: 'service-worker',
|
|
135
140
|
reason: 'Service Worker controls the page',
|
|
136
141
|
severity: 'info',
|
|
137
142
|
source: 'note',
|
|
@@ -255,8 +260,10 @@
|
|
|
255
260
|
notRestoredReasons: results.notRestoredReasons,
|
|
256
261
|
},
|
|
257
262
|
issues: results.blockingReasons.map((i) => ({
|
|
263
|
+
code: i.code,
|
|
258
264
|
severity: i.severity === 'high' ? 'error' : i.severity === 'medium' ? 'warning' : 'info',
|
|
259
265
|
message: i.reason,
|
|
266
|
+
...(i.code === 'not-restored' ? { target: i.reason } : {}),
|
|
260
267
|
})),
|
|
261
268
|
});
|
|
262
269
|
|
|
@@ -151,21 +151,23 @@ async function analyzeCSSMediaQueries(minWidth = 768) {
|
|
|
151
151
|
|
|
152
152
|
const issues = [];
|
|
153
153
|
if (rules.length === 0) {
|
|
154
|
-
issues.push({ severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
|
|
154
|
+
issues.push({ code: "no-wide-rules", severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
|
|
155
155
|
} else {
|
|
156
156
|
issues.push({
|
|
157
|
+
code: "wide-rules",
|
|
157
158
|
severity: totalBytes > WARNING_BYTES ? "warning" : "info",
|
|
158
159
|
message: `${rules.length} @media rules for viewports wider than ${minWidth}px add ${formatBytes(totalBytes)} of CSS that narrower screens download and parse without using. Move them to a stylesheet with a media attribute, such as <link rel="stylesheet" href="wide.css" media="(min-width: ${minWidth + 1}px)">, which narrower screens fetch at low priority without blocking rendering`,
|
|
159
160
|
});
|
|
160
161
|
if (rules.length >= 20 && uniquenessRatio < 0.1) {
|
|
161
162
|
issues.push({
|
|
163
|
+
code: "repeated-queries",
|
|
162
164
|
severity: "info",
|
|
163
165
|
message: `${queries.length} distinct queries repeat across ${rules.length} @media blocks, the pattern of component-scoped or compiled CSS. Grouping the rules of each query under one block shortens the stylesheets`,
|
|
164
166
|
});
|
|
165
167
|
}
|
|
166
168
|
}
|
|
167
169
|
if (corsBlockedCount > 0) {
|
|
168
|
-
issues.push({ severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
|
|
170
|
+
issues.push({ code: "unreadable-stylesheets", severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
// --- Console output --------------------------------------------------------------------
|
|
@@ -337,6 +337,7 @@
|
|
|
337
337
|
|
|
338
338
|
if (isStaticAsset(type) && cc["no-store"]) {
|
|
339
339
|
entry.antiPatterns.push({
|
|
340
|
+
code: "static-no-store",
|
|
340
341
|
id: "static-no-store",
|
|
341
342
|
severity: "error",
|
|
342
343
|
message: `${type} asset with no-store; static assets should be cacheable`,
|
|
@@ -345,6 +346,7 @@
|
|
|
345
346
|
|
|
346
347
|
if (entry.hasHash && maxAge !== null && maxAge < 86400 && !cc["no-store"]) {
|
|
347
348
|
entry.antiPatterns.push({
|
|
349
|
+
code: "versioned-short-cache",
|
|
348
350
|
id: "versioned-short-cache",
|
|
349
351
|
severity: "warning",
|
|
350
352
|
message: `Versioned URL (hash detected) but max-age=${maxAge}s: consider long cache or immutable`,
|
|
@@ -353,6 +355,7 @@
|
|
|
353
355
|
|
|
354
356
|
if (entry.hasHash && !cc["immutable"] && !cc["no-store"] && (maxAge === null || maxAge >= 86400)) {
|
|
355
357
|
entry.antiPatterns.push({
|
|
358
|
+
code: "versioned-not-immutable",
|
|
356
359
|
id: "versioned-not-immutable",
|
|
357
360
|
severity: "info",
|
|
358
361
|
message: `Versioned URL (hash detected) without Cache-Control: immutable`,
|
|
@@ -364,6 +367,7 @@
|
|
|
364
367
|
["none", "no-store", "no-cache"].includes(strategy)
|
|
365
368
|
) {
|
|
366
369
|
entry.antiPatterns.push({
|
|
370
|
+
code: "large-no-cache",
|
|
367
371
|
id: "large-no-cache",
|
|
368
372
|
severity: "error",
|
|
369
373
|
message: `Large resource (${formatBytes(entry.decodedBodySize)}) with no effective cache`,
|
|
@@ -372,6 +376,7 @@
|
|
|
372
376
|
|
|
373
377
|
if (entry.headers["expires"] && !entry.headers["cache-control"]) {
|
|
374
378
|
entry.antiPatterns.push({
|
|
379
|
+
code: "expires-without-cc",
|
|
375
380
|
id: "expires-without-cc",
|
|
376
381
|
severity: "warning",
|
|
377
382
|
message: `Uses Expires header without Cache-Control (outdated pattern)`,
|
|
@@ -380,6 +385,7 @@
|
|
|
380
385
|
|
|
381
386
|
if (isStaticAsset(type) && maxAge === 0) {
|
|
382
387
|
entry.antiPatterns.push({
|
|
388
|
+
code: "maxage-zero-static",
|
|
383
389
|
id: "maxage-zero-static",
|
|
384
390
|
severity: "warning",
|
|
385
391
|
message: `Static asset with max-age=0, which forces revalidation on every request`,
|
|
@@ -394,6 +400,7 @@
|
|
|
394
400
|
!cc["no-store"]
|
|
395
401
|
) {
|
|
396
402
|
entry.antiPatterns.push({
|
|
403
|
+
code: "short-cache-static",
|
|
397
404
|
id: "short-cache-static",
|
|
398
405
|
severity: "warning",
|
|
399
406
|
message: `Static asset with max-age=${maxAge}s (< 1h): consider a longer cache duration`,
|
|
@@ -472,6 +479,7 @@
|
|
|
472
479
|
// are anti-patterns like the header-based ones: they reach `items` and `issues`.
|
|
473
480
|
for (const entry of uncompressedResources) {
|
|
474
481
|
entry.antiPatterns.push({
|
|
482
|
+
code: "uncompressed",
|
|
475
483
|
id: "uncompressed",
|
|
476
484
|
severity: "warning",
|
|
477
485
|
message: `${entry.type} sent uncompressed (${formatBytes(entry.encodedBodySize)} on the wire for ${formatBytes(entry.decodedBodySize)}): enable Brotli or gzip`,
|
|
@@ -480,6 +488,7 @@
|
|
|
480
488
|
for (const duplicate of costlyDuplicates) {
|
|
481
489
|
// One finding per URL, on the first request, not one per request
|
|
482
490
|
entries.find((e) => e.url === duplicate.url).antiPatterns.push({
|
|
491
|
+
code: "duplicate-uncached",
|
|
483
492
|
id: "duplicate-uncached",
|
|
484
493
|
severity: "warning",
|
|
485
494
|
message: `Loaded ${duplicate.count} times, ${duplicate.uncachedLoads} from the network: load it once or make it cacheable`,
|
|
@@ -551,6 +560,7 @@
|
|
|
551
560
|
.join(", ");
|
|
552
561
|
coverageIssue = {
|
|
553
562
|
severity: "info",
|
|
563
|
+
code: "header-coverage",
|
|
554
564
|
id: "header-coverage",
|
|
555
565
|
message: `Cache headers were read for ${headersAnalyzed} of ${entries.length} resources (${headerCoveragePercent}%): the rest are on other origins or are not static assets, so their Cache-Control cannot be read from the page. Most of them are on ${topHosts}; check those hosts in the Network panel or with curl -I`,
|
|
556
566
|
};
|
|
@@ -916,10 +926,12 @@
|
|
|
916
926
|
})),
|
|
917
927
|
issues: [
|
|
918
928
|
...allAntiPatterns.map((p) => ({
|
|
929
|
+
code: p.code,
|
|
919
930
|
severity: p.severity,
|
|
920
931
|
id: p.id,
|
|
921
932
|
resource: p.resource,
|
|
922
933
|
message: p.message,
|
|
934
|
+
target: p.url,
|
|
923
935
|
})),
|
|
924
936
|
...(coverageIssue ? [coverageIssue] : []),
|
|
925
937
|
],
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
if (sameOrigin && referrerPath !== currentPath && referrerPath !== '') {
|
|
100
100
|
hasClientRedirect = true;
|
|
101
101
|
indicators.push({
|
|
102
|
+
code: 'same-origin-navigation',
|
|
102
103
|
type: 'Same-origin navigation',
|
|
103
104
|
from: referrerPath,
|
|
104
105
|
to: currentPath,
|
|
@@ -153,6 +154,7 @@
|
|
|
153
154
|
// Only flag if it's BOTH fast AND small (likely a redirect page)
|
|
154
155
|
if (sameOrigin && domContentLoaded < 500 && responseSize < 10000) {
|
|
155
156
|
indicators.push({
|
|
157
|
+
code: 'fast-minimal-navigation',
|
|
156
158
|
type: 'Fast minimal-content navigation',
|
|
157
159
|
duration: domContentLoaded.toFixed(1) + 'ms',
|
|
158
160
|
size: (responseSize / 1024).toFixed(1) + ' KB',
|
|
@@ -337,9 +339,9 @@
|
|
|
337
339
|
referrerPath,
|
|
338
340
|
},
|
|
339
341
|
issues: [
|
|
340
|
-
...(serverRedirects > 0 ? [{ severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []),
|
|
341
|
-
...(documentNavigations.length > 0 ? [{ severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []),
|
|
342
|
-
...indicators.filter(i => i.severity === "warning").map(i => ({ severity: "warning", message: i.type })),
|
|
342
|
+
...(serverRedirects > 0 ? [{ code: "server-redirects", severity: "warning", message: `${serverRedirects} server-side redirect(s) detected (${Math.round(redirectTime)}ms)` }] : []),
|
|
343
|
+
...(documentNavigations.length > 0 ? [{ code: "document-navigation", severity: "error", message: `${documentNavigations.length} same-origin document navigation(s) detected, likely a client-side redirect` }] : []),
|
|
344
|
+
...indicators.filter(i => i.severity === "warning").map(i => ({ code: i.code, severity: "warning", message: i.type })),
|
|
343
345
|
],
|
|
344
346
|
};
|
|
345
347
|
})();
|
|
@@ -119,18 +119,21 @@
|
|
|
119
119
|
const issues = [];
|
|
120
120
|
if (uncompressed.length > 0) {
|
|
121
121
|
issues.push({
|
|
122
|
+
code: "uncompressed",
|
|
122
123
|
severity: "warning",
|
|
123
124
|
message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`,
|
|
124
125
|
});
|
|
125
126
|
}
|
|
126
127
|
if (corsLimitedAnalysis) {
|
|
127
128
|
issues.push({
|
|
129
|
+
code: "unknown-size",
|
|
128
130
|
severity: "info",
|
|
129
131
|
message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`,
|
|
130
132
|
});
|
|
131
133
|
}
|
|
132
134
|
if (!contentEncodingSupported && candidates.length > 0) {
|
|
133
135
|
issues.push({
|
|
136
|
+
code: "content-encoding-unavailable",
|
|
134
137
|
severity: "info",
|
|
135
138
|
message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize",
|
|
136
139
|
});
|
|
@@ -348,9 +348,9 @@ window.analyzeContentVisibilityOpportunities = analyzeContentVisibilityOpportuni
|
|
|
348
348
|
issues: [
|
|
349
349
|
...cvResults.autoElements
|
|
350
350
|
.filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
|
|
351
|
-
.map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)
|
|
351
|
+
.map(el => ({ code: "missing-intrinsic-size", severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)`, target: el.selector })),
|
|
352
352
|
...(cvResults.autoElements.length + cvResults.hiddenElements.length === 0
|
|
353
|
-
? [{ severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
|
|
353
|
+
? [{ code: "not-used", severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
|
|
354
354
|
: []),
|
|
355
355
|
],
|
|
356
356
|
};
|
|
@@ -221,6 +221,7 @@
|
|
|
221
221
|
|
|
222
222
|
if (renderBlockingCSS.length > 0 && inlineStyles.length === 0) {
|
|
223
223
|
issues.push({
|
|
224
|
+
code: "no-inline-critical-css",
|
|
224
225
|
severity: "high",
|
|
225
226
|
message: "No critical CSS inlined: all CSS loads via render-blocking requests",
|
|
226
227
|
suggestion:
|
|
@@ -230,6 +231,7 @@
|
|
|
230
231
|
|
|
231
232
|
if (totalRenderBlockingDecoded > criticalBudget) {
|
|
232
233
|
issues.push({
|
|
234
|
+
code: "render-blocking-over-budget",
|
|
233
235
|
severity: "high",
|
|
234
236
|
message: `Render-blocking CSS (${formatBytes(totalRenderBlockingDecoded)}) exceeds the 14 KB critical budget`,
|
|
235
237
|
suggestion:
|
|
@@ -242,6 +244,7 @@
|
|
|
242
244
|
);
|
|
243
245
|
if (largeUnpreloaded.length > 0) {
|
|
244
246
|
issues.push({
|
|
247
|
+
code: "large-not-preloaded",
|
|
245
248
|
severity: "medium",
|
|
246
249
|
message: `${largeUnpreloaded.length} large render-blocking stylesheet(s) without preload`,
|
|
247
250
|
details: largeUnpreloaded.map(
|
|
@@ -253,6 +256,7 @@
|
|
|
253
256
|
|
|
254
257
|
if (totalInlineBytes > criticalBudget) {
|
|
255
258
|
issues.push({
|
|
259
|
+
code: "inline-over-budget",
|
|
256
260
|
severity: "high",
|
|
257
261
|
message: `Inline CSS (${formatBytes(totalInlineBytes)}) exceeds the 14 KB critical budget`,
|
|
258
262
|
suggestion:
|
|
@@ -263,6 +267,7 @@
|
|
|
263
267
|
const bodyInlineStyles = inlineStyles.filter((s) => !s.inHead);
|
|
264
268
|
if (bodyInlineStyles.length > 0) {
|
|
265
269
|
issues.push({
|
|
270
|
+
code: "styles-in-body",
|
|
266
271
|
severity: "medium",
|
|
267
272
|
message: `${bodyInlineStyles.length} inline style block(s) found in <body>`,
|
|
268
273
|
suggestion:
|
|
@@ -332,6 +337,7 @@
|
|
|
332
337
|
corsBlocked: s.corsBlocked,
|
|
333
338
|
})),
|
|
334
339
|
issues: issues.map(i => ({
|
|
340
|
+
code: i.code,
|
|
335
341
|
severity: i.severity === 'high' ? 'error' : 'warning',
|
|
336
342
|
message: i.message,
|
|
337
343
|
})),
|
|
@@ -265,8 +265,8 @@
|
|
|
265
265
|
isLcpCandidate: image.isLcpCandidate,
|
|
266
266
|
})),
|
|
267
267
|
issues: [
|
|
268
|
-
...(results.summary.lcpAffected ? [{ severity: "error", message: 'LCP candidate image has lazy loading: remove loading="lazy" and add fetchpriority="high"' }] : []),
|
|
269
|
-
...(results.lazyImages.filter(i => !i.isLcpCandidate).length > 0 ? [{ severity: "warning", message: `${results.lazyImages.filter(i => !i.isLcpCandidate).length} above-fold image(s) have lazy loading: remove loading="lazy"` }] : []),
|
|
268
|
+
...(results.summary.lcpAffected ? [{ code: "lcp-lazy", severity: "error", message: 'LCP candidate image has lazy loading: remove loading="lazy" and add fetchpriority="high"' }] : []),
|
|
269
|
+
...(results.lazyImages.filter(i => !i.isLcpCandidate).length > 0 ? [{ code: "lazy-above-fold", severity: "warning", message: `${results.lazyImages.filter(i => !i.isLcpCandidate).length} above-fold image(s) have lazy loading: remove loading="lazy"` }] : []),
|
|
270
270
|
],
|
|
271
271
|
};
|
|
272
272
|
})();
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
};
|
|
150
150
|
}),
|
|
151
151
|
issues: conflictingElements.length > 0
|
|
152
|
-
? [{ severity: "error", message: `${conflictingElements.length} element(s) have conflicting loading="lazy" and fetchpriority="high"` }]
|
|
152
|
+
? [{ code: "lazy-and-high-priority", severity: "error", message: `${conflictingElements.length} element(s) have conflicting loading="lazy" and fetchpriority="high"` }]
|
|
153
153
|
: [],
|
|
154
154
|
};
|
|
155
155
|
})();
|
|
@@ -304,7 +304,7 @@
|
|
|
304
304
|
})),
|
|
305
305
|
],
|
|
306
306
|
issues: totalImages > 0
|
|
307
|
-
? [{ severity: "warning", message: `${totalImages} image(s) outside the viewport are missing loading="lazy"` }]
|
|
307
|
+
? [{ code: "eager-off-viewport", severity: "warning", message: `${totalImages} image(s) outside the viewport are missing loading="lazy"` }]
|
|
308
308
|
: [],
|
|
309
309
|
};
|
|
310
310
|
})();
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
sizeBytes: r.size,
|
|
162
162
|
})),
|
|
163
163
|
issues: blockingResources.length > 0
|
|
164
|
-
? [{ severity: "error", message: `${blockingResources.length} render-blocking resource(s) delay rendering until ${Math.round(lastBlockingEnd)}ms` }]
|
|
164
|
+
? [{ code: "render-blocking", severity: "error", message: `${blockingResources.length} render-blocking resource(s) delay rendering until ${Math.round(lastBlockingEnd)}ms` }]
|
|
165
165
|
: [],
|
|
166
166
|
};
|
|
167
167
|
})();
|
|
@@ -321,14 +321,15 @@
|
|
|
321
321
|
})),
|
|
322
322
|
issues: [
|
|
323
323
|
...(scripts.some((s) => !s.sizeKnown)
|
|
324
|
-
? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
|
|
324
|
+
? [{ code: "unknown-size", severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
|
|
325
325
|
: []),
|
|
326
326
|
...(!renderBlockingSupported && totalScripts > 0
|
|
327
|
-
? [{ severity: "info", message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which scripts block rendering is unknown; run the snippet in Chrome or Edge to check" }]
|
|
327
|
+
? [{ code: "render-blocking-unsupported", severity: "info", message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which scripts block rendering is unknown; run the snippet in Chrome or Edge to check" }]
|
|
328
328
|
: []),
|
|
329
329
|
...(thirdMetrics.blocking > 0
|
|
330
330
|
? [
|
|
331
331
|
{
|
|
332
|
+
code: "third-party-blocking",
|
|
332
333
|
severity: "error",
|
|
333
334
|
message: `${thirdMetrics.blocking} render-blocking third-party script(s)`,
|
|
334
335
|
},
|
|
@@ -337,6 +338,7 @@
|
|
|
337
338
|
...(thirdMetrics.hosts.length > 3
|
|
338
339
|
? [
|
|
339
340
|
{
|
|
341
|
+
code: "many-third-party-hosts",
|
|
340
342
|
severity: "warning",
|
|
341
343
|
message: `${thirdMetrics.hosts.length} different third-party hosts require separate DNS lookups`,
|
|
342
344
|
},
|
|
@@ -345,6 +347,7 @@
|
|
|
345
347
|
...(thirdMetrics.totalSize > 100 * 1024
|
|
346
348
|
? [
|
|
347
349
|
{
|
|
350
|
+
code: "large-third-party",
|
|
348
351
|
severity: "warning",
|
|
349
352
|
message: `Third-party scripts total ${Math.round(thirdMetrics.totalSize / 1024)} KB`,
|
|
350
353
|
},
|
|
@@ -328,10 +328,10 @@
|
|
|
328
328
|
},
|
|
329
329
|
items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs: roundOrNull(s.dns), tcpMs: roundOrNull(s.tcp), requestMs: roundOrNull(s.request), responseMs: roundOrNull(s.response), hasTiming: s.hasTiming })),
|
|
330
330
|
issues: [
|
|
331
|
-
...(slowScripts.length > 0 ? [{ severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
|
|
332
|
-
...(hasSlowDns ? [{ severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
|
|
333
|
-
...(hasSlowTcp ? [{ severity: "warning", message: "Slow TCP connections detected (>100ms). Add preconnect for critical origins." }] : []),
|
|
334
|
-
...(hasSlowRequest ? [{ severity: "warning", message: "Slow server response times detected (>200ms). Consider CDN or self-hosting." }] : []),
|
|
331
|
+
...(slowScripts.length > 0 ? [{ code: "slow-scripts", severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
|
|
332
|
+
...(hasSlowDns ? [{ code: "slow-dns", severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
|
|
333
|
+
...(hasSlowTcp ? [{ code: "slow-connection", severity: "warning", message: "Slow TCP connections detected (>100ms). Add preconnect for critical origins." }] : []),
|
|
334
|
+
...(hasSlowRequest ? [{ code: "slow-server", severity: "warning", message: "Slow server response times detected (>200ms). Consider CDN or self-hosting." }] : []),
|
|
335
335
|
],
|
|
336
336
|
};
|
|
337
337
|
})();
|
|
@@ -359,9 +359,9 @@
|
|
|
359
359
|
items: uniqueLoadedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, display: f.display })),
|
|
360
360
|
usedFonts: usedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, elements: f.elements })),
|
|
361
361
|
issues: [
|
|
362
|
-
...preloadedNotUsed.map(f => ({ severity: "warning", message: `Preloaded but not used above fold: ${f.name}
|
|
363
|
-
...usedNotPreloaded.map(f => ({ severity: "warning", message: `Used above fold but not preloaded: ${f.family} (${f.weight})` })),
|
|
364
|
-
...preloadedFonts.filter(f => !f.crossorigin).map(f => ({ severity: "error", message: `Font preloaded without crossorigin (double fetch): ${f.name}
|
|
362
|
+
...preloadedNotUsed.map(f => ({ code: "preloaded-unused", severity: "warning", message: `Preloaded but not used above fold: ${f.name}`, target: f.name })),
|
|
363
|
+
...usedNotPreloaded.map(f => ({ code: "used-not-preloaded", severity: "warning", message: `Used above fold but not preloaded: ${f.family} (${f.weight})`, target: `${f.family} (${f.weight})` })),
|
|
364
|
+
...preloadedFonts.filter(f => !f.crossorigin).map(f => ({ code: "preload-no-crossorigin", severity: "error", message: `Font preloaded without crossorigin (double fetch): ${f.name}`, target: f.name })),
|
|
365
365
|
],
|
|
366
366
|
};
|
|
367
367
|
})();
|