webperf-snippets 1.4.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 (48) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +8 -9
  4. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +14 -13
  5. package/snippets/Interaction/DOM-Size-and-Depth.js +4 -0
  6. package/snippets/Interaction/Forced-Synchronous-Layout.js +2 -0
  7. package/snippets/Interaction/Input-Latency-Breakdown.js +2 -0
  8. package/snippets/Interaction/Interactions.js +2 -0
  9. package/snippets/Interaction/Scroll-Performance.js +2 -0
  10. package/snippets/Loading/Back-Forward-Cache.js +7 -0
  11. package/snippets/Loading/CSS-Media-Queries-Analysis.js +4 -2
  12. package/snippets/Loading/Cache-Strategy-Analysis.js +12 -0
  13. package/snippets/Loading/Client-Side-Redirect-Detection.js +5 -3
  14. package/snippets/Loading/Compression-Audit.js +3 -0
  15. package/snippets/Loading/Content-Visibility.js +2 -2
  16. package/snippets/Loading/Critical-CSS-Detection.js +6 -0
  17. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +2 -2
  18. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +1 -1
  19. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +1 -1
  20. package/snippets/Loading/Find-render-blocking-resources.js +1 -1
  21. package/snippets/Loading/First-And-Third-Party-Script-Info.js +5 -2
  22. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +4 -4
  23. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +3 -3
  24. package/snippets/Loading/Inline-CSS-Info-and-Size.js +5 -1
  25. package/snippets/Loading/Inline-Script-Info-and-Size.js +5 -1
  26. package/snippets/Loading/JS-Execution-Time-Breakdown.js +4 -4
  27. package/snippets/Loading/Prefetch-Resource-Validation.js +12 -3
  28. package/snippets/Loading/Priority-Hints-Audit.js +10 -0
  29. package/snippets/Loading/Resource-Hints-Validation.js +3 -3
  30. package/snippets/Loading/Resource-Hints.js +7 -1
  31. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +5 -2
  32. package/snippets/Loading/Script-Loading.js +6 -2
  33. package/snippets/Loading/Server-Timing-Early-Hints.js +3 -0
  34. package/snippets/Loading/Service-Worker-Analysis.js +12 -2
  35. package/snippets/Loading/Speculation-Rules-Inspector.js +7 -4
  36. package/snippets/Loading/TTFB-Resources.js +1 -1
  37. package/snippets/Loading/TTFB-Sub-Parts.js +1 -0
  38. package/snippets/Loading/Third-Party-Impact-by-Domain.js +6 -0
  39. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +5 -0
  40. package/snippets/Loading/Webfont-Usage-Analyzer.js +13 -14
  41. package/snippets/Media/Image-Element-Audit.js +16 -13
  42. package/snippets/Media/Oversized-Images.js +4 -0
  43. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +2 -1
  44. package/snippets/Media/Video-Element-Audit.js +13 -2
  45. package/snippets/SCHEMA.md +425 -3
  46. package/src/baseline.js +30 -6
  47. package/src/blocking.js +1 -1
  48. package/src/mcp/documents.js +41 -21
@@ -146,6 +146,7 @@
146
146
 
