webperf-snippets 0.1.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 (67) hide show
  1. package/README.md +139 -0
  2. package/package.json +54 -0
  3. package/snippets/CoreWebVitals/CLS.js +88 -0
  4. package/snippets/CoreWebVitals/INP.js +432 -0
  5. package/snippets/CoreWebVitals/LCP-Image-Entropy.js +198 -0
  6. package/snippets/CoreWebVitals/LCP-Subparts.js +286 -0
  7. package/snippets/CoreWebVitals/LCP-Trail.js +184 -0
  8. package/snippets/CoreWebVitals/LCP-Video-Candidate.js +228 -0
  9. package/snippets/CoreWebVitals/LCP.js +135 -0
  10. package/snippets/CoreWebVitals/WORKFLOWS.md +203 -0
  11. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-inject.js +73 -0
  12. package/snippets/DevTools-Overrides/Fetch-XHR-Timeline-read.js +191 -0
  13. package/snippets/Interaction/Forced-Synchronous-Layout.js +286 -0
  14. package/snippets/Interaction/Input-Latency-Breakdown.js +199 -0
  15. package/snippets/Interaction/Interactions.js +220 -0
  16. package/snippets/Interaction/Layout-Shift-Loading-and-Interaction.js +266 -0
  17. package/snippets/Interaction/Long-Animation-Frames-Helpers.js +397 -0
  18. package/snippets/Interaction/Long-Animation-Frames-Script-Attribution.js +137 -0
  19. package/snippets/Interaction/Long-Animation-Frames.js +298 -0
  20. package/snippets/Interaction/LongTask.js +203 -0
  21. package/snippets/Interaction/Scroll-Performance.js +359 -0
  22. package/snippets/Interaction/WORKFLOWS.md +205 -0
  23. package/snippets/Loading/Back-Forward-Cache.js +473 -0
  24. package/snippets/Loading/CSS-Media-Queries-Analysis.js +512 -0
  25. package/snippets/Loading/Cache-Strategy-Analysis.js +808 -0
  26. package/snippets/Loading/Client-Side-Redirect-Detection.js +337 -0
  27. package/snippets/Loading/Content-Visibility.js +352 -0
  28. package/snippets/Loading/Critical-CSS-Detection.js +329 -0
  29. package/snippets/Loading/Event-Processing-Time.js +179 -0
  30. package/snippets/Loading/FCP.js +101 -0
  31. package/snippets/Loading/Find-Above-The-Fold-Lazy-Loaded-Images.js +264 -0
  32. package/snippets/Loading/Find-Images-With-Lazy-and-Fetchpriority.js +155 -0
  33. package/snippets/Loading/Find-non-Lazy-Loaded-Images-outside-of-the-viewport.js +300 -0
  34. package/snippets/Loading/Find-render-blocking-resources.js +161 -0
  35. package/snippets/Loading/First-And-Third-Party-Script-Info.js +269 -0
  36. package/snippets/Loading/First-And-Third-Party-Script-Timings.js +295 -0
  37. package/snippets/Loading/Fonts-Preloaded-Loaded-and-used-above-the-fold.js +317 -0
  38. package/snippets/Loading/Inline-CSS-Info-and-Size.js +234 -0
  39. package/snippets/Loading/Inline-Script-Info-and-Size.js +323 -0
  40. package/snippets/Loading/JS-Execution-Time-Breakdown.js +336 -0
  41. package/snippets/Loading/Prefetch-Resource-Validation.js +456 -0
  42. package/snippets/Loading/Priority-Hints-Audit.js +336 -0
  43. package/snippets/Loading/Resource-Hints-Validation.js +503 -0
  44. package/snippets/Loading/Resource-Hints.js +400 -0
  45. package/snippets/Loading/SSR-Hydration-Data-Analysis.js +364 -0
  46. package/snippets/Loading/Script-Loading.js +332 -0
  47. package/snippets/Loading/Service-Worker-Analysis.js +192 -0
  48. package/snippets/Loading/TTFB-Resources.js +115 -0
  49. package/snippets/Loading/TTFB-Sub-Parts.js +117 -0
  50. package/snippets/Loading/TTFB.js +37 -0
  51. package/snippets/Loading/Validate-Preload-Async-Defer-Scripts.js +394 -0
  52. package/snippets/Loading/WORKFLOWS.md +163 -0
  53. package/snippets/Media/Image-Element-Audit.js +350 -0
  54. package/snippets/Media/SVG-Embedded-Bitmap-Analysis.js +225 -0
  55. package/snippets/Media/Video-Element-Audit.js +282 -0
  56. package/snippets/Media/WORKFLOWS.md +190 -0
  57. package/snippets/README.md +98 -0
  58. package/snippets/Resources/Network-Bandwidth-Connection-Quality.js +103 -0
  59. package/snippets/Resources/WORKFLOWS.md +173 -0
  60. package/src/bin.js +229 -0
  61. package/src/decision-tree.js +19 -0
  62. package/src/load-snippet.js +23 -0
  63. package/src/reporters/human.js +179 -0
  64. package/src/reporters/json.js +3 -0
  65. package/src/runner.js +61 -0
  66. package/src/workflows/audit.js +21 -0
  67. package/src/workflows/cwv.js +10 -0
@@ -0,0 +1,323 @@
1
+ // Inline Script Analysis - Find and analyze all inline scripts
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const formatBytes = (bytes) => {
6
+ if (bytes === 0) return "0 B";
7
+ const k = 1024;
8
+ const sizes = ["B", "KB", "MB"];
9
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
10
+ return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
11
+ };
12
+
13
+ const formatKB = (bytes) => (bytes / 1024).toFixed(2);
14
+
15
+ // Get all inline scripts (no src attribute)
16
+ const scriptTags = Array.from(
17
+ document.querySelectorAll("script:not([src])")
18
+ ).filter((s) => s.innerHTML.trim().length > 0);
19
+
20
+ if (scriptTags.length === 0) {
21
+ console.group("%cšŸ“œ Inline Script Analysis", "font-weight: bold; font-size: 14px;");
22
+ console.log("%cāœ… No inline scripts found.", "color: #22c55e; font-weight: bold;");
23
+ console.log("The page uses external scripts only.");
24
+ console.groupEnd();
25
+ return { script: "Inline-Script-Info-and-Size", status: "ok", count: 0, items: [], issues: [] };
26
+ }
27
+
28
+ // Analyze each script
29
+ const scripts = scriptTags.map((script, index) => {
30
+ const content = script.innerHTML;
31
+ const size = new Blob([content]).size;
32
+ const type = script.type || "text/javascript";
33
+ const inHead = script.closest("head") !== null;
34
+
35
+ // Determine script category
36
+ let category = "classic";
37
+ if (type === "module") {
38
+ category = "module";
39
+ } else if (type === "application/ld+json") {
40
+ category = "json-ld";
41
+ } else if (type.includes("template") || type === "text/html") {
42
+ category = "template";
43
+ } else if (type !== "text/javascript" && type !== "") {
44
+ category = "other";
45
+ }
46
+
47
+ // Check if parser-blocking (classic scripts without async/defer)
48
+ const isParserBlocking =
49
+ category === "classic" &&
50
+ !script.hasAttribute("async") &&
51
+ !script.hasAttribute("defer");
52
+
53
+ // Get preview (first 80 chars, cleaned up)
54
+ const preview = content
55
+ .replace(/\s+/g, " ")
56
+ .trim()
57
+ .slice(0, 80);
58
+
59
+ // Check for common patterns
60
+ const hasGlobalVars = /^(var|let|const|window\.)/.test(content.trim());
61
+ const isConfig = /config|settings|options|__/i.test(content.slice(0, 200));
62
+ const isAnalytics = /gtag|analytics|gtm|fbq|_gaq/i.test(content);
63
+
64
+ return {
65
+ index: index + 1,
66
+ element: script,
67
+ size,
68
+ sizeKB: formatKB(size),
69
+ type,
70
+ category,
71
+ inHead,
72
+ isParserBlocking,
73
+ hasGlobalVars,
74
+ isConfig,
75
+ isAnalytics,
76
+ preview: preview + (content.length > 80 ? "..." : ""),
77
+ content,
78
+ };
79
+ });
80
+
81
+ // Separate by category
82
+ const executableScripts = scripts.filter(
83
+ (s) => s.category === "classic" || s.category === "module"
84
+ );
85
+ const jsonLdScripts = scripts.filter((s) => s.category === "json-ld");
86
+ const otherScripts = scripts.filter(
87
+ (s) => s.category === "template" || s.category === "other"
88
+ );
89
+
90
+ // Calculate totals for executable scripts
91
+ const totalSize = executableScripts.reduce((sum, s) => sum + s.size, 0);
92
+ const parserBlockingScripts = executableScripts.filter((s) => s.isParserBlocking);
93
+ const parserBlockingInHead = parserBlockingScripts.filter((s) => s.inHead);
94
+
95
+ // Find duplicated content
96
+ const contentMap = new Map();
97
+ executableScripts.forEach((s) => {
98
+ const normalized = s.content.replace(/\s+/g, " ").trim();
99
+ if (normalized.length > 50) {
100
+ // Only check substantial scripts
101
+ if (!contentMap.has(normalized)) {
102
+ contentMap.set(normalized, []);
103
+ }
104
+ contentMap.get(normalized).push(s.index);
105
+ }
106
+ });
107
+ const duplicates = Array.from(contentMap.entries())
108
+ .filter(([, indices]) => indices.length > 1)
109
+ .map(([, indices]) => indices);
110
+
111
+ // Rating
112
+ let rating, ratingColor;
113
+ const inlineThreshold = 3 * 1024; // 3 KB recommended max
114
+ if (totalSize <= inlineThreshold && parserBlockingInHead.length === 0) {
115
+ rating = "Good";
116
+ ratingColor = "#22c55e";
117
+ } else if (totalSize <= inlineThreshold * 2 || parserBlockingInHead.length <= 2) {
118
+ rating = "Needs Review";
119
+ ratingColor = "#f59e0b";
120
+ } else {
121
+ rating = "Needs Optimization";
122
+ ratingColor = "#ef4444";
123
+ }
124
+
125
+ // Display results
126
+ console.group("%cšŸ“œ Inline Script Analysis", "font-weight: bold; font-size: 14px;");
127
+
128
+ // Summary
129
+ console.log("");
130
+ console.log("%cSummary:", "font-weight: bold;");
131
+ console.log(` Total inline scripts: ${scripts.length}`);
132
+ console.log(` Executable scripts: ${executableScripts.length}`);
133
+ if (jsonLdScripts.length > 0) {
134
+ console.log(` JSON-LD (structured data): ${jsonLdScripts.length}`);
135
+ }
136
+ if (otherScripts.length > 0) {
137
+ console.log(` Templates/Other: ${otherScripts.length}`);
138
+ }
139
+ console.log(` Total executable size: ${formatBytes(totalSize)} (${formatKB(totalSize)} KB)`);
140
+ console.log(` Parser-blocking scripts: ${parserBlockingScripts.length}`);
141
+ if (parserBlockingInHead.length > 0) {
142
+ console.log(
143
+ ` %cāš ļø Parser-blocking in <head>: ${parserBlockingInHead.length}`,
144
+ "color: #ef4444;"
145
+ );
146
+ }
147
+ console.log(` Rating: %c${rating}`, `color: ${ratingColor}; font-weight: bold;`);
148
+
149
+ // Executable scripts table
150
+ if (executableScripts.length > 0) {
151
+ console.log("");
152
+ console.group(
153
+ `%c⚔ Executable Scripts (${executableScripts.length})`,
154
+ "color: #f59e0b; font-weight: bold;"
155
+ );
156
+
157
+ const execTable = executableScripts
158
+ .sort((a, b) => b.size - a.size)
159
+ .map((s) => ({
160
+ "#": s.index,
161
+ Location: s.inHead ? "head" : "body",
162
+ Type: s.category,
163
+ Size: formatBytes(s.size),
164
+ "Parser-Blocking": s.isParserBlocking ? "āš ļø Yes" : "No",
165
+ Pattern: s.isAnalytics
166
+ ? "Analytics"
167
+ : s.isConfig
168
+ ? "Config"
169
+ : s.hasGlobalVars
170
+ ? "Globals"
171
+ : "-",
172
+ Preview: s.preview,
173
+ }));
174
+ console.table(execTable);
175
+
176
+ // Elements for inspection
177
+ console.log("");
178
+ console.log("%cšŸ”Ž Elements for inspection:", "font-weight: bold;");
179
+ executableScripts
180
+ .sort((a, b) => b.size - a.size)
181
+ .forEach((s) => {
182
+ const warnings = [];
183
+ if (s.isParserBlocking && s.inHead) warnings.push("blocking");
184
+ if (s.size > 2 * 1024) warnings.push("large");
185
+ const warningStr = warnings.length > 0 ? ` āš ļø ${warnings.join(", ")}` : "";
186
+ console.log(`${s.index}. (${formatBytes(s.size)})${warningStr}`, s.element);
187
+ });
188
+
189
+ console.groupEnd();
190
+ }
191
+
192
+ // JSON-LD scripts
193
+ if (jsonLdScripts.length > 0) {
194
+ console.log("");
195
+ console.group(
196
+ `%cšŸ“‹ JSON-LD Structured Data (${jsonLdScripts.length})`,
197
+ "color: #8b5cf6; font-weight: bold;"
198
+ );
199
+
200
+ const jsonTable = jsonLdScripts.map((s) => {
201
+ let schemaType = "-";
202
+ try {
203
+ const parsed = JSON.parse(s.content);
204
+ schemaType = parsed["@type"] || (Array.isArray(parsed) ? "Array" : "-");
205
+ } catch {}
206
+ return {
207
+ "#": s.index,
208
+ "Schema Type": schemaType,
209
+ Size: formatBytes(s.size),
210
+ };
211
+ });
212
+ console.table(jsonTable);
213
+ console.groupEnd();
214
+ }
215
+
216
+ // Issues
217
+ const issues = [];
218
+
219
+ if (parserBlockingInHead.length > 0) {
220
+ issues.push({
221
+ type: "blocking",
222
+ message: `${parserBlockingInHead.length} parser-blocking script(s) in <head>`,
223
+ details: parserBlockingInHead.map(
224
+ (s) => `Script #${s.index}: ${formatBytes(s.size)}`
225
+ ),
226
+ suggestion: "Move to end of <body> or add 'defer' attribute",
227
+ });
228
+ }
229
+
230
+ const largeScripts = executableScripts.filter((s) => s.size > 2 * 1024);
231
+ if (largeScripts.length > 0) {
232
+ issues.push({
233
+ type: "large",
234
+ message: `${largeScripts.length} script(s) over 2 KB`,
235
+ details: largeScripts.map((s) => `Script #${s.index}: ${formatBytes(s.size)}`),
236
+ suggestion: "Consider moving large scripts to external cacheable files",
237
+ });
238
+ }
239
+
240
+ if (duplicates.length > 0) {
241
+ issues.push({
242
+ type: "duplicate",
243
+ message: `${duplicates.length} duplicated script(s) detected`,
244
+ details: duplicates.map((d) => `Scripts #${d.join(", #")} have identical content`),
245
+ suggestion: "Remove duplicate scripts or consolidate into one",
246
+ });
247
+ }
248
+
249
+ const analyticsInHead = executableScripts.filter(
250
+ (s) => s.isAnalytics && s.inHead && s.isParserBlocking
251
+ );
252
+ if (analyticsInHead.length > 0) {
253
+ issues.push({
254
+ type: "analytics",
255
+ message: `${analyticsInHead.length} analytics script(s) blocking in <head>`,
256
+ details: analyticsInHead.map((s) => `Script #${s.index}`),
257
+ suggestion: "Load analytics scripts with 'async' or move to <body>",
258
+ });
259
+ }
260
+
261
+ if (issues.length > 0) {
262
+ console.log("");
263
+ console.group("%cāš ļø Issues Found", "color: #ef4444; font-weight: bold;");
264
+
265
+ issues.forEach((issue) => {
266
+ console.log("");
267
+ console.log(`%c${issue.message}`, "font-weight: bold;");
268
+ if (issue.details) {
269
+ issue.details.forEach((d) => console.log(` • ${d}`));
270
+ }
271
+ console.log(` → ${issue.suggestion}`);
272
+ });
273
+
274
+ console.groupEnd();
275
+ } else if (executableScripts.length > 0) {
276
+ console.log("");
277
+ console.log(
278
+ "%cāœ… No major issues found. Inline scripts look reasonable.",
279
+ "color: #22c55e; font-weight: bold;"
280
+ );
281
+ }
282
+
283
+ // Best practices
284
+ console.log("");
285
+ console.group("%cšŸ“ Best Practices", "color: #3b82f6; font-weight: bold;");
286
+ console.log("");
287
+ console.log("• Keep inline scripts small (< 1-2 KB)");
288
+ console.log("• Avoid parser-blocking scripts in <head>");
289
+ console.log("• Use 'defer' or 'async' for non-critical scripts");
290
+ console.log("• Move large scripts to external files for caching");
291
+ console.log("• Place non-critical scripts at end of <body>");
292
+ console.log("");
293
+ console.log("%cFor critical inline scripts:", "font-weight: bold;");
294
+ console.log(
295
+ '%c<script>\n // Small critical code only\n window.__CONFIG = { ... };\n</script>',
296
+ "font-family: monospace; color: #22c55e;"
297
+ );
298
+ console.log("");
299
+ console.log("%cFor larger scripts:", "font-weight: bold;");
300
+ console.log(
301
+ '%c<script src="app.js" defer></script>',
302
+ "font-family: monospace; color: #22c55e;"
303
+ );
304
+ console.groupEnd();
305
+
306
+ console.groupEnd();
307
+
308
+ return {
309
+ script: "Inline-Script-Info-and-Size",
310
+ status: "ok",
311
+ count: scripts.length,
312
+ details: {
313
+ executableCount: executableScripts.length,
314
+ jsonLdCount: jsonLdScripts.length,
315
+ otherCount: otherScripts.length,
316
+ totalSizeBytes: totalSize,
317
+ parserBlockingCount: parserBlockingScripts.length,
318
+ parserBlockingInHeadCount: parserBlockingInHead.length,
319
+ },
320
+ items: executableScripts.map(s => ({ index: s.index, sizeBytes: s.size, category: s.category, inHead: s.inHead, isParserBlocking: s.isParserBlocking, isAnalytics: s.isAnalytics, isConfig: s.isConfig })),
321
+ issues: issues.map(i => ({ severity: i.type === "blocking" ? "error" : "warning", message: i.message })),
322
+ };
323
+ })();
@@ -0,0 +1,336 @@
1
+ // JavaScript Execution Time Breakdown
2
+ // https://webperf-snippets.nucliweb.net
3
+
4
+ (() => {
5
+ const formatMs = (ms) => (ms > 0 ? ms.toFixed(0) + "ms" : "-");
6
+ const formatBytes = (bytes) => {
7
+ if (!bytes || bytes === 0) return "-";
8
+ const k = 1024;
9
+ const sizes = ["B", "KB", "MB"];
10
+ const i = Math.floor(Math.log(Math.max(bytes, 1)) / Math.log(k));
11
+ return (bytes / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
12
+ };
13
+ const formatBar = (value, max, width = 16) => {
14
+ if (value <= 0 || max <= 0) return "ā–‘".repeat(width);
15
+ const filled = Math.min(Math.round((value / max) * width), width);
16
+ return "ā–ˆ".repeat(filled) + "ā–‘".repeat(width - filled);
17
+ };
18
+
19
+ // 1. Page load timeline
20
+ const nav = performance.getEntriesByType("navigation")[0];
21
+ const domInteractive = nav?.domInteractive || 0;
22
+ const domContentLoaded = nav?.domContentLoadedEventEnd || 0;
23
+ const loadEvent = nav?.loadEventEnd || 0;
24
+ // 2. Script resource timing (download phase)
25
+ const scriptResources = performance
26
+ .getEntriesByType("resource")
27
+ .filter((r) => r.initiatorType === "script")
28
+ .map((r) => {
29
+ let shortName;
30
+ try {
31
+ const url = new URL(r.name);
32
+ shortName = url.pathname.split("/").pop() || url.hostname;
33
+ } catch {
34
+ shortName = r.name.split("/").pop() || r.name;
35
+ }
36
+
37
+ const transferSize = r.transferSize || 0;
38
+ const decodedSize = r.decodedBodySize || 0;
39
+ const downloadDuration = Math.round(r.responseEnd - r.startTime);
40
+
41
+ // CORS-restricted: third-party scripts without Timing-Allow-Origin return 0 for sizes
42
+ const corsRestricted = transferSize === 0 && decodedSize === 0 && downloadDuration > 0;
43
+
44
+ // Parse time estimates based on V8 data (v8.dev/blog/cost-of-javascript-2019)
45
+ // Mobile (~1ms/KB): Moto G4 class device — worst-case baseline
46
+ // Desktop (~0.3ms/KB): ~3x faster than mobile baseline
47
+ const estimatedParseMobile = Math.round(decodedSize / 1024);
48
+ const estimatedParseDesktop = Math.round(decodedSize / 1024 / 3);
49
+
50
+ return {
51
+ name: r.name,
52
+ shortName: shortName.split("?")[0] || shortName,
53
+ startTime: r.startTime,
54
+ responseEnd: r.responseEnd,
55
+ downloadDuration,
56
+ transferSize,
57
+ decodedSize,
58
+ corsRestricted,
59
+ estimatedParseMobile,
60
+ estimatedParseDesktop,
61
+ totalCostMs: downloadDuration + estimatedParseMobile,
62
+ };
63
+ })
64
+ .sort((a, b) => a.startTime - b.startTime);
65
+
66
+ // 3. Detect render-blocking scripts (in <head>, no async/defer/module)
67
+ const blockingSet = new Set();
68
+ Array.from(document.querySelectorAll("script[src]")).forEach((el) => {
69
+ const isModule = el.type === "module";
70
+ if (!el.async && !el.defer && !isModule && el.closest("head")) {
71
+ blockingSet.add(el.src);
72
+ }
73
+ });
74
+
75
+ const scripts = scriptResources.map((s) => ({
76
+ ...s,
77
+ isBlocking: blockingSet.has(s.name),
78
+ }));
79
+
80
+ // 4. Summary calculations
81
+ const blocking = scripts.filter((s) => s.isBlocking);
82
+ const nonBlocking = scripts.filter((s) => !s.isBlocking);
83
+ const totalDownload = scripts.reduce((sum, s) => sum + s.downloadDuration, 0);
84
+ const totalEstParse = scripts.reduce((sum, s) => sum + s.estimatedParseMobile, 0);
85
+ const totalSize = scripts.reduce((sum, s) => sum + s.transferSize, 0);
86
+ const totalDecodedSize = scripts.reduce((sum, s) => sum + s.decodedSize, 0);
87
+
88
+ // Estimate how much blocking scripts delay domInteractive
89
+ // The critical path ends when the last blocking script finishes download + parse
90
+ const blockingCriticalPathEnd = blocking.reduce((max, s) => {
91
+ const estimatedDone = s.responseEnd + s.estimatedParseMobile;
92
+ return Math.max(max, estimatedDone);
93
+ }, 0);
94
+
95
+ const jsBlockingDelay =
96
+ blocking.length > 0 && domInteractive > 0
97
+ ? Math.min(blockingCriticalPathEnd, domInteractive)
98
+ : 0;
99
+
100
+ // Large script threshold (50KB compressed = good candidate for splitting)
101
+ const SPLIT_THRESHOLD = 50 * 1024;
102
+ const splitCandidates = scripts.filter((s) => s.decodedSize > SPLIT_THRESHOLD);
103
+
104
+ // 5. Output
105
+ console.group(
106
+ "%c⚔ JavaScript Execution Time Breakdown",
107
+ "font-weight: bold; font-size: 14px;"
108
+ );
109
+
110
+ // Page load timeline
111
+ console.log("");
112
+ console.log("%cšŸ“… Page Load Timeline:", "font-weight: bold;");
113
+
114
+ if (domInteractive > 0) {
115
+ const timelineMax = loadEvent || domContentLoaded || domInteractive;
116
+ const rows = [
117
+ { label: "Navigation start", time: 0 },
118
+ { label: "DOM Interactive", time: domInteractive },
119
+ { label: "DOM Content Loaded", time: domContentLoaded },
120
+ { label: "Load event", time: loadEvent },
121
+ ].filter((r) => r.time > 0);
122
+
123
+ rows.forEach(({ label, time }) => {
124
+ const bar = formatBar(time, timelineMax, 20);
125
+ console.log(` ${label.padEnd(22)} ${formatMs(time).padStart(8)} ${bar}`);
126
+ });
127
+
128
+ if (domInteractive > 0) {
129
+ const jsPct =
130
+ domInteractive > 0 ? ((jsBlockingDelay / domInteractive) * 100).toFixed(0) : 0;
131
+ console.log("");
132
+ console.log(
133
+ ` JS blocking scripts contribute ~${jsPct}% of domInteractive (${formatMs(jsBlockingDelay)} est.)`
134
+ );
135
+ }
136
+ } else {
137
+ console.log(" %cāš ļø Navigation Timing unavailable", "color: #f59e0b;");
138
+ }
139
+
140
+ // Script overview
141
+ console.log("");
142
+ console.log("%cšŸ“Š Script Overview:", "font-weight: bold;");
143
+ console.log(` Total scripts: ${scripts.length}`);
144
+ console.log(` Render-blocking: ${blocking.length}`);
145
+ console.log(` Non-blocking: ${nonBlocking.length}`);
146
+ console.log(` Total transfer size: ${formatBytes(totalSize)}`);
147
+ console.log(` Total decoded size: ${formatBytes(totalDecodedSize)}`);
148
+ console.log(` Compression ratio: ${totalDecodedSize > 0 && totalSize > 0 ? (totalDecodedSize / totalSize).toFixed(1) + "x" : "-"}`);
149
+ console.log("");
150
+ console.log("%c Cost Estimates (download + parse):", "color: #6b7280;");
151
+ console.log(` Total download time: ${formatMs(totalDownload)}`);
152
+ console.log(` Est. total parse time: ${formatMs(totalEstParse)} (~1ms/KB decoded)`);
153
+ console.log(` Est. total JS cost: ${formatMs(totalDownload + totalEstParse)}`);
154
+
155
+ // Per-script breakdown table
156
+ if (scripts.length > 0) {
157
+ console.log("");
158
+ console.group("%cšŸ“‹ Per-Script Breakdown", "color: #3b82f6; font-weight: bold;");
159
+
160
+ console.log("%cšŸ“± Parse estimates: mobile ~1ms/KB Ā· desktop ~0.3ms/KB (source: v8.dev/blog/cost-of-javascript-2019)", "color: #6b7280;");
161
+ console.log("");
162
+
163
+ const corsCount = scripts.filter((s) => s.corsRestricted).length;
164
+ if (corsCount > 0) {
165
+ console.log(
166
+ `%cāš ļø ${corsCount} script(s) show "CORS" — third-party size data blocked by missing Timing-Allow-Origin header`,
167
+ "color: #f59e0b;"
168
+ );
169
+ console.log(" Parse columns unavailable for these scripts.");
170
+ console.log("");
171
+ }
172
+
173
+ const maxCost = Math.max(...scripts.map((s) => s.totalCostMs));
174
+ const tableData = scripts
175
+ .sort((a, b) => b.totalCostMs - a.totalCostMs)
176
+ .slice(0, 20)
177
+ .map((s) => {
178
+ const name = s.shortName.length > 35 ? s.shortName.slice(0, 32) + "..." : s.shortName;
179
+ return {
180
+ "": s.isBlocking ? "šŸ”“" : "āœ…",
181
+ Script: name,
182
+ Download: formatMs(s.downloadDuration),
183
+ "Parse(mob)": s.corsRestricted ? "CORS" : formatMs(s.estimatedParseMobile),
184
+ "Parse(dsk)": s.corsRestricted ? "CORS" : formatMs(s.estimatedParseDesktop),
185
+ "Transfer": s.corsRestricted ? "CORS" : formatBytes(s.transferSize),
186
+ "Decoded": s.corsRestricted ? "CORS" : formatBytes(s.decodedSize),
187
+ "": formatBar(s.totalCostMs, maxCost),
188
+ };
189
+ });
190
+
191
+ console.table(tableData);
192
+
193
+ if (scripts.length > 20) {
194
+ console.log(` ... and ${scripts.length - 20} more scripts`);
195
+ }
196
+
197
+ // Critical bundle warning (> 1 MB decoded)
198
+ const criticalBundles = scripts.filter((s) => !s.corsRestricted && s.decodedSize > 1024 * 1024);
199
+ if (criticalBundles.length > 0) {
200
+ console.log("");
201
+ console.log("%c🚨 Critically large bundles (> 1 MB decoded):", "color: #ef4444; font-weight: bold;");
202
+ criticalBundles
203
+ .sort((a, b) => b.decodedSize - a.decodedSize)
204
+ .forEach((s) => {
205
+ console.log(` ${s.shortName}`);
206
+ console.log(` Decoded: ${formatBytes(s.decodedSize)} | Mobile parse: ~${formatMs(s.estimatedParseMobile)} | Desktop parse: ~${formatMs(s.estimatedParseDesktop)}`);
207
+ console.log(" → Split with dynamic import() — only load what the current route needs");
208
+ });
209
+ }
210
+
211
+ console.groupEnd();
212
+ }
213
+
214
+ // Blocking scripts TTI impact
215
+ if (blocking.length > 0) {
216
+ console.log("");
217
+ console.group(
218
+ `%c🚦 Render-Blocking Scripts (${blocking.length})`,
219
+ "color: #ef4444; font-weight: bold;"
220
+ );
221
+
222
+ blocking
223
+ .sort((a, b) => b.totalCostMs - a.totalCostMs)
224
+ .forEach((s) => {
225
+ const name = s.shortName.length > 40 ? s.shortName.slice(0, 37) + "..." : s.shortName;
226
+ console.log(
227
+ ` šŸ”“ ${name}`
228
+ );
229
+ console.log(
230
+ ` Download: ${formatMs(s.downloadDuration)} | Parse mob: ~${formatMs(s.estimatedParseMobile)} | Parse dsk: ~${formatMs(s.estimatedParseDesktop)} | Size: ${formatBytes(s.transferSize)}`
231
+ );
232
+ });
233
+
234
+ console.log("");
235
+ console.log(" These scripts block HTML parsing until fully downloaded and executed.");
236
+ console.log(" → Add defer to execute after parsing without blocking.");
237
+ console.log(" → Add async for independent scripts (analytics, ads).");
238
+ console.groupEnd();
239
+ }
240
+
241
+ // Code splitting opportunities
242
+ if (splitCandidates.length > 0) {
243
+ console.log("");
244
+ console.group(
245
+ `%cāœ‚ļø Code Splitting Candidates (${splitCandidates.length} scripts > 50KB decoded)`,
246
+ "color: #8b5cf6; font-weight: bold;"
247
+ );
248
+
249
+ splitCandidates
250
+ .sort((a, b) => b.decodedSize - a.decodedSize)
251
+ .forEach((s) => {
252
+ const name = s.shortName.length > 40 ? s.shortName.slice(0, 37) + "..." : s.shortName;
253
+ console.log(
254
+ ` šŸ“¦ ${name}`
255
+ );
256
+ console.log(
257
+ ` Decoded: ${formatBytes(s.decodedSize)} | Parse mob: ~${formatMs(s.estimatedParseMobile)} | Parse dsk: ~${formatMs(s.estimatedParseDesktop)} | Transfer: ${formatBytes(s.transferSize)}`
258
+ );
259
+ });
260
+
261
+ console.log("");
262
+ console.log(" Large scripts increase parse time on every page load, even if cached.");
263
+ console.log(" → Use dynamic import() to split at route or feature boundaries.");
264
+ console.log(" → Enable tree-shaking to eliminate unused exports.");
265
+ console.log(" → Consider module/nomodule pattern for modern browsers.");
266
+ console.groupEnd();
267
+ }
268
+
269
+ // Recommendations
270
+ const hasHighParseTime = totalEstParse > 500;
271
+ const hasBlockingScripts = blocking.length > 0;
272
+ const compressionRatio = totalDecodedSize / Math.max(totalSize, 1);
273
+ const poorCompression = compressionRatio < 2 && totalSize > 10 * 1024;
274
+
275
+ console.log("");
276
+ console.group("%cšŸ“ Recommendations", "color: #3b82f6; font-weight: bold;");
277
+
278
+ if (!hasBlockingScripts && !hasHighParseTime && splitCandidates.length === 0) {
279
+ console.log(" %cāœ… No critical JS issues found.", "color: #22c55e; font-weight: bold;");
280
+ }
281
+
282
+ if (hasBlockingScripts) {
283
+ console.log(` šŸ”“ ${blocking.length} render-blocking script(s) delay the page:`);
284
+ console.log(" → defer: safe for most scripts (runs after parsing, in order)");
285
+ console.log(" → async: for independent scripts with no DOM dependency");
286
+ }
287
+
288
+ if (hasHighParseTime) {
289
+ console.log(` āš ļø High estimated parse cost (~${formatMs(totalEstParse)}):`);
290
+ console.log(" → Split bundles with dynamic import() at route boundaries");
291
+ console.log(" → Lazy-load non-critical features on demand");
292
+ console.log(" → Use module preload for critical chunks: <link rel='modulepreload'>");
293
+ }
294
+
295
+ if (poorCompression) {
296
+ console.log(` šŸ“¦ Low compression ratio (${compressionRatio.toFixed(1)}x) for ${formatBytes(totalSize)} transferred:`);
297
+ console.log(" → Enable Brotli compression on your server/CDN");
298
+ console.log(" → Ensure minification is enabled in your build");
299
+ }
300
+
301
+ console.log("");
302
+ console.log("%c šŸ’” For measured execution time (not estimates):", "color: #6b7280;");
303
+ console.log(
304
+ " Run the Long Animation Frames Script Attribution snippet to measure"
305
+ );
306
+ console.log(" actual execution time during user interactions.");
307
+
308
+ console.groupEnd();
309
+
310
+ console.groupEnd();
311
+
312
+ return {
313
+ script: "JS-Execution-Time-Breakdown",
314
+ status: "ok",
315
+ count: scripts.length,
316
+ details: {
317
+ blockingCount: blocking.length,
318
+ nonBlockingCount: nonBlocking.length,
319
+ totalTransferBytes: totalSize,
320
+ totalDecodedBytes: totalDecodedSize,
321
+ totalDownloadMs: Math.round(totalDownload),
322
+ totalEstParseMobileMs: Math.round(totalEstParse),
323
+ splitCandidatesCount: splitCandidates.length,
324
+ domInteractiveMs: Math.round(domInteractive),
325
+ domContentLoadedMs: Math.round(domContentLoaded),
326
+ loadEventMs: Math.round(loadEvent),
327
+ },
328
+ 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 })),
329
+ issues: [
330
+ ...(blocking.length > 0 ? [{ severity: "error", message: `${blocking.length} render-blocking script(s) delay HTML parsing` }] : []),
331
+ ...(hasHighParseTime ? [{ severity: "warning", message: `High estimated parse cost (~${Math.round(totalEstParse)}ms on mobile)` }] : []),
332
+ ...(splitCandidates.length > 0 ? [{ severity: "warning", message: `${splitCandidates.length} script(s) over 50KB decoded — consider code splitting` }] : []),
333
+ ...(poorCompression ? [{ severity: "warning", message: `Low compression ratio (${compressionRatio.toFixed(1)}x) — enable Brotli/gzip` }] : []),
334
+ ],
335
+ };
336
+ })();