@stati/core 1.21.0 → 1.22.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 (65) hide show
  1. package/dist/config/loader.d.ts +4 -0
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/loader.js +49 -4
  4. package/dist/core/build.d.ts +6 -0
  5. package/dist/core/build.d.ts.map +1 -1
  6. package/dist/core/build.js +134 -24
  7. package/dist/core/dev.d.ts.map +1 -1
  8. package/dist/core/dev.js +86 -19
  9. package/dist/core/isg/builder.d.ts +4 -1
  10. package/dist/core/isg/builder.d.ts.map +1 -1
  11. package/dist/core/isg/builder.js +89 -2
  12. package/dist/core/isg/deps.d.ts +5 -0
  13. package/dist/core/isg/deps.d.ts.map +1 -1
  14. package/dist/core/isg/deps.js +38 -3
  15. package/dist/core/isg/dev-server-lock.d.ts +85 -0
  16. package/dist/core/isg/dev-server-lock.d.ts.map +1 -0
  17. package/dist/core/isg/dev-server-lock.js +248 -0
  18. package/dist/core/isg/hash.d.ts +4 -0
  19. package/dist/core/isg/hash.d.ts.map +1 -1
  20. package/dist/core/isg/hash.js +24 -1
  21. package/dist/core/isg/index.d.ts +3 -2
  22. package/dist/core/isg/index.d.ts.map +1 -1
  23. package/dist/core/isg/index.js +3 -2
  24. package/dist/core/markdown.d.ts +6 -0
  25. package/dist/core/markdown.d.ts.map +1 -1
  26. package/dist/core/markdown.js +23 -0
  27. package/dist/core/templates.js +5 -5
  28. package/dist/core/utils/index.d.ts +1 -1
  29. package/dist/core/utils/index.d.ts.map +1 -1
  30. package/dist/core/utils/index.js +1 -1
  31. package/dist/core/utils/partial-validation.utils.js +2 -2
  32. package/dist/core/utils/paths.utils.d.ts +18 -0
  33. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  34. package/dist/core/utils/paths.utils.js +23 -0
  35. package/dist/core/utils/tailwind-inventory.utils.d.ts +1 -16
  36. package/dist/core/utils/tailwind-inventory.utils.d.ts.map +1 -1
  37. package/dist/core/utils/tailwind-inventory.utils.js +35 -3
  38. package/dist/core/utils/typescript.utils.d.ts +9 -0
  39. package/dist/core/utils/typescript.utils.d.ts.map +1 -1
  40. package/dist/core/utils/typescript.utils.js +41 -0
  41. package/dist/env.d.ts +45 -0
  42. package/dist/env.d.ts.map +1 -1
  43. package/dist/env.js +51 -0
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/metrics/index.d.ts +1 -1
  48. package/dist/metrics/index.d.ts.map +1 -1
  49. package/dist/metrics/index.js +2 -0
  50. package/dist/metrics/recorder.d.ts.map +1 -1
  51. package/dist/metrics/types.d.ts +31 -0
  52. package/dist/metrics/types.d.ts.map +1 -1
  53. package/dist/metrics/utils/html-report.utils.d.ts +24 -0
  54. package/dist/metrics/utils/html-report.utils.d.ts.map +1 -0
  55. package/dist/metrics/utils/html-report.utils.js +1547 -0
  56. package/dist/metrics/utils/index.d.ts +1 -0
  57. package/dist/metrics/utils/index.d.ts.map +1 -1
  58. package/dist/metrics/utils/index.js +2 -0
  59. package/dist/metrics/utils/writer.utils.d.ts +6 -2
  60. package/dist/metrics/utils/writer.utils.d.ts.map +1 -1
  61. package/dist/metrics/utils/writer.utils.js +20 -4
  62. package/dist/search/generator.d.ts +1 -9
  63. package/dist/search/generator.d.ts.map +1 -1
  64. package/dist/search/generator.js +26 -2
  65. package/package.json +1 -1
