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.
- package/README.md +150 -12
- package/package.json +3 -2
- package/snippets/Loading/CSS-Media-Queries-Analysis.js +190 -188
- package/snippets/Loading/Content-Visibility.js +273 -264
- package/snippets/SCHEMA.md +13 -0
- package/snippets/_view/index.js +1 -0
- package/snippets/_view/report.js +60 -0
- package/src/baseline.js +21 -6
- package/src/bin.js +143 -41
- package/src/blocking.js +29 -8
- package/src/interactions.js +63 -6
- package/src/live-baseline.js +76 -0
- package/src/mcp/server.js +29 -17
- package/src/mcp/steps.js +12 -0
- package/src/overview.js +20 -0
- package/src/reporters/baseline-view.js +39 -9
- package/src/reporters/human.js +12 -3
- package/src/reporters/markdown.js +44 -11
- package/src/view.js +1 -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// max-width only queries are for smaller viewports
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Helper to calculate byte size
|
|
58
|
+
function getByteSize(text) {
|
|
59
|
+
return new Blob([text]).size;
|
|
60
|
+
}
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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: "
|
|
164
|
-
severity: "info",
|
|
165
|
-
message: `${
|
|
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
|
-
|
|
174
|
+
// --- Console output --------------------------------------------------------------------
|
|
174
175
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
console
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
})();
|