webperf-snippets 1.16.0 → 1.17.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 CHANGED
@@ -91,7 +91,7 @@ The human and markdown reports start the same way, when the results count issues
91
91
  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, the browser (`build` `chromium` for the full Chromium or `chromium-headless-shell`, its `mode` and its `version`), the version of the CLI and the machine: its CPU model, cores, architecture and memory, as the operating system reports them, and a benchmark index, a short CPU test in Node before Chromium starts, which only informs, since on the same machine it moves by up to 40 % with the load.
92
92
 
93
93
  ```json
94
- "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "browser": { "build": "chromium", "mode": "headless", "version": "153.0.8010.12" }, "cliVersion": "1.16.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
94
+ "conditions": { "viewport": "mobile", "workflow": "core-web-vitals", "waitMs": 3000, "browser": { "build": "chromium", "mode": "headless", "version": "153.0.8010.12" }, "cliVersion": "1.17.0", "machine": { "cpu": "Apple M4", "cores": 10, "arch": "arm64", "memoryGB": 16, "benchmarkIndex": 2371 } }
95
95
  ```
96
96
 
97
97
  Single snippet:
@@ -111,6 +111,8 @@ The Interaction snippets (`Interactions`, `Input-Latency-Breakdown`, `LongTask`,
111
111
 
112
112
  The CLI waits for the browser to report the timing of the last interaction before it reads the snippets. A script that ends right after a click still reads the page before anything the click sets in motion later has happened, such as a request or an animation, so end it with a `wait` step when you want to measure that.
113
113
 
114
+ The file is checked before the browser opens, and a step that is wrong exits with code `2` and a message that names it. On the page, a step waits up to 5 seconds for its element; when it does not appear, the run stops with the number of the step and exit code `1`.
115
+
114
116
  Interaction audit (long tasks, long animation frames, scroll, layout shifts, forced layouts, interaction latency). It scrolls the page, or runs your `--interact-script`; the steps that need a click are skipped when the script has none:
115
117
 
116
118
  ```bash
@@ -191,7 +193,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
191
193
  "overview": { "snippets": 22, "withErrors": 5, "withWarnings": 6, "withNothingToFix": 11 },
192
194
  "issues": [],
193
195
  "results": [],
194
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.16.0" }
196
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.17.0" }
195
197
  }
196
198
  ```
197
199
 
@@ -275,7 +277,7 @@ The report is a table with one row per combination and the rated median of each
275
277
 
276
278
  With CrUX, the CLI asks once for each URL and form factor, so the variants of a device share their field data. With `--report-to`, it posts one report for each combination, the body of one page with `meta.device`, `meta.variant` and `meta.runs`; the headers of the command line and of the file are conditions of the load, and no report carries their values.
277
279
 
278
- Compare a run with an earlier one with `--baseline <file>`, the `--json` output of that run. The comparison says which metrics got worse or better beyond the noise of the lab, which errors are new or fixed, and what differs in how both runs were measured:
280
+ Compare a run with an earlier one with `--baseline <file>`, the `--json` output of that run, or with another page measured in the same run with `--baseline <url>`, a live baseline. The comparison says which metrics got worse or better beyond the noise of the lab, which errors are new or fixed, and what differs in how both runs were measured:
279
281
 
280
282
  ```bash
281
283
  # On main, once: the baseline
@@ -288,9 +290,9 @@ npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.jso
288
290
  npx webperf-snippets https://pr-123.example.com --runs 5 --baseline baseline.json --fail-on-regression
289
291
  ```
290
292
 
291
- LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of each with `--runs`; a metric that only one side measured is `not measured`. A metric regresses when it gets worse by more than its threshold, the larger of a share of the baseline and a minimum, and, when the baseline has two runs or more, ends above the slowest of them: a median inside the range of the baseline is noise the baseline already had. It improves under the same rules the other way, and otherwise it is unchanged.
293
+ LCP, FCP, TTFB, CLS and INP compare when both runs measured them, the median of each with `--runs`; a metric that only one side measured is `not measured`. A metric regresses when it gets worse by more than its threshold, the larger of a share of the baseline and a minimum, and, when the baseline has two runs or more, ends above the slowest of them: a median inside the range of the baseline is noise the baseline already had. It improves under the same rules the other way, and otherwise it is `unchanged`.
292
294
 
293
- With five runs or more on each side, the range gives way to a test of the runs themselves: a change that beats its threshold also needs a one-sided Mann-Whitney U p-value under 0.05, the chance that runs of an unchanged page would differ this much. A median can move with two slow runs out of five; the test asks whether most runs moved. The JSON of the metric adds `stats`, with `p`, the `shift` (the Hodges-Lehmann estimate, the median of the differences between every run of one side and every run of the other) and its 95 % interval `ci`, and the reports add them to the verdict: `regressed (p = 0.028, +500ms, 95% CI +20ms to +600ms)`, or `unchanged (no evidence: p = 0.35)` for a change over the threshold that the runs do not back. Three runs a side can never reach p < 0.05, so under five the range decides.
295
+ With five runs or more on each side, the range gives way to a test of the runs themselves: a change that beats its threshold also needs a one-sided Mann-Whitney U p-value under 0.05, the chance that runs of an unchanged page would differ this much. A median can move with two slow runs out of five; the test asks whether most runs moved. The JSON of the metric adds `stats`, with `p`, the `shift` (the Hodges-Lehmann estimate, the median of the differences between every run of one side and every run of the other) and its 95 % interval `ci`, and the reports add them to the verdict: `regressed (p = 0.028, +500ms, 95% CI +20ms to +600ms)`. A change over the threshold, for the worse or for the better, that the runs do not back (p not under 0.05) is `inconclusive`: the runs can tell neither a change nor its absence, and the reports say what to do, `inconclusive (no evidence: p = 0.35; measure again, or with more runs)`. Three runs a side can never reach p < 0.05, so under five the range decides, and no metric is inconclusive.
294
296
 
295
297
  | Metric | Share of the baseline | Minimum |
296
298
  | -------------- | --------------------- | ------- |
@@ -319,7 +321,49 @@ The report adds the comparison after the usual one, and `--markdown` a table for
319
321
 
320
322
  When both runs were measured differently, with another workflow or snippet, viewport, wait, throttling, number of runs, browser (build, mode or version) or version of the CLI, on another CPU model or with other cores, or on another path or query, the CLI compares anyway and warns on stderr, in the reports and in `warnings`: whoever reads the pull request comment does not see the stderr of the CI. Another host does not warn, since it is the review app of a pull request against production. A baseline of one run warns that only the thresholds apply, and any other metric compared without statistics gets a line with the values left on each side, as in `no statistics for LCP: the baseline has 5 values, and this run 4 after 1 failed run; they need 5 a side, so measure both with 5 runs or more`.
321
323
 
322
- The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
324
+ The comparison only informs, and the exit code is the same as without it, unless `--fail-on-regression`, which exits with `1` when a metric regressed or an error is new, and names them on stderr, or `--fail-on-inconclusive`, which exits with `1` when a metric is inconclusive for the worse and names it with its p-value; an inconclusive improvement never fails. A baseline that does not exist or is not an output of the CLI, a baseline of a page for a matrix or of a matrix for a page, or a `--threshold` the CLI does not understand exits with code `2`, before the page loads.
325
+
326
+ A live baseline is `--baseline` with a URL, one that starts with `http://` or `https://`: it measures that page in the same run as the page of the command, under the same conditions (viewport, throttling, workflow or snippet, interactions, headers, wait), and alternates their runs: baseline, current, baseline, current, `--runs` times each, or once without it. The request headers of `--header` go to the origin of each page, so a token meant for one of them reaches the other as well; `--storage-state` and `--own-domains` apply to both pages too.
327
+
328
+ ```bash
329
+ # The pull request against production, in one command
330
+ npx webperf-snippets https://pr-123.example.com --runs 5 --baseline https://example.com --fail-on-regression
331
+
332
+ # Measure up to 7 runs a side when a metric is inconclusive for the worse after 5
333
+ npx webperf-snippets https://pr-123.example.com --runs 5 --max-runs 7 --baseline https://example.com --fail-on-regression
334
+ ```
335
+
336
+ A baseline saved earlier was measured in another moment of the machine: a process in the background that slows it down during one of the two measurements moves the timings of that side only, and the comparison reads it as a change of the page. Alternated runs share those moments, so a slower machine moves both pages alike, and the comparison sees the difference between the pages. The progress on stderr names the side of each run (`[3/10] baseline https://example.com · run 2/5`), a run that fails is left out of its side as with `--runs`, and when every run of a side fails the command exits with `1`.
337
+
338
+ The comparison, its thresholds, `--threshold`, `--fail-on-regression`, `--fail-on-inconclusive` and the reports are the ones of a file. The reports name the baseline by its URL, `Compared with https://example.com (measured in this run, 5 runs, median by LCP)`, and the `baseline` of the JSON output has `"live": true` and no `file`:
339
+
340
+ ```json
341
+ "baseline": {
342
+ "live": true,
343
+ "url": "https://example.com/",
344
+ "runs": { "count": 7, "medianBy": "LCP" },
345
+ "extended": { "after": 5, "metrics": ["LCP"] },
346
+ "metrics": {
347
+ "LCP": { "baseline": 2030, "current": 3030, "delta": 1000, "deltaPercent": 49.3, "threshold": 203, "verdict": "regressed", "stats": { "p": 0.0131, "shift": 1000, "ci": [960, 1060], "method": "exact" } }
348
+ },
349
+ "errors": { "new": [], "fixed": [] },
350
+ "warnings": []
351
+ }
352
+ ```
353
+
354
+ A metric is `inconclusive` when, with five runs or more a side, its change beats the threshold and p is not under 0.05: a slow run or two of one side can hide a real change. Measure again, or with more runs. `--max-runs <n>` does it in the same command: when a metric is inconclusive for the worse after `--runs` runs a side, the CLI measures both pages up to `n` runs each and compares once more with every run, and `extended` says after how many runs it measured more and for which metrics; the reports add the same line under their title. It decides twice at most, after `--runs` and after `--max-runs`, never after each added run, since a p-value read after every run would find a change by chance. When the first decision is clear, nothing more is measured. `--max-runs` needs a live baseline and `--runs` of 5 or more, the runs the statistics need, and an `n` greater than `--runs` and at most 9; otherwise the command exits with code `2`.
355
+
356
+ The runs a side decide how many slow runs of the baseline a regression survives. With every run of the page slower than every run of the baseline, except `k` runs of the baseline slower than all of them, the one-sided p-value is:
357
+
358
+ | Runs a side | k = 0 | k = 1 | k = 2 |
359
+ | ----------- | -------- | ------ | ------ |
360
+ | 5 | 0.004 | 0.0754 | 0.3452 |
361
+ | 7 | 0.0003 | 0.0131 | 0.1043 |
362
+ | 9 | < 0.0001 | 0.002 | 0.0252 |
363
+
364
+ Five runs a side tolerate no slow run of the baseline, seven tolerate one, and nine tolerate two.
365
+
366
+ A live baseline compares one page: with `--matrix` the command exits with code `2`.
323
367
 
324
368
  A matrix compares with the `--json` output of an earlier matrix, each combination with the one of the same `id`:
325
369
 
@@ -327,7 +371,7 @@ A matrix compares with the `--json` output of an earlier matrix, each combinatio
327
371
  npx webperf-snippets --matrix matrix.json --runs 3 --baseline last-week.json
328
372
  ```
329
373
 
330
- The table adds a change column after each metric: the change when it counts (`▲ +380ms`, `▼ −90ms`), `=` when it is unchanged, and `new` for a combination the baseline did not have. Below the table come the warnings, once when every combination has them, the new and fixed errors of each combination, and the combinations that only one side has. `--markdown` gives the same, and the `<details>` block of each combination carries its own comparison. In the JSON output each combination carries its `baseline`, and the root `baseline` names the file and the `added` and `missing` combinations. `--fail-on-regression` exits with `1` when any combination regressed or has a new error.
374
+ The table adds a change column after each metric: the change when it counts (`▲ +380ms`, `▼ −90ms`), followed by `?` when it is inconclusive (`▲ +380ms?`), `=` when it is unchanged, and `new` for a combination the baseline did not have. Below the table come the warnings, once when every combination has them, the new and fixed errors of each combination, and the combinations that only one side has. `--markdown` gives the same, and the `<details>` block of each combination carries its own comparison. In the JSON output each combination carries its `baseline`, and the root `baseline` names the file and the `added` and `missing` combinations. `--fail-on-regression` exits with `1` when any combination regressed or has a new error, and `--fail-on-inconclusive` when a metric of any combination is inconclusive for the worse.
331
375
 
332
376
  CI gating:
333
377
 
@@ -356,9 +400,11 @@ Each budget compares the value measured, or the median with `--runs`, and fails
356
400
  | `--runs <n>` | Measure the page n times, from 1 to 9, one after the other, and report the median run. |
357
401
  | `--median-by <metric>` | Metric that picks the median run: `LCP`, `FCP`, `TTFB`, `CLS`, `INP` or `navMs`. Default: the first of LCP, FCP, TTFB, INP and `navMs` measured. |
358
402
  | `--matrix <file>` | Measure every URL of a JSON file on every device and in every variant it lists, and report a table of the medians. |
359
- | `--baseline <file>` | Compare the page, or each combination of a matrix, with the `--json` output of an earlier run: which metrics got worse or better beyond the noise, and which errors are new or fixed. |
403
+ | `--baseline <file\|url>` | Compare the page, or each combination of a matrix, with the `--json` output of an earlier run, or the page with another URL measured in the same run, their runs alternating (a live baseline): which metrics got worse or better beyond the noise, and which errors are new or fixed. |
360
404
  | `--threshold <metric>=<v>` | The threshold of a metric for `--baseline`, such as `LCP=15%`, `LCP=200ms` or `CLS=0.1`. Repeat it for more metrics. |
361
405
  | `--fail-on-regression` | Exit `1` when a metric regressed or an error is new against `--baseline`. |
406
+ | `--fail-on-inconclusive` | Exit `1` when a metric is inconclusive for the worse against `--baseline`: over its threshold, without the evidence of the runs. |
407
+ | `--max-runs <n>` | With a live baseline and `--runs`: when a metric is inconclusive for the worse, measure both pages up to `n` runs each, at most 9, and compare once more. |
362
408
  | `--fail-on <level>` | The issues of the snippets that exit `1`: `error` (default), `warning` (errors and warnings) or `never`. Budgets, `--fail-on-regression` and runs that fail keep exiting `1`. |
363
409
  | `--crux-key <key>` | Add CrUX field data (p75) next to the measured values. Prefer `CRUX_API_KEY`. |
364
410
  | `--crux-history <weeks>` | Add the trend of the field data over the last 1 to 40 weeks. Needs a CrUX key. |
@@ -409,8 +455,8 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
409
455
  | Code | Meaning |
410
456
  | ---- | --------------------------------------------- |
411
457
  | `0` | All checks passed. |
412
- | `1` | Budget violation, a snippet errored, a step of the workflow reported an `error` issue (or a `warning` one with `--fail-on warning`; neither with `--fail-on never`), or, with `--fail-on-regression`, a regression against the baseline. |
413
- | `2` | Usage error (missing URL, unknown workflow, a baseline or threshold that is not valid). |
458
+ | `1` | Budget violation, a snippet errored, a step of the workflow reported an `error` issue (or a `warning` one with `--fail-on warning`; neither with `--fail-on never`), with `--fail-on-regression`, a regression against the baseline, with `--fail-on-inconclusive`, a metric inconclusive for the worse, or every run of a live baseline failed. |
459
+ | `2` | Usage error (missing URL, unknown workflow, a baseline or threshold that is not valid, a live baseline with `--matrix`, a `--max-runs` without a live baseline and `--runs` or not greater than `--runs`). |
414
460
 
415
461
  ## MCP server
416
462
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.16.0",
3
+ "version": "1.17.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",
@@ -17,7 +17,8 @@
17
17
  "test:e2e": "vitest run tests/e2e"
18
18
  },
19
19
  "dependencies": {
20
- "@modelcontextprotocol/server": "^2.3.1"
20
+ "@modelcontextprotocol/server": "^2.3.1",
21
+ "zod": "^4.6.6"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@modelcontextprotocol/client": "^2.3.1",
@@ -1,211 +1,213 @@
1
1
  // Analyze CSS @media rules for viewports bigger than a specified breakpoint
2
2
  // Default minWidth = 768 (px), but you can customize it
3
3
 
4
- async function analyzeCSSMediaQueries(minWidth = 768) {
5
- // @shared ownRequestUrl
6
- // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
7
- // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
8
- // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
9
- // which is how isOwnRequest leaves these requests out of what the page loaded.
10
- function ownRequestUrl(url) {
11
- try {
12
- const parsed = new URL(url, location.href);
13
- if (!/^https?:$/.test(parsed.protocol)) return url;
14
- parsed.hash = "webperf-snippets";
15
- return parsed.href;
16
- } catch {
17
- return url;
18
- }
19
- }
20
- // @end-shared ownRequestUrl
21
-
22
- const SCRIPT = "CSS-Media-Queries-Analysis";
23
- const MAX_ITEMS = 50;
24
- const WARNING_BYTES = 50 * 1024;
25
- const stylesheets = [...document.styleSheets];
26
- const rules = []; // one per @media rule: { source, inline, mediaQuery, classes, properties, bytes }
27
- let corsBlockedCount = 0;
28
-
29
- // Helper to check if media query targets bigger than specified breakpoint
30
- function isBiggerThanBreakpoint(mediaText) {
31
- if (!mediaText) return false;
32
-
33
- // Check for min-width greater than specified breakpoint
34
- const minWidthMatch = mediaText.match(/min-width:\s*(\d+)(px|em|rem)/i);
35
- if (minWidthMatch) {
36
- const value = parseInt(minWidthMatch[1]);
37
- const unit = minWidthMatch[2].toLowerCase();
38
-
39
- if (unit === "px" && value > minWidth) return true;
40
- if (unit === "em" && value > minWidth / 16) return true; // Convert to em
41
- if (unit === "rem" && value > minWidth / 16) return true; // Convert to rem
4
+ (() => {
5
+ async function analyzeCSSMediaQueries(minWidth = 768) {
6
+ // @shared ownRequestUrl
7
+ // The URL a snippet requests when it reads a file of the page itself (fetch, a HEAD request,
8
+ // new Image()). The fragment never reaches the server or the HTTP cache key, so the file still
9
+ // comes from the cache, but it stays in the name of the Resource Timing entry of the request,
10
+ // which is how isOwnRequest leaves these requests out of what the page loaded.
11
+ function ownRequestUrl(url) {
12
+ try {
13
+ const parsed = new URL(url, location.href);
14
+ if (!/^https?:$/.test(parsed.protocol)) return url;
15
+ parsed.hash = "webperf-snippets";
16
+ return parsed.href;
17
+ } catch {
18
+ return url;
19
+ }
42
20
  }
21
+ // @end-shared ownRequestUrl
22
+
23
+ const SCRIPT = "CSS-Media-Queries-Analysis";
24
+ const MAX_ITEMS = 50;
25
+ const WARNING_BYTES = 50 * 1024;
26
+ const stylesheets = [...document.styleSheets];
27
+ const rules = []; // one per @media rule: { source, inline, mediaQuery, classes, properties, bytes }
28
+ let corsBlockedCount = 0;
29
+
30
+ // Helper to check if media query targets bigger than specified breakpoint
31
+ function isBiggerThanBreakpoint(mediaText) {
32
+ if (!mediaText) return false;
33
+
34
+ // Check for min-width greater than specified breakpoint
35
+ const minWidthMatch = mediaText.match(/min-width:\s*(\d+)(px|em|rem)/i);
36
+ if (minWidthMatch) {
37
+ const value = parseInt(minWidthMatch[1]);
38
+ const unit = minWidthMatch[2].toLowerCase();
39
+
40
+ if (unit === "px" && value > minWidth) return true;
41
+ if (unit === "em" && value > minWidth / 16) return true; // Convert to em
42
+ if (unit === "rem" && value > minWidth / 16) return true; // Convert to rem
43
+ }
43
44
 
44
- // max-width only queries are for smaller viewports
45
- return false;
46
- }
45
+ // max-width only queries are for smaller viewports
46
+ return false;
47
+ }
47
48
 
48
- // Classes and declarations inside the rule, not in its media query. A class starts with a
49
- // letter, an underscore or a hyphen, so values such as 1.5rem do not count
50
- function countClassesAndProperties(innerCss) {
51
- const classMatches = innerCss.match(/\.-?[_a-zA-Z][\w-]*/g) || [];
52
- const propertyMatches = innerCss.match(/[a-z-]+\s*:/g) || [];
53
- return { classes: classMatches.length, properties: propertyMatches.length };
54
- }
49
+ // Classes and declarations inside the rule, not in its media query. A class starts with a
50
+ // letter, an underscore or a hyphen, so values such as 1.5rem do not count
51
+ function countClassesAndProperties(innerCss) {
52
+ const classMatches = innerCss.match(/\.-?[_a-zA-Z][\w-]*/g) || [];
53
+ const propertyMatches = innerCss.match(/[a-z-]+\s*:/g) || [];
54
+ return { classes: classMatches.length, properties: propertyMatches.length };
55
+ }
55
56
 
56
- // Helper to calculate byte size
57
- function getByteSize(text) {
58
- return new Blob([text]).size;
59
- }
57
+ // Helper to calculate byte size
58
+ function getByteSize(text) {
59
+ return new Blob([text]).size;
60
+ }
60
61
 
61
- // Helper to format bytes
62
- // @shared formatBytes
63
- function formatBytes(bytes) {
64
- if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
65
- if (bytes === 0) return "0 B";
66
- const units = ["B", "KB", "MB", "GB"];
67
- const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
68
- return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
69
- }
70
- // @end-shared formatBytes
62
+ // Helper to format bytes
63
+ // @shared formatBytes
64
+ function formatBytes(bytes) {
65
+ if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "-";
66
+ if (bytes === 0) return "0 B";
67
+ const units = ["B", "KB", "MB", "GB"];
68
+ const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
69
+ return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
70
+ }
71
+ // @end-shared formatBytes
71
72
 
72
- function addRule(source, inline, mediaQuery, fullCss, innerCss) {
73
- if (!isBiggerThanBreakpoint(mediaQuery)) return;
74
- rules.push({ source, inline, mediaQuery, ...countClassesAndProperties(innerCss), bytes: getByteSize(fullCss) });
75
- }
73
+ function addRule(source, inline, mediaQuery, fullCss, innerCss) {
74
+ if (!isBiggerThanBreakpoint(mediaQuery)) return;
75
+ rules.push({ source, inline, mediaQuery, ...countClassesAndProperties(innerCss), bytes: getByteSize(fullCss) });
76
+ }
76
77
 
77
- // Parse CSS text to find @media rules, for stylesheets read with fetch
78
- function parseMediaQueriesFromCSS(cssText, source) {
79
- const mediaRegex = /@media\s*([^{]+)\{((?:[^{}]|\{[^{}]*\})*)\}/g;
80
- let match;
81
- while ((match = mediaRegex.exec(cssText)) !== null) {
82
- addRule(source, false, match[1].trim(), match[0], match[2]);
78
+ // Parse CSS text to find @media rules, for stylesheets read with fetch
79
+ function parseMediaQueriesFromCSS(cssText, source) {
80
+ const mediaRegex = /@media\s*([^{]+)\{((?:[^{}]|\{[^{}]*\})*)\}/g;
81
+ let match;
82
+ while ((match = mediaRegex.exec(cssText)) !== null) {
83
+ addRule(source, false, match[1].trim(), match[0], match[2]);
84
+ }
83
85
  }
84
- }
85
86
 
86
- // Process stylesheets
87
- for (let sheetIndex = 0; sheetIndex < stylesheets.length; sheetIndex++) {
88
- const sheet = stylesheets[sheetIndex];
89
- const inline = !sheet.href;
90
- const source = sheet.href || `<style> tag #${sheetIndex}`;
91
-
92
- try {
93
- const sheetRules = sheet.cssRules || sheet.rules;
94
- if (sheetRules) {
95
- [...sheetRules].forEach((rule) => {
96
- if (rule.type === CSSRule.MEDIA_RULE) {
97
- const inner = [...rule.cssRules].map((r) => r.cssText).join("");
98
- addRule(source, inline, rule.media.mediaText, rule.cssText, inner);
87
+ // Process stylesheets
88
+ for (let sheetIndex = 0; sheetIndex < stylesheets.length; sheetIndex++) {
89
+ const sheet = stylesheets[sheetIndex];
90
+ const inline = !sheet.href;
91
+ const source = sheet.href || `<style> tag #${sheetIndex}`;
92
+
93
+ try {
94
+ const sheetRules = sheet.cssRules || sheet.rules;
95
+ if (sheetRules) {
96
+ [...sheetRules].forEach((rule) => {
97
+ if (rule.type === CSSRule.MEDIA_RULE) {
98
+ const inner = [...rule.cssRules].map((r) => r.cssText).join("");
99
+ addRule(source, inline, rule.media.mediaText, rule.cssText, inner);
100
+ }
101
+ });
102
+ }
103
+ } catch {
104
+ // Cross-origin stylesheet without CORS: try to fetch it
105
+ if (sheet.href) {
106
+ try {
107
+ const response = await fetch(ownRequestUrl(sheet.href));
108
+ parseMediaQueriesFromCSS(await response.text(), sheet.href);
109
+ } catch {
110
+ corsBlockedCount++;
99
111
  }
100
- });
101
- }
102
- } catch {
103
- // Cross-origin stylesheet without CORS: try to fetch it
104
- if (sheet.href) {
105
- try {
106
- const response = await fetch(ownRequestUrl(sheet.href));
107
- parseMediaQueriesFromCSS(await response.text(), sheet.href);
108
- } catch {
109
- corsBlockedCount++;
110
112
  }
111
113
  }
112
114
  }
113
- }
114
115
 
115
- // --- One item per distinct query ------------------------------------------------------
116
-
117
- const byQuery = new Map();
118
- for (const r of rules) {
119
- const q = byQuery.get(r.mediaQuery) ?? { mediaQuery: r.mediaQuery, rules: 0, sources: new Set(), classes: 0, properties: 0, bytes: 0 };
120
- q.rules++;
121
- q.sources.add(r.source);
122
- q.classes += r.classes;
123
- q.properties += r.properties;
124
- q.bytes += r.bytes;
125
- byQuery.set(r.mediaQuery, q);
126
- }
127
- const queries = [...byQuery.values()]
128
- .map(({ sources, ...q }) => ({
129
- mediaQuery: q.mediaQuery,
130
- rules: q.rules,
131
- files: sources.size,
132
- classes: q.classes,
133
- properties: q.properties,
134
- bytes: q.bytes,
135
- }))
136
- .sort((a, b) => b.bytes - a.bytes);
137
-
138
- const bySourceMap = new Map();
139
- for (const r of rules) {
140
- const s = bySourceMap.get(r.source) ?? { source: r.source, inline: r.inline, rules: 0, bytes: 0 };
141
- s.rules++;
142
- s.bytes += r.bytes;
143
- bySourceMap.set(r.source, s);
144
- }
145
- const bySource = [...bySourceMap.values()];
146
-
147
- const totalBytes = rules.reduce((sum, r) => sum + r.bytes, 0);
148
- const uniquenessRatio = rules.length ? Math.round((queries.length / rules.length) * 1000) / 1000 : 0;
149
-
150
- // --- Issues ----------------------------------------------------------------------------
151
-
152
- const issues = [];
153
- if (rules.length === 0) {
154
- issues.push({ code: "no-wide-rules", severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
155
- } else {
156
- issues.push({
157
- code: "wide-rules",
158
- severity: totalBytes > WARNING_BYTES ? "warning" : "info",
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`,
160
- });
161
- if (rules.length >= 20 && uniquenessRatio < 0.1) {
116
+ // --- One item per distinct query ------------------------------------------------------
117
+
118
+ const byQuery = new Map();
119
+ for (const r of rules) {
120
+ const q = byQuery.get(r.mediaQuery) ?? { mediaQuery: r.mediaQuery, rules: 0, sources: new Set(), classes: 0, properties: 0, bytes: 0 };
121
+ q.rules++;
122
+ q.sources.add(r.source);
123
+ q.classes += r.classes;
124
+ q.properties += r.properties;
125
+ q.bytes += r.bytes;
126
+ byQuery.set(r.mediaQuery, q);
127
+ }
128
+ const queries = [...byQuery.values()]
129
+ .map(({ sources, ...q }) => ({
130
+ mediaQuery: q.mediaQuery,
131
+ rules: q.rules,
132
+ files: sources.size,
133
+ classes: q.classes,
134
+ properties: q.properties,
135
+ bytes: q.bytes,
136
+ }))
137
+ .sort((a, b) => b.bytes - a.bytes);
138
+
139
+ const bySourceMap = new Map();
140
+ for (const r of rules) {
141
+ const s = bySourceMap.get(r.source) ?? { source: r.source, inline: r.inline, rules: 0, bytes: 0 };
142
+ s.rules++;
143
+ s.bytes += r.bytes;
144
+ bySourceMap.set(r.source, s);
145
+ }
146
+ const bySource = [...bySourceMap.values()];
147
+
148
+ const totalBytes = rules.reduce((sum, r) => sum + r.bytes, 0);
149
+ const uniquenessRatio = rules.length ? Math.round((queries.length / rules.length) * 1000) / 1000 : 0;
150
+
151
+ // --- Issues ----------------------------------------------------------------------------
152
+
153
+ const issues = [];
154
+ if (rules.length === 0) {
155
+ issues.push({ code: "no-wide-rules", severity: "info", message: `No @media rules for viewports wider than ${minWidth}px` });
156
+ } else {
162
157
  issues.push({
163
- code: "repeated-queries",
164
- severity: "info",
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`,
158
+ code: "wide-rules",
159
+ severity: totalBytes > WARNING_BYTES ? "warning" : "info",
160
+ 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`,
166
161
  });
162
+ if (rules.length >= 20 && uniquenessRatio < 0.1) {
163
+ issues.push({
164
+ code: "repeated-queries",
165
+ severity: "info",
166
+ 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`,
167
+ });
168
+ }
169
+ }
170
+ if (corsBlockedCount > 0) {
171
+ issues.push({ code: "unreadable-stylesheets", severity: "info", message: `${corsBlockedCount} cross-origin stylesheet(s) could not be read, so their @media rules are not counted` });
167
172
  }
168
- }
169
- if (corsBlockedCount > 0) {
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` });
171
- }
172
173
 
173
- // --- Console output --------------------------------------------------------------------
174
+ // --- Console output --------------------------------------------------------------------
174
175
 
175
- console.group(`CSS Media Queries Analysis (min-width > ${minWidth}px)`);
176
- console.log(`@media rules: ${rules.length} | distinct queries: ${queries.length} | size: ${formatBytes(totalBytes)}`);
177
- if (queries.length > 0) {
178
- console.table(
179
- queries.map((q) => ({ Query: q.mediaQuery, Rules: q.rules, Files: q.files, Classes: q.classes, Properties: q.properties, Size: formatBytes(q.bytes) }))
180
- );
181
- console.table(bySource.map((s) => ({ Source: s.source, Rules: s.rules, Size: formatBytes(s.bytes) })));
176
+ console.group(`CSS Media Queries Analysis (min-width > ${minWidth}px)`);
177
+ console.log(`@media rules: ${rules.length} | distinct queries: ${queries.length} | size: ${formatBytes(totalBytes)}`);
178
+ if (queries.length > 0) {
179
+ console.table(
180
+ queries.map((q) => ({ Query: q.mediaQuery, Rules: q.rules, Files: q.files, Classes: q.classes, Properties: q.properties, Size: formatBytes(q.bytes) }))
181
+ );
182
+ console.table(bySource.map((s) => ({ Source: s.source, Rules: s.rules, Size: formatBytes(s.bytes) })));
183
+ }
184
+ issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
185
+ console.groupEnd();
186
+
187
+ return {
188
+ script: SCRIPT,
189
+ status: "ok",
190
+ count: queries.length,
191
+ details: {
192
+ minWidth,
193
+ rules: rules.length,
194
+ totalUnique: queries.length,
195
+ uniquenessRatio,
196
+ classes: rules.reduce((sum, r) => sum + r.classes, 0),
197
+ properties: rules.reduce((sum, r) => sum + r.properties, 0),
198
+ bytes: totalBytes,
199
+ bySource: bySource.slice(0, MAX_ITEMS),
200
+ corsBlockedCount,
201
+ },
202
+ items: queries.slice(0, MAX_ITEMS),
203
+ issues,
204
+ };
182
205
  }
183
- issues.forEach((i) => console.log(`[${i.severity}] ${i.message}`));
184
- console.groupEnd();
185
-
186
- return {
187
- script: SCRIPT,
188
- status: "ok",
189
- count: queries.length,
190
- details: {
191
- minWidth,
192
- rules: rules.length,
193
- totalUnique: queries.length,
194
- uniquenessRatio,
195
- classes: rules.reduce((sum, r) => sum + r.classes, 0),
196
- properties: rules.reduce((sum, r) => sum + r.properties, 0),
197
- bytes: totalBytes,
198
- bySource: bySource.slice(0, MAX_ITEMS),
199
- corsBlockedCount,
200
- },
201
- items: queries.slice(0, MAX_ITEMS),
202
- issues,
203
- };
204
- }
205
-
206
- // Run with default breakpoint (768px)
207
- analyzeCSSMediaQueries();
208
-
209
- // Or customize the breakpoint:
210
- // analyzeCSSMediaQueries(1024); // for desktop
211
- // analyzeCSSMediaQueries(480); // for small tablets
206
+
207
+ // The breakpoint is 768px. For another one, call the function from the console:
208
+ // analyzeCSSMediaQueries(1024); // for desktop
209
+ // analyzeCSSMediaQueries(480); // for small tablets
210
+ window.analyzeCSSMediaQueries = analyzeCSSMediaQueries;
211
+
212
+ return analyzeCSSMediaQueries();
213
+ })();