147
147
  if (totalSize > criticalBudget) {
148
148
  issues.push({
149
+ code: "over-budget",
149
150
  type: "size",
150
151
  message: `Total inline CSS (${formatBytes(totalSize)}) exceeds 14 KB critical budget`,
151
152
  suggestion: "Move non-critical styles to external stylesheet",
@@ -154,6 +155,7 @@
154
155
 
155
156
  if (duplicates.length > 0) {
156
157
  issues.push({
158
+ code: "duplicate-blocks",
157
159
  type: "duplicate",
158
160
  message: `Found ${duplicates.length} duplicated style block(s)`,
159
161
  details: duplicates.map((d) => `Tags #${d.join(", #")} have identical content`),
@@ -164,6 +166,7 @@
164
166
  const largeBlocks = styles.filter((s) => s.size > 5 * 1024);
165
167
  if (largeBlocks.length > 0) {
166
168
  issues.push({
169
+ code: "large-blocks",
167
170
  type: "large",
168
171
  message: `${largeBlocks.length} style block(s) over 5 KB`,
169
172
  details: largeBlocks.map((s) => `Tag #${s.index}: ${formatBytes(s.size)}`),
@@ -174,6 +177,7 @@
174
177
  const bodyStyles = styles.filter((s) => !s.inHead);
175
178
  if (bodyStyles.length > 0) {
176
179
  issues.push({
180
+ code: "styles-in-body",
177
181
  type: "location",
178
182
  message: `${bodyStyles.length} style block(s) in <body>`,
179
183
  details: bodyStyles.map((s) => `Tag #${s.index}`),
@@ -231,6 +235,6 @@
231
235
  bodyStyleBlocks: styles.filter(s => !s.inHead).length,
232
236
  },
233
237
  items: styles.map(s => ({ index: s.index, sizeBytes: s.size, parent: s.parent, inHead: s.inHead, ruleCount: s.ruleCount, hasMediaQueries: s.hasMediaQueries, hasKeyframes: s.hasKeyframes })),
234
- issues: issues.map(i => ({ severity: "warning", message: i.message })),
238
+ issues: issues.map(i => ({ code: i.code, severity: "warning", message: i.message })),
235
239
  };
236
240
  })();
@@ -239,6 +239,7 @@
239
239
 
240
240
  if (parserBlockingInHead.length > 0) {
241
241
  issues.push({
242
+ code: "parser-blocking-in-head",
242
243
  type: "blocking",
243
244
  message: `${parserBlockingInHead.length} parser-blocking script(s) in <head>`,
244
245
  details: parserBlockingInHead.map(
@@ -251,6 +252,7 @@
251
252
  const largeScripts = executableScripts.filter((s) => s.size > 2 * 1024);
252
253
  if (largeScripts.length > 0) {
253
254
  issues.push({
255
+ code: "large-inline",
254
256
  type: "large",
255
257
  message: `${largeScripts.length} script(s) over 2 KB`,
256
258
  details: largeScripts.map((s) => `Script #${s.index}: ${formatBytes(s.size)}`),
@@ -260,6 +262,7 @@
260
262
 
261
263
  if (duplicates.length > 0) {
262
264
  issues.push({
265
+ code: "duplicate-scripts",
263
266
  type: "duplicate",
264
267
  message: `${duplicates.length} duplicated script(s) detected`,
265
268
  details: duplicates.map((d) => `Scripts #${d.join(", #")} have identical content`),
@@ -272,6 +275,7 @@
272
275
  );
273
276
  if (analyticsInHead.length > 0) {
274
277
  issues.push({
278
+ code: "analytics-in-head",
275
279
  type: "analytics",
276
280
  message: `${analyticsInHead.length} analytics script(s) blocking in <head>`,
277
281
  details: analyticsInHead.map((s) => `Script #${s.index}`),
@@ -345,6 +349,6 @@
345
349
  dataPayloadCount: scripts.filter((s) => s.isDataPayload).length,
346
350
  },
347
351
  items: rankedScripts.slice(0, MAX_ITEMS).map(s => ({ index: s.index, sizeBytes: s.size, category: s.category, inHead: s.inHead, isParserBlocking: s.isParserBlocking, isAnalytics: s.isAnalytics, isConfig: s.isConfig, isDataPayload: s.isDataPayload })),
348
- issues: issues.map(i => ({ severity: i.type === "blocking" ? "error" : "warning", message: i.message })),
352
+ issues: issues.map(i => ({ code: i.code, severity: i.type === "blocking" ? "error" : "warning", message: i.message })),
349
353
  };
350
354
  })();
@@ -348,10 +348,10 @@
348
348
  },
349
349
  items: scripts.slice(0, 50).map(s => ({ shortName: s.shortName, isBlocking: s.isBlocking, downloadMs: Math.round(s.downloadDuration), estimatedParseMobileMs: s.estimatedParseMobile, transferBytes: s.transferSize, decodedBytes: s.decodedSize, corsRestricted: s.corsRestricted })),
350
350
  issues: [
351
- ...(blocking.length > 0 ? [{ severity: "error", message: `${blocking.length} render-blocking script(s) delay HTML parsing` }] : []),
352
- ...(hasHighParseTime ? [{ severity: "warning", message: `High estimated parse cost (~${Math.round(totalEstParse)}ms on mobile)` }] : []),
353
- ...(splitCandidates.length > 0 ? [{ severity: "warning", message: `${splitCandidates.length} script(s) over 50KB decoded. Consider code splitting.` }] : []),
354
- ...(poorCompression ? [{ severity: "warning", message: `Low compression ratio (${compressionRatio.toFixed(1)}x). Enable Brotli or gzip.` }] : []),
351
+ ...(blocking.length > 0 ? [{ code: "render-blocking", severity: "error", message: `${blocking.length} render-blocking script(s) delay HTML parsing` }] : []),
352
+ ...(hasHighParseTime ? [{ code: "high-parse-cost", severity: "warning", message: `High estimated parse cost (~${Math.round(totalEstParse)}ms on mobile)` }] : []),
353
+ ...(splitCandidates.length > 0 ? [{ code: "split-candidates", severity: "warning", message: `${splitCandidates.length} script(s) over 50KB decoded. Consider code splitting.` }] : []),
354
+ ...(poorCompression ? [{ code: "low-compression", severity: "warning", message: `Low compression ratio (${compressionRatio.toFixed(1)}x). Enable Brotli or gzip.` }] : []),
355
355
  ],
356
356
  };
357
357
  })();
@@ -28,7 +28,7 @@
28
28
  status: "ok",
29
29
  count: 0,
30
30
  items: [],
31
- issues: [{ severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' }],
31
+ issues: [{ code: "no-prefetch", severity: "info", message: 'No prefetch hints on this page (rel="prefetch"), so there was nothing to validate.' }],
32
32
  };
33
33
  }
34
34
 
@@ -116,12 +116,14 @@
116
116
  // Check for missing or invalid 'as' attribute
117
117
  if (as === "unknown") {
118
118
  analysis.warnings.push({
119
+ code: "missing-as",
119
120
  type: "missing-as",
120
121
  severity: "warning",
121
122
  message: "Missing 'as' attribute - browser cannot apply correct MIME type matching",
122
123
  });
123
124
  } else if (!validAsValues.has(as)) {
124
125
  analysis.warnings.push({
126
+ code: "invalid-as",
125
127
  type: "invalid-as",
126
128
  severity: "warning",
127
129
  message: `Invalid 'as' value: "${as}" - should be one of: ${Array.from(validAsValues).join(", ")}`,
@@ -131,6 +133,7 @@
131
133
  // Check for duplicate prefetch
132
134
  if (seenUrls.has(normalizedUrl)) {
133
135
  analysis.warnings.push({
136
+ code: "duplicate-prefetch",
134
137
  type: "duplicate-prefetch",
135
138
  severity: "warning",
136
139
  message: "Duplicate prefetch - this URL is prefetched multiple times",
@@ -169,6 +172,7 @@
169
172
  if (isCurrentPageResource) {
170
173
  analysis.isCurrentPage = true;
171
174
  analysis.warnings.push({
175
+ code: "used-on-current-page",
172
176
  type: "wrong-hint",
173
177
  severity: "error",
174
178
  message: "Resource used on current page - should use preload instead",
@@ -179,6 +183,7 @@
179
183
  if (analysis.size > THRESHOLDS.largeFileSize) {
180
184
  const sizeMB = (analysis.size / (1024 * 1024)).toFixed(2);
181
185
  analysis.warnings.push({
186
+ code: "large-file",
182
187
  type: "large-file",
183
188
  severity: "warning",
184
189
  message: `Large file (${sizeMB}MB) - consider if prefetch is appropriate`,
@@ -203,6 +208,7 @@
203
208
 
204
209
  if (isInappropriate) {
205
210
  analysis.warnings.push({
211
+ code: "inappropriate-type",
206
212
  type: "inappropriate-type",
207
213
  severity: "warning",
208
214
  message: `Type "${as}" may not be suitable for prefetch: ${inappropriateReason}`,
@@ -229,6 +235,7 @@
229
235
  const countIssues = [];
230
236
  if (prefetchLinks.length > THRESHOLDS.maxCount) {
231
237
  countIssues.push({
238
+ code: "too-many-prefetches",
232
239
  type: "excessive-count",
233
240
  severity: "warning",
234
241
  message: `${prefetchLinks.length} prefetch hints found (recommended: <${THRESHOLDS.maxCount})`,
@@ -242,6 +249,7 @@
242
249
 
243
250
  if (totalSize > THRESHOLDS.totalSizeCritical) {
244
251
  countIssues.push({
252
+ code: "large-total-size",
245
253
  type: "excessive-size",
246
254
  severity: "error",
247
255
  message: `Total prefetch size: ${totalSizeMB}MB (critical threshold exceeded)`,
@@ -249,6 +257,7 @@
249
257
  });
250
258
  } else if (totalSize > THRESHOLDS.totalSizeWarning) {
251
259
  countIssues.push({
260
+ code: "large-total-size",
252
261
  type: "high-size",
253
262
  severity: "warning",
254
263
  message: `Total prefetch size: ${totalSizeMB}MB (warning threshold exceeded)`,
@@ -463,8 +472,8 @@
463
472
  },
464
473
  items: [...validPrefetch, ...issues].map(item => ({ href: item.shortUrl, as: item.as, sizeBytes: item.size, loaded: item.loaded, isCurrentPage: item.isCurrentPage || false })),
465
474
  issues: [
466
- ...countIssues.map(i => ({ severity: i.severity === "error" ? "error" : "warning", message: i.message })),
467
- ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ severity: w.severity, message: `${item.shortUrl}: ${w.message}` }))),
475
+ ...countIssues.map(i => ({ code: i.code, severity: i.severity === "error" ? "error" : "warning", message: i.message })),
476
+ ...issues.flatMap(item => item.warnings.filter(w => w.severity !== "info").map(w => ({ code: w.code, severity: w.severity, message: `${item.shortUrl}: ${w.message}`, target: item.href }))),
468
477
  ],
469
478
  };
470
479
  })();
@@ -77,8 +77,10 @@
77
77
  .filter((link) => link.getAttribute("fetchpriority") === "low")
78
78
  .forEach((link) => {
79
79
  issues.push({
80
+ code: "preload-low-priority",
80
81
  severity: "error",
81
82
  element: link,
83
+ url: link.href,
82
84
  resource: shortUrl(link),
83
85
  message: 'preload + fetchpriority="low" is contradictory',
84
86
  detail:
@@ -94,8 +96,10 @@
94
96
  const entry = getEntry(el);
95
97
  if (entry && entry.startTime < 500) {
96
98
  issues.push({
99
+ code: "low-priority-loaded-early",
97
100
  severity: "warning",
98
101
  element: el,
102
+ url: el.src || el.href,
99
103
  resource: shortUrl(el),
100
104
  message: `fetchpriority="low" but loaded at ${entry.startTime.toFixed(0)}ms`,
101
105
  detail:
@@ -112,8 +116,10 @@
112
116
  const entry = getEntry(el);
113
117
  if (entry && entry.startTime > 3000) {
114
118
  issues.push({
119
+ code: "high-priority-loaded-late",
115
120
  severity: "warning",
116
121
  element: el,
122
+ url: el.src || el.href,
117
123
  resource: shortUrl(el),
118
124
  message: `fetchpriority="high" but loaded at ${entry.startTime.toFixed(0)}ms`,
119
125
  detail:
@@ -127,8 +133,10 @@
127
133
  if (lcpCandidate && lcpCandidate.getAttribute("fetchpriority") !== "high") {
128
134
  const fp = lcpCandidate.getAttribute("fetchpriority");
129
135
  issues.push({
136
+ code: "lcp-no-fetchpriority",
130
137
  severity: "warning",
131
138
  element: lcpCandidate,
139
+ url: lcpCandidate.currentSrc || lcpCandidate.src,
132
140
  resource:
133
141
  (lcpCandidate.currentSrc || lcpCandidate.src).split("/").pop()?.split("?")[0] ||
134
142
  "LCP image",
@@ -340,8 +348,10 @@
340
348
  resource: (el.src || el.href || el.getAttribute("href") || "").split("/").pop()?.split("?")[0] || "",
341
349
  })),
342
350
  issues: issues.map(i => ({
351
+ code: i.code,
343
352
  severity: i.severity,
344
353
  message: i.fix ? `${i.message.replace(/\.$/, "")}. Fix: ${i.fix}` : i.message,
354
+ ...(i.url ? { target: i.url } : {}),
345
355
  })),
346
356
  };
347
357
  })();
@@ -538,9 +538,9 @@
538
538
  })),
539
539
  ].slice(0, 50),
540
540
  issues: [
541
- ...(totalIssues > 0 ? [{ severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []),
542
- ...(missingHints.length > 0 ? [{ severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []),
543
- ...(redundantDomains.length > 0 ? [{ severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` }] : []),
541
+ ...(totalIssues > 0 ? [{ code: "unused-hints", severity: "warning", message: `${totalIssues} unused hint(s) waste bandwidth` }] : []),
542
+ ...(missingHints.length > 0 ? [{ code: "missing-connection-hints", severity: "info", message: `${missingHints.length} domain(s) could benefit from connection hints` }] : []),
543
+ ...(redundantDomains.length > 0 ? [{ code: "redundant-hints", severity: "warning", message: `${redundantDomains.length} domain(s) have both preconnect and dns-prefetch (redundant)` }] : []),
544
544
  ],
545
545
  };
546
546
  })();
@@ -93,6 +93,7 @@
93
93
  if (type.requiresAs && !hint.as) {
94
94
  issues.push({
95
95
  hint,
96
+ code: "preload-missing-as",
96
97
  issue: "Missing 'as' attribute (required for preload)",
97
98
  severity: "error",
98
99
  fix: `Add as="script|style|font|image|fetch"`,
@@ -102,6 +103,7 @@
102
103
  if (hint.as === "font" && !hint.crossorigin) {
103
104
  issues.push({
104
105
  hint,
106
+ code: "font-preload-no-crossorigin",
105
107
  issue: "Font preload without crossorigin (causes double fetch)",
106
108
  severity: "error",
107
109
  fix: 'Add crossorigin="anonymous"',
@@ -122,6 +124,7 @@
122
124
  if (corsResources.length > 0) {
123
125
  issues.push({
124
126
  hint,
127
+ code: "preconnect-no-crossorigin",
125
128
  issue: "Preconnect may need crossorigin for CORS resources",
126
129
  severity: "warning",
127
130
  fix: 'Consider adding crossorigin="anonymous"',
@@ -140,6 +143,7 @@
140
143
  if (!wasUsed) {
141
144
  issues.push({
142
145
  hint: preload,
146
+ code: "unused-preload",
143
147
  issue: "Preloaded resource not used within page load",
144
148
  severity: "warning",
145
149
  fix: "Remove unused preload or verify it's needed",
@@ -162,6 +166,7 @@
162
166
  if (!hasResources && pc.hostname) {
163
167
  issues.push({
164
168
  hint: pc,
169
+ code: "unused-preconnect",
165
170
  issue: `No resources loaded from ${pc.hostname}`,
166
171
  severity: "warning",
167
172
  fix: "Remove unused preconnect or verify it's needed for future resources",
@@ -173,6 +178,7 @@
173
178
  if (preloads.length > 6) {
174
179
  issues.push({
175
180
  hint: { rel: "preload", href: `${preloads.length} preloads` },
181
+ code: "too-many-preloads",
176
182
  issue: `Too many preloads (${preloads.length}) - they compete for bandwidth`,
177
183
  severity: "warning",
178
184
  fix: "Limit to 5-6 most critical resources",
@@ -424,6 +430,6 @@
424
430
  })),
425
431
  },
426
432
  items: rankedHints.slice(0, MAX_ITEMS).map(h => ({ rel: h.rel, href: h.shortHref, as: h.as, crossorigin: h.crossorigin, fetchpriority: h.fetchpriority })),
427
- issues: issues.map(i => ({ severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}` })),
433
+ issues: issues.map(i => ({ code: i.code, severity: i.severity, message: `${i.hint.rel}: ${i.issue}. Fix: ${i.fix}`, ...(i.hint.element ? { target: i.hint.href } : {}) })),
428
434
  };
429
435
  })();
@@ -178,8 +178,10 @@
178
178
  detected.forEach((fw) => {
179
179
  if (fw.exceedsThreshold) {
180
180
  issuesData.push({
181
+ code: "large-hydration-data",
181
182
  severity: "warning",
182
183
  message: `${fw.name} hydration data (${Math.round(fw.size / 1024)} KB) exceeds ${Math.round(fw.threshold / 1024)} KB threshold`,
184
+ target: fw.name,
183
185
  });
184
186
  }
185
187
  console.log("");
@@ -296,13 +298,14 @@
296
298
  });
297
299
  });
298
300
  if (largeArrays.length > 0) {
299
- issuesData.push({ severity: "warning", message: `pageProps has large arrays (${largeArrays.join(", ")}). Consider pagination.` });
301
+ issuesData.push({ code: "large-arrays", severity: "warning", message: `pageProps has large arrays (${largeArrays.join(", ")}). Consider pagination.` });
300
302
  }
301
303
  if (checkDepth(pageProps)) {
302
- issuesData.push({ severity: "warning", message: "pageProps contains deeply nested data (more than 5 levels)." });
304
+ issuesData.push({ code: "deep-nesting", severity: "warning", message: "pageProps contains deeply nested data (more than 5 levels)." });
303
305
  }
304
306
  if (sensitiveKeys.length > 0) {
305
307
  issuesData.push({
308
+ code: "sensitive-keys",
306
309
  severity: "warning",
307
310
  message: `pageProps has keys that look sensitive (${sensitiveKeys.slice(0, 5).join(", ")}). Everything in pageProps is sent to every visitor.`,
308
311
  });
@@ -226,6 +226,7 @@
226
226
 
227
227
  if (blockingInHead.length > 0) {
228
228
  issues.push({
229
+ code: "blocking-in-head",
229
230
  severity: "error",
230
231
  message: `${blockingInHead.length} blocking script(s) in <head>`,
231
232
  scripts: blockingInHead,
@@ -235,6 +236,7 @@
235
236
 
236
237
  if (thirdPartyBlocking.length > 0) {
237
238
  issues.push({
239
+ code: "third-party-blocking",
238
240
  severity: "error",
239
241
  message: `${thirdPartyBlocking.length} third-party blocking script(s)`,
240
242
  scripts: thirdPartyBlocking,
@@ -245,6 +247,7 @@
245
247
  const largeBlocking = blocking.filter((s) => s.size > 50 * 1024);
246
248
  if (largeBlocking.length > 0) {
247
249
  issues.push({
250
+ code: "large-blocking",
248
251
  severity: "warning",
249
252
  message: `${largeBlocking.length} large blocking script(s) (> 50 KB)`,
250
253
  scripts: largeBlocking,
@@ -262,6 +265,7 @@
262
265
 
263
266
  if (couldDefer.length > 0 && blocking.length > couldDefer.length) {
264
267
  issues.push({
268
+ code: "could-defer",
265
269
  severity: "info",
266
270
  message: `${blocking.length - couldDefer.length} blocking script(s) could potentially use defer`,
267
271
  fix: "Test with defer attribute to improve parsing performance",
@@ -391,9 +395,9 @@
391
395
  durationMs: Math.round(s.duration),
392
396
  })),
393
397
  issues: [
394
- ...issues.map((i) => ({ severity: i.severity, message: i.message })),
398
+ ...issues.map((i) => ({ code: i.code, severity: i.severity, message: i.message })),
395
399
  ...(scripts.some((s) => !s.sizeKnown)
396
- ? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); total size is a lower bound` }]
400
+ ? [{ code: "unknown-size", severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); total size is a lower bound` }]
397
401
  : []),
398
402
  ],
399
403
  };
@@ -83,6 +83,7 @@
83
83
  const issues = [];
84
84
  if (nav.serverTiming.length === 0) {
85
85
  issues.push({
86
+ code: "no-server-timing",
86
87
  severity: "info",
87
88
  message:
88
89
  "The document response sends no Server-Timing header. Add one, for example Server-Timing: db;dur=53, to expose backend timings in DevTools and in the Performance API.",
@@ -90,12 +91,14 @@
90
91
  }
91
92
  if (corsRestrictedCount > 0) {
92
93
  issues.push({
94
+ code: "timing-hidden",
93
95
  severity: "info",
94
96
  message: `${corsRestrictedCount} cross-origin resource(s) hide their timing because they lack Timing-Allow-Origin, so their Server-Timing cannot be read. Add Timing-Allow-Origin to expose it.`,
95
97
  });
96
98
  }
97
99
  if (!earlyHintsReceived) {
98
100
  issues.push({
101
+ code: "no-early-hints",
99
102
  severity: "info",
100
103
  message:
101
104
  "No 103 Early Hints response was observed for the document. If the server can send Link preload or preconnect headers before the final response, Early Hints lets the browser start those fetches earlier.",
@@ -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
  })();