webperf-snippets 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +14 -6
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/INP.js +6 -4
  4. package/snippets/Interaction/DOM-Size-and-Depth.js +40 -2
  5. package/snippets/Interaction/Input-Latency-Breakdown.js +3 -2
  6. package/snippets/Interaction/Interactions.js +8 -1
  7. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +37 -7
  8. package/snippets/Loading/Cache-Strategy-Analysis.js +25 -23
  9. package/snippets/Loading/Content-Visibility.js +36 -56
  10. package/snippets/Loading/Critical-CSS-Detection.js +2 -1
  11. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +33 -19
  12. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +34 -20
  13. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +36 -22
  14. package/snippets/Media/Image-Element-Audit.js +37 -1
  15. package/snippets/Media/Oversized-Images.js +33 -5
  16. package/snippets/SCHEMA.md +11 -6
  17. package/snippets/_view/fields.js +36 -0
  18. package/snippets/_view/format.js +39 -0
  19. package/snippets/_view/index.js +304 -0
  20. package/snippets/_view/locales/en.js +39 -0
  21. package/snippets/_view/package.json +1 -0
  22. package/src/bin.js +46 -39
  23. package/src/budgets.js +73 -0
  24. package/src/matrix.js +3 -10
  25. package/src/mcp/server.js +15 -7
  26. package/src/reporters/budgets-view.js +38 -0
  27. package/src/reporters/human.js +42 -20
  28. package/src/reporters/markdown.js +53 -1
  29. package/src/reporters/summary-view.js +12 -0
  30. package/src/runner.js +3 -3
  31. package/src/url.js +8 -0
  32. package/src/view.js +11 -0
  33. package/src/workflows/interaction.js +1 -0
@@ -10,26 +10,40 @@
10
10
  }
11
11
  // @end-shared isOwnRequest
12
12
 
13
- function getSelector(node) {
14
- let sel = "";
15
- try {
16
- while (node && node.nodeType !== 9) {
17
- const el = node;
18
- const name = el.nodeName.toLowerCase();
19
- const part = el.id
20
- ? "#" + el.id
21
- : name +
22
- (el.classList && el.classList.value && el.classList.value.trim()
23
- ? "." + el.classList.value.trim().split(/\s+/).slice(0, 2).join(".")
24
- : "");
25
- if (sel.length + part.length > 80) return sel || part;
26
- sel = sel ? part + ">" + sel : part;
27
- if (el.id) break;
28
- node = el.parentNode;
13
+ // @shared uniqueSelector
14
+ // A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
15
+ // otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
16
+ // unique id, or to the root, and stops as soon as it matches one element.
17
+ function uniqueSelector(el) {
18
+ const root = el.getRootNode();
19
+ const matchesOne = (selector) => {
20
+ try {
21
+ return root.querySelectorAll(selector).length === 1;
22
+ } catch {
23
+ return false;
29
24
  }
30
- } catch {}
31
- return sel;
25
+ };
26
+ const parts = [];
27
+ let node = el;
28
+ while (node && node.nodeType === 1) {
29
+ if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
30
+ parts.unshift(`#${window.CSS.escape(node.id)}`);
31
+ break;
32
+ }
33
+ const tag = node.localName;
34
+ let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
35
+ const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
36
+ if (siblings.filter((s) => s.matches(part)).length > 1) {
37
+ part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
38
+ }
39
+ parts.unshift(part);
40
+ if (matchesOne(parts.join(" > "))) break;
41
+ node = node.parentElement;
42
+ }
43
+ return parts.join(" > ");
32
44
  }
45
+ // @end-shared uniqueSelector
46
+
33
47
 
34
48
  function isInViewport(element) {
35
49
  const rect = element.getBoundingClientRect();
@@ -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: getSelector(parent) };
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: getSelector(parent) };
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 = getSelector(img);
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: getSelector(img),
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: shortSrc(lcpData.src),
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
- function selectorOf(el) {
46
- if (el.id) return `img#${el.id}`;
47
- const cls = Array.from(el.classList).slice(0, 2);
48
- return cls.length ? `img.${cls.join(".")}` : "img";
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: selectorOf(img),
185
+ selector: uniqueSelector(img),
158
186
  url: shortUrl(url),
159
187
  verdict,
160
188
  naturalWidth: file.width,
@@ -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: "error"` with an `error` message when there is nothing to report yet and the agent needs to act first (no interactions recorded).
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: "error"` with `getDataFn: "getINP"` — retry after user interaction.
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: "error"`, `count: 0` and no items.
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": "img#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 }
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
+ }