webperf-snippets 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/package.json +1 -1
- package/snippets/CoreWebVitals/INP.js +6 -4
- package/snippets/Interaction/DOM-Size-and-Depth.js +40 -2
- package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
- package/snippets/Interaction/Interactions.js +8 -1
- package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +37 -7
- package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
- package/snippets/Loading/Content-Visibility.js +36 -56
- package/snippets/Loading/Critical-CSS-Detection.js +2 -1
- package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +33 -19
- package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +34 -20
- package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +36 -22
- package/snippets/Media/Image-Element-Audit.js +37 -1
- package/snippets/Media/Oversized-Images.js +33 -5
- package/snippets/SCHEMA.md +11 -6
- package/snippets/_view/fields.js +36 -0
- package/snippets/_view/format.js +39 -0
- package/snippets/_view/index.js +304 -0
- package/snippets/_view/locales/en.js +39 -0
- package/snippets/_view/package.json +1 -0
- package/src/bin.js +46 -39
- package/src/budgets.js +73 -0
- package/src/matrix.js +3 -10
- package/src/mcp/server.js +15 -7
- package/src/reporters/budgets-view.js +38 -0
- package/src/reporters/human.js +42 -20
- package/src/reporters/markdown.js +53 -1
- package/src/reporters/summary-view.js +12 -0
- package/src/runner.js +3 -3
- package/src/url.js +8 -0
- package/src/view.js +11 -0
- package/src/workflows/interaction.js +1 -0
|
@@ -10,26 +10,40 @@
|
|
|
10
10
|
}
|
|
11
11
|
// @end-shared isOwnRequest
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
: "");
|
|
25
|
-
if (sel.length + part.length > 80) return sel || part;
|
|
26
|
-
sel = sel ? part + ">" + sel : part;
|
|
27
|
-
if (el.id) break;
|
|
28
|
-
node = el.parentNode;
|
|
13
|
+
// @shared uniqueSelector
|
|
14
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
15
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
16
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
17
|
+
function uniqueSelector(el) {
|
|
18
|
+
const root = el.getRootNode();
|
|
19
|
+
const matchesOne = (selector) => {
|
|
20
|
+
try {
|
|
21
|
+
return root.querySelectorAll(selector).length === 1;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
29
24
|
}
|
|
30
|
-
}
|
|
31
|
-
|
|
25
|
+
};
|
|
26
|
+
const parts = [];
|
|
27
|
+
let node = el;
|
|
28
|
+
while (node && node.nodeType === 1) {
|
|
29
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
30
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
const tag = node.localName;
|
|
34
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
35
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
36
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
37
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
38
|
+
}
|
|
39
|
+
parts.unshift(part);
|
|
40
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
41
|
+
node = node.parentElement;
|
|
42
|
+
}
|
|
43
|
+
return parts.join(" > ");
|
|
32
44
|
}
|
|
45
|
+
// @end-shared uniqueSelector
|
|
46
|
+
|
|
33
47
|
|
|
34
48
|
function isInViewport(element) {
|
|
35
49
|
const rect = element.getBoundingClientRect();
|
|
@@ -59,12 +73,12 @@
|
|
|
59
73
|
while (parent && parent !== document.body) {
|
|
60
74
|
const cs = window.getComputedStyle(parent);
|
|
61
75
|
if (cs.display === "none" || cs.visibility === "hidden") {
|
|
62
|
-
return { hidden: true, reason: "CSS hidden", container:
|
|
76
|
+
return { hidden: true, reason: "CSS hidden", container: uniqueSelector(parent) };
|
|
63
77
|
}
|
|
64
78
|
for (const selector of hiddenSelectors) {
|
|
65
79
|
try {
|
|
66
80
|
if (parent.matches(selector)) {
|
|
67
|
-
return { hidden: true, reason: selector, container:
|
|
81
|
+
return { hidden: true, reason: selector, container: uniqueSelector(parent) };
|
|
68
82
|
}
|
|
69
83
|
} catch {}
|
|
70
84
|
}
|
|
@@ -133,7 +147,7 @@
|
|
|
133
147
|
// Skip images in viewport (they shouldn't be lazy loaded)
|
|
134
148
|
if (isInViewport(img)) {
|
|
135
149
|
if (img === lcpCandidate) {
|
|
136
|
-
results.excluded.lcpCandidate =
|
|
150
|
+
results.excluded.lcpCandidate = uniqueSelector(img);
|
|
137
151
|
} else {
|
|
138
152
|
results.excluded.inViewport++;
|
|
139
153
|
}
|
|
@@ -151,7 +165,7 @@
|
|
|
151
165
|
const hiddenCheck = isInHiddenContainer(img);
|
|
152
166
|
|
|
153
167
|
const imageData = {
|
|
154
|
-
selector:
|
|
168
|
+
selector: uniqueSelector(img),
|
|
155
169
|
src: src.length > 60 ? "..." + src.slice(-57) : src,
|
|
156
170
|
fullSrc: src,
|
|
157
171
|
dimensions: `${img.naturalWidth}×${img.naturalHeight}`,
|
|
@@ -181,6 +181,40 @@
|
|
|
181
181
|
// if the server does not expose CORS headers.
|
|
182
182
|
const formats = await Promise.all(images.map((img) => fetchFormat(img.currentSrc || img.src)));
|
|
183
183
|
|
|
184
|
+
// @shared uniqueSelector
|
|
185
|
+
// A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
|
|
186
|
+
// otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
|
|
187
|
+
// unique id, or to the root, and stops as soon as it matches one element.
|
|
188
|
+
function uniqueSelector(el) {
|
|
189
|
+
const root = el.getRootNode();
|
|
190
|
+
const matchesOne = (selector) => {
|
|
191
|
+
try {
|
|
192
|
+
return root.querySelectorAll(selector).length === 1;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const parts = [];
|
|
198
|
+
let node = el;
|
|
199
|
+
while (node && node.nodeType === 1) {
|
|
200
|
+
if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
|
|
201
|
+
parts.unshift(`#${window.CSS.escape(node.id)}`);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
const tag = node.localName;
|
|
205
|
+
let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
|
|
206
|
+
const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
|
|
207
|
+
if (siblings.filter((s) => s.matches(part)).length > 1) {
|
|
208
|
+
part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
|
|
209
|
+
}
|
|
210
|
+
parts.unshift(part);
|
|
211
|
+
if (matchesOne(parts.join(" > "))) break;
|
|
212
|
+
node = node.parentElement;
|
|
213
|
+
}
|
|
214
|
+
return parts.join(" > ");
|
|
215
|
+
}
|
|
216
|
+
// @end-shared uniqueSelector
|
|
217
|
+
|
|
184
218
|
const audited = images.map((img, i) => {
|
|
185
219
|
const inViewport = isInViewport(img);
|
|
186
220
|
const isLcp = img === lcpCandidate;
|
|
@@ -230,6 +264,7 @@
|
|
|
230
264
|
|
|
231
265
|
return {
|
|
232
266
|
img,
|
|
267
|
+
selector: uniqueSelector(img),
|
|
233
268
|
inViewport,
|
|
234
269
|
isLcp,
|
|
235
270
|
src,
|
|
@@ -393,7 +428,7 @@
|
|
|
393
428
|
totalInfos,
|
|
394
429
|
lcpCandidate: lcpData
|
|
395
430
|
? {
|
|
396
|
-
selector:
|
|
431
|
+
selector: lcpData.selector,
|
|
397
432
|
format: lcpData.format,
|
|
398
433
|
fetchpriority: lcpData.fetchpriority,
|
|
399
434
|
loading: lcpData.loading,
|
|
@@ -402,6 +437,7 @@
|
|
|
402
437
|
: null,
|
|
403
438
|
},
|
|
404
439
|
items: rankedImages.slice(0, MAX_ITEMS).map((r) => ({
|
|
440
|
+
selector: r.selector,
|
|
405
441
|
url: r.src,
|
|
406
442
|
format: r.format,
|
|
407
443
|
inViewport: r.inViewport,
|
|
@@ -42,11 +42,39 @@
|
|
|
42
42
|
const shortUrl = (url) => (url.startsWith("data:") ? url.slice(0, 60) : url);
|
|
43
43
|
const fileName = (url) => url.split("?")[0].split("/").pop() || url;
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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;
|
|
74
|
+
}
|
|
75
|
+
return parts.join(" > ");
|
|
49
76
|
}
|
|
77
|
+
// @end-shared uniqueSelector
|
|
50
78
|
|
|
51
79
|
function isVector(url) {
|
|
52
80
|
return /^data:image\/svg/i.test(url) || /\.svg([?#]|$)/i.test(url);
|
|
@@ -154,7 +182,7 @@
|
|
|
154
182
|
if (wastedBytes !== null) wastedBytesByUrl.set(url, Math.max(wastedBytesByUrl.get(url) ?? 0, wastedBytes));
|
|
155
183
|
|
|
156
184
|
flagged.push({
|
|
157
|
-
selector:
|
|
185
|
+
selector: uniqueSelector(img),
|
|
158
186
|
url: shortUrl(url),
|
|
159
187
|
verdict,
|
|
160
188
|
naturalWidth: file.width,
|
package/snippets/SCHEMA.md
CHANGED
|
@@ -59,6 +59,9 @@ Every script must return an object matching this shape:
|
|
|
59
59
|
target?: string; // The URL, selector or name the issue is about, when there is one issue per thing
|
|
60
60
|
}>;
|
|
61
61
|
|
|
62
|
+
// Next steps in plain text, most important first, without emoji (Cache-Strategy-Analysis)
|
|
63
|
+
recommendations?: string[];
|
|
64
|
+
|
|
62
65
|
// Tracking scripts (status: "tracking")
|
|
63
66
|
message?: string; // Human-readable status message
|
|
64
67
|
getDataFn?: string; // window function to call for data: evaluate_script(`${getDataFn}()`). May be a dotted path such as "loafHelpers.getData"
|
|
@@ -68,6 +71,8 @@ Every script must return an object matching this shape:
|
|
|
68
71
|
}
|
|
69
72
|
```
|
|
70
73
|
|
|
74
|
+
A field named `selector`, `element` or `finalElement`, at any level, is a CSS selector that matches only its element in the document, so `document.querySelector()` returns that element and no other: the id when it is unique, otherwise a path of tag, classes and `:nth-of-type()` up to the nearest ancestor with a unique id, such as `div.carousel-item:nth-of-type(8) > img`. A selector of an element inside a shadow root marks the boundary with `>>>` or ends in `(shadow root)`, and cannot be read from the document. `cli/tests/e2e/snippet-contract.test.js` checks it on its pages.
|
|
75
|
+
|
|
71
76
|
---
|
|
72
77
|
|
|
73
78
|
## Execution Patterns
|
|
@@ -170,7 +175,7 @@ return {
|
|
|
170
175
|
**The window function must also return a structured object** matching the same schema, including when nothing has been recorded yet:
|
|
171
176
|
|
|
172
177
|
- `status: "ok"` with `count: 0` when zero is a valid result (no long tasks, no long animation frames).
|
|
173
|
-
- `status: "
|
|
178
|
+
- `status: "tracking"` with a `message` and its `getDataFn` when there is nothing to report yet and someone has to act first (no interactions recorded): nothing failed, the snippet is still recording. `status: "error"` is for a snippet that failed.
|
|
174
179
|
|
|
175
180
|
**Scripts using this pattern:** INP, Interactions, Input-Latency-Breakdown, Layout-Shift-Loading-and-Interaction, Scroll-Performance, Long-Animation-Frames (ongoing tracking), LongTask (ongoing tracking), Long-Animation-Frames-Script-Attribution.
|
|
176
181
|
|
|
@@ -262,11 +267,11 @@ Returns buffered CLS immediately and keeps tracking. Always call `getCLS()` afte
|
|
|
262
267
|
}
|
|
263
268
|
```
|
|
264
269
|
|
|
265
|
-
If no interactions yet, `getINP()` returns `status: "
|
|
270
|
+
If no interactions yet, `getINP()` returns `status: "tracking"` with a `message` and `getDataFn: "getINP"`: interact with the page and call it again.
|
|
266
271
|
|
|
267
272
|
The phases describe the whole interaction, not one of its events. A tap fires `pointerdown`, `pointerup` and `click`, and a key press `keydown` and `keyup`, with one `interactionId`. INP, Interactions and Input-Latency-Breakdown group the entries by `interactionId`, also when they arrive in separate observer callbacks, and measure the interaction over its entries painted in the frame of the longest one: `inputDelay` runs from the first `startTime` to the first `processingStart`, `processingTime` to the last `processingEnd`, and `presentationDelay` from there to the paint (`startTime + duration`, never below zero). The interaction is reported under the event whose handler ran longest, or under the `click` or `keydown` when no handler took a millisecond more than theirs; `worstEvent`, the Interactions `type` and the Input-Latency-Breakdown event type follow that rule.
|
|
268
273
|
|
|
269
|
-
`getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "
|
|
274
|
+
`getINPDetails()` returns the 15 slowest interactions, slowest first, with the total in `count`. `target` is a CSS selector built like `details.element` of LCP, or `null` when the browser reports no target. Use it when `getINP()` shows poor INP and you need to identify patterns across multiple slow interactions. With no interactions yet it returns `status: "tracking"`, a `message`, `count: 0` and no items.
|
|
270
275
|
|
|
271
276
|
```json
|
|
272
277
|
{
|
|
@@ -1132,7 +1137,7 @@ Returns buffered long tasks immediately. Ongoing tracking continues. `items` lis
|
|
|
1132
1137
|
|
|
1133
1138
|
#### Oversized-Images (async)
|
|
1134
1139
|
|
|
1135
|
-
Compares the pixels of each loaded image file with the pixels drawn (rendered size times `devicePixelRatio`, scaled by `object-fit`). `ratio` is file pixels per pixel drawn: `oversized` at 1.5 or more, `undersized` (blurry) under 0.9. `items` lists the flagged images, the most wasted pixels first (at most 50); `count` and `details` cover all of them. `wastedBytes` and `transferBytes` are `null` when the transfer size is hidden (cross-origin without `Timing-Allow-Origin`); `details.wastedBytes` counts each URL once. Images with a zero-size box, unloaded images and SVGs are counted in `details.skipped*`.
|
|
1140
|
+
Compares the pixels of each loaded image file with the pixels drawn (rendered size times `devicePixelRatio`, scaled by `object-fit`). `ratio` is file pixels per pixel drawn: `oversized` at 1.5 or more, `undersized` (blurry) under 0.9. `items` lists the flagged images, the most wasted pixels first (at most 50); `count` and `details` cover all of them. `wastedBytes` and `transferBytes` are `null` when the transfer size is hidden (cross-origin without `Timing-Allow-Origin`); `details.wastedBytes` counts each URL once. Images with a zero-size box, unloaded images and SVGs are counted in `details.skipped*`. `selector` matches only the image of its item, built like `details.element` of LCP, so an image with no id or class has a path such as `div.partners > img:nth-of-type(3)` instead of `img`.
|
|
1136
1141
|
|
|
1137
1142
|
```json
|
|
1138
1143
|
{
|
|
@@ -1141,7 +1146,7 @@ Compares the pixels of each loaded image file with the pixels drawn (rendered si
|
|
|
1141
1146
|
"count": 1,
|
|
1142
1147
|
"details": { "devicePixelRatio": 2, "totalImages": 9, "imagesChecked": 7, "oversized": 1, "undersized": 0, "wastedPixels": 1800000, "wastedBytes": 1614610, "skippedHidden": 1, "skippedNotLoaded": 1, "skippedVector": 0 },
|
|
1143
1148
|
"items": [
|
|
1144
|
-
{ "selector": "
|
|
1149
|
+
{ "selector": "#hero", "url": "https://example.com/hero.png", "verdict": "oversized", "naturalWidth": 1600, "naturalHeight": 1200, "renderedWidth": 200, "renderedHeight": 150, "neededWidth": 400, "neededHeight": 300, "ratio": 4, "wastedPixels": 1800000, "transferBytes": 1922153, "wastedBytes": 1614610, "hasSrcset": false, "hasSizes": false }
|
|
1145
1150
|
],
|
|
1146
1151
|
"issues": [{ "severity": "warning", "message": "hero.png: 1600x1200 file for a 400x300 slot at 2x (4x too large, about 1577 KB wasted)" }]
|
|
1147
1152
|
}
|
|
@@ -1398,7 +1403,7 @@ Each issue names the preloaded script in `target`.
|
|
|
1398
1403
|
|
|
1399
1404
|
### Cache-Strategy-Analysis
|
|
1400
1405
|
|
|
1401
|
-
Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes.
|
|
1406
|
+
Each issue names its resource in `target`, except `header-coverage`. `id` repeats the code, as before the codes. `recommendations` lists the next steps in plain text, the most important first; the console marks their priority with 🔴, 🟡 and ℹ️.
|
|
1402
1407
|
|
|
1403
1408
|
| Code | Severity | When |
|
|
1404
1409
|
|------|----------|------|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// How each field of a result is shown, the same in every language: its format, its place among the
|
|
2
|
+
// others (a lower order comes first; a field without one keeps its place after them) and whether it is
|
|
3
|
+
// hidden. The entries of a snippet win over the general ones under "*". A field without an entry takes
|
|
4
|
+
// its format from the suffix of its key (format.js). The words are in locales/.
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
'*': {
|
|
8
|
+
// A CSS selector of an element of the page, which a consumer can make inspectable
|
|
9
|
+
selector: { format: 'selector' },
|
|
10
|
+
element: { format: 'selector' },
|
|
11
|
+
finalElement: { format: 'selector' },
|
|
12
|
+
// The name of a phase, read as its label
|
|
13
|
+
slowestPhase: { format: 'key' },
|
|
14
|
+
// The issues of a row: the issues section of the result lists them already
|
|
15
|
+
issues: { hidden: true },
|
|
16
|
+
},
|
|
17
|
+
'Find-Above-The-Fold-Lazy-Loaded-Images': {
|
|
18
|
+
// Its top and left in px: the distance from the edge of the viewport says what matters
|
|
19
|
+
position: { hidden: true },
|
|
20
|
+
},
|
|
21
|
+
'Input-Latency-Breakdown': {
|
|
22
|
+
// The same as its rows, one per event type
|
|
23
|
+
eventTypes: { hidden: true },
|
|
24
|
+
},
|
|
25
|
+
INP: {
|
|
26
|
+
phases: { format: 'phases' },
|
|
27
|
+
},
|
|
28
|
+
'Find-render-blocking-resources': {
|
|
29
|
+
shortName: { order: 1 },
|
|
30
|
+
type: { order: 2 },
|
|
31
|
+
responseEndMs: { order: 3 },
|
|
32
|
+
durationMs: { order: 4 },
|
|
33
|
+
sizeBytes: { order: 5 },
|
|
34
|
+
url: { order: 6 },
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The formats of the values of a result, with the numbers written as the locale writes them. A field
|
|
2
|
+
// without a format in fields.js takes it from the suffix of its key.
|
|
3
|
+
|
|
4
|
+
const KB = 1024
|
|
5
|
+
const MB = 1024 * 1024
|
|
6
|
+
|
|
7
|
+
const number = (value, locale, digits = 0, minimum = 0) =>
|
|
8
|
+
new Intl.NumberFormat(locale, { maximumFractionDigits: digits, minimumFractionDigits: minimum }).format(value)
|
|
9
|
+
|
|
10
|
+
const FORMATS = {
|
|
11
|
+
ms: (value, locale) => `${number(Math.round(value), locale)} ms`,
|
|
12
|
+
bytes: (value, locale) =>
|
|
13
|
+
value >= MB ? `${number(value / MB, locale, 1, 1)} MB` : value >= KB ? `${number(value / KB, locale, 1, 1)} KB` : `${number(value, locale)} B`,
|
|
14
|
+
percent: (value, locale) => new Intl.NumberFormat(locale, { style: 'unit', unit: 'percent', maximumFractionDigits: 1 }).format(value),
|
|
15
|
+
count: (value, locale) => number(value, locale),
|
|
16
|
+
score: (value, locale) => number(value, locale, 3, 3),
|
|
17
|
+
bpp: (value, locale) => `${number(value, locale, 3, 3)} bpp`,
|
|
18
|
+
fps: (value, locale) => `${number(Math.round(value), locale)} fps`,
|
|
19
|
+
px: (value, locale) => `${number(Math.round(value), locale)} px`,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The suffixes that name a unit, and the format each one gives
|
|
23
|
+
export const SUFFIXES = [
|
|
24
|
+
['Ms', 'ms'],
|
|
25
|
+
['Bytes', 'bytes'],
|
|
26
|
+
['Percent', 'percent'],
|
|
27
|
+
['Count', 'count'],
|
|
28
|
+
['Px', 'px'],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export const formatOfKey = (key) => SUFFIXES.find(([suffix]) => key.endsWith(suffix) && key !== suffix)?.[1]
|
|
32
|
+
|
|
33
|
+
// Text as it is, a number in its format, and an object or a list as compact JSON
|
|
34
|
+
export function formatValue(value, format, locale = 'en') {
|
|
35
|
+
if (value === undefined || value === null) return ''
|
|
36
|
+
if (typeof value === 'number') return (FORMATS[format] ?? ((v, l) => number(v, l, 3)))(value, locale)
|
|
37
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
38
|
+
return String(value)
|
|
39
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// The view of a snippet result, shared by the panel of the extension, the reports of the CLI and the
|
|
2
|
+
// visualizer of the site: the title of its table, its details and its columns with labels, formats and
|
|
3
|
+
// order. Each one paints the view its own way, so this module writes no HTML. A field with no entry in
|
|
4
|
+
// fields.js or locales/ still reads well: its format comes from the suffix of its key, and its label
|
|
5
|
+
// from the key in words.
|
|
6
|
+
|
|
7
|
+
import FIELDS from './fields.js'
|
|
8
|
+
import en from './locales/en.js'
|
|
9
|
+
import { formatOfKey, formatValue, SUFFIXES } from './format.js'
|
|
10
|
+
|
|
11
|
+
export { formatValue }
|
|
12
|
+
|
|
13
|
+
const LOCALES = { en }
|
|
14
|
+
|
|
15
|
+
// Kept in capitals when a key is turned into words
|
|
16
|
+
const ACRONYMS = new Set(
|
|
17
|
+
'api bfcache cdn cls cors cpu css csr dns dom ect fcp fid fps html http https id inp js json lcp loaf loafs rtt ssr svg sw tbt tcp tls ttfb ui url urls'.split(' ')
|
|
18
|
+
)
|
|
19
|
+
const CAPITALS = { bfcache: 'bfcache', loaf: 'LoAF', loafs: 'LoAFs', urls: 'URLs' }
|
|
20
|
+
|
|
21
|
+
// A key in words, without the unit its suffix names, since the value shows it: totalBlockingUntilMs is
|
|
22
|
+
// "Total blocking until", isLCP "Is LCP". A count is not a unit, so imageCount is "Image count"
|
|
23
|
+
export function words(key) {
|
|
24
|
+
const suffix = SUFFIXES.find(([s, format]) => format !== 'count' && key.endsWith(s) && key !== s)?.[0]
|
|
25
|
+
const stem = suffix ? key.slice(0, -suffix.length) : key
|
|
26
|
+
const parts = stem
|
|
27
|
+
// Words in mixed case that the split by capitals would break
|
|
28
|
+
.replace(/LoAF(s?)/g, ' loaf$1 ')
|
|
29
|
+
.replace(/[_-]+/g, ' ')
|
|
30
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
31
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
32
|
+
.trim()
|
|
33
|
+
.split(/\s+/)
|
|
34
|
+
.map((w) => {
|
|
35
|
+
const lower = w.toLowerCase()
|
|
36
|
+
if (ACRONYMS.has(lower)) return CAPITALS[lower] ?? lower.toUpperCase()
|
|
37
|
+
return w.length > 1 && w === w.toUpperCase() ? w : lower
|
|
38
|
+
})
|
|
39
|
+
const text = parts.join(' ')
|
|
40
|
+
return text.charAt(0).toUpperCase() + text.slice(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A field with nothing to show: no value, an empty string, or an empty list or object
|
|
44
|
+
const isEmpty = (value) =>
|
|
45
|
+
value === undefined || value === null || value === '' || (typeof value === 'object' && Object.keys(value).length === 0)
|
|
46
|
+
|
|
47
|
+
// The locale, its language, and English, which has every word
|
|
48
|
+
const chainOf = (locale) => [...new Set([locale, locale.split('-')[0], 'en'])]
|
|
49
|
+
|
|
50
|
+
function dictionaries(script, { locale = 'en', fields = {}, locales = {} }) {
|
|
51
|
+
const allFields = { ...FIELDS, ...fields }
|
|
52
|
+
const allLocales = { ...LOCALES, ...locales }
|
|
53
|
+
const chain = chainOf(locale).map((l) => allLocales[l]).filter(Boolean)
|
|
54
|
+
// The entries of the snippet come before the general ones in any language: a key can mean something
|
|
55
|
+
// else in a snippet, and its own label in English says it better than a general one translated
|
|
56
|
+
const scopes = [...chain.map((d) => d[script]), ...chain.map((d) => d['*'])].filter(Boolean)
|
|
57
|
+
// The words of the view itself (Yes, No, none, rows), in the first language that has them
|
|
58
|
+
const ui = (name) => chain.map((l) => l['*']?.words?.[name]).find((w) => w !== undefined)
|
|
59
|
+
return {
|
|
60
|
+
title: scopes.find((s) => s.title)?.title,
|
|
61
|
+
// Label and phrase come from the same entry, so a sentence never mixes two languages
|
|
62
|
+
words: (key) => scopes.find((s) => s.fields?.[key])?.fields[key] ?? {},
|
|
63
|
+
shape: (key) => ({ ...allFields['*']?.[key], ...allFields[script]?.[key] }),
|
|
64
|
+
ui,
|
|
65
|
+
locale,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function describeField(key, d) {
|
|
70
|
+
const shape = d.shape(key)
|
|
71
|
+
const { label, phrase } = d.words(key)
|
|
72
|
+
return { key, label: label ?? words(key), format: shape.format ?? formatOfKey(key), order: shape.order, hidden: shape.hidden, phrase }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A lower order first; a field without one keeps its place after them
|
|
76
|
+
const byOrder = (a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)
|
|
77
|
+
|
|
78
|
+
const splitPhrase = (template) => {
|
|
79
|
+
const [before, after = ''] = template.split('{value}')
|
|
80
|
+
return { before, after }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
84
|
+
|
|
85
|
+
// The format a value has by its shape, when neither its entry nor the suffix of its key gives one
|
|
86
|
+
function shapeOf(value) {
|
|
87
|
+
if (typeof value === 'boolean') return 'boolean'
|
|
88
|
+
if (typeof value === 'string' && /^https?:\/\//.test(value)) return 'url'
|
|
89
|
+
if (Array.isArray(value)) return value.every(isObject) ? 'table' : 'list'
|
|
90
|
+
if (!isObject(value)) return undefined
|
|
91
|
+
const values = Object.values(value)
|
|
92
|
+
if (values.every((v) => typeof v === 'number')) return 'distribution'
|
|
93
|
+
if (values.every((v) => isObject(v) && typeof v.value === 'number')) return 'phases'
|
|
94
|
+
if (values.every(isObject)) return 'table'
|
|
95
|
+
return 'fields'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The file of a URL, or its host when the path has none
|
|
99
|
+
function shortUrl(url) {
|
|
100
|
+
try {
|
|
101
|
+
const { hostname, pathname } = new URL(url)
|
|
102
|
+
return pathname.split('/').filter(Boolean).pop() ?? hostname
|
|
103
|
+
} catch {
|
|
104
|
+
return url
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A part of a distribution reads inside a line, so in lower case, unless it starts with an acronym
|
|
109
|
+
const inLine = (label) => (/^[A-Z][a-z]/.test(label) ? label.charAt(0).toLowerCase() + label.slice(1) : label)
|
|
110
|
+
|
|
111
|
+
const plural = (forms, n, locale) => (forms?.[new Intl.PluralRules(locale).select(n)] ?? forms?.other ?? '{n}').replace('{n}', n)
|
|
112
|
+
|
|
113
|
+
// The phases of a metric: a part each, with its share of the whole and the slowest marked, and the
|
|
114
|
+
// thresholds of the metric as the scale to draw them on
|
|
115
|
+
function phases(value, d, result) {
|
|
116
|
+
const entries = Object.entries(value).map(([key, v]) => [key, typeof v === 'number' ? { value: v } : v])
|
|
117
|
+
const sum = entries.reduce((total, [, v]) => total + v.value, 0)
|
|
118
|
+
// The snippet knows which one is the slowest, also on a tie; otherwise, the longest
|
|
119
|
+
const named = result?.details?.slowestPhase
|
|
120
|
+
const slowest = entries.some(([key]) => key === named)
|
|
121
|
+
? named
|
|
122
|
+
: entries.reduce((max, [key, v]) => (v.value > (max?.value ?? 0) ? { key, value: v.value } : max), null)?.key
|
|
123
|
+
const parts = entries.map(([key, v]) => ({
|
|
124
|
+
key,
|
|
125
|
+
label: describeField(key, d).label,
|
|
126
|
+
value: v.value,
|
|
127
|
+
text: formatValue(v.value, 'ms', d.locale),
|
|
128
|
+
percent: v.percent ?? (sum > 0 ? Math.round((v.value / sum) * 100) : 0),
|
|
129
|
+
overTarget: v.overTarget === true,
|
|
130
|
+
slowest: key === slowest,
|
|
131
|
+
}))
|
|
132
|
+
const t = result?.thresholds
|
|
133
|
+
const scale = { total: typeof result?.value === 'number' ? result.value : sum, ...(t ? { good: t.good, poor: t.needsImprovement } : {}) }
|
|
134
|
+
const text = parts
|
|
135
|
+
.filter((p) => p.value > 0)
|
|
136
|
+
.map((p) => `${p.label} ${p.text} (${formatValue(p.percent, 'percent', d.locale)})`)
|
|
137
|
+
.join(' · ')
|
|
138
|
+
return { parts, scale, text }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A width and a height of the same thing, as one value of its size: naturalWidth and naturalHeight are
|
|
142
|
+
// naturalSize, 1,200×900
|
|
143
|
+
function joinSizes(rows, locale) {
|
|
144
|
+
const keys = new Set(rows.flatMap((row) => Object.keys(row)))
|
|
145
|
+
const heightOf = (width) => width.replace(/^width/, 'height').replace(/Width(Px)?$/, 'Height$1')
|
|
146
|
+
const pairs = [...keys]
|
|
147
|
+
.filter((key) => /^(width|.+Width)(Px)?$/.test(key) && keys.has(heightOf(key)))
|
|
148
|
+
.map((width) => [width, heightOf(width), width.replace(/^width/, 'size').replace(/Width(Px)?$/, 'Size$1')])
|
|
149
|
+
.filter(([, , size]) => !keys.has(size))
|
|
150
|
+
if (pairs.length === 0) return rows
|
|
151
|
+
const number = (n) => formatValue(n, 'count', locale)
|
|
152
|
+
return rows.map((row) =>
|
|
153
|
+
Object.fromEntries(
|
|
154
|
+
Object.entries(row).flatMap(([key, value]) => {
|
|
155
|
+
const pair = pairs.find(([width, height]) => key === width || key === height)
|
|
156
|
+
if (!pair) return [[key, value]]
|
|
157
|
+
if (key === pair[1]) return []
|
|
158
|
+
const [width, height, size] = pair
|
|
159
|
+
const both = typeof row[width] === 'number' && typeof row[height] === 'number'
|
|
160
|
+
const unit = size.endsWith('Px') ? ' px' : ''
|
|
161
|
+
return [[size, both ? `${number(row[width])}×${number(row[height])}${unit}` : undefined]]
|
|
162
|
+
})
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
|
168
|
+
|
|
169
|
+
// A list of objects as a table: its columns, each with its label, and a row of cells per object. A
|
|
170
|
+
// column with the same value in every row is a constant, a line above the table; a column with the
|
|
171
|
+
// file of a URL column gives that one its place and label; and two or more columns of booleans are
|
|
172
|
+
// one column of marks
|
|
173
|
+
function table(items, d) {
|
|
174
|
+
const rows = joinSizes(
|
|
175
|
+
items.map((item) => (isObject(item) ? item : { value: item })),
|
|
176
|
+
d.locale
|
|
177
|
+
)
|
|
178
|
+
// A column empty in every row says nothing
|
|
179
|
+
let columns = [...new Set(rows.flatMap((row) => Object.keys(row)))]
|
|
180
|
+
.filter((key) => rows.some((row) => !isEmpty(row[key])))
|
|
181
|
+
.map((key) => describeField(key, d))
|
|
182
|
+
.filter((c) => !c.hidden)
|
|
183
|
+
.sort(byOrder)
|
|
184
|
+
const cells = rows.map((row) => Object.fromEntries(columns.map(({ key }) => [key, describeValue(key, row[key], d, { inTable: true })])))
|
|
185
|
+
|
|
186
|
+
const same = (key) => rows.length > 1 && rows.every((row) => sameValue(row[key], rows[0][key]))
|
|
187
|
+
// Rows that are all the same keep their columns: a table without any says nothing of how many there are
|
|
188
|
+
const allSame = columns.every(({ key }) => same(key))
|
|
189
|
+
const constant = (key) => !allSame && same(key)
|
|
190
|
+
const constants = columns
|
|
191
|
+
.filter(({ key }) => constant(key))
|
|
192
|
+
.map(({ key }) => {
|
|
193
|
+
const { label, text } = describeValue(key, rows[0][key], d)
|
|
194
|
+
return { key, label, text }
|
|
195
|
+
})
|
|
196
|
+
columns = columns.filter(({ key }) => !constant(key))
|
|
197
|
+
|
|
198
|
+
for (const url of columns.filter(({ key }) => cells.every((c) => c[key].format === 'url' || c[key].text === ''))) {
|
|
199
|
+
const short = columns.find(
|
|
200
|
+
(c) => c !== url && cells.every((row) => (row[url.key].text === '' ? row[c.key].text === '' : row[c.key].value === row[url.key].text))
|
|
201
|
+
)
|
|
202
|
+
if (!short) continue
|
|
203
|
+
columns = columns.filter((c) => c !== url).map((c) => (c === short ? { ...url, label: short.label } : c))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const booleans = columns.filter(({ key }) => rows.every((row) => row[key] === undefined || typeof row[key] === 'boolean'))
|
|
207
|
+
const flagged = booleans.length > 1
|
|
208
|
+
if (flagged) columns = [...columns.filter((c) => !booleans.includes(c)), { key: 'flags', label: d.ui('flags'), format: 'flags' }]
|
|
209
|
+
const flagLabel = (c) => inLine(c.label.replace(/^(Is|Has) /, ''))
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
columns: columns.map(({ key, label, format }) => ({ key, label, format })),
|
|
213
|
+
constants,
|
|
214
|
+
rows: cells.map((row, i) => ({
|
|
215
|
+
cells: columns.map(({ key }) => {
|
|
216
|
+
if (key !== 'flags' || !flagged) return row[key]
|
|
217
|
+
const flags = booleans.filter((c) => rows[i][c.key] === true).map(flagLabel)
|
|
218
|
+
return { key, format: 'flags', value: booleans.filter((c) => rows[i][c.key] === true).map((c) => c.key), flags, text: flags.join(' · ') }
|
|
219
|
+
}),
|
|
220
|
+
})),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A value with its label and its text, and the parts, fields or rows of a value made of others
|
|
225
|
+
function describeValue(key, value, d, { inTable = false, result } = {}) {
|
|
226
|
+
const field = describeField(key, d)
|
|
227
|
+
const format = field.format ?? shapeOf(value)
|
|
228
|
+
const base = { key, label: field.label, format, value }
|
|
229
|
+
if (value === undefined || value === null) return { ...base, text: '' }
|
|
230
|
+
switch (format) {
|
|
231
|
+
case 'distribution': {
|
|
232
|
+
const parts = Object.entries(value)
|
|
233
|
+
.filter(([, n]) => n !== 0)
|
|
234
|
+
.map(([k, n]) => ({ key: k, label: inLine(describeField(k, d).label), value: n, text: formatValue(n, 'count', d.locale) }))
|
|
235
|
+
return { ...base, parts, text: parts.length ? parts.map((p) => `${p.label} ${p.text}`).join(' · ') : d.ui('none') }
|
|
236
|
+
}
|
|
237
|
+
case 'phases':
|
|
238
|
+
return { ...base, ...phases(value, d, result) }
|
|
239
|
+
case 'key':
|
|
240
|
+
return { ...base, text: describeField(String(value), d).label }
|
|
241
|
+
case 'table': {
|
|
242
|
+
// An object of objects is a table too, with the key of each one in its row
|
|
243
|
+
const items = Array.isArray(value) ? value : Object.entries(value).map(([name, v]) => ({ name, ...v }))
|
|
244
|
+
return { ...base, ...table(items, d), text: plural(d.ui('rows'), items.length, d.locale) }
|
|
245
|
+
}
|
|
246
|
+
case 'fields': {
|
|
247
|
+
const fields = Object.entries(value)
|
|
248
|
+
.filter(([k, v]) => !isEmpty(v) && !describeField(k, d).hidden)
|
|
249
|
+
.map(([k, v]) => describeValue(k, v, d))
|
|
250
|
+
return { ...base, fields, text: fields.map((f) => `${f.label} ${f.text}`).join(' · ') }
|
|
251
|
+
}
|
|
252
|
+
case 'list':
|
|
253
|
+
return { ...base, text: value.map((v) => formatValue(v, undefined, d.locale)).join(', ') }
|
|
254
|
+
case 'boolean':
|
|
255
|
+
return { ...base, text: inTable ? (value ? '✓' : '') : d.ui(value ? 'yes' : 'no') }
|
|
256
|
+
case 'url':
|
|
257
|
+
return { ...base, text: shortUrl(value), href: value }
|
|
258
|
+
default:
|
|
259
|
+
return { ...base, text: formatValue(value, format, d.locale) }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function describeResult(result, options = {}) {
|
|
264
|
+
const d = dictionaries(result?.script, options)
|
|
265
|
+
|
|
266
|
+
const details = Object.entries(isObject(result?.details) ? result.details : {})
|
|
267
|
+
.filter(([, value]) => !isEmpty(value))
|
|
268
|
+
.map(([key, value]) => ({ field: describeField(key, d), value, key }))
|
|
269
|
+
.filter(({ field }) => !field.hidden)
|
|
270
|
+
.sort((a, b) => byOrder(a.field, b.field))
|
|
271
|
+
.map(({ field, key, value }) => ({
|
|
272
|
+
...describeValue(key, value, d, { result }),
|
|
273
|
+
...(field.phrase ? { phrase: splitPhrase(field.phrase) } : {}),
|
|
274
|
+
}))
|
|
275
|
+
|
|
276
|
+
const items = Array.isArray(result?.items) ? result.items : []
|
|
277
|
+
const { columns, constants, rows } = table(items, d)
|
|
278
|
+
|
|
279
|
+
// An issue with a target belongs to the row that has that URL, selector or name in one of its fields
|
|
280
|
+
const rowOf = (target) => {
|
|
281
|
+
const index = target === undefined ? -1 : items.findIndex((item) => isObject(item) && Object.values(item).includes(target))
|
|
282
|
+
return index < 0 ? undefined : index
|
|
283
|
+
}
|
|
284
|
+
const issues = (Array.isArray(result?.issues) ? result.issues : []).map(({ severity, code, message, target }) => ({
|
|
285
|
+
severity,
|
|
286
|
+
code,
|
|
287
|
+
message,
|
|
288
|
+
target,
|
|
289
|
+
row: rowOf(target),
|
|
290
|
+
}))
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
title: d.title,
|
|
294
|
+
details,
|
|
295
|
+
columns,
|
|
296
|
+
constants,
|
|
297
|
+
rows: rows.map((row, i) => ({ ...row, issues: issues.filter((issue) => issue.row === i) })),
|
|
298
|
+
issues,
|
|
299
|
+
recommendations: Array.isArray(result?.recommendations) ? result.recommendations.filter((r) => typeof r === 'string') : [],
|
|
300
|
+
// The message of a snippet that records is written for the console (call getINP() again); the view
|
|
301
|
+
// says what to do in its own words
|
|
302
|
+
message: result?.status === 'tracking' && result.getDataFn ? d.ui('tracking') : result?.message,
|
|
303
|
+
}
|
|
304
|
+
}
|