webperf-snippets 1.3.0 → 1.5.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 (56) hide show
  1. package/README.md +67 -6
  2. package/package.json +3 -2
  3. package/snippets/CoreWebVitals/CLS.js +3 -2
  4. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
  5. package/snippets/CoreWebVitals/LCP-Subparts.js +32 -35
  6. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
  7. package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
  8. package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
  9. package/snippets/Interaction/Input-Latency-Breakdown.js +2 -0
  10. package/snippets/Interaction/Interactions.js +2 -0
  11. package/snippets/Interaction/Scroll-Performance.js +2 -0
  12. package/snippets/Loading/Back-Forward-Cache.js +7 -0
  13. package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
  14. package/snippets/Loading/Cache-Strategy-Analysis.js +12 -0
  15. package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
  16. package/snippets/Loading/Compression-Audit.js +3 -0
  17. package/snippets/Loading/Content-Visibility.js +2 -2
  18. package/snippets/Loading/Critical-CSS-Detection.js +6 -0
  19. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
  20. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  21. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
  22. package/snippets/Loading/Find-render-blocking-resources.js +1 -1
  23. package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
  24. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
  25. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
  26. package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
  27. package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
  28. package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
  29. package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
  30. package/snippets/Loading/Priority-Hints-Audit.js +10 -0
  31. package/snippets/Loading/Resource-Hints-Validation.js +3 -3
  32. package/snippets/Loading/Resource-Hints.js +7 -1
  33. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
  34. package/snippets/Loading/Script-Loading.js +6 -2
  35. package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
  36. package/snippets/Loading/Service-Worker-Analysis.js +12 -2
  37. package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
  38. package/snippets/Loading/TTFB-Resources.js +1 -1
  39. package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
  40. package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
  41. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
  42. package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
  43. package/snippets/Media/Image-Element-Audit.js +16 -13
  44. package/snippets/Media/Oversized-Images.js +4 -0
  45. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
  46. package/snippets/Media/Video-Element-Audit.js +13 -2
  47. package/snippets/SCHEMA.md +425 -3
  48. package/src/baseline.js +225 -0
  49. package/src/bin.js +68 -7
  50. package/src/blocking.js +3 -1
  51. package/src/conditions.js +7 -0
  52. package/src/mcp/documents.js +41 -21
  53. package/src/reporters/baseline-view.js +83 -0
  54. package/src/reporters/human.js +43 -5
  55. package/src/reporters/markdown.js +46 -6
  56. package/src/reporters/matrix-view.js +8 -2
@@ -22,7 +22,7 @@
22
22
  count: 0,
23
23
  details: {},
24
24
  items: [],
25
- issues: [{ severity: "error", message: "Service Workers not supported in this browser" }],
25
+ issues: [{ code: "unsupported", severity: "error", message: "Service Workers not supported in this browser" }],
26
26
  };
27
27
  }
28
28
 
@@ -116,11 +116,12 @@
116
116
  const issues = [];
117
117
 
118
118
  if (registrations.length === 0) {
119
- issues.push({ severity: "info", message: "No Service Workers registered" });
119
+ issues.push({ code: "no-service-worker", severity: "info", message: "No Service Workers registered" });
120
120
  }
121
121
 
