webperf-snippets 1.5.0 → 1.7.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 +14 -6
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +6 -4
- package/snippets/Interaction/DOM-Size-and-Depth.js +40 -2
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
- package/snippets/Interaction/Interactions.js +8 -1
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +37 -7
- package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
- package/snippets/Loading/Content-Visibility.js +36 -56
- package/snippets/Loading/Critical-CSS-Detection.js +2 -1
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +33 -19
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +34 -20
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +36 -22
- package/snippets/Media/Image-Element-Audit.js +37 -1
- package/snippets/Media/Oversized-Images.js +33 -5
- package/snippets/SCHEMA.md +11 -6
- package/snippets/_view/fields.js +36 -0
- package/snippets/_view/format.js +39 -0
- package/snippets/_view/index.js +304 -0
- package/snippets/_view/locales/en.js +39 -0
- package/snippets/_view/package.json +1 -0
- package/src/bin.js +46 -39
- package/src/budgets.js +73 -0
- package/src/matrix.js +3 -10
- package/src/mcp/server.js +15 -7
- package/src/reporters/budgets-view.js +38 -0
- package/src/reporters/human.js +42 -20
- package/src/reporters/markdown.js +53 -1
- package/src/reporters/summary-view.js +12 -0
- package/src/runner.js +3 -3
- package/src/url.js +8 -0
- package/src/view.js +11 -0
- package/src/workflows/interaction.js +1 -0
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.7.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.7.0" }
|
|
162
162
|
}
|
|
163
163
|
```
|
|
164
164
|
|
|
@@ -300,6 +300,8 @@ CI gating:
|
|
|
300
300
|
npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
+
Each budget compares the value measured, or the median with `--runs`, and fails when the value is above it. `--budget-lcp` and `--budget-cls` need the `core-web-vitals` workflow, `--budget-fcp` the `loading` workflow, `--budget-ttfb` the `loading` or `audit` workflow, and `--budget-inp` the `interaction` workflow with a click or a key press; `--snippet` with the snippet of the metric works too. A budget of a metric that the run does not measure exits with code `2` before the page loads, and one whose metric was not measured, such as INP without interactions, exits with code `1`: a budget never passes unchecked. The report lists every budget with its value and verdict, `passed`, `exceeded` with by how much, or `not measured` with why: a Budgets section in the human and markdown reports, and `budgets` in the JSON output and in the body of `--report-to`. A matrix lists the failed budgets with their combination under its table. Each failed budget is also written to stderr, with `--json` too.
|
|
304
|
+
|
|
303
305
|
### Options
|
|
304
306
|
|
|
305
307
|
| Option | Description |
|
|
@@ -325,8 +327,11 @@ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
|
|
|
325
327
|
| `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
|
|
326
328
|
| `--report-to <url>` | POST the results to this `https` URL after the run. No external calls without it. |
|
|
327
329
|
| `--api-key <key>` | Sent as a Bearer token with `--report-to`. Prefer `PERF_REVIEWS_API_KEY`. |
|
|
328
|
-
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value. |
|
|
329
|
-
| `--budget-
|
|
330
|
+
| `--budget-lcp <ms>` | Exit `1` if LCP exceeds this value or was not measured. |
|
|
331
|
+
| `--budget-fcp <ms>` | Exit `1` if FCP exceeds this value or was not measured. |
|
|
332
|
+
| `--budget-ttfb <ms>` | Exit `1` if TTFB exceeds this value or was not measured. |
|
|
333
|
+
| `--budget-cls <score>` | Exit `1` if CLS exceeds this value or was not measured. |
|
|
334
|
+
| `--budget-inp <ms>` | Exit `1` if INP exceeds this value or was not measured. |
|
|
330
335
|
| `--verbose` | Show all items, including passing checks. |
|
|
331
336
|
| `--headed` | Show the browser window (debug). |
|
|
332
337
|
| `-h, --help` | Show help. |
|
|
@@ -453,8 +458,11 @@ jobs:
|
|
|
453
458
|
| ---------------- | ------------------- | -------------------------------------------------------------------------------------------- |
|
|
454
459
|
| `url` | required | URL to measure. |
|
|
455
460
|
| `workflow` | `core-web-vitals` | Workflow to run: `core-web-vitals`, `loading`, `audit` or `interaction`. |
|
|
456
|
-
| `budget-lcp` | `
|
|
457
|
-
| `budget-
|
|
461
|
+
| `budget-lcp` | `auto` | LCP budget in ms. `auto` is `2500` with `core-web-vitals` and no budget with the other workflows. An empty value disables the check. |
|
|
462
|
+
| `budget-fcp` | | FCP budget in ms, for `loading`. |
|
|
463
|
+
| `budget-ttfb` | | TTFB budget in ms, for `loading` or `audit`. |
|
|
464
|
+
| `budget-cls` | `auto` | CLS budget. `auto` is `0.1` with `core-web-vitals` and no budget with the other workflows. An empty value disables the check. |
|
|
465
|
+
| `budget-inp` | | INP budget in ms, for `interaction` with a click or a key press. |
|
|
458
466
|
| `fail-on-budget` | `true` | Fail the step when a budget is exceeded or a snippet errors. Use `"false"` to read `exit-code` and decide yourself. |
|
|
459
467
|
| `cli-version` | `latest` | Version of the `webperf-snippets` npm package to run. |
|
|
460
468
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webperf-snippets",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -349,10 +349,11 @@
|
|
|
349
349
|
};
|
|
350
350
|
}
|
|
351
351
|
if (interactions.length === 0) {
|
|
352
|
+
// Nothing failed: someone has to use the page first, so it is still tracking
|
|
352
353
|
return {
|
|
353
354
|
script: "INP",
|
|
354
|
-
status: "
|
|
355
|
-
|
|
355
|
+
status: "tracking",
|
|
356
|
+
message: "No interactions yet: click, tap or type on the page, then call getINP() again.",
|
|
356
357
|
getDataFn: "getINP",
|
|
357
358
|
details,
|
|
358
359
|
};
|
|
@@ -378,8 +379,9 @@
|
|
|
378
379
|
console.groupEnd();
|
|
379
380
|
return {
|
|
380
381
|
script: "INP",
|
|
381
|
-
status: "
|
|
382
|
-
|
|
382
|
+
status: "tracking",
|
|
383
|
+
message: "No interactions yet: click, tap or type on the page, then call getINPDetails() again.",
|
|
384
|
+
getDataFn: "getINPDetails",
|
|
383
385
|
count: 0,
|
|
384
386
|
items: [],
|
|
385
387
|
};
|
|
@@ -37,6 +37,44 @@
|
|
|
37
37
|
return (truncated ? "... > " : "") + parts.join(" > ").replace(/> >>> >/g, ">>>");
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// @shared uniqueSelector
|
|
41
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
42
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
43
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
44
|
+
function uniqueSelector(el) {
|
|
45
|
+
const root = el.getRootNode();
|
|
46
|
+
const matchesOne = (selector) => {
|
|
47
|
+
try {
|
|
48
|
+
return root.querySelectorAll(selector).length === 1;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const parts = [];
|
|
54
|
+
let node = el;
|
|
55
|
+
while (node && node.nodeType === 1) {
|
|
56
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
57
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const tag = node.localName;
|
|
61
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
62
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
63
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
64
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
65
|
+
}
|
|
66
|
+
parts.unshift(part);
|
|
67
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
68
|
+
node = node.parentElement;
|
|
69
|
+
}
|
|
70
|
+
return parts.join(" > ");
|
|
71
|
+
}
|
|
72
|
+
// @end-shared uniqueSelector
|
|
73
|
+
|
|
74
|
+
// An element of the document by its unique selector, which Inspect can select; one inside a shadow
|
|
75
|
+
// root by the short path above, which marks the boundary
|
|
76
|
+
const inspectableSelectorFor = (el) => (el.getRootNode() === document ? uniqueSelector(el) : selectorFor(el));
|
|
77
|
+
|
|
40
78
|
// Iterative walk (explicit stack) so very deep trees cannot overflow the call stack.
|
|
41
79
|
// Depth counts <html> as level 1 and continues through open shadow roots.
|
|
42
80
|
let totalElements = 0;
|
|
@@ -69,10 +107,10 @@
|
|
|
69
107
|
const ranked = parents
|
|
70
108
|
.sort((a, b) => b.children - a.children || a.depth - b.depth);
|
|
71
109
|
const widest = ranked[0] ?? null;
|
|
72
|
-
const widestSelector = widest ?
|
|
110
|
+
const widestSelector = widest ? inspectableSelectorFor(widest.el) + (widest.root !== widest.el ? " (shadow root)" : "") : null;
|
|
73
111
|
|
|
74
112
|
const items = ranked.slice(0, 10).map((p) => ({
|
|
75
|
-
selector:
|
|
113
|
+
selector: inspectableSelectorFor(p.el) + (p.root !== p.el ? " (shadow root)" : ""),
|
|
76
114
|
children: p.children,
|
|
77
115
|
depth: p.depth,
|
|
78
116
|
}));
|
|
@@ -132,10 +132,11 @@
|
|
|
132
132
|
console.log(
|
|
133
133
|
" Interact with the page (click, type, etc.) and call this again."
|
|
134
134
|
);
|
|
135
|
+
// Nothing failed: someone has to use the page first, so it is still tracking
|
|
135
136
|
return {
|
|
136
137
|
script: "Input-Latency-Breakdown",
|
|
137
|
-
status: "
|
|
138
|
-
|
|
138
|
+
status: "tracking",
|
|
139
|
+
message: "No interactions yet: click, tap or type on the page, then call getInputLatencyBreakdown() again.",
|
|
139
140
|
getDataFn: "getInputLatencyBreakdown",
|
|
140
141
|
};
|
|
141
142
|
}
|
|
@@ -184,7 +184,14 @@
|
|
|
184
184
|
if (allInteractions.length === 0) {
|
|
185
185
|
console.log("%c📊 No interactions recorded yet.", "font-weight: bold;");
|
|
186
186
|
console.log(" Interact with the page (click, type, etc.) and call this again.");
|
|
187
|
-
|
|
187
|
+
// Nothing failed: someone has to use the page first, so it is still tracking
|
|
188
|
+
return {
|
|
189
|
+
script: "Interactions",
|
|
190
|
+
status: "tracking",
|
|
191
|
+
message: "No interactions yet: click, tap or type on the page, then call getInteractionSummary() again.",
|
|
192
|
+
getDataFn: "getInteractionSummary",
|
|
193
|
+
count: 0,
|
|
194
|
+
};
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
console.group("%c📊 Interaction Summary", "font-weight: bold; font-size: 14px;");
|
|
@@ -42,14 +42,44 @@
|
|
|
42
42
|
totalImpact: Math.round(data.totalShift * 10000) / 10000,
|
|
43
43
|
}));
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
// @shared uniqueSelector
|
|
46
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
47
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
48
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
49
|
+
function uniqueSelector(el) {
|
|
50
|
+
const root = el.getRootNode();
|
|
51
|
+
const matchesOne = (selector) => {
|
|
52
|
+
try {
|
|
53
|
+
return root.querySelectorAll(selector).length === 1;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const parts = [];
|
|
59
|
+
let node = el;
|
|
60
|
+
while (node && node.nodeType === 1) {
|
|
61
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
62
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
const tag = node.localName;
|
|
66
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
67
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
68
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
69
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
70
|
+
}
|
|
71
|
+
parts.unshift(part);
|
|
72
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
73
|
+
node = node.parentElement;
|
|
51
74
|
}
|
|
52
|
-
return
|
|
75
|
+
return parts.join(" > ");
|
|
76
|
+
}
|
|
77
|
+
// @end-shared uniqueSelector
|
|
78
|
+
|
|
79
|
+
// The node of a source can be a text node, named by its element
|
|
80
|
+
const getElementSelector = (node) => {
|
|
81
|
+
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
82
|
+
return element ? uniqueSelector(element) : "(unknown)";
|
|
53
83
|
};
|
|
54
84
|
|
|
55
85
|
const observer = new PerformanceObserver((list) => {
|
|
@@ -800,56 +800,58 @@
|
|
|
800
800
|
|
|
801
801
|
console.groupEnd(); // Additional Insights
|
|
802
802
|
|
|
803
|
-
// Recommendations
|
|
803
|
+
// Recommendations: the console marks their priority; the result orders them by it, as plain text
|
|
804
|
+
const PRIORITY = ["🔴", "🟡", "ℹ️"];
|
|
804
805
|
const recommendations = [];
|
|
806
|
+
const recommend = (icon, text) => recommendations.push({ icon, text });
|
|
805
807
|
|
|
806
808
|
if (noCacheEntries.filter((e) => isStaticAsset(e.type)).length > 0) {
|
|
807
|
-
|
|
808
|
-
"
|
|
809
|
+
recommend("🔴",
|
|
810
|
+
"Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."
|
|
809
811
|
);
|
|
810
812
|
}
|
|
811
813
|
if (shortCacheEntries.filter((e) => e.hasHash).length > 0) {
|
|
812
|
-
|
|
813
|
-
"
|
|
814
|
+
recommend("🟡",
|
|
815
|
+
"Versioned files (with content hash) should use long cache or Cache-Control: immutable; they can be cached indefinitely since the URL changes on update."
|
|
814
816
|
);
|
|
815
817
|
}
|
|
816
818
|
if (allAntiPatterns.some((p) => p.id === "expires-without-cc")) {
|
|
817
|
-
|
|
818
|
-
"
|
|
819
|
+
recommend("🟡",
|
|
820
|
+
"Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."
|
|
819
821
|
);
|
|
820
822
|
}
|
|
821
823
|
if (cacheEfficiencyPercent !== null && cacheEfficiencyPercent < 50) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
+
recommend("🔴",
|
|
825
|
+
`Cache efficiency is low (${cacheEfficiencyPercent}%, measured on the ${analyzableEntries.length} resources whose cache headers could be read). Review caching strategy for those static assets.`
|
|
824
826
|
);
|
|
825
827
|
}
|
|
826
828
|
if (cdnHitRate !== null && cdnHitRate < 70) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
+
recommend("🟡",
|
|
830
|
+
`CDN hit rate is ${cdnHitRate}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`
|
|
829
831
|
);
|
|
830
832
|
}
|
|
831
833
|
if (protocolDistribution["http/1.1"] > 0) {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
+
recommend("🟡",
|
|
835
|
+
`${protocolDistribution["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`
|
|
834
836
|
);
|
|
835
837
|
}
|
|
836
838
|
if (uncompressedResources.length > 0) {
|
|
837
|
-
|
|
838
|
-
|
|
839
|
+
recommend("🟡",
|
|
840
|
+
`${uncompressedResources.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`
|
|
839
841
|
);
|
|
840
842
|
}
|
|
841
843
|
if (costlyDuplicates.length > 0) {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
+
recommend("🟡",
|
|
845
|
+
`${costlyDuplicates.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`
|
|
844
846
|
);
|
|
845
847
|
} else if (cachedDuplicates.length > 0) {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
+
recommend("ℹ️",
|
|
849
|
+
`${cachedDuplicates.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`
|
|
848
850
|
);
|
|
849
851
|
}
|
|
850
852
|
if (corsRestricted > 0 && headersAnalyzed === 0) {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
+
recommend("ℹ️",
|
|
854
|
+
`All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights.`
|
|
853
855
|
);
|
|
854
856
|
}
|
|
855
857
|
|
|
@@ -857,7 +859,7 @@
|
|
|
857
859
|
console.log("");
|
|
858
860
|
console.group("%c📌 Recommendations", "font-weight: bold;");
|
|
859
861
|
for (const rec of recommendations) {
|
|
860
|
-
console.log(` ${rec}`);
|
|
862
|
+
console.log(` ${rec.icon} ${rec.text}`);
|
|
861
863
|
}
|
|
862
864
|
console.groupEnd();
|
|
863
865
|
}
|
|
@@ -935,6 +937,6 @@
|
|
|
935
937
|
})),
|
|
936
938
|
...(coverageIssue ? [coverageIssue] : []),
|
|
937
939
|
],
|
|
938
|
-
recommendations,
|
|
940
|
+
recommendations: [...recommendations].sort((a, b) => PRIORITY.indexOf(a.icon) - PRIORITY.indexOf(b.icon)).map((r) => r.text),
|
|
939
941
|
};
|
|
940
942
|
})();
|
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
// Detect elements with content-visibility: auto and analyze optimization opportunities
|
|
2
2
|
|
|
3
|
+
// @shared uniqueSelector
|
|
4
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
5
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
6
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
7
|
+
function uniqueSelector(el) {
|
|
8
|
+
const root = el.getRootNode();
|
|
9
|
+
const matchesOne = (selector) => {
|
|
10
|
+
try {
|
|
11
|
+
return root.querySelectorAll(selector).length === 1;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const parts = [];
|
|
17
|
+
let node = el;
|
|
18
|
+
while (node && node.nodeType === 1) {
|
|
19
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
20
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
const tag = node.localName;
|
|
24
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
25
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
26
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
27
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
28
|
+
}
|
|
29
|
+
parts.unshift(part);
|
|
30
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
31
|
+
node = node.parentElement;
|
|
32
|
+
}
|
|
33
|
+
return parts.join(" > ");
|
|
34
|
+
}
|
|
35
|
+
// @end-shared uniqueSelector
|
|
36
|
+
|
|
3
37
|
function detectContentVisibility() {
|
|
4
38
|
// Create an object to store the results
|
|
5
39
|
const results = {
|
|
@@ -9,39 +43,6 @@ function detectContentVisibility() {
|
|
|
9
43
|
nodeArray: [],
|
|
10
44
|
};
|
|
11
45
|
|
|
12
|
-
// Get the name of the node
|
|
13
|
-
function getName(node) {
|
|
14
|
-
const name = node.nodeName;
|
|
15
|
-
return node.nodeType === 1 ? name.toLowerCase() : name.toUpperCase().replace(/^#/, "");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Get the selector for an element
|
|
19
|
-
function getSelector(node) {
|
|
20
|
-
let sel = "";
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
while (node && node.nodeType !== 9) {
|
|
24
|
-
const el = node;
|
|
25
|
-
const part = el.id
|
|
26
|
-
? "#" + el.id
|
|
27
|
-
: getName(el) +
|
|
28
|
-
(el.classList &&
|
|
29
|
-
el.classList.value &&
|
|
30
|
-
el.classList.value.trim() &&
|
|
31
|
-
el.classList.value.trim().length
|
|
32
|
-
? "." + el.classList.value.trim().replace(/\s+/g, ".")
|
|
33
|
-
: "");
|
|
34
|
-
if (sel.length + part.length > 100 - 1) return sel || part;
|
|
35
|
-
sel = sel ? part + ">" + sel : part;
|
|
36
|
-
if (el.id) break;
|
|
37
|
-
node = el.parentNode;
|
|
38
|
-
}
|
|
39
|
-
} catch {
|
|
40
|
-
// Do nothing...
|
|
41
|
-
}
|
|
42
|
-
return sel;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
46
|
// Check if element is in viewport
|
|
46
47
|
function isInViewport(element) {
|
|
47
48
|
const rect = element.getBoundingClientRect();
|
|
@@ -59,7 +60,7 @@ function detectContentVisibility() {
|
|
|
59
60
|
const cs = window.getComputedStyle(node);
|
|
60
61
|
|
|
61
62
|
return {
|
|
62
|
-
selector:
|
|
63
|
+
selector: uniqueSelector(node),
|
|
63
64
|
contentVisibility: cs["content-visibility"],
|
|
64
65
|
containIntrinsicSize: cs["contain-intrinsic-size"] || "not set",
|
|
65
66
|
width: Math.round(rect.width),
|
|
@@ -171,27 +172,6 @@ function analyzeContentVisibilityOpportunities(options = {}) {
|
|
|
171
172
|
const opportunities = [];
|
|
172
173
|
const processedElements = new Set();
|
|
173
174
|
|
|
174
|
-
function getSelector(node) {
|
|
175
|
-
let sel = "";
|
|
176
|
-
try {
|
|
177
|
-
while (node && node.nodeType !== 9) {
|
|
178
|
-
const el = node;
|
|
179
|
-
const name = el.nodeName.toLowerCase();
|
|
180
|
-
const part = el.id
|
|
181
|
-
? "#" + el.id
|
|
182
|
-
: name +
|
|
183
|
-
(el.classList && el.classList.value && el.classList.value.trim()
|
|
184
|
-
? "." + el.classList.value.trim().split(/\s+/).slice(0, 2).join(".")
|
|
185
|
-
: "");
|
|
186
|
-
if (sel.length + part.length > 80) return sel || part;
|
|
187
|
-
sel = sel ? part + ">" + sel : part;
|
|
188
|
-
if (el.id) break;
|
|
189
|
-
node = el.parentNode;
|
|
190
|
-
}
|
|
191
|
-
} catch {}
|
|
192
|
-
return sel;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
175
|
function estimateRenderSavings(childCount) {
|
|
196
176
|
const baseMs = childCount * 0.2;
|
|
197
177
|
if (baseMs < 5) return "Low (~" + baseMs.toFixed(1) + "ms)";
|
|
@@ -233,7 +213,7 @@ function analyzeContentVisibilityOpportunities(options = {}) {
|
|
|
233
213
|
processedElements.add(el);
|
|
234
214
|
|
|
235
215
|
opportunities.push({
|
|
236
|
-
selector:
|
|
216
|
+
selector: uniqueSelector(el),
|
|
237
217
|
height: Math.round(rect.height) + "px",
|
|
238
218
|
distanceFromViewport: Math.round(distanceFromViewport) + "px",
|
|
239
219
|
childElements: childCount,
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
const criticalBudget = 14 * 1024; // 14 KB (first TCP round-trip)
|
|
25
25
|
|
|
26
26
|
// --- External stylesheets ---
|
|
27
|
+
// A link without href or disabled loads nothing and blocks nothing, so it is not a stylesheet of the page
|
|
27
28
|
const linkStylesheets = Array.from(
|
|
28
29
|
document.querySelectorAll('link[rel="stylesheet"]')
|
|
29
|
-
);
|
|
30
|
+
).filter((link) => link.href && !link.disabled);
|
|
30
31
|
|
|
31
32
|
const perfEntries = performance.getEntriesByType("resource").filter((e) => !isOwnRequest(e));
|
|
32
33
|
|
|
@@ -10,26 +10,40 @@
|
|
|
10
10
|
}
|
|
11
11
|
// @end-shared isOwnRequest
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
: "");
|
|
25
|
-
if (sel.length + part.length > 80) return sel || part;
|
|
26
|
-
sel = sel ? part + ">" + sel : part;
|
|
27
|
-
if (el.id) break;
|
|
28
|
-
node = el.parentNode;
|
|
13
|
+
// @shared uniqueSelector
|
|
14
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
15
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
16
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
17
|
+
function uniqueSelector(el) {
|
|
18
|
+
const root = el.getRootNode();
|
|
19
|
+
const matchesOne = (selector) => {
|
|
20
|
+
try {
|
|
21
|
+
return root.querySelectorAll(selector).length === 1;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
29
24
|
}
|
|
30
|
-
}
|
|
31
|
-
|
|
25
|
+
};
|
|
26
|
+
const parts = [];
|
|
27
|
+
let node = el;
|
|
28
|
+
while (node && node.nodeType === 1) {
|
|
29
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
30
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
const tag = node.localName;
|
|
34
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
35
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
36
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
37
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
38
|
+
}
|
|
39
|
+
parts.unshift(part);
|
|
40
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
41
|
+
node = node.parentElement;
|
|
42
|
+
}
|
|
43
|
+
return parts.join(" > ");
|
|
32
44
|
}
|
|
45
|
+
// @end-shared uniqueSelector
|
|
46
|
+
|
|
33
47
|
|
|
34
48
|
function isInViewport(element) {
|
|
35
49
|
const rect = element.getBoundingClientRect();
|
|
@@ -81,7 +95,7 @@
|
|
|
81
95
|
const area = rect.width * rect.height;
|
|
82
96
|
|
|
83
97
|
return {
|
|
84
|
-
selector:
|
|
98
|
+
selector: uniqueSelector(img),
|
|
85
99
|
lazyType: getLazyType(img),
|
|
86
100
|
dimensions: `${Math.round(rect.width)}×${Math.round(rect.height)}`,
|
|
87
101
|
position: {
|
|
@@ -2,26 +2,40 @@
|
|
|
2
2
|
// https://webperf-snippets.net
|
|
3
3
|
|
|
4
4
|
(() => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
: "");
|
|
17
|
-
if (sel.length + part.length > 80) return sel || part;
|
|
18
|
-
sel = sel ? part + ">" + sel : part;
|
|
19
|
-
if (el.id) break;
|
|
20
|
-
node = el.parentNode;
|
|
5
|
+
// @shared uniqueSelector
|
|
6
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
7
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
8
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
9
|
+
function uniqueSelector(el) {
|
|
10
|
+
const root = el.getRootNode();
|
|
11
|
+
const matchesOne = (selector) => {
|
|
12
|
+
try {
|
|
13
|
+
return root.querySelectorAll(selector).length === 1;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
21
16
|
}
|
|
22
|
-
}
|
|
23
|
-
|
|
17
|
+
};
|
|
18
|
+
const parts = [];
|
|
19
|
+
let node = el;
|
|
20
|
+
while (node && node.nodeType === 1) {
|
|
21
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
22
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
const tag = node.localName;
|
|
26
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
27
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
28
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
29
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
30
|
+
}
|
|
31
|
+
parts.unshift(part);
|
|
32
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
33
|
+
node = node.parentElement;
|
|
34
|
+
}
|
|
35
|
+
return parts.join(" > ");
|
|
24
36
|
}
|
|
37
|
+
// @end-shared uniqueSelector
|
|
38
|
+
|
|
25
39
|
|
|
26
40
|
function isInViewport(element) {
|
|
27
41
|
const rect = element.getBoundingClientRect();
|
|
@@ -77,7 +91,7 @@
|
|
|
77
91
|
const isLcp = el === lcpCandidate;
|
|
78
92
|
|
|
79
93
|
return {
|
|
80
|
-
selector:
|
|
94
|
+
selector: uniqueSelector(el),
|
|
81
95
|
dimensions: `${Math.round(rect.width)}×${Math.round(rect.height)}`,
|
|
82
96
|
inViewport: inViewport ? "Yes" : "No",
|
|
83
97
|
isLcpCandidate: isLcp ? "⚠️ Yes" : "No",
|
|
@@ -140,7 +154,7 @@
|
|
|
140
154
|
items: Array.from(conflictingElements).map((el) => {
|
|
141
155
|
const rect = el.getBoundingClientRect();
|
|
142
156
|
return {
|
|
143
|
-
selector:
|
|
157
|
+
selector: uniqueSelector(el),
|
|
144
158
|
src: el.currentSrc || el.src || "",
|
|
145
159
|
inViewport: isInViewport(el),
|
|
146
160
|
isLcpCandidate: el === lcpCandidate,
|