webperf-snippets 1.15.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.
@@ -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
+ })();