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
@@ -119,18 +119,21 @@
119
119
  const issues = [];
120
120
  if (uncompressed.length > 0) {
121
121
  issues.push({
122
+ code: "uncompressed",
122
123
  severity: "warning",
123
124
  message: `${uncompressed.length} text resource(s) served without compression; enabling gzip or brotli could save about ${formatBytes(estimatedSavingsBytes)}`,
124
125
  });
125
126
  }
126
127
  if (corsLimitedAnalysis) {
127
128
  issues.push({
129
+ code: "unknown-size",
128
130
  severity: "info",
129
131
  message: `${sizeUnknownCount} cross-origin text resource(s) without Timing-Allow-Origin report zero sizes, so their compression is unknown`,
130
132
  });
131
133
  }
132
134
  if (!contentEncodingSupported && candidates.length > 0) {
133
135
  issues.push({
136
+ code: "content-encoding-unavailable",
134
137
  severity: "info",
135
138
  message: "PerformanceResourceTiming.contentEncoding is not available; compression is inferred from encodedBodySize versus decodedBodySize",
136
139
  });
@@ -348,9 +348,9 @@ window.analyzeContentVisibilityOpportunities = analyzeContentVisibilityOpportuni
348
348
  issues: [
349
349
  ...cvResults.autoElements
350
350
  .filter(el => el.containIntrinsicSize === "not set" || el.containIntrinsicSize === "none")
351
- .map(el => ({ severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)` })),
351
+ .map(el => ({ code: "missing-intrinsic-size", severity: "warning", message: `${el.selector}: missing contain-intrinsic-size (CLS risk)`, target: el.selector })),
352
352
  ...(cvResults.autoElements.length + cvResults.hiddenElements.length === 0
353
- ? [{ severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
353
+ ? [{ code: "not-used", severity: "info", message: "No content-visibility usage found. Consider content-visibility: auto on below-the-fold sections to reduce the initial render cost." }]
354
354
  : []),
355
355
  ],
356
356
  };
@@ -221,6 +221,7 @@
221
221
 
222
222
  if (renderBlockingCSS.length > 0 && inlineStyles.length === 0) {
223
223
  issues.push({
224
+ code: "no-inline-critical-css",
224
225
  severity: "high",
225
226
  message: "No critical CSS inlined: all CSS loads via render-blocking requests",
226
227
  suggestion:
@@ -230,6 +231,7 @@
230
231
 
231
232
  if (totalRenderBlockingDecoded > criticalBudget) {
232
233
  issues.push({
234
+ code: "render-blocking-over-budget",
233
235
  severity: "high",
234
236
  message: `Render-blocking CSS (${formatBytes(totalRenderBlockingDecoded)}) exceeds the 14 KB critical budget`,
235
237
  suggestion:
@@ -242,6 +244,7 @@
242
244
  );
243
245
  if (largeUnpreloaded.length > 0) {
244
246
  issues.push({
247
+ code: "large-not-preloaded",
245
248
  severity: "medium",
246
249
  message: `${largeUnpreloaded.length} large render-blocking stylesheet(s) without preload`,
247
250
  details: largeUnpreloaded.map(
@@ -253,6 +256,7 @@
253
256
 
254
257
  if (totalInlineBytes > criticalBudget) {
255
258
  issues.push({
259
+ code: "inline-over-budget",
256
260
  severity: "high",
257
261
  message: `Inline CSS (${formatBytes(totalInlineBytes)}) exceeds the 14 KB critical budget`,
258
262
  suggestion:
@@ -263,6 +267,7 @@
263
267
  const bodyInlineStyles = inlineStyles.filter((s) => !s.inHead);
264
268
  if (bodyInlineStyles.length > 0) {
265
269
  issues.push({
270
+ code: "styles-in-body",
266
271
  severity: "medium",
267
272
  message: `${bodyInlineStyles.length} inline style block(s) found in <body>`,
268
273
  suggestion:
@@ -332,6 +337,7 @@
332
337
  corsBlocked: s.corsBlocked,
333
338
  })),
334
339
  issues: issues.map(i => ({
340
+ code: i.code,
335
341
  severity: i.severity === 'high' ? 'error' : 'warning',
336
342
  message: i.message,
337
343
  })),
@@ -265,8 +265,8 @@
265
265
  isLcpCandidate: image.isLcpCandidate,
266
266
  })),
267
267
  issues: [
268
- ...(results.summary.lcpAffected ? [{ severity: "error", message: 'LCP candidate image has lazy loading: remove loading="lazy" and add fetchpriority="high"' }] : []),
269
- ...(results.lazyImages.filter(i => !i.isLcpCandidate).length > 0 ? [{ severity: "warning", message: `${results.lazyImages.filter(i => !i.isLcpCandidate).length} above-fold image(s) have lazy loading: remove loading="lazy"` }] : []),
268
+ ...(results.summary.lcpAffected ? [{ code: "lcp-lazy", severity: "error", message: 'LCP candidate image has lazy loading: remove loading="lazy" and add fetchpriority="high"' }] : []),
269
+ ...(results.lazyImages.filter(i => !i.isLcpCandidate).length > 0 ? [{ code: "lazy-above-fold", severity: "warning", message: `${results.lazyImages.filter(i => !i.isLcpCandidate).length} above-fold image(s) have lazy loading: remove loading="lazy"` }] : []),
270
270
  ],
271
271
  };
272
272
  })();
@@ -149,7 +149,7 @@
149
149
  };
150
150
  }),
151
151
  issues: conflictingElements.length > 0
152
- ? [{ severity: "error", message: `${conflictingElements.length} element(s) have conflicting loading="lazy" and fetchpriority="high"` }]
152
+ ? [{ code: "lazy-and-high-priority", severity: "error", message: `${conflictingElements.length} element(s) have conflicting loading="lazy" and fetchpriority="high"` }]
153
153
  : [],
154
154
  };
155
155
  })();
@@ -304,7 +304,7 @@
304
304
  })),
305
305
  ],
306
306
  issues: totalImages > 0
307
- ? [{ severity: "warning", message: `${totalImages} image(s) outside the viewport are missing loading="lazy"` }]
307
+ ? [{ code: "eager-off-viewport", severity: "warning", message: `${totalImages} image(s) outside the viewport are missing loading="lazy"` }]
308
308
  : [],
309
309
  };
310
310
  })();
@@ -161,7 +161,7 @@
161
161
  sizeBytes: r.size,
162
162
  })),
163
163
  issues: blockingResources.length > 0
164
- ? [{ severity: "error", message: `${blockingResources.length} render-blocking resource(s) delay rendering until ${Math.round(lastBlockingEnd)}ms` }]
164
+ ? [{ code: "render-blocking", severity: "error", message: `${blockingResources.length} render-blocking resource(s) delay rendering until ${Math.round(lastBlockingEnd)}ms` }]
165
165
  : [],
166
166
  };
167
167
  })();
@@ -321,14 +321,15 @@
321
321
  })),
322
322
  issues: [
323
323
  ...(scripts.some((s) => !s.sizeKnown)
324
- ? [{ severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
324
+ ? [{ code: "unknown-size", severity: "info", message: `${scripts.filter((s) => !s.sizeKnown).length} script(s) have an unknown size (missing Timing-Allow-Origin); size totals are a lower bound` }]
325
325
  : []),
326
326
  ...(!renderBlockingSupported && totalScripts > 0
327
- ? [{ severity: "info", message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which scripts block rendering is unknown; run the snippet in Chrome or Edge to check" }]
327
+ ? [{ code: "render-blocking-unsupported", severity: "info", message: "renderBlockingStatus is not supported in this browser (Chrome 107+), so which scripts block rendering is unknown; run the snippet in Chrome or Edge to check" }]
328
328
  : []),
329
329
  ...(thirdMetrics.blocking > 0
330
330
  ? [
331
331
  {
332
+ code: "third-party-blocking",
332
333
  severity: "error",
333
334
  message: `${thirdMetrics.blocking} render-blocking third-party script(s)`,
334
335
  },
@@ -337,6 +338,7 @@
337
338
  ...(thirdMetrics.hosts.length > 3
338
339
  ? [
339
340
  {
341
+ code: "many-third-party-hosts",
340
342
  severity: "warning",
341
343
  message: `${thirdMetrics.hosts.length} different third-party hosts require separate DNS lookups`,
342
344
  },
@@ -345,6 +347,7 @@
345
347
  ...(thirdMetrics.totalSize > 100 * 1024
346
348
  ? [
347
349
  {
350
+ code: "large-third-party",
348
351
  severity: "warning",
349
352
  message: `Third-party scripts total ${Math.round(thirdMetrics.totalSize / 1024)} KB`,
350
353
  },
@@ -328,10 +328,10 @@
328
328
  },
329
329
  items: slowestScripts.slice(0, MAX_ITEMS).map(s => ({ shortName: s.shortName, host: s.host, firstParty: s.firstParty, totalMs: Math.round(s.total), dnsMs: roundOrNull(s.dns), tcpMs: roundOrNull(s.tcp), requestMs: roundOrNull(s.request), responseMs: roundOrNull(s.response), hasTiming: s.hasTiming })),
330
330
  issues: [
331
- ...(slowScripts.length > 0 ? [{ severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
332
- ...(hasSlowDns ? [{ severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
333
- ...(hasSlowTcp ? [{ severity: "warning", message: "Slow TCP connections detected (>100ms). Add preconnect for critical origins." }] : []),
334
- ...(hasSlowRequest ? [{ severity: "warning", message: "Slow server response times detected (>200ms). Consider CDN or self-hosting." }] : []),
331
+ ...(slowScripts.length > 0 ? [{ code: "slow-scripts", severity: "warning", message: `${slowScripts.length} script(s) take over ${slowThreshold}ms to load` }] : []),
332
+ ...(hasSlowDns ? [{ code: "slow-dns", severity: "warning", message: "Slow DNS lookups detected (>100ms). Add dns-prefetch or preconnect." }] : []),
333
+ ...(hasSlowTcp ? [{ code: "slow-connection", severity: "warning", message: "Slow TCP connections detected (>100ms). Add preconnect for critical origins." }] : []),
334
+ ...(hasSlowRequest ? [{ code: "slow-server", severity: "warning", message: "Slow server response times detected (>200ms). Consider CDN or self-hosting." }] : []),
335
335
  ],
336
336
  };
337
337
  })();
@@ -359,9 +359,9 @@
359
359
  items: uniqueLoadedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, display: f.display })),
360
360
  usedFonts: usedFonts.slice(0, 50).map(f => ({ family: f.family, weight: f.weight, style: f.style, elements: f.elements })),
361
361
  issues: [
362
- ...preloadedNotUsed.map(f => ({ severity: "warning", message: `Preloaded but not used above fold: ${f.name}` })),
363
- ...usedNotPreloaded.map(f => ({ severity: "warning", message: `Used above fold but not preloaded: ${f.family} (${f.weight})` })),
364
- ...preloadedFonts.filter(f => !f.crossorigin).map(f => ({ severity: "error", message: `Font preloaded without crossorigin (double fetch): ${f.name}` })),
362
+ ...preloadedNotUsed.map(f => ({ code: "preloaded-unused", severity: "warning", message: `Preloaded but not used above fold: ${f.name}`, target: f.name })),
363
+ ...usedNotPreloaded.map(f => ({ code: "used-not-preloaded", severity: "warning", message: `Used above fold but not preloaded: ${f.family} (${f.weight})`, target: `${f.family} (${f.weight})` })),
364
+ ...preloadedFonts.filter(f => !f.crossorigin).map(f => ({ code: "preload-no-crossorigin", severity: "error", message: `Font preloaded without crossorigin (double fetch): ${f.name}`, target: f.name })),
365
365
  ],
366
366
  };
367
367
  })();
@@ -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.",