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.
Files changed (33) hide show
  1. package/README.md +14 -6
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/INP.js +6 -4
  4. package/snippets/Interaction/DOM-Size-and-Depth.js +40 -2
  5. package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
  6. package/snippets/Interaction/Interactions.js +8 -1
  7. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +37 -7
  8. package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
  9. package/snippets/Loading/Content-Visibility.js +36 -56
  10. package/snippets/Loading/Critical-CSS-Detection.js +2 -1
  11. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +33 -19
  12. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +34 -20
  13. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +36 -22
  14. package/snippets/Media/Image-Element-Audit.js +37 -1
  15. package/snippets/Media/Oversized-Images.js +33 -5
  16. package/snippets/SCHEMA.md +11 -6
  17. package/snippets/_view/fields.js +36 -0
  18. package/snippets/_view/format.js +39 -0
  19. package/snippets/_view/index.js +304 -0
  20. package/snippets/_view/locales/en.js +39 -0
  21. package/snippets/_view/package.json +1 -0
  22. package/src/bin.js +46 -39
  23. package/src/budgets.js +73 -0
  24. package/src/matrix.js +3 -10
  25. package/src/mcp/server.js +15 -7
  26. package/src/reporters/budgets-view.js +38 -0
  27. package/src/reporters/human.js +42 -20
  28. package/src/reporters/markdown.js +53 -1
  29. package/src/reporters/summary-view.js +12 -0
  30. package/src/runner.js +3 -3
  31. package/src/url.js +8 -0
  32. package/src/view.js +11 -0
  33. 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.5.0" }
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.5.0" }
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-cls <score>` | Exit `1` if CLS exceeds this value. |
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` | `2500` | LCP budget in ms. An empty value disables the check. |
457
- | `budget-cls` | `0.1` | CLS budget. An empty value disables the check. |
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.5.0",
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: "error",
355
- error: "No interactions recorded yet. Interact with the page and call getINP() again.",
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: "error",
382
- error: "No interactions recorded yet. Interact with the page and call getINPDetails() again.",
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 ? selectorFor(widest.el) + (widest.root !== widest.el ? " (shadow root)" : "") : null;
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: selectorFor(p.el) + (p.root !== p.el ? " (shadow root)" : ""),
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: "error",
138
- error: "No interactions recorded yet. Interact with the page (click, type, etc.) and call getInputLatencyBreakdown() again.",
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
- return { script: "Interactions", status: "error", error: "No interactions recorded yet", count: 0 };
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
- const getElementSelector = (element) => {
46
- if (!element) return "(unknown)";
47
- if (element.id) return `#${element.id}`;
48
- if (element.className && typeof element.className === "string") {
49
- const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
50
- if (classes) return `${element.tagName.toLowerCase()}.${classes}`;
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 element.tagName?.toLowerCase() || "(unknown)";
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
- recommendations.push(
808
- "🔴 Add Cache-Control headers to static assets (JS, CSS, fonts, images). Start with max-age=86400 and move to immutable for versioned files."
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
- recommendations.push(
813
- "🟡 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
+ 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
- recommendations.push(
818
- "🟡 Replace Expires headers with Cache-Control: max-age. Expires is an outdated mechanism and less reliable."
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
- recommendations.push(
823
- `🔴 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
+ 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
- recommendations.push(
828
- `🟡 CDN hit rate is ${cdnHitRate}%. Check CDN cache rules and ensure static assets have appropriate TTLs.`
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
- recommendations.push(
833
- `🟡 ${protocolDistribution["http/1.1"]} resources still use HTTP/1.1. Consider upgrading to HTTP/2 for better multiplexing.`
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
- recommendations.push(
838
- `🟡 ${uncompressedResources.length} text resources appear uncompressed. Enable Brotli or gzip compression on your server.`
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
- recommendations.push(
843
- `🟡 ${costlyDuplicates.length} resources are loaded multiple times with uncached network requests. Check for duplicate script, stylesheet, or fetch includes.`
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
- recommendations.push(
847
- `ℹ️ ${cachedDuplicates.length} resources are requested multiple times but served from cache; low performance impact, but worth reviewing for correctness.`
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
- recommendations.push(
852
- `ℹ️ All resources are cross-origin CORS-restricted, so header analysis was limited. Add Timing-Allow-Origin for more insights.`
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: getSelector(node),
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: getSelector(el),
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
- function getSelector(node) {
14
- let sel = "";
15
- try {
16
- while (node && node.nodeType !== 9) {
17
- const el = node;
18
- const name = el.nodeName.toLowerCase();
19
- const part = el.id
20
- ? "#" + el.id
21
- : name +
22
- (el.classList && el.classList.value && el.classList.value.trim()
23
- ? "." + el.classList.value.trim().split(/\s+/).slice(0, 2).join(".")
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
- } catch {}
31
- return sel;
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: getSelector(img),
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
- function getSelector(node) {
6
- let sel = "";
7
- try {
8
- while (node && node.nodeType !== 9) {
9
- const el = node;
10
- const name = el.nodeName.toLowerCase();
11
- const part = el.id
12
- ? "#" + el.id
13
- : name +
14
- (el.classList && el.classList.value && el.classList.value.trim()
15
- ? "." + el.classList.value.trim().split(/\s+/).slice(0, 2).join(".")
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
- } catch {}
23
- return sel;
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: getSelector(el),
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: getSelector(el),
157
+ selector: uniqueSelector(el),
144
158
  src: el.currentSrc || el.src || "",
145
159
  inViewport: isInViewport(el),
146
160
  isLcpCandidate: el === lcpCandidate,