122
122
  if (registrations.length > 0 && !controller) {
123
123
  issues.push({
124
+ code: "not-controlled",
124
125
  severity: "warning",
125
126
  message: "Page is not controlled by a Service Worker (hard reload or first visit). Do a normal reload.",
126
127
  });
@@ -129,25 +130,31 @@
129
130
  for (const item of items) {
130
131
  if (item.hasWaiting) {
131
132
  issues.push({
133
+ code: "update-pending",
132
134
  severity: "warning",
133
135
  message: `Service Worker update pending for ${item.scope}. Call skipWaiting() to activate the new version.`,
136
+ target: item.scope,
134
137
  });
135
138
  }
136
139
  if (item.navigationPreloadEnabled === false) {
137
140
  issues.push({
141
+ code: "navigation-preload-disabled",
138
142
  severity: "info",
139
143
  message: `Navigation Preload is disabled for ${item.scope}. Consider enabling it with registration.navigationPreload.enable().`,
144
+ target: item.scope,
140
145
  });
141
146
  }
142
147
  }
143
148
 
144
149
  if (swOverheadMs !== null && swOverheadMs > 100) {
145
150
  issues.push({
151
+ code: "slow-startup",
146
152
  severity: "error",
147
153
  message: `High SW startup time (${swOverheadMs}ms). Enable Navigation Preload to reduce overhead.`,
148
154
  });
149
155
  } else if (swOverheadMs !== null && swOverheadMs > 50) {
150
156
  issues.push({
157
+ code: "slow-startup",
151
158
  severity: "warning",
152
159
  message: `Moderate SW startup time (${swOverheadMs}ms)`,
153
160
  });
@@ -155,11 +162,13 @@
155
162
 
156
163
  if (cacheHitRate !== null && cacheHitRate < 50) {
157
164
  issues.push({
165
+ code: "low-cache-hit-rate",
158
166
  severity: "error",
159
167
  message: `Low SW cache hit rate (${cacheHitRate}%). Review the caching strategy.`,
160
168
  });
161
169
  } else if (cacheHitRate !== null && cacheHitRate < 80) {
162
170
  issues.push({
171
+ code: "low-cache-hit-rate",
163
172
  severity: "warning",
164
173
  message: `Moderate SW cache hit rate (${cacheHitRate}%). Review the caching strategy for more resources.`,
165
174
  });
@@ -167,6 +176,7 @@
167
176
 
168
177
  if (unknownSource.length > 0) {
169
178
  issues.push({
179
+ code: "unknown-source",
170
180
  severity: "info",
171
181
  message: `${unknownSource.length} intercepted resource(s) are cross-origin without Timing-Allow-Origin, so their source is unknown. The hit rate excludes them.`,
172
182
  });
@@ -39,6 +39,7 @@
39
39
  parsed = JSON.parse(script.textContent);
40
40
  } catch (e) {
41
41
  issues.push({
42
+ code: 'invalid-json',
42
43
  severity: 'error',
43
44
  message: `Speculation rules script #${index + 1} is not valid JSON (${e.message}). The browser ignores the whole script.`,
44
45
  });
@@ -68,12 +69,14 @@
68
69
  const isPrerender = action === 'prerender' || action === 'prerender_until_script';
69
70
  if (isPrerender && source === 'list' && (eagerness === 'immediate' || eagerness === 'eager') && urls.length > EAGER_PRERENDER_LIMIT) {
70
71
  issues.push({
72
+ code: 'eager-list-capped',
71
73
  severity: 'warning',
72
74
  message: `A ${action} rule lists ${urls.length} URLs with eagerness "${eagerness}". Chrome caps eager prerenders, so the extra URLs are not prerendered. Fix: list fewer URLs or use "moderate".`,
73
75
  });
74
76
  }
75
77
  if (isPrerender && source === 'document' && (eagerness === 'immediate' || eagerness === 'eager')) {
76
78
  issues.push({
79
+ code: 'eager-document-rule',
77
80
  severity: 'warning',
78
81
  message: `A document ${action} rule uses eagerness "${eagerness}", which prerenders matching links without a user signal and can waste bandwidth and CPU. Fix: use "moderate" or "conservative".`,
79
82
  });
@@ -83,15 +86,15 @@
83
86
  });
84
87
 
85
88
  if (scripts.length === 0) {
86
- issues.push({ severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' });
89
+ issues.push({ code: 'no-rules', severity: 'info', message: 'No speculation rules found in the DOM. Rules delivered in the Speculation-Rules HTTP header are not readable from JavaScript.' });
87
90
  } else if (items.length === 0 && !issues.some((i) => i.severity === 'error')) {
88
- issues.push({ severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' });
91
+ issues.push({ code: 'no-prefetch-or-prerender', severity: 'info', message: 'Speculation rules scripts were found but contain no prefetch or prerender rules.' });
89
92
  }
90
93
  if (!supported && scripts.length > 0) {
91
- issues.push({ severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' });
94
+ issues.push({ code: 'unsupported', severity: 'info', message: 'This browser does not support speculation rules, so the rules are ignored.' });
92
95
  }
93
96
  if (activationStart > 0) {
94
- issues.push({ severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` });
97
+ issues.push({ code: 'prerendered', severity: 'info', message: `This page was prerendered and activated at ${activationStart}ms. Navigation timings are relative to activationStart.` });
95
98
  }
96
99
 
97
100
  return {
@@ -168,7 +168,7 @@
168
168
  corsLimitedAnalysis: corsRestrictedCount > 0,
169
169
  details: { avgTtfbMs: null, maxTtfbMs: null, minTtfbMs: null, thirdPartyCount: 0, slowCount: 0, corsRestrictedCount },
170
170
  items: [],
171
- issues: [{ severity: "info", message: "No resources with TTFB data available" }],
171
+ issues: [{ code: "no-resources", severity: "info", message: "No resources with TTFB data available" }],
172
172
  };
173
173
  }
174
174
  const ttfbVals = resourcesSync.map((r) => r.ttfbMs);
@@ -129,6 +129,7 @@
129
129
  const slowestMs = Math.round(slowestSync.duration);
130
130
  const share = ttfbValueSync > 0 ? Math.round((slowestSync.duration / ttfbValueSync) * 100) : 0;
131
131
  issues.push({
132
+ code: "slow-ttfb",
132
133
  severity: rating === "poor" ? "error" : "warning",
133
134
  message: `TTFB is ${value} ms (${rating}); ${phaseLabels[slowestSync.key]} takes ${slowestMs} ms of it (${share}%). Fix: ${phaseFixes[slowestSync.key]}.`,
134
135
  });
@@ -187,30 +187,36 @@
187
187
  const issues = [];
188
188
  if (blocking.length > 0) {
189
189
  issues.push({
190
+ code: "render-blocking-domains",
190
191
  severity: "warning",
191
192
  message: `${blocking.length} third-party domain(s) serve render-blocking resources (${listDomains(blocking)}); load them async or defer, or self-host them`,
192
193
  });
193
194
  }
194
195
  heavy.slice(0, MAX_LISTED_DOMAINS).forEach((d) => {
195
196
  issues.push({
197
+ code: "heavy-domain",
196
198
  severity: "warning",
197
199
  message: `${d.domain} scripts take ${d.loafMs} ms of main-thread time in long animation frames (over ${LOAF_WARNING_MS} ms); defer, delay or remove them`,
200
+ target: d.domain,
198
201
  });
199
202
  });
200
203
  if (!renderBlockingSupported && all.length > 0) {
201
204
  issues.push({
205
+ code: "render-blocking-unsupported",
202
206
  severity: "info",
203
207
  message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which third-party domains block rendering is unknown; run the snippet in Chrome or Edge to check",
204
208
  });
205
209
  }
206
210
  if (loafUnattributed) {
207
211
  issues.push({
212
+ code: "loaf-unattributed",
208
213
  severity: "info",
209
214
  message: `${longFrames} long animation frame(s) took ${longFramesMs} ms, but no third-party script was attributed in them (Long Animation Frames only list scripts over 5 ms), so 0 ms per domain does not rule out third-party cost`,
210
215
  });
211
216
  }
212
217
  if (corsLimitedAnalysis) {
213
218
  issues.push({
219
+ code: "unknown-size",
214
220
  severity: "info",
215
221
  message: `${sizeUnknownCount} third-party request(s) without Timing-Allow-Origin report zero sizes, so the transfer size is a lower bound`,
216
222
  });
@@ -83,6 +83,7 @@
83
83
  } else {
84
84
  // Preload without matching script and not loaded - likely unused
85
85
  issues.push({
86
+ code: "orphan-preload",
86
87
  type: "orphan",
87
88
  severity: "warning",
88
89
  preload,
@@ -137,6 +138,7 @@
137
138
  });
138
139
  } else {
139
140
  issues.push({
141
+ code: "async-defer-preload",
140
142
  type: "async-defer-preload",
141
143
  severity: "error",
142
144
  preload,
@@ -156,6 +158,7 @@
156
158
  } else if (isModule) {
157
159
  // ES modules without async/defer behave like defer by default
158
160
  issues.push({
161
+ code: "module-preload",
159
162
  type: "module-preload",
160
163
  severity: "warning",
161
164
  preload,
@@ -413,8 +416,10 @@
413
416
  },
414
417
  items: Array.from(itemsByUrl.values()).slice(0, 50),
415
418
  issues: issues.map(i => ({
419
+ code: i.code,
416
420
  severity: i.severity,
417
421
  message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message,
422
+ target: i.url,
418
423
  })),
419
424
  };
420
425
  })();
@@ -269,43 +269,42 @@
269
269
  };
270
270
 
271
271
  const issues = [
272
- ...failed.map((f) => ({ severity: "error", message: `Font failed to load: ${label(f)}` })),
272
+ ...failed.map((f) => ({ code: "load-failed", severity: "error", message: `Font failed to load: ${label(f)}`, target: f.url || label(f) })),
273
273
  ...[...new Map(loadedUnused.map((f) => [`${label(f)}|${f.url}`, f])).values()].map((f) => {
274
274
  const bytes = wastedBytes(f) ? `, ${kb(wastedBytes(f))} downloaded for nothing` : "";
275
- return {
276
- severity: "warning",
277
- message: f._redeclared
278
- ? `Loaded but declared again later with the same descriptors, so the page renders with the other file: ${label(f)}${f.url ? ` from ${f.url}` : ""}${bytes}`
279
- : `Loaded but not used by any rendered element: ${label(f)}${bytes}`,
280
- };
275
+ const target = f.url || label(f);
276
+ return f._redeclared
277
+ ? { code: "redeclared", severity: "warning", message: `Loaded but declared again later with the same descriptors, so the page renders with the other file: ${label(f)}${f.url ? ` from ${f.url}` : ""}${bytes}`, target }
278
+ : { code: "loaded-unused", severity: "warning", message: `Loaded but not used by any rendered element: ${label(f)}${bytes}`, target };
281
279
  }),
282
280
  ];
283
281
  if (blockingDisplay.length > 0) {
284
282
  issues.push({
283
+ code: "blocking-font-display",
285
284
  severity: "warning",
286
285
  message: `font-display is auto or block on ${uniqueLabels(blockingDisplay).length} face(s), so text can stay invisible while they load. Use swap or optional: ${names(blockingDisplay)}`,
287
286
  });
288
287
  }
289
288
  if (totalFontBytes > BYTES_WARNING) {
290
- issues.push({ severity: "warning", message: `Web fonts weigh ${kb(totalFontBytes)} in total (over ${kb(BYTES_WARNING)}). Subset, use WOFF2 or drop unused weights` });
289
+ issues.push({ code: "heavy-fonts", severity: "warning", message: `Web fonts weigh ${kb(totalFontBytes)} in total (over ${kb(BYTES_WARNING)}). Subset, use WOFF2 or drop unused weights` });
291
290
  }
292
291
  if (neverLoadedWhole.length > 0) {
293
- issues.push({ severity: "info", message: `Declared but never loaded (${uniqueLabels(neverLoadedWhole).length}); nothing on this page needed them: ${names(neverLoadedWhole)}` });
292
+ issues.push({ code: "never-loaded", severity: "info", message: `Declared but never loaded (${uniqueLabels(neverLoadedWhole).length}); nothing on this page needed them: ${names(neverLoadedWhole)}` });
294
293
  }
295
294
  if (shadowed.length > 0) {
296
- issues.push({ severity: "info", message: `Declared more than once with the same descriptors; the page uses a later declaration, so these never loaded (${uniqueLabels(shadowed).length}): ${names(shadowed)}` });
295
+ issues.push({ code: "shadowed", severity: "info", message: `Declared more than once with the same descriptors; the page uses a later declaration, so these never loaded (${uniqueLabels(shadowed).length}): ${names(shadowed)}` });
297
296
  }
298
297
  if (neverLoadedSubsets.length > 0) {
299
- issues.push({ severity: "info", message: `unicode-range subsets this page did not need (${uniqueLabels(neverLoadedSubsets).length}), which is the intended behavior of a split font: ${names(neverLoadedSubsets)}` });
298
+ issues.push({ code: "unused-subsets", severity: "info", message: `unicode-range subsets this page did not need (${uniqueLabels(neverLoadedSubsets).length}), which is the intended behavior of a split font: ${names(neverLoadedSubsets)}` });
300
299
  }
301
300
  if (!bytesReadable) {
302
- issues.push({ severity: "info", message: "Some font files are cross-origin without Timing-Allow-Origin, so their size reads as 0 and the byte totals are a lower bound" });
301
+ issues.push({ code: "size-unknown", severity: "info", message: "Some font files are cross-origin without Timing-Allow-Origin, so their size reads as 0 and the byte totals are a lower bound" });
303
302
  }
304
303
  if (unreadableStylesheets > 0) {
305
- issues.push({ severity: "info", message: `${unreadableStylesheets} cross-origin stylesheet(s) could not be read, so their font files cannot be matched to faces` });
304
+ issues.push({ code: "unreadable-stylesheets", severity: "info", message: `${unreadableStylesheets} cross-origin stylesheet(s) could not be read, so their font files cannot be matched to faces` });
306
305
  }
307
306
  if (scanned < elements.length) {
308
- issues.push({ severity: "info", message: `Only the first ${MAX_ELEMENTS} elements were scanned for text; some faces may be reported as unused` });
307
+ issues.push({ code: "elements-capped", severity: "info", message: `Only the first ${MAX_ELEMENTS} elements were scanned for text; some faces may be reported as unused` });
309
308
  }
310
309
 
311
310
  // --- One row per face and file --------------------------------------------------------
@@ -199,34 +199,34 @@
199
199
 
200
200
  if (isLcp) {
201
201
  if (fetchpriority !== "high")
202
- issues.push({ s: "error", msg: 'Add fetchpriority="high" to the LCP image' });
202
+ issues.push({ code: "lcp-no-fetchpriority", s: "error", msg: 'Add fetchpriority="high" to the LCP image' });
203
203
  if (loading === "lazy")
204
- issues.push({ s: "error", msg: 'Remove loading="lazy" from the LCP image' });
204
+ issues.push({ code: "lcp-lazy", s: "error", msg: 'Remove loading="lazy" from the LCP image' });
205
205
  if (decoding !== "sync")
206
- issues.push({ s: "warning", msg: 'Consider decoding="sync" for the LCP image' });
206
+ issues.push({ code: "lcp-not-decoding-sync", s: "warning", msg: 'Consider decoding="sync" for the LCP image' });
207
207
  lcpPreload = findPreloadForLcp(img, imagePreloads);
208
208
  if (!lcpPreload)
209
- issues.push({ s: "warning", msg: 'LCP image has no <link rel="preload" as="image">' });
209
+ issues.push({ code: "lcp-not-preloaded", s: "warning", msg: 'LCP image has no <link rel="preload" as="image">' });
210
210
  else if (lcpPreload.getAttribute("fetchpriority") !== "high")
211
- issues.push({ s: "info", msg: 'LCP image preload is missing fetchpriority="high"' });
211
+ issues.push({ code: "lcp-preload-no-fetchpriority", s: "info", msg: 'LCP image preload is missing fetchpriority="high"' });
212
212
  } else if (inViewport) {
213
213
  if (loading === "lazy")
214
- issues.push({ s: "warning", msg: 'Remove loading="lazy" (image is above the fold)' });
214
+ issues.push({ code: "lazy-in-viewport", s: "warning", msg: 'Remove loading="lazy" (image is above the fold)' });
215
215
  } else {
216
216
  if (loading !== "lazy")
217
- issues.push({ s: "warning", msg: 'Add loading="lazy" (image is off-viewport)' });
217
+ issues.push({ code: "eager-off-viewport", s: "warning", msg: 'Add loading="lazy" (image is off-viewport)' });
218
218
  if (fetchpriority === "high")
219
- issues.push({ s: "error", msg: 'Remove fetchpriority="high" (image is off-viewport)' });
219
+ issues.push({ code: "high-priority-off-viewport", s: "error", msg: 'Remove fetchpriority="high" (image is off-viewport)' });
220
220
  }
221
221
 
222
222
  if (loading === "lazy" && fetchpriority === "high")
223
- issues.push({ s: "error", msg: 'Conflict: loading="lazy" + fetchpriority="high"' });
223
+ issues.push({ code: "lazy-and-high-priority", s: "error", msg: 'Conflict: loading="lazy" + fetchpriority="high"' });
224
224
 
225
225
  if (!hasDimensions)
226
- issues.push({ s: "warning", msg: "Missing width/height attributes (CLS risk)" });
226
+ issues.push({ code: "missing-dimensions", s: "warning", msg: "Missing width/height attributes (CLS risk)" });
227
227
 
228
228
  if (!isModernFormat(format) && !inPicture && format !== "svg")
229
- issues.push({ s: "info", msg: "No modern format detected (WebP / AVIF / JXL)" });
229
+ issues.push({ code: "no-modern-format", s: "info", msg: "No modern format detected (WebP / AVIF / JXL)" });
230
230
 
231
231
  return {
232
232
  img,
@@ -413,16 +413,19 @@
413
413
  hasSrcset: r.hasSrcset,
414
414
  hasSizes: r.hasSizes,
415
415
  inPicture: r.inPicture,
416
- issues: r.issues.map((i) => ({ severity: i.s, message: i.msg })),
416
+ issues: r.issues.map((i) => ({ code: i.code, severity: i.s, message: i.msg })),
417
417
  })),
418
418
  issues: [
419
419
  ...(nonImageTag
420
- ? [{ severity: "info", message: `The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP` }]
420
+ ? [{ code: "lcp-not-img", severity: "info", message: `The LCP element is not an <img> (${nonImageTag === "unknown" ? "not in the page's images" : nonImageTag}), so no image is audited as the LCP` }]
421
421
  : []),
422
422
  ...audited.flatMap((r) =>
423
423
  r.issues.map((i) => ({
424
+ code: i.code,
424
425
  severity: i.s,
425
426
  message: `${shortSrc(r.src) || "(no src)"}: ${i.msg}`,
427
+ // The URL names the image; a data: URL is cut, as it can weigh more than the result
428
+ ...(r.src ? { target: r.src.startsWith("data:") ? r.src.slice(0, 60) : r.src } : {}),
426
429
  }))
427
430
  ),
428
431
  ],
@@ -186,13 +186,17 @@
186
186
  if (f.verdict === "oversized") {
187
187
  const kb = f.wastedBytes === null ? "" : `, about ${Math.round(f.wastedBytes / 1024)} KB wasted`;
188
188
  return {
189
+ code: "oversized",
189
190
  severity: f.ratio >= SEVERE_OVERSIZED ? "warning" : "info",
190
191
  message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (${f.ratio}x too large${kb})`,
192
+ target: f.url,
191
193
  };
192
194
  }
193
195
  return {
196
+ code: "undersized",
194
197
  severity: f.ratio < SEVERE_UNDERSIZED ? "warning" : "info",
195
198
  message: `${fileName(f.url)}: ${size} file for a ${shown} slot at ${dpr}x (blurry, needs ${round2(1 / f.ratio)}x more pixels)`,
199
+ target: f.url,
196
200
  };
197
201
  });
198
202
 
@@ -242,7 +242,7 @@
242
242
 
243
243
  const issues = [];
244
244
  if (withBitmaps.length > 0) {
245
- issues.push({ severity: "warning", message: `${withBitmaps.length} SVG file(s) contain embedded bitmaps` });
245
+ issues.push({ code: "embedded-bitmaps", severity: "warning", message: `${withBitmaps.length} SVG file(s) contain embedded bitmaps` });
246
246
  }
247
247
  if (spriteOpportunity) {
248
248
  const withUse =
@@ -250,6 +250,7 @@
250
250
  ? ` (${svgsWithUse} inline SVG${svgsWithUse === 1 ? " already uses" : "s already use"} <use>)`
251
251
  : "";
252
252
  issues.push({
253
+ code: "sprite-opportunity",
253
254
  severity: "info",
254
255
  message: `${standaloneInlineSvgs} standalone inline SVGs could share one <symbol> sprite referenced with <use>${withUse}`,
255
256
  });
@@ -60,7 +60,7 @@
60
60
  status: "ok",
61
61
  count: 0,
62
62
  items: [],
63
- issues: [{ severity: "info", message: "No <video> elements on this page, so there was nothing to audit." }],
63
+ issues: [{ code: "no-videos", severity: "info", message: "No <video> elements on this page, so there was nothing to audit." }],
64
64
  };
65
65
  }
66
66
 
@@ -84,6 +84,7 @@
84
84
 
85
85
  if (preload === "auto" && !autoplay) {
86
86
  issues.push({
87
+ code: "preload-auto",
87
88
  s: "error",
88
89
  msg: 'preload="auto" loads the full video eagerly: use preload="none" or preload="metadata"',
89
90
  });
@@ -91,6 +92,7 @@
91
92
 
92
93
  if (!autoplay && !inViewport && preload !== "none") {
93
94
  issues.push({
95
+ code: "off-viewport-preload",
94
96
  s: "warning",
95
97
  msg: 'Off-viewport video without preload="none": set preload="none" to avoid unnecessary loading',
96
98
  });
@@ -98,6 +100,7 @@
98
100
 
99
101
  if (autoplay && !muted) {
100
102
  issues.push({
103
+ code: "autoplay-not-muted",
101
104
  s: "error",
102
105
  msg: "autoplay without muted: browsers block unmuted autoplay",
103
106
  });
@@ -105,6 +108,7 @@
105
108
 
106
109
  if (autoplay && !playsinline) {
107
110
  issues.push({
111
+ code: "autoplay-no-playsinline",
108
112
  s: "warning",
109
113
  msg: "autoplay without playsinline: iOS Safari forces fullscreen mode",
110
114
  });
@@ -112,6 +116,7 @@
112
116
 
113
117
  if (!poster) {
114
118
  issues.push({
119
+ code: "missing-poster",
115
120
  s: "warning",
116
121
  msg: "Missing poster: no preview frame shown before playback starts",
117
122
  });
@@ -119,6 +124,7 @@
119
124
 
120
125
  if (!hasDimensions) {
121
126
  issues.push({
127
+ code: "missing-dimensions",
122
128
  s: "warning",
123
129
  msg: "Missing width/height attributes (CLS risk)",
124
130
  });
@@ -126,6 +132,7 @@
126
132
 
127
133
  if (!isModernCodec(codec) && codec !== "no source") {
128
134
  issues.push({
135
+ code: "no-modern-codec",
129
136
  s: "info",
130
137
  msg: "No modern codec source detected: consider adding a WebM source (VP9 or AV1) for better compression",
131
138
  });
@@ -133,6 +140,7 @@
133
140
 
134
141
  if (!controls && !autoplay) {
135
142
  issues.push({
143
+ code: "no-controls",
136
144
  s: "info",
137
145
  msg: "No controls attribute and no autoplay, so users may not be able to play the video",
138
146
  });
@@ -276,12 +284,15 @@
276
284
  hasPoster: r.poster !== "(not set)",
277
285
  hasDimensions: r.hasDimensions,
278
286
  sourceCount: r.sourceCount,
279
- issues: r.issues.map((i) => ({ severity: i.s, message: i.msg })),
287
+ issues: r.issues.map((i) => ({ code: i.code, severity: i.s, message: i.msg })),
280
288
  })),
281
289
  issues: audited.flatMap((r) =>
282
290
  r.issues.map((i) => ({
291
+ code: i.code,
283
292
  severity: i.s,
284
293
  message: `${shortSrc(r.src) || "(no src)"}: ${i.msg}`,
294
+ // The URL names the video; a data: URL is cut, as it can weigh more than the result
295
+ ...(r.src ? { target: r.src.startsWith("data:") ? r.src.slice(0, 60) : r.src } : {}),
285
296
  }))
286
297
  ),
287
298
  };