@@ -0,0 +1,1547 @@
1
+ /**
2
+ * HTML Metrics Report Generator
3
+ *
4
+ * Generates a self-contained HTML file for visualizing build performance metrics.
5
+ * Handles both standard and detailed (with pageTimings) metrics files.
6
+ */
7
+ import { writeFile } from 'node:fs/promises';
8
+ /**
9
+ * Format bytes to human-readable string
10
+ */
11
+ function formatBytes(bytes) {
12
+ if (bytes === 0)
13
+ return '0 B';
14
+ const k = 1024;
15
+ const sizes = ['B', 'KB', 'MB', 'GB'];
16
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
17
+ return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
18
+ }
19
+ /**
20
+ * Format milliseconds to human-readable duration
21
+ */
22
+ function formatDuration(ms) {
23
+ if (ms < 1)
24
+ return `${(ms * 1000).toFixed(0)}μs`;
25
+ if (ms < 1000)
26
+ return `${ms.toFixed(2)}ms`;
27
+ return `${(ms / 1000).toFixed(2)}s`;
28
+ }
29
+ /**
30
+ * Format percentage
31
+ */
32
+ function formatPercent(value) {
33
+ return `${(value * 100).toFixed(1)}%`;
34
+ }
35
+ /**
36
+ * Get human-readable phase name
37
+ */
38
+ function getPhaseLabel(key) {
39
+ const labels = {
40
+ configLoadMs: 'Config Loading',
41
+ contentDiscoveryMs: 'Content Discovery',
42
+ navigationBuildMs: 'Navigation Build',
43
+ cacheManifestLoadMs: 'Cache Manifest Load',
44
+ typescriptCompileMs: 'TypeScript Compile',
45
+ pageRenderingMs: 'Page Rendering',
46
+ shouldRebuildTotalMs: 'Rebuild Checks',
47
+ renderPageTotalMs: 'Render Pages',
48
+ fileWriteTotalMs: 'File Writing',
49
+ cacheEntryTotalMs: 'Cache Entry Updates',
50
+ searchIndexGenerationMs: 'Search Index Generation',
51
+ searchIndexWriteMs: 'Search Index Write',
52
+ assetCopyMs: 'Asset Copying',
53
+ cacheManifestSaveMs: 'Cache Manifest Save',
54
+ sitemapGenerationMs: 'Sitemap Generation',
55
+ rssGenerationMs: 'RSS Generation',
56
+ tailwindInitMs: 'Tailwind Init',
57
+ tailwindInventoryMs: 'Tailwind Inventory',
58
+ getDirectorySizeMs: 'Directory Size Calc',
59
+ lockAcquireMs: 'Lock Acquire',
60
+ lockReleaseMs: 'Lock Release',
61
+ hookBeforeAllMs: 'Hook: beforeAll',
62
+ hookAfterAllMs: 'Hook: afterAll',
63
+ hookBeforeRenderTotalMs: 'Hook: beforeRender (total)',
64
+ hookAfterRenderTotalMs: 'Hook: afterRender (total)',
65
+ };
66
+ return (labels[key] ||
67
+ key
68
+ .replace(/Ms$/, '')
69
+ .replace(/([A-Z])/g, ' $1')
70
+ .trim());
71
+ }
72
+ /**
73
+ * Get detailed description for each phase
74
+ */
75
+ function getPhaseDescription(key) {
76
+ const descriptions = {
77
+ configLoadMs: 'Time spent loading and validating the stati.config.ts configuration file. This includes parsing the config, resolving paths, and setting up the build context. A slow config load may indicate complex config logic or slow dynamic imports.',
78
+ contentDiscoveryMs: 'Time spent scanning the site directory for markdown files and extracting frontmatter metadata. This includes parsing YAML frontmatter, building the page list, and collecting tags/categories. Large sites with many files will have longer discovery times.',
79
+ navigationBuildMs: 'Time spent constructing the navigation tree from discovered pages. This includes sorting pages, building parent-child relationships, and generating breadcrumbs. Complex nested structures may increase this time.',
80
+ cacheManifestLoadMs: 'Time spent reading the ISG cache manifest from disk. The manifest tracks which pages are cached and their dependencies. A large manifest or slow disk I/O can increase this time.',
81
+ typescriptCompileMs: 'Time spent compiling TypeScript bundles using esbuild. This includes bundling client-side scripts defined in the config. More bundles or larger codebases will increase compile time.',
82
+ pageRenderingMs: 'Total time spent in the page rendering pipeline, including rebuild checks, template rendering, file writing, and cache updates. This is typically the largest phase in a build.',
83
+ shouldRebuildTotalMs: 'Aggregate time spent checking if each page needs to be rebuilt. This includes computing content hashes and comparing dependencies. Fast hash comparisons keep this time low.',
84
+ renderPageTotalMs: 'Aggregate time spent actually rendering pages through the Eta template engine. Complex templates with many partials or heavy computations will increase this time.',
85
+ fileWriteTotalMs: 'Aggregate time spent writing rendered HTML files to the dist directory. Slow disk I/O or antivirus scanning can increase this time significantly.',
86
+ cacheEntryTotalMs: 'Aggregate time spent updating the ISG cache manifest with new page entries. This includes serializing page metadata and dependency information.',
87
+ searchIndexGenerationMs: 'Time spent generating the search index from rendered page content. This includes extracting text, building the index structure, and computing relevance scores.',
88
+ searchIndexWriteMs: 'Time spent writing the search index JSON file to disk. Large indices with many documents will take longer to serialize and write.',
89
+ assetCopyMs: 'Time spent copying static assets from the public directory to dist. Many files or large files will increase copy time. File watching may add overhead in dev mode.',
90
+ cacheManifestSaveMs: 'Time spent persisting the updated cache manifest to disk. This happens at the end of every build to preserve ISG state for subsequent builds.',
91
+ sitemapGenerationMs: 'Time spent generating the sitemap.xml file. This includes formatting URLs, setting priorities, and writing the XML structure.',
92
+ rssGenerationMs: 'Time spent generating RSS/Atom feed files. This includes sorting posts by date, formatting content, and writing XML.',
93
+ tailwindInitMs: 'Time spent initializing Tailwind CSS detection and setting up the class inventory system. This is a one-time cost at the start of each build.',
94
+ tailwindInventoryMs: 'Time spent writing the Tailwind class inventory file at the end of the build. This file tracks which classes were used for purging unused CSS.',
95
+ getDirectorySizeMs: 'Time spent calculating the total size of the dist directory after the build completes. This is used for the build summary statistics.',
96
+ lockAcquireMs: 'Time spent acquiring the build lock to prevent concurrent builds. Lock contention can increase this time if another build is in progress.',
97
+ lockReleaseMs: 'Time spent releasing the build lock after the build completes. This should be nearly instantaneous.',
98
+ hookBeforeAllMs: 'Time spent executing the beforeAll hook defined in your config. Long-running setup code will directly impact this time.',
99
+ hookAfterAllMs: 'Time spent executing the afterAll hook defined in your config. Post-build processing like compression or uploads will show here.',
100
+ hookBeforeRenderTotalMs: 'Aggregate time spent in beforeRender hooks across all pages. Per-page setup logic accumulates here.',
101
+ hookAfterRenderTotalMs: 'Aggregate time spent in afterRender hooks across all pages. Per-page post-processing accumulates here.',
102
+ };
103
+ return descriptions[key] || 'No description available for this phase.';
104
+ }
105
+ /**
106
+ * Define the canonical execution order of phases
107
+ */
108
+ const PHASE_EXECUTION_ORDER = [
109
+ 'lockAcquireMs',
110
+ 'configLoadMs',
111
+ 'tailwindInitMs',
112
+ 'cacheManifestLoadMs',
113
+ 'contentDiscoveryMs',
114
+ 'navigationBuildMs',
115
+ 'hookBeforeAllMs',
116
+ 'typescriptCompileMs',
117
+ 'shouldRebuildTotalMs',
118
+ 'hookBeforeRenderTotalMs',
119
+ 'renderPageTotalMs',
120
+ 'hookAfterRenderTotalMs',
121
+ 'fileWriteTotalMs',
122
+ 'cacheEntryTotalMs',
123
+ 'pageRenderingMs',
124
+ 'searchIndexGenerationMs',
125
+ 'searchIndexWriteMs',
126
+ 'tailwindInventoryMs',
127
+ 'cacheManifestSaveMs',
128
+ 'assetCopyMs',
129
+ 'sitemapGenerationMs',
130
+ 'rssGenerationMs',
131
+ 'hookAfterAllMs',
132
+ 'getDirectorySizeMs',
133
+ 'lockReleaseMs',
134
+ ];
135
+ /**
136
+ * Generate phase breakdown data sorted by duration
137
+ */
138
+ function generatePhaseData(phases) {
139
+ return Object.entries(phases)
140
+ .filter(([, value]) => value !== undefined && value > 0)
141
+ .map(([key, value]) => ({
142
+ name: key,
143
+ value: value,
144
+ label: getPhaseLabel(key),
145
+ description: getPhaseDescription(key),
146
+ }))
147
+ .sort((a, b) => b.value - a.value);
148
+ }
149
+ /**
150
+ * Generate phase data in execution order for timeline view
151
+ */
152
+ function generateTimelineData(phases) {
153
+ const activePhases = Object.entries(phases)
154
+ .filter(([, value]) => value !== undefined && value > 0)
155
+ .map(([key, value]) => ({
156
+ name: key,
157
+ value: value,
158
+ label: getPhaseLabel(key),
159
+ description: getPhaseDescription(key),
160
+ order: PHASE_EXECUTION_ORDER.indexOf(key),
161
+ }));
162
+ // Sort by execution order, unknown phases go to the end
163
+ return activePhases.sort((a, b) => {
164
+ const orderA = a.order === -1 ? 999 : a.order;
165
+ const orderB = b.order === -1 ? 999 : b.order;
166
+ return orderA - orderB;
167
+ });
168
+ }
169
+ /**
170
+ * Generate the HTML report content
171
+ */
172
+ export function generateMetricsHtml(metrics) {
173
+ const { meta, totals, phases, counts, isg, pageTimings, incremental } = metrics;
174
+ const phaseData = generatePhaseData(phases);
175
+ const timelineData = generateTimelineData(phases);
176
+ const totalPhaseTime = phaseData.reduce((sum, p) => sum + p.value, 0);
177
+ const hasDetailedTimings = pageTimings && pageTimings.length > 0;
178
+ const hasIncremental = !!incremental;
179
+ // Calculate phase percentages for the chart
180
+ const phaseChartData = phaseData.map((p) => ({
181
+ ...p,
182
+ percent: (p.value / totals.durationMs) * 100,
183
+ }));
184
+ // Calculate timeline data with cumulative offsets for visualization
185
+ let cumulativeTime = 0;
186
+ const timelineChartData = timelineData.map((p) => {
187
+ const item = {
188
+ ...p,
189
+ percent: (p.value / totals.durationMs) * 100,
190
+ startOffset: (cumulativeTime / totals.durationMs) * 100,
191
+ };
192
+ cumulativeTime += p.value;
193
+ return item;
194
+ });
195
+ // Sort page timings by duration for detailed view
196
+ const sortedPageTimings = hasDetailedTimings
197
+ ? [...pageTimings].sort((a, b) => b.durationMs - a.durationMs)
198
+ : [];
199
+ // Generate JSON for embedding
200
+ const jsonData = JSON.stringify(metrics, null, 2);
201
+ return `<!DOCTYPE html>
202
+ <html lang="en">
203
+ <head>
204
+ <meta charset="UTF-8">
205
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
206
+ <title>Stati Build Metrics Report - ${meta.timestamp}</title>
207
+ <style>
208
+ :root {
209
+ --bg-primary: #0d1117;
210
+ --bg-secondary: #161b22;
211
+ --bg-tertiary: #21262d;
212
+ --border-color: #30363d;
213
+ --text-primary: #e6edf3;
214
+ --text-secondary: #8b949e;
215
+ --text-muted: #6e7681;
216
+ --accent-blue: #58a6ff;
217
+ --accent-green: #3fb950;
218
+ --accent-yellow: #d29922;
219
+ --accent-red: #f85149;
220
+ --accent-purple: #a371f7;
221
+ --accent-cyan: #39c5cf;
222
+ --accent-orange: #db6d28;
223
+ --shadow: 0 3px 6px rgba(0,0,0,0.4);
224
+ --radius: 8px;
225
+ }
226
+
227
+ * {
228
+ box-sizing: border-box;
229
+ margin: 0;
230
+ padding: 0;
231
+ }
232
+
233
+ body {
234
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
235
+ background: var(--bg-primary);
236
+ color: var(--text-primary);
237
+ line-height: 1.5;
238
+ min-height: 100vh;
239
+ }
240
+
241
+ .container {
242
+ max-width: 1400px;
243
+ margin: 0 auto;
244
+ padding: 24px;
245
+ }
246
+
247
+ /* Header */
248
+ header {
249
+ background: var(--bg-secondary);
250
+ border: 1px solid var(--border-color);
251
+ border-radius: var(--radius);
252
+ padding: 24px;
253
+ margin-bottom: 24px;
254
+ box-shadow: var(--shadow);
255
+ }
256
+
257
+ header h1 {
258
+ font-size: 24px;
259
+ font-weight: 600;
260
+ margin-bottom: 8px;
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 12px;
264
+ }
265
+
266
+ header h1 svg {
267
+ width: 32px;
268
+ height: 32px;
269
+ color: var(--accent-blue);
270
+ }
271
+
272
+ .meta-info {
273
+ display: flex;
274
+ flex-wrap: wrap;
275
+ gap: 16px 32px;
276
+ margin-top: 16px;
277
+ font-size: 13px;
278
+ color: var(--text-secondary);
279
+ }
280
+
281
+ .meta-info span {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 6px;
285
+ }
286
+
287
+ .meta-info svg {
288
+ width: 14px;
289
+ height: 14px;
290
+ opacity: 0.7;
291
+ }
292
+
293
+ /* Summary Cards */
294
+ .summary-grid {
295
+ display: grid;
296
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
297
+ gap: 16px;
298
+ margin-bottom: 24px;
299
+ }
300
+
301
+ .summary-card {
302
+ background: var(--bg-secondary);
303
+ border: 1px solid var(--border-color);
304
+ border-radius: var(--radius);
305
+ padding: 20px;
306
+ box-shadow: var(--shadow);
307
+ }
308
+
309
+ .summary-card .label {
310
+ font-size: 12px;
311
+ font-weight: 500;
312
+ color: var(--text-secondary);
313
+ text-transform: uppercase;
314
+ letter-spacing: 0.5px;
315
+ margin-bottom: 8px;
316
+ }
317
+
318
+ .summary-card .value {
319
+ font-size: 28px;
320
+ font-weight: 600;
321
+ color: var(--text-primary);
322
+ }
323
+
324
+ .summary-card .subtext {
325
+ font-size: 12px;
326
+ color: var(--text-muted);
327
+ margin-top: 4px;
328
+ }
329
+
330
+ .summary-card.highlight {
331
+ border-color: var(--accent-blue);
332
+ background: linear-gradient(135deg, var(--bg-secondary) 0%, rgba(88, 166, 255, 0.1) 100%);
333
+ }
334
+
335
+ .summary-card.success .value {
336
+ color: var(--accent-green);
337
+ }
338
+
339
+ .summary-card.warning .value {
340
+ color: var(--accent-yellow);
341
+ }
342
+
343
+ /* Section */
344
+ .section {
345
+ background: var(--bg-secondary);
346
+ border: 1px solid var(--border-color);
347
+ border-radius: var(--radius);
348
+ margin-bottom: 24px;
349
+ box-shadow: var(--shadow);
350
+ overflow: hidden;
351
+ }
352
+
353
+ .section-header {
354
+ display: flex;
355
+ align-items: center;
356
+ justify-content: space-between;
357
+ padding: 16px 20px;
358
+ border-bottom: 1px solid var(--border-color);
359
+ background: var(--bg-tertiary);
360
+ }
361
+
362
+ .section-header h2 {
363
+ font-size: 16px;
364
+ font-weight: 600;
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 10px;
368
+ }
369
+
370
+ .section-header svg {
371
+ width: 18px;
372
+ height: 18px;
373
+ color: var(--accent-blue);
374
+ }
375
+
376
+ .section-content {
377
+ padding: 20px;
378
+ }
379
+
380
+ /* Phase Breakdown */
381
+ .phase-list {
382
+ display: flex;
383
+ flex-direction: column;
384
+ gap: 8px;
385
+ }
386
+
387
+ .phase-item {
388
+ display: grid;
389
+ grid-template-columns: 200px 1fr 100px 80px 32px;
390
+ align-items: center;
391
+ gap: 16px;
392
+ padding: 12px 16px;
393
+ border-radius: 8px;
394
+ background: var(--bg-tertiary);
395
+ cursor: pointer;
396
+ transition: all 0.2s ease;
397
+ }
398
+
399
+ .phase-item:hover {
400
+ background: var(--bg-secondary);
401
+ box-shadow: 0 0 0 1px var(--border-color);
402
+ }
403
+
404
+ .phase-item.expanded {
405
+ background: var(--bg-secondary);
406
+ box-shadow: 0 0 0 1px var(--accent-blue);
407
+ }
408
+
409
+ .phase-name {
410
+ font-size: 14px;
411
+ font-weight: 500;
412
+ color: var(--text-primary);
413
+ }
414
+
415
+ .phase-bar-container {
416
+ height: 24px;
417
+ background: rgba(0,0,0,0.3);
418
+ border-radius: 4px;
419
+ overflow: hidden;
420
+ position: relative;
421
+ }
422
+
423
+ .phase-bar {
424
+ height: 100%;
425
+ background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
426
+ border-radius: 4px;
427
+ transition: width 0.3s ease;
428
+ min-width: 2px;
429
+ }
430
+
431
+ .phase-duration {
432
+ font-size: 14px;
433
+ font-weight: 600;
434
+ color: var(--text-primary);
435
+ text-align: right;
436
+ font-variant-numeric: tabular-nums;
437
+ }
438
+
439
+ .phase-percent {
440
+ font-size: 13px;
441
+ color: var(--text-secondary);
442
+ text-align: right;
443
+ font-variant-numeric: tabular-nums;
444
+ }
445
+
446
+ .phase-expand-icon {
447
+ width: 20px;
448
+ height: 20px;
449
+ color: var(--text-muted);
450
+ transition: transform 0.2s ease;
451
+ }
452
+
453
+ .phase-item.expanded .phase-expand-icon {
454
+ transform: rotate(180deg);
455
+ color: var(--accent-blue);
456
+ }
457
+
458
+ .phase-description {
459
+ grid-column: 1 / -1;
460
+ padding: 16px;
461
+ margin-top: 8px;
462
+ background: var(--bg-primary);
463
+ border-radius: 6px;
464
+ font-size: 13px;
465
+ line-height: 1.6;
466
+ color: var(--text-secondary);
467
+ display: none;
468
+ }
469
+
470
+ .phase-item.expanded .phase-description {
471
+ display: block;
472
+ }
473
+
474
+ .phase-description code {
475
+ background: var(--bg-tertiary);
476
+ padding: 2px 6px;
477
+ border-radius: 4px;
478
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
479
+ font-size: 12px;
480
+ color: var(--accent-cyan);
481
+ }
482
+
483
+ /* Timeline View */
484
+ .timeline-container {
485
+ position: relative;
486
+ padding: 20px 0;
487
+ }
488
+
489
+ .timeline-track {
490
+ height: 40px;
491
+ background: var(--bg-tertiary);
492
+ border-radius: 8px;
493
+ position: relative;
494
+ overflow: hidden;
495
+ margin-bottom: 16px;
496
+ }
497
+
498
+ .timeline-segment {
499
+ position: absolute;
500
+ height: 100%;
501
+ display: flex;
502
+ align-items: center;
503
+ justify-content: center;
504
+ font-size: 10px;
505
+ font-weight: 500;
506
+ color: white;
507
+ text-shadow: 0 1px 2px rgba(0,0,0,0.5);
508
+ overflow: hidden;
509
+ cursor: pointer;
510
+ transition: filter 0.2s ease;
511
+ min-width: 2px;
512
+ }
513
+
514
+ .timeline-segment:hover {
515
+ filter: brightness(1.2);
516
+ z-index: 10;
517
+ }
518
+
519
+ .timeline-segment span {
520
+ white-space: nowrap;
521
+ overflow: hidden;
522
+ text-overflow: ellipsis;
523
+ padding: 0 4px;
524
+ }
525
+
526
+ .timeline-list {
527
+ display: flex;
528
+ flex-direction: column;
529
+ gap: 6px;
530
+ }
531
+
532
+ .timeline-item {
533
+ display: grid;
534
+ grid-template-columns: 32px 180px 1fr 100px;
535
+ align-items: center;
536
+ gap: 12px;
537
+ padding: 10px 12px;
538
+ border-radius: 6px;
539
+ background: var(--bg-tertiary);
540
+ cursor: pointer;
541
+ transition: all 0.2s ease;
542
+ }
543
+
544
+ .timeline-item:hover {
545
+ background: var(--bg-secondary);
546
+ }
547
+
548
+ .timeline-item.expanded {
549
+ background: var(--bg-secondary);
550
+ box-shadow: 0 0 0 1px var(--accent-blue);
551
+ }
552
+
553
+ .timeline-order {
554
+ width: 24px;
555
+ height: 24px;
556
+ border-radius: 50%;
557
+ background: var(--bg-primary);
558
+ display: flex;
559
+ align-items: center;
560
+ justify-content: center;
561
+ font-size: 11px;
562
+ font-weight: 600;
563
+ color: var(--text-secondary);
564
+ }
565
+
566
+ .timeline-item:nth-child(1) .timeline-order { background: var(--accent-blue); color: white; }
567
+ .timeline-item:nth-child(2) .timeline-order { background: var(--accent-cyan); color: white; }
568
+ .timeline-item:nth-child(3) .timeline-order { background: var(--accent-green); color: white; }
569
+
570
+ .timeline-name {
571
+ font-size: 13px;
572
+ font-weight: 500;
573
+ color: var(--text-primary);
574
+ }
575
+
576
+ .timeline-bar-container {
577
+ height: 20px;
578
+ background: rgba(0,0,0,0.3);
579
+ border-radius: 4px;
580
+ overflow: hidden;
581
+ position: relative;
582
+ }
583
+
584
+ .timeline-bar {
585
+ height: 100%;
586
+ border-radius: 4px;
587
+ min-width: 2px;
588
+ }
589
+
590
+ .timeline-time {
591
+ font-size: 13px;
592
+ font-weight: 600;
593
+ color: var(--text-primary);
594
+ text-align: right;
595
+ font-variant-numeric: tabular-nums;
596
+ }
597
+
598
+ .timeline-description {
599
+ grid-column: 1 / -1;
600
+ padding: 12px;
601
+ margin-top: 8px;
602
+ background: var(--bg-primary);
603
+ border-radius: 6px;
604
+ font-size: 12px;
605
+ line-height: 1.6;
606
+ color: var(--text-secondary);
607
+ display: none;
608
+ }
609
+
610
+ .timeline-item.expanded .timeline-description {
611
+ display: block;
612
+ }
613
+
614
+ /* Phase colors for timeline */
615
+ .phase-color-0 { background: linear-gradient(90deg, #58a6ff, #388bfd); }
616
+ .phase-color-1 { background: linear-gradient(90deg, #39c5cf, #2ea9b3); }
617
+ .phase-color-2 { background: linear-gradient(90deg, #3fb950, #2ea043); }
618
+ .phase-color-3 { background: linear-gradient(90deg, #a371f7, #8957e5); }
619
+ .phase-color-4 { background: linear-gradient(90deg, #d29922, #bb8009); }
620
+ .phase-color-5 { background: linear-gradient(90deg, #f85149, #da3633); }
621
+ .phase-color-6 { background: linear-gradient(90deg, #db6d28, #bd561d); }
622
+ .phase-color-7 { background: linear-gradient(90deg, #8b949e, #6e7681); }
623
+
624
+ /* Waterfall Chart */
625
+ .waterfall-container {
626
+ overflow-x: auto;
627
+ margin-top: 16px;
628
+ }
629
+
630
+ .waterfall {
631
+ min-width: 600px;
632
+ position: relative;
633
+ }
634
+
635
+ .waterfall-row {
636
+ display: flex;
637
+ align-items: center;
638
+ height: 32px;
639
+ gap: 12px;
640
+ border-bottom: 1px solid var(--border-color);
641
+ }
642
+
643
+ .waterfall-row:last-child {
644
+ border-bottom: none;
645
+ }
646
+
647
+ .waterfall-label {
648
+ width: 160px;
649
+ flex-shrink: 0;
650
+ font-size: 12px;
651
+ color: var(--text-secondary);
652
+ overflow: hidden;
653
+ text-overflow: ellipsis;
654
+ white-space: nowrap;
655
+ }
656
+
657
+ .waterfall-track {
658
+ flex: 1;
659
+ height: 16px;
660
+ background: var(--bg-tertiary);
661
+ border-radius: 2px;
662
+ position: relative;
663
+ }
664
+
665
+ .waterfall-bar {
666
+ position: absolute;
667
+ height: 100%;
668
+ border-radius: 2px;
669
+ min-width: 3px;
670
+ }
671
+
672
+ .waterfall-bar.cached {
673
+ background: var(--accent-green);
674
+ opacity: 0.6;
675
+ }
676
+
677
+ .waterfall-bar.rendered {
678
+ background: var(--accent-blue);
679
+ }
680
+
681
+ .waterfall-time {
682
+ width: 80px;
683
+ flex-shrink: 0;
684
+ font-size: 12px;
685
+ color: var(--text-secondary);
686
+ text-align: right;
687
+ font-variant-numeric: tabular-nums;
688
+ }
689
+
690
+ /* Stats Grid */
691
+ .stats-grid {
692
+ display: grid;
693
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
694
+ gap: 16px;
695
+ }
696
+
697
+ .stat-item {
698
+ display: flex;
699
+ flex-direction: column;
700
+ gap: 4px;
701
+ }
702
+
703
+ .stat-label {
704
+ font-size: 12px;
705
+ color: var(--text-secondary);
706
+ }
707
+
708
+ .stat-value {
709
+ font-size: 18px;
710
+ font-weight: 600;
711
+ color: var(--text-primary);
712
+ font-variant-numeric: tabular-nums;
713
+ }
714
+
715
+ /* ISG Section */
716
+ .isg-grid {
717
+ display: grid;
718
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
719
+ gap: 20px;
720
+ }
721
+
722
+ .isg-stat {
723
+ text-align: center;
724
+ padding: 20px;
725
+ background: var(--bg-tertiary);
726
+ border-radius: var(--radius);
727
+ }
728
+
729
+ .isg-stat .value {
730
+ font-size: 32px;
731
+ font-weight: 700;
732
+ }
733
+
734
+ .isg-stat .label {
735
+ font-size: 13px;
736
+ color: var(--text-secondary);
737
+ margin-top: 4px;
738
+ }
739
+
740
+ .isg-stat.hit .value {
741
+ color: var(--accent-green);
742
+ }
743
+
744
+ .isg-stat.miss .value {
745
+ color: var(--accent-yellow);
746
+ }
747
+
748
+ /* Cache Hit Rate Ring */
749
+ .cache-ring-container {
750
+ display: flex;
751
+ justify-content: center;
752
+ align-items: center;
753
+ padding: 20px;
754
+ }
755
+
756
+ .cache-ring {
757
+ position: relative;
758
+ width: 150px;
759
+ height: 150px;
760
+ }
761
+
762
+ .cache-ring svg {
763
+ transform: rotate(-90deg);
764
+ }
765
+
766
+ .cache-ring-bg {
767
+ fill: none;
768
+ stroke: var(--bg-tertiary);
769
+ stroke-width: 12;
770
+ }
771
+
772
+ .cache-ring-progress {
773
+ fill: none;
774
+ stroke: var(--accent-green);
775
+ stroke-width: 12;
776
+ stroke-linecap: round;
777
+ transition: stroke-dashoffset 0.5s ease;
778
+ }
779
+
780
+ .cache-ring-text {
781
+ position: absolute;
782
+ top: 50%;
783
+ left: 50%;
784
+ transform: translate(-50%, -50%);
785
+ text-align: center;
786
+ }
787
+
788
+ .cache-ring-value {
789
+ font-size: 28px;
790
+ font-weight: 700;
791
+ color: var(--accent-green);
792
+ }
793
+
794
+ .cache-ring-label {
795
+ font-size: 11px;
796
+ color: var(--text-secondary);
797
+ }
798
+
799
+ /* Page Timings Table */
800
+ .page-table {
801
+ width: 100%;
802
+ border-collapse: collapse;
803
+ font-size: 13px;
804
+ }
805
+
806
+ .page-table th {
807
+ text-align: left;
808
+ padding: 12px 16px;
809
+ background: var(--bg-tertiary);
810
+ color: var(--text-secondary);
811
+ font-weight: 500;
812
+ font-size: 12px;
813
+ text-transform: uppercase;
814
+ letter-spacing: 0.5px;
815
+ border-bottom: 1px solid var(--border-color);
816
+ }
817
+
818
+ .page-table td {
819
+ padding: 12px 16px;
820
+ border-bottom: 1px solid var(--border-color);
821
+ vertical-align: middle;
822
+ }
823
+
824
+ .page-table tr:hover {
825
+ background: var(--bg-tertiary);
826
+ }
827
+
828
+ .page-table .url {
829
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
830
+ font-size: 12px;
831
+ color: var(--accent-blue);
832
+ max-width: 400px;
833
+ overflow: hidden;
834
+ text-overflow: ellipsis;
835
+ white-space: nowrap;
836
+ }
837
+
838
+ .page-table .duration {
839
+ font-variant-numeric: tabular-nums;
840
+ font-weight: 500;
841
+ }
842
+
843
+ .page-table .status {
844
+ display: inline-flex;
845
+ align-items: center;
846
+ gap: 6px;
847
+ padding: 4px 10px;
848
+ border-radius: 20px;
849
+ font-size: 11px;
850
+ font-weight: 500;
851
+ }
852
+
853
+ .page-table .status.cached {
854
+ background: rgba(63, 185, 80, 0.15);
855
+ color: var(--accent-green);
856
+ }
857
+
858
+ .page-table .status.rendered {
859
+ background: rgba(88, 166, 255, 0.15);
860
+ color: var(--accent-blue);
861
+ }
862
+
863
+ /* Tabs */
864
+ .tabs {
865
+ display: flex;
866
+ gap: 4px;
867
+ padding: 8px;
868
+ background: var(--bg-tertiary);
869
+ border-radius: var(--radius);
870
+ margin-bottom: 16px;
871
+ }
872
+
873
+ .tab {
874
+ padding: 8px 16px;
875
+ border-radius: 6px;
876
+ font-size: 13px;
877
+ font-weight: 500;
878
+ color: var(--text-secondary);
879
+ background: transparent;
880
+ border: none;
881
+ cursor: pointer;
882
+ transition: all 0.2s ease;
883
+ }
884
+
885
+ .tab:hover {
886
+ color: var(--text-primary);
887
+ background: var(--bg-secondary);
888
+ }
889
+
890
+ .tab.active {
891
+ color: var(--text-primary);
892
+ background: var(--bg-secondary);
893
+ box-shadow: var(--shadow);
894
+ }
895
+
896
+ .tab-content {
897
+ display: none;
898
+ }
899
+
900
+ .tab-content.active {
901
+ display: block;
902
+ }
903
+
904
+ .phase-view {
905
+ display: none;
906
+ }
907
+
908
+ .phase-view.active {
909
+ display: block;
910
+ }
911
+
912
+ /* JSON View */
913
+ .json-view {
914
+ background: var(--bg-tertiary);
915
+ border-radius: var(--radius);
916
+ padding: 16px;
917
+ overflow-x: auto;
918
+ }
919
+
920
+ .json-view pre {
921
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
922
+ font-size: 12px;
923
+ line-height: 1.6;
924
+ color: var(--text-primary);
925
+ margin: 0;
926
+ }
927
+
928
+ /* Incremental Section */
929
+ .incremental-info {
930
+ display: grid;
931
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
932
+ gap: 20px;
933
+ }
934
+
935
+ .trigger-file {
936
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
937
+ font-size: 12px;
938
+ color: var(--accent-yellow);
939
+ background: rgba(210, 153, 34, 0.1);
940
+ padding: 8px 12px;
941
+ border-radius: 4px;
942
+ word-break: break-all;
943
+ }
944
+
945
+ /* Badges */
946
+ .badge {
947
+ display: inline-flex;
948
+ align-items: center;
949
+ gap: 4px;
950
+ padding: 2px 8px;
951
+ border-radius: 12px;
952
+ font-size: 11px;
953
+ font-weight: 500;
954
+ }
955
+
956
+ .badge.ci {
957
+ background: rgba(163, 113, 247, 0.15);
958
+ color: var(--accent-purple);
959
+ }
960
+
961
+ .badge.local {
962
+ background: rgba(88, 166, 255, 0.15);
963
+ color: var(--accent-blue);
964
+ }
965
+
966
+ .badge.detailed {
967
+ background: rgba(57, 197, 207, 0.15);
968
+ color: var(--accent-cyan);
969
+ }
970
+
971
+ /* Flags */
972
+ .flags {
973
+ display: flex;
974
+ gap: 8px;
975
+ flex-wrap: wrap;
976
+ }
977
+
978
+ .flag {
979
+ display: inline-flex;
980
+ align-items: center;
981
+ gap: 4px;
982
+ padding: 4px 10px;
983
+ background: var(--bg-tertiary);
984
+ border-radius: 4px;
985
+ font-size: 12px;
986
+ color: var(--text-secondary);
987
+ }
988
+
989
+ .flag.enabled {
990
+ color: var(--accent-green);
991
+ }
992
+
993
+ .flag.disabled {
994
+ color: var(--text-muted);
995
+ }
996
+
997
+ /* Tooltip */
998
+ [data-tooltip] {
999
+ position: relative;
1000
+ cursor: help;
1001
+ }
1002
+
1003
+ [data-tooltip]:hover::after {
1004
+ content: attr(data-tooltip);
1005
+ position: absolute;
1006
+ bottom: 100%;
1007
+ left: 50%;
1008
+ transform: translateX(-50%);
1009
+ padding: 6px 10px;
1010
+ background: var(--bg-tertiary);
1011
+ color: var(--text-primary);
1012
+ font-size: 12px;
1013
+ border-radius: 4px;
1014
+ white-space: nowrap;
1015
+ z-index: 100;
1016
+ box-shadow: var(--shadow);
1017
+ border: 1px solid var(--border-color);
1018
+ }
1019
+
1020
+ /* Responsive */
1021
+ @media (max-width: 768px) {
1022
+ .container {
1023
+ padding: 16px;
1024
+ }
1025
+
1026
+ .phase-item {
1027
+ grid-template-columns: 1fr;
1028
+ gap: 8px;
1029
+ }
1030
+
1031
+ .phase-duration,
1032
+ .phase-percent {
1033
+ text-align: left;
1034
+ }
1035
+
1036
+ .meta-info {
1037
+ flex-direction: column;
1038
+ gap: 8px;
1039
+ }
1040
+ }
1041
+
1042
+ /* Print styles */
1043
+ @media print {
1044
+ body {
1045
+ background: white;
1046
+ color: black;
1047
+ }
1048
+
1049
+ .section {
1050
+ break-inside: avoid;
1051
+ }
1052
+
1053
+ .tab {
1054
+ display: none !important;
1055
+ }
1056
+
1057
+ .tab-content {
1058
+ display: block !important;
1059
+ }
1060
+ }
1061
+
1062
+ /* Animations */
1063
+ @keyframes fadeIn {
1064
+ from { opacity: 0; transform: translateY(10px); }
1065
+ to { opacity: 1; transform: translateY(0); }
1066
+ }
1067
+
1068
+ .section {
1069
+ animation: fadeIn 0.3s ease-out;
1070
+ }
1071
+
1072
+ .summary-card {
1073
+ animation: fadeIn 0.3s ease-out;
1074
+ }
1075
+
1076
+ .summary-card:nth-child(2) { animation-delay: 0.05s; }
1077
+ .summary-card:nth-child(3) { animation-delay: 0.1s; }
1078
+ .summary-card:nth-child(4) { animation-delay: 0.15s; }
1079
+ .summary-card:nth-child(5) { animation-delay: 0.2s; }
1080
+ .summary-card:nth-child(6) { animation-delay: 0.25s; }
1081
+ </style>
1082
+ </head>
1083
+ <body>
1084
+ <div class="container">
1085
+ <!-- Header -->
1086
+ <header>
1087
+ <h1>
1088
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1089
+ <path d="M12 20V10M18 20V4M6 20v-4"/>
1090
+ </svg>
1091
+ Stati Build Metrics Report
1092
+ ${hasDetailedTimings ? '<span class="badge detailed">Detailed</span>' : ''}
1093
+ ${meta.ci ? '<span class="badge ci">CI</span>' : '<span class="badge local">Local</span>'}
1094
+ </h1>
1095
+ <div class="meta-info">
1096
+ <span>
1097
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1098
+ <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
1099
+ </svg>
1100
+ ${new Date(meta.timestamp).toLocaleString()}
1101
+ </span>
1102
+ <span>
1103
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1104
+ <path d="M4 19.5A2.5 2.5 0 016.5 17H20"/>
1105
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/>
1106
+ </svg>
1107
+ Command: ${meta.command}
1108
+ </span>
1109
+ <span>
1110
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1111
+ <rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
1112
+ </svg>
1113
+ ${meta.platform} / ${meta.arch}
1114
+ </span>
1115
+ <span>
1116
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1117
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1118
+ </svg>
1119
+ Node ${meta.nodeVersion}
1120
+ </span>
1121
+ <span>
1122
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1123
+ <path d="M20 7h-9M14 17H5M17 17a3 3 0 100-6 3 3 0 000 6zM7 7a3 3 0 100-6 3 3 0 000 6z"/>
1124
+ </svg>
1125
+ CLI v${meta.cliVersion} / Core v${meta.coreVersion}
1126
+ </span>
1127
+ ${meta.gitBranch
1128
+ ? `
1129
+ <span>
1130
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1131
+ <line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>
1132
+ <path d="M18 9a9 9 0 01-9 9"/>
1133
+ </svg>
1134
+ ${meta.gitBranch}${meta.gitCommit ? ` (${meta.gitCommit.substring(0, 7)})` : ''}
1135
+ </span>`
1136
+ : ''}
1137
+ </div>
1138
+ ${Object.keys(meta.flags).length > 0
1139
+ ? `
1140
+ <div class="flags" style="margin-top: 12px;">
1141
+ ${meta.flags.force !== undefined ? `<span class="flag ${meta.flags.force ? 'enabled' : 'disabled'}">--force: ${meta.flags.force}</span>` : ''}
1142
+ ${meta.flags.clean !== undefined ? `<span class="flag ${meta.flags.clean ? 'enabled' : 'disabled'}">--clean: ${meta.flags.clean}</span>` : ''}
1143
+ ${meta.flags.includeDrafts !== undefined ? `<span class="flag ${meta.flags.includeDrafts ? 'enabled' : 'disabled'}">--include-drafts: ${meta.flags.includeDrafts}</span>` : ''}
1144
+ </div>`
1145
+ : ''}
1146
+ </header>
1147
+
1148
+ <!-- Summary Cards -->
1149
+ <div class="summary-grid">
1150
+ <div class="summary-card highlight">
1151
+ <div class="label">Total Duration</div>
1152
+ <div class="value">${formatDuration(totals.durationMs)}</div>
1153
+ <div class="subtext">${(totals.durationMs / 1000).toFixed(3)} seconds</div>
1154
+ </div>
1155
+ <div class="summary-card ${counts.cachedPages > 0 ? 'success' : ''}">
1156
+ <div class="label">Pages</div>
1157
+ <div class="value">${counts.totalPages}</div>
1158
+ <div class="subtext">${counts.renderedPages} rendered, ${counts.cachedPages} cached</div>
1159
+ </div>
1160
+ <div class="summary-card">
1161
+ <div class="label">Cache Hit Rate</div>
1162
+ <div class="value" style="color: ${isg.cacheHitRate > 0.5 ? 'var(--accent-green)' : isg.cacheHitRate > 0 ? 'var(--accent-yellow)' : 'var(--text-secondary)'}">${formatPercent(isg.cacheHitRate)}</div>
1163
+ <div class="subtext">${isg.manifestEntries} manifest entries</div>
1164
+ </div>
1165
+ <div class="summary-card">
1166
+ <div class="label">Peak Memory</div>
1167
+ <div class="value">${formatBytes(totals.peakRssBytes)}</div>
1168
+ <div class="subtext">Heap: ${formatBytes(totals.heapUsedBytes)}</div>
1169
+ </div>
1170
+ <div class="summary-card">
1171
+ <div class="label">Templates Loaded</div>
1172
+ <div class="value">${counts.templatesLoaded}</div>
1173
+ <div class="subtext">${(counts.templatesLoaded / counts.totalPages).toFixed(1)} avg per page</div>
1174
+ </div>
1175
+ <div class="summary-card">
1176
+ <div class="label">Assets Copied</div>
1177
+ <div class="value">${counts.assetsCopied}</div>
1178
+ <div class="subtext">${counts.markdownFilesProcessed} markdown files</div>
1179
+ </div>
1180
+ </div>
1181
+
1182
+ <!-- Phase Breakdown -->
1183
+ <div class="section">
1184
+ <div class="section-header">
1185
+ <h2>
1186
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1187
+ <path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
1188
+ </svg>
1189
+ Phase Breakdown
1190
+ </h2>
1191
+ <span style="font-size: 13px; color: var(--text-secondary);">
1192
+ ${phaseData.length} phases tracked • ${formatDuration(totalPhaseTime)} total
1193
+ </span>
1194
+ </div>
1195
+ <div class="section-content">
1196
+ <!-- View Tabs -->
1197
+ <div class="tabs" style="margin-bottom: 20px;">
1198
+ <button class="tab active" onclick="switchPhaseView('duration')">
1199
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 6px;">
1200
+ <path d="M3 3v18h18"/><path d="M18 9l-5 5-4-4-3 3"/>
1201
+ </svg>
1202
+ By Duration
1203
+ </button>
1204
+ <button class="tab" onclick="switchPhaseView('timeline')">
1205
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 6px;">
1206
+ <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
1207
+ </svg>
1208
+ Timeline
1209
+ </button>
1210
+ </div>
1211
+
1212
+ <!-- Duration View (sorted by duration) -->
1213
+ <div id="duration-view" class="phase-view active">
1214
+ <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 16px;">
1215
+ Click on any phase to see a detailed description of what happens during that phase.
1216
+ </p>
1217
+ <div class="phase-list">
1218
+ ${phaseChartData
1219
+ .map((phase) => `
1220
+ <div class="phase-item" onclick="togglePhase(this)">
1221
+ <div class="phase-name" title="${phase.name}">${phase.label}</div>
1222
+ <div class="phase-bar-container">
1223
+ <div class="phase-bar" style="width: ${Math.max(phase.percent, 0.5)}%"></div>
1224
+ </div>
1225
+ <div class="phase-duration">${formatDuration(phase.value)}</div>
1226
+ <div class="phase-percent">${phase.percent.toFixed(1)}%</div>
1227
+ <svg class="phase-expand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1228
+ <polyline points="6,9 12,15 18,9"/>
1229
+ </svg>
1230
+ <div class="phase-description">${phase.description}</div>
1231
+ </div>`)
1232
+ .join('')}
1233
+ </div>
1234
+ </div>
1235
+
1236
+ <!-- Timeline View (in execution order) -->
1237
+ <div id="timeline-view" class="phase-view" style="display: none;">
1238
+ <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 16px;">
1239
+ Phases shown in their approximate execution order during the build process.
1240
+ </p>
1241
+
1242
+ <!-- Visual Timeline Track -->
1243
+ <div class="timeline-track">
1244
+ ${timelineChartData
1245
+ .map((phase, i) => `
1246
+ <div class="timeline-segment phase-color-${i % 8}"
1247
+ style="left: ${phase.startOffset}%; width: ${Math.max(phase.percent, 0.5)}%;"
1248
+ title="${phase.label}: ${formatDuration(phase.value)} (${phase.percent.toFixed(1)}%)"
1249
+ onclick="expandTimelineItem(${i})">
1250
+ <span>${phase.percent > 8 ? phase.label : ''}</span>
1251
+ </div>`)
1252
+ .join('')}
1253
+ </div>
1254
+
1255
+ <!-- Timeline List -->
1256
+ <div class="timeline-list">
1257
+ ${timelineChartData
1258
+ .map((phase, i) => `
1259
+ <div class="timeline-item" id="timeline-item-${i}" onclick="toggleTimeline(this)">
1260
+ <div class="timeline-order">${i + 1}</div>
1261
+ <div class="timeline-name">${phase.label}</div>
1262
+ <div class="timeline-bar-container">
1263
+ <div class="timeline-bar phase-color-${i % 8}" style="width: ${Math.max(phase.percent, 0.5)}%"></div>
1264
+ </div>
1265
+ <div class="timeline-time">${formatDuration(phase.value)}</div>
1266
+ <div class="timeline-description">${phase.description}</div>
1267
+ </div>`)
1268
+ .join('')}
1269
+ </div>
1270
+ </div>
1271
+ </div>
1272
+ </div>
1273
+
1274
+ <!-- ISG Cache Details -->
1275
+ <div class="section">
1276
+ <div class="section-header">
1277
+ <h2>
1278
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1279
+ <path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/>
1280
+ </svg>
1281
+ ISG Cache Performance
1282
+ </h2>
1283
+ <span style="font-size: 13px; color: ${isg.enabled ? 'var(--accent-green)' : 'var(--text-muted)'};">
1284
+ ${isg.enabled ? '● Enabled' : '○ Disabled'}
1285
+ </span>
1286
+ </div>
1287
+ <div class="section-content">
1288
+ <div style="display: flex; gap: 40px; align-items: center; flex-wrap: wrap;">
1289
+ <div class="cache-ring-container">
1290
+ <div class="cache-ring">
1291
+ <svg width="150" height="150" viewBox="0 0 150 150">
1292
+ <circle class="cache-ring-bg" cx="75" cy="75" r="60"/>
1293
+ <circle class="cache-ring-progress" cx="75" cy="75" r="60"
1294
+ stroke-dasharray="${2 * Math.PI * 60}"
1295
+ stroke-dashoffset="${2 * Math.PI * 60 * (1 - isg.cacheHitRate)}"/>
1296
+ </svg>
1297
+ <div class="cache-ring-text">
1298
+ <div class="cache-ring-value">${formatPercent(isg.cacheHitRate)}</div>
1299
+ <div class="cache-ring-label">Cache Hit Rate</div>
1300
+ </div>
1301
+ </div>
1302
+ </div>
1303
+ <div class="isg-grid" style="flex: 1;">
1304
+ <div class="isg-stat hit">
1305
+ <div class="value">${counts.cachedPages}</div>
1306
+ <div class="label">Pages from Cache</div>
1307
+ </div>
1308
+ <div class="isg-stat miss">
1309
+ <div class="value">${counts.renderedPages}</div>
1310
+ <div class="label">Pages Rendered</div>
1311
+ </div>
1312
+ <div class="isg-stat">
1313
+ <div class="value" style="color: var(--accent-purple)">${isg.manifestEntries}</div>
1314
+ <div class="label">Manifest Entries</div>
1315
+ </div>
1316
+ <div class="isg-stat">
1317
+ <div class="value" style="color: var(--accent-orange)">${isg.invalidatedEntries}</div>
1318
+ <div class="label">Invalidated</div>
1319
+ </div>
1320
+ </div>
1321
+ </div>
1322
+ </div>
1323
+ </div>
1324
+
1325
+ ${hasIncremental
1326
+ ? `
1327
+ <!-- Incremental Rebuild -->
1328
+ <div class="section">
1329
+ <div class="section-header">
1330
+ <h2>
1331
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1332
+ <path d="M23 4v6h-6M1 20v-6h6"/>
1333
+ <path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
1334
+ </svg>
1335
+ Incremental Rebuild
1336
+ </h2>
1337
+ </div>
1338
+ <div class="section-content">
1339
+ <div class="incremental-info">
1340
+ <div class="stat-item">
1341
+ <div class="stat-label">Trigger File</div>
1342
+ <div class="trigger-file">${incremental.triggerFile}</div>
1343
+ </div>
1344
+ <div class="stat-item">
1345
+ <div class="stat-label">Trigger Type</div>
1346
+ <div class="stat-value">${incremental.triggerType}</div>
1347
+ </div>
1348
+ <div class="stat-item">
1349
+ <div class="stat-label">Rebuild Duration</div>
1350
+ <div class="stat-value">${formatDuration(incremental.durationMs)}</div>
1351
+ </div>
1352
+ <div class="stat-item">
1353
+ <div class="stat-label">Pages Affected</div>
1354
+ <div class="stat-value">${incremental.renderedPages} rendered / ${incremental.cachedPages} cached</div>
1355
+ </div>
1356
+ </div>
1357
+ </div>
1358
+ </div>`
1359
+ : ''}
1360
+
1361
+ ${hasDetailedTimings
1362
+ ? `
1363
+ <!-- Detailed Page Timings -->
1364
+ <div class="section">
1365
+ <div class="section-header">
1366
+ <h2>
1367
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1368
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
1369
+ <path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>
1370
+ </svg>
1371
+ Page Timings
1372
+ </h2>
1373
+ <span style="font-size: 13px; color: var(--text-secondary);">
1374
+ ${sortedPageTimings.length} pages • Sorted by duration
1375
+ </span>
1376
+ </div>
1377
+ <div class="section-content">
1378
+ <div class="tabs">
1379
+ <button class="tab active" onclick="switchTab('table')">Table View</button>
1380
+ <button class="tab" onclick="switchTab('waterfall')">Waterfall View</button>
1381
+ </div>
1382
+
1383
+ <div id="tab-table" class="tab-content active">
1384
+ <div style="overflow-x: auto;">
1385
+ <table class="page-table">
1386
+ <thead>
1387
+ <tr>
1388
+ <th>URL</th>
1389
+ <th>Duration</th>
1390
+ <th>Status</th>
1391
+ <th>Templates</th>
1392
+ </tr>
1393
+ </thead>
1394
+ <tbody>
1395
+ ${sortedPageTimings
1396
+ .map((page) => `
1397
+ <tr>
1398
+ <td class="url" title="${page.url}">${page.url}</td>
1399
+ <td class="duration">${formatDuration(page.durationMs)}</td>
1400
+ <td><span class="status ${page.cached ? 'cached' : 'rendered'}">${page.cached ? '● Cached' : '◐ Rendered'}</span></td>
1401
+ <td>${page.templatesLoaded !== undefined ? page.templatesLoaded : '-'}</td>
1402
+ </tr>`)
1403
+ .join('')}
1404
+ </tbody>
1405
+ </table>
1406
+ </div>
1407
+ </div>
1408
+
1409
+ <div id="tab-waterfall" class="tab-content">
1410
+ <div class="waterfall-container">
1411
+ <div class="waterfall">
1412
+ ${(() => {
1413
+ const maxDuration = Math.max(...sortedPageTimings.map((p) => p.durationMs));
1414
+ return sortedPageTimings
1415
+ .slice(0, 50)
1416
+ .map((page) => {
1417
+ const widthPercent = (page.durationMs / maxDuration) * 100;
1418
+ return `
1419
+ <div class="waterfall-row">
1420
+ <div class="waterfall-label" title="${page.url}">${page.url.split('/').pop() || page.url}</div>
1421
+ <div class="waterfall-track">
1422
+ <div class="waterfall-bar ${page.cached ? 'cached' : 'rendered'}" style="width: ${Math.max(widthPercent, 1)}%"></div>
1423
+ </div>
1424
+ <div class="waterfall-time">${formatDuration(page.durationMs)}</div>
1425
+ </div>`;
1426
+ })
1427
+ .join('');
1428
+ })()}
1429
+ ${sortedPageTimings.length > 50
1430
+ ? `
1431
+ <div style="text-align: center; padding: 12px; color: var(--text-muted); font-size: 12px;">
1432
+ Showing 50 of ${sortedPageTimings.length} pages
1433
+ </div>`
1434
+ : ''}
1435
+ </div>
1436
+ </div>
1437
+ </div>
1438
+ </div>
1439
+ </div>`
1440
+ : ''}
1441
+
1442
+ <!-- Raw JSON -->
1443
+ <div class="section">
1444
+ <div class="section-header">
1445
+ <h2>
1446
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1447
+ <path d="M16 18l6-6-6-6M8 6l-6 6 6 6"/>
1448
+ </svg>
1449
+ Raw JSON Data
1450
+ </h2>
1451
+ <button onclick="copyJson()" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px;">
1452
+ Copy to Clipboard
1453
+ </button>
1454
+ </div>
1455
+ <div class="section-content">
1456
+ <div class="json-view">
1457
+ <pre id="json-content">${jsonData.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre>
1458
+ </div>
1459
+ </div>
1460
+ </div>
1461
+
1462
+ <!-- Footer -->
1463
+ <footer style="text-align: center; padding: 24px; color: var(--text-muted); font-size: 12px;">
1464
+ Generated by Stati Build Metrics • ${new Date().toISOString()}
1465
+ </footer>
1466
+ </div>
1467
+
1468
+ <script>
1469
+ function switchTab(tabName) {
1470
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1471
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
1472
+ document.querySelector(\`[onclick="switchTab('\${tabName}')"]\`).classList.add('active');
1473
+ document.getElementById(\`tab-\${tabName}\`).classList.add('active');
1474
+ }
1475
+
1476
+ function switchPhaseView(viewName) {
1477
+ // Update tabs
1478
+ document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
1479
+ document.querySelector(\`[onclick="switchPhaseView('\${viewName}')"]\`).classList.add('active');
1480
+
1481
+ // Update views
1482
+ document.querySelectorAll('.phase-view').forEach(v => {
1483
+ v.style.display = 'none';
1484
+ v.classList.remove('active');
1485
+ });
1486
+ const activeView = document.getElementById(\`\${viewName}-view\`);
1487
+ if (activeView) {
1488
+ activeView.style.display = 'block';
1489
+ activeView.classList.add('active');
1490
+ }
1491
+ }
1492
+
1493
+ function togglePhase(element) {
1494
+ element.classList.toggle('expanded');
1495
+ }
1496
+
1497
+ function toggleTimeline(element) {
1498
+ element.classList.toggle('expanded');
1499
+ }
1500
+
1501
+ function expandTimelineItem(index) {
1502
+ const item = document.getElementById(\`timeline-item-\${index}\`);
1503
+ if (item) {
1504
+ // Collapse others first
1505
+ document.querySelectorAll('.timeline-item.expanded').forEach(el => {
1506
+ if (el !== item) el.classList.remove('expanded');
1507
+ });
1508
+ item.classList.add('expanded');
1509
+ item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1510
+ }
1511
+ }
1512
+
1513
+ function copyJson() {
1514
+ const json = document.getElementById('json-content').textContent;
1515
+ navigator.clipboard.writeText(json).then(() => {
1516
+ const btn = event.target;
1517
+ const originalText = btn.textContent;
1518
+ btn.textContent = 'Copied!';
1519
+ btn.style.color = 'var(--accent-green)';
1520
+ setTimeout(() => {
1521
+ btn.textContent = originalText;
1522
+ btn.style.color = '';
1523
+ }, 2000);
1524
+ });
1525
+ }
1526
+ </script>
1527
+ </body>
1528
+ </html>`;
1529
+ }
1530
+ /**
1531
+ * Write the HTML metrics report to a file.
1532
+ *
1533
+ * @param metrics - The build metrics data
1534
+ * @param outputPath - Path to write the HTML file
1535
+ * @returns Promise resolving to success boolean and path
1536
+ */
1537
+ export async function writeMetricsHtml(metrics, outputPath) {
1538
+ try {
1539
+ const html = generateMetricsHtml(metrics);
1540
+ await writeFile(outputPath, html, 'utf-8');
1541
+ return { success: true, path: outputPath };
1542
+ }
1543
+ catch (error) {
1544
+ const errorMessage = error instanceof Error ? error.message : String(error);
1545
+ return { success: false, error: `Failed to write HTML report: ${errorMessage}` };
1546
+ }
1547
+ }