@decocms/start 0.19.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 (185) hide show
  1. package/.cursor/skills/deco-api-call-dedup/SKILL.md +443 -0
  2. package/.cursor/skills/deco-apps-architecture/SKILL.md +255 -0
  3. package/.cursor/skills/deco-apps-architecture/app-pattern.md +288 -0
  4. package/.cursor/skills/deco-apps-architecture/commerce-types.md +239 -0
  5. package/.cursor/skills/deco-apps-architecture/new-app-guide.md +268 -0
  6. package/.cursor/skills/deco-apps-architecture/scripts-codegen.md +148 -0
  7. package/.cursor/skills/deco-apps-architecture/shared-utils.md +181 -0
  8. package/.cursor/skills/deco-apps-architecture/vtex-deep-structure.md +253 -0
  9. package/.cursor/skills/deco-apps-architecture/website-app.md +169 -0
  10. package/.cursor/skills/deco-apps-vtex-porting/SKILL.md +189 -0
  11. package/.cursor/skills/deco-apps-vtex-porting/adaptation-patterns.md +335 -0
  12. package/.cursor/skills/deco-apps-vtex-porting/commerce-porting.md +155 -0
  13. package/.cursor/skills/deco-apps-vtex-porting/cookie-auth-patterns.md +148 -0
  14. package/.cursor/skills/deco-apps-vtex-porting/structure-map.md +234 -0
  15. package/.cursor/skills/deco-apps-vtex-porting/transform-mapping.md +99 -0
  16. package/.cursor/skills/deco-apps-vtex-porting/website-porting.md +194 -0
  17. package/.cursor/skills/deco-apps-vtex-review/SKILL.md +234 -0
  18. package/.cursor/skills/deco-async-rendering-architecture/SKILL.md +270 -0
  19. package/.cursor/skills/deco-async-rendering-site-guide/SKILL.md +417 -0
  20. package/.cursor/skills/deco-cms-layout-caching/SKILL.md +293 -0
  21. package/.cursor/skills/deco-cms-route-config/SKILL.md +388 -0
  22. package/.cursor/skills/deco-core-architecture/SKILL.md +185 -0
  23. package/.cursor/skills/deco-core-architecture/blocks.md +196 -0
  24. package/.cursor/skills/deco-core-architecture/deco-vs-deco-start.md +191 -0
  25. package/.cursor/skills/deco-core-architecture/engine.md +220 -0
  26. package/.cursor/skills/deco-core-architecture/hooks-components.md +157 -0
  27. package/.cursor/skills/deco-core-architecture/plugins-clients.md +136 -0
  28. package/.cursor/skills/deco-core-architecture/runtime.md +116 -0
  29. package/.cursor/skills/deco-core-architecture/site-usage.md +165 -0
  30. package/.cursor/skills/deco-e2e-testing/SKILL.md +372 -0
  31. package/.cursor/skills/deco-e2e-testing/discovery.md +337 -0
  32. package/.cursor/skills/deco-e2e-testing/scripts/scaffold.sh +81 -0
  33. package/.cursor/skills/deco-e2e-testing/selectors.md +175 -0
  34. package/.cursor/skills/deco-e2e-testing/templates/package.json +18 -0
  35. package/.cursor/skills/deco-e2e-testing/templates/playwright.config.ts +65 -0
  36. package/.cursor/skills/deco-e2e-testing/templates/scripts/baseline.ts +279 -0
  37. package/.cursor/skills/deco-e2e-testing/templates/scripts/run-e2e.ts +194 -0
  38. package/.cursor/skills/deco-e2e-testing/templates/specs/ecommerce-flow.spec.ts +612 -0
  39. package/.cursor/skills/deco-e2e-testing/templates/tsconfig.json +12 -0
  40. package/.cursor/skills/deco-e2e-testing/templates/utils/metrics-collector.ts +918 -0
  41. package/.cursor/skills/deco-e2e-testing/troubleshooting.md +602 -0
  42. package/.cursor/skills/deco-edge-caching/SKILL.md +316 -0
  43. package/.cursor/skills/deco-full-analysis/SKILL.md +898 -0
  44. package/.cursor/skills/deco-full-analysis/checklists/asset-optimization.md +251 -0
  45. package/.cursor/skills/deco-full-analysis/checklists/bug-fix.md +189 -0
  46. package/.cursor/skills/deco-full-analysis/checklists/cache-strategy.md +144 -0
  47. package/.cursor/skills/deco-full-analysis/checklists/dependency-update.md +150 -0
  48. package/.cursor/skills/deco-full-analysis/checklists/hydration-fix.md +191 -0
  49. package/.cursor/skills/deco-full-analysis/checklists/image-optimization.md +180 -0
  50. package/.cursor/skills/deco-full-analysis/checklists/loader-optimization.md +165 -0
  51. package/.cursor/skills/deco-full-analysis/checklists/seo-fix.md +183 -0
  52. package/.cursor/skills/deco-full-analysis/checklists/site-cleanup.md +281 -0
  53. package/.cursor/skills/deco-full-analysis/discovery.md +548 -0
  54. package/.cursor/skills/deco-incident-debugging/SKILL.md +378 -0
  55. package/.cursor/skills/deco-incident-debugging/headless-mode.md +510 -0
  56. package/.cursor/skills/deco-incident-debugging/learnings-index.md +227 -0
  57. package/.cursor/skills/deco-incident-debugging/triage-workflow.md +312 -0
  58. package/.cursor/skills/deco-islands-migration/SKILL.md +251 -0
  59. package/.cursor/skills/deco-loader-n-plus-1-detector/SKILL.md +275 -0
  60. package/.cursor/skills/deco-performance-audit/SKILL.md +530 -0
  61. package/.cursor/skills/deco-performance-audit/tools-reference.md +428 -0
  62. package/.cursor/skills/deco-performance-audit/workflow.md +457 -0
  63. package/.cursor/skills/deco-server-functions-invoke/SKILL.md +92 -0
  64. package/.cursor/skills/deco-server-functions-invoke/architecture.md +166 -0
  65. package/.cursor/skills/deco-server-functions-invoke/generator.md +122 -0
  66. package/.cursor/skills/deco-server-functions-invoke/problem.md +98 -0
  67. package/.cursor/skills/deco-server-functions-invoke/troubleshooting.md +110 -0
  68. package/.cursor/skills/deco-site-deployment/SKILL.md +396 -0
  69. package/.cursor/skills/deco-site-memory-debugging/SKILL.md +121 -0
  70. package/.cursor/skills/deco-site-memory-debugging/cdp-connection.md +222 -0
  71. package/.cursor/skills/deco-site-memory-debugging/memory-analysis.md +362 -0
  72. package/.cursor/skills/deco-site-patterns/SKILL.md +124 -0
  73. package/.cursor/skills/deco-site-patterns/app-composition.md +337 -0
  74. package/.cursor/skills/deco-site-patterns/client-patterns.md +341 -0
  75. package/.cursor/skills/deco-site-patterns/cms-wiring.md +230 -0
  76. package/.cursor/skills/deco-site-patterns/section-patterns.md +340 -0
  77. package/.cursor/skills/deco-site-scaling-tuning/SKILL.md +240 -0
  78. package/.cursor/skills/deco-site-scaling-tuning/analysis-scripts.md +267 -0
  79. package/.cursor/skills/deco-start-architecture/SKILL.md +218 -0
  80. package/.cursor/skills/deco-start-architecture/admin-protocol.md +156 -0
  81. package/.cursor/skills/deco-start-architecture/cms-resolution.md +201 -0
  82. package/.cursor/skills/deco-start-architecture/code-quality.md +158 -0
  83. package/.cursor/skills/deco-start-architecture/gap-analysis.md +129 -0
  84. package/.cursor/skills/deco-start-architecture/sdk-utilities.md +197 -0
  85. package/.cursor/skills/deco-start-architecture/worker-entry-caching.md +154 -0
  86. package/.cursor/skills/deco-startup-analysis/SKILL.md +248 -0
  87. package/.cursor/skills/deco-storefront-test-checklist/SKILL.md +369 -0
  88. package/.cursor/skills/deco-tanstack-hydration-fixes/SKILL.md +468 -0
  89. package/.cursor/skills/deco-tanstack-navigation/SKILL.md +681 -0
  90. package/.cursor/skills/deco-tanstack-search/SKILL.md +411 -0
  91. package/.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md +1013 -0
  92. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +518 -0
  93. package/.cursor/skills/deco-to-tanstack-migration/references/codemod-commands.md +174 -0
  94. package/.cursor/skills/deco-to-tanstack-migration/references/commerce/README.md +78 -0
  95. package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +128 -0
  96. package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +719 -0
  97. package/.cursor/skills/deco-to-tanstack-migration/references/imports/README.md +70 -0
  98. package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +154 -0
  99. package/.cursor/skills/deco-to-tanstack-migration/references/signals/README.md +220 -0
  100. package/.cursor/skills/deco-to-tanstack-migration/references/vite-config/README.md +78 -0
  101. package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +55 -0
  102. package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +110 -0
  103. package/.cursor/skills/deco-to-tanstack-migration/templates/router.md +96 -0
  104. package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +167 -0
  105. package/.cursor/skills/deco-to-tanstack-migration/templates/vite-config.md +122 -0
  106. package/.cursor/skills/deco-to-tanstack-migration/templates/worker-entry.md +67 -0
  107. package/.cursor/skills/deco-typescript-fixes/SKILL.md +178 -0
  108. package/.cursor/skills/deco-typescript-fixes/common-fixes.md +330 -0
  109. package/.cursor/skills/deco-typescript-fixes/strategy.md +148 -0
  110. package/.cursor/skills/deco-variant-selection-perf/SKILL.md +272 -0
  111. package/.cursor/skills/deco-vtex-fetch-cache/SKILL.md +225 -0
  112. package/.cursor/skills/find-skills/SKILL.md +133 -0
  113. package/.cursor/skills/incident-report/SKILL.md +179 -0
  114. package/.cursor/skills/incident-report/references/5-whys.md +75 -0
  115. package/.cursor/skills/incident-report/templates/client-report.md +187 -0
  116. package/.cursor/skills/incident-report/templates/internal-report.md +206 -0
  117. package/.cursor/skills/template-skill/SKILL.md +38 -0
  118. package/.github/workflows/release.yml +32 -0
  119. package/.releaserc.json +25 -0
  120. package/CLAUDE.md +135 -0
  121. package/GAP_ANALYSIS.md +224 -0
  122. package/GAP_ANALYSIS_V2.md +1013 -0
  123. package/biome.json +39 -0
  124. package/knip.json +5 -0
  125. package/package.json +87 -0
  126. package/scripts/generate-blocks.ts +69 -0
  127. package/scripts/generate-invoke.ts +378 -0
  128. package/scripts/generate-schema.ts +657 -0
  129. package/src/admin/cors.ts +29 -0
  130. package/src/admin/decofile.ts +72 -0
  131. package/src/admin/index.ts +24 -0
  132. package/src/admin/invoke.ts +163 -0
  133. package/src/admin/liveControls.ts +29 -0
  134. package/src/admin/meta.ts +70 -0
  135. package/src/admin/render.ts +205 -0
  136. package/src/admin/schema.ts +686 -0
  137. package/src/admin/setup.ts +44 -0
  138. package/src/cms/index.ts +59 -0
  139. package/src/cms/loader.ts +180 -0
  140. package/src/cms/registry.ts +162 -0
  141. package/src/cms/resolve.ts +1005 -0
  142. package/src/cms/sectionLoaders.ts +294 -0
  143. package/src/hooks/DecoPageRenderer.tsx +444 -0
  144. package/src/hooks/LazySection.tsx +109 -0
  145. package/src/hooks/LiveControls.tsx +108 -0
  146. package/src/hooks/SectionErrorFallback.tsx +85 -0
  147. package/src/hooks/index.ts +8 -0
  148. package/src/index.ts +5 -0
  149. package/src/matchers/builtins.ts +184 -0
  150. package/src/matchers/posthog.ts +154 -0
  151. package/src/middleware/decoState.ts +55 -0
  152. package/src/middleware/healthMetrics.ts +131 -0
  153. package/src/middleware/index.ts +80 -0
  154. package/src/middleware/liveness.ts +21 -0
  155. package/src/middleware/observability.ts +205 -0
  156. package/src/routes/adminRoutes.ts +83 -0
  157. package/src/routes/cmsRoute.ts +302 -0
  158. package/src/routes/components.tsx +34 -0
  159. package/src/routes/index.ts +15 -0
  160. package/src/sdk/analytics.ts +72 -0
  161. package/src/sdk/cacheHeaders.ts +268 -0
  162. package/src/sdk/cachedLoader.ts +206 -0
  163. package/src/sdk/clx.ts +3 -0
  164. package/src/sdk/cookie.ts +39 -0
  165. package/src/sdk/createInvoke.ts +57 -0
  166. package/src/sdk/csp.ts +59 -0
  167. package/src/sdk/env.ts +27 -0
  168. package/src/sdk/index.ts +63 -0
  169. package/src/sdk/instrumentedFetch.ts +137 -0
  170. package/src/sdk/invoke.ts +133 -0
  171. package/src/sdk/mergeCacheControl.ts +150 -0
  172. package/src/sdk/redirects.ts +217 -0
  173. package/src/sdk/requestContext.ts +184 -0
  174. package/src/sdk/serverTimings.ts +68 -0
  175. package/src/sdk/signal.ts +41 -0
  176. package/src/sdk/sitemap.ts +143 -0
  177. package/src/sdk/urlUtils.ts +117 -0
  178. package/src/sdk/useDevice.ts +82 -0
  179. package/src/sdk/useId.ts +7 -0
  180. package/src/sdk/useScript.ts +101 -0
  181. package/src/sdk/workerEntry.ts +703 -0
  182. package/src/sdk/wrapCaughtErrors.ts +107 -0
  183. package/src/types/index.ts +39 -0
  184. package/src/types/widgets.ts +13 -0
  185. package/tsconfig.json +13 -0
@@ -0,0 +1,918 @@
1
+ import type { Page, CDPSession, Response, Request } from '@playwright/test'
2
+
3
+ export interface PerformanceMetrics {
4
+ LCP: number | null
5
+ FCP: number | null
6
+ CLS: number | null
7
+ TTFB: number | null
8
+ domContentLoaded: number | null
9
+ }
10
+
11
+ export interface NetworkMetrics {
12
+ totalRequests: number
13
+ totalBytes: number
14
+ totalBytesFormatted: string
15
+ slowestRequests: Array<{ url: string; duration: number; type: string }>
16
+ failedRequests: number
17
+ }
18
+
19
+ /**
20
+ * Individual loader timing from Server-Timing header
21
+ */
22
+ export interface LoaderTiming {
23
+ name: string
24
+ duration: number
25
+ status: string | null // 'bypass', 'HIT', 'MISS', 'STALE', etc.
26
+ }
27
+
28
+ /**
29
+ * Server-side timing metrics from Deco's ?__d debug mode
30
+ */
31
+ export interface ServerTimingMetrics {
32
+ loaders: LoaderTiming[]
33
+ totalServerTime: number
34
+ slowestLoaders: LoaderTiming[]
35
+ cacheStats: {
36
+ total: number
37
+ bypass: number
38
+ hit: number
39
+ miss: number
40
+ stale: number
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Lazy render request tracking (/deco/render)
46
+ */
47
+ export interface LazyRenderRequest {
48
+ url: string
49
+ sectionName: string
50
+ duration: number
51
+ cached: boolean
52
+ cacheStatus: string | null // 'HIT' | 'MISS' | 'STALE' | 'DYNAMIC' | null
53
+ cacheControl: string | null
54
+ serverTiming: LoaderTiming[]
55
+ status: number
56
+ }
57
+
58
+ /**
59
+ * Page cache analysis
60
+ */
61
+ export interface CacheAnalysis {
62
+ pageUrl: string
63
+ pageCached: boolean
64
+ pageCacheControl: string | null
65
+ lazyRenders: LazyRenderRequest[]
66
+ lazyRendersCached: number
67
+ lazyRendersUncached: number
68
+ serverSideLoaders: LoaderTiming[]
69
+ warnings: string[]
70
+ }
71
+
72
+ export interface PageMetrics {
73
+ url: string
74
+ pageName: string
75
+ timestamp: string
76
+ performance: PerformanceMetrics
77
+ network: NetworkMetrics
78
+ serverTiming: ServerTimingMetrics
79
+ cacheAnalysis: CacheAnalysis
80
+ renderTime: number
81
+ errors: string[]
82
+ /** Deco observability headers */
83
+ decoHeaders: {
84
+ page: string | null // x-deco-page - matched page block name
85
+ route: string | null // x-deco-route - matched route template
86
+ platform: string | null // x-deco-platform
87
+ }
88
+ }
89
+
90
+ interface NetworkEntry {
91
+ url: string
92
+ type: string
93
+ startTime: number
94
+ endTime?: number
95
+ size: number
96
+ status?: number
97
+ }
98
+
99
+ /**
100
+ * Metrics collector for Deco e2e tests with Server-Timing and lazy render tracking
101
+ * Captures browser Web Vitals, server-side loader timings, and /deco/render patterns
102
+ */
103
+ export class MetricsCollector {
104
+ private page: Page
105
+ private cdp: CDPSession | null = null
106
+ private requests = new Map<string, NetworkEntry>()
107
+ private errors: string[] = []
108
+ private startTime = 0
109
+ private serverTimingHeader: string | null = null
110
+ private pageCacheControl: string | null = null
111
+ private lazyRenders: LazyRenderRequest[] = []
112
+ private pendingLazyRenders = 0 // Track in-flight /deco/render requests
113
+ // Deco observability headers
114
+ private decoPageHeader: string | null = null
115
+ private decoRouteHeader: string | null = null
116
+ private decoPlatformHeader: string | null = null
117
+
118
+ constructor(page: Page) {
119
+ this.page = page
120
+ }
121
+
122
+ async init(): Promise<void> {
123
+ // CDP for performance metrics
124
+ this.cdp = await this.page.context().newCDPSession(this.page)
125
+ await this.cdp.send('Performance.enable')
126
+
127
+ // Track network
128
+ this.page.on('request', (req: Request) => {
129
+ this.requests.set(req.url() + Date.now(), {
130
+ url: req.url(),
131
+ type: req.resourceType(),
132
+ startTime: Date.now(),
133
+ size: 0,
134
+ })
135
+ // Track pending lazy render requests
136
+ if (req.url().includes('/deco/render')) {
137
+ this.pendingLazyRenders++
138
+ }
139
+ })
140
+
141
+ this.page.on('response', async (res: Response) => {
142
+ const url = res.request().url()
143
+ const id = [...this.requests.keys()].find(k =>
144
+ this.requests.get(k)?.url === url &&
145
+ !this.requests.get(k)?.endTime
146
+ )
147
+ if (id) {
148
+ const entry = this.requests.get(id)!
149
+ entry.endTime = Date.now()
150
+ entry.status = res.status()
151
+ try {
152
+ const body = await res.body().catch(() => null)
153
+ entry.size = body?.length || parseInt(res.headers()['content-length'] || '0', 10)
154
+ } catch {}
155
+ }
156
+
157
+ const headers = res.headers()
158
+
159
+ // Capture Server-Timing header from main document response
160
+ if (res.request().resourceType() === 'document') {
161
+ const serverTiming = headers['server-timing']
162
+ if (serverTiming) {
163
+ this.serverTimingHeader = serverTiming
164
+ }
165
+ this.pageCacheControl = headers['cache-control'] || null
166
+
167
+ // Capture Deco observability headers
168
+ this.decoPageHeader = headers['x-deco-page'] || null
169
+ this.decoRouteHeader = headers['x-deco-route'] || null
170
+ this.decoPlatformHeader = headers['x-deco-platform'] || null
171
+ }
172
+
173
+ // Track /deco/render requests (lazy loading)
174
+ if (url.includes('/deco/render')) {
175
+ this.pendingLazyRenders = Math.max(0, this.pendingLazyRenders - 1)
176
+
177
+ const startTime = this.requests.get(id!)?.startTime || Date.now()
178
+ const endTime = Date.now()
179
+ const cacheControl = headers['cache-control'] || null
180
+ const serverTiming = headers['server-timing'] || ''
181
+
182
+ // Extract section name from x-deco-section header (preferred) or URL fallback
183
+ const decoSectionHeader = headers['x-deco-section']
184
+ const headerUsable = decoSectionHeader && !decoSectionHeader.includes('Rendering/')
185
+ const sectionName = headerUsable ? decoSectionHeader : this.extractSectionName(url)
186
+
187
+ // Parse server timing from this specific render request
188
+ const loaderTimings = this.parseServerTimingString(serverTiming)
189
+
190
+ // Check if cached
191
+ const cached = this.isCached(cacheControl, res.status())
192
+
193
+ // Get actual cache status from x-cache header
194
+ const xCache = headers['x-cache'] || null
195
+
196
+ this.lazyRenders.push({
197
+ url: url.substring(0, 100) + (url.length > 100 ? '...' : ''),
198
+ sectionName,
199
+ duration: endTime - startTime,
200
+ cached,
201
+ cacheStatus: xCache,
202
+ cacheControl,
203
+ serverTiming: loaderTimings,
204
+ status: res.status(),
205
+ })
206
+ }
207
+ })
208
+
209
+ this.page.on('requestfailed', (req: Request) => {
210
+ const url = req.url()
211
+ const id = [...this.requests.keys()].find(k =>
212
+ this.requests.get(k)?.url === url && !this.requests.get(k)?.endTime
213
+ )
214
+ if (id) {
215
+ const entry = this.requests.get(id)!
216
+ entry.endTime = Date.now()
217
+ entry.status = 0
218
+ }
219
+ if (url.includes('/deco/render')) {
220
+ this.pendingLazyRenders = Math.max(0, this.pendingLazyRenders - 1)
221
+ }
222
+ })
223
+
224
+ // Track errors
225
+ this.page.on('console', (msg) => {
226
+ if (msg.type() === 'error') this.errors.push(msg.text())
227
+ })
228
+ this.page.on('pageerror', (err) => this.errors.push(err.message))
229
+ }
230
+
231
+ private isCached(cacheControl: string | null, status: number): boolean {
232
+ if (status === 304) return true
233
+ if (!cacheControl) return false
234
+
235
+ const hasMaxAge = /max-age=\d+/.test(cacheControl) && !/max-age=0/.test(cacheControl)
236
+ const hasSMaxAge = /s-maxage=\d+/.test(cacheControl)
237
+ const noStore = /no-store/.test(cacheControl)
238
+ const noCache = /no-cache/.test(cacheControl)
239
+
240
+ return (hasMaxAge || hasSMaxAge) && !noStore && !noCache
241
+ }
242
+
243
+ private extractSectionName(url: string): string {
244
+ try {
245
+ const urlObj = new URL(url, 'http://localhost')
246
+
247
+ // Priority 1: Look in props for section type and title
248
+ const props = urlObj.searchParams.get('props')
249
+ if (props) {
250
+ try {
251
+ const parsed = JSON.parse(decodeURIComponent(props))
252
+ const nameFromProps = this.extractNameFromProps(parsed)
253
+ if (nameFromProps) return nameFromProps
254
+ } catch {}
255
+ }
256
+
257
+ // Priority 2: sectionId parameter
258
+ const sectionId = urlObj.searchParams.get('sectionId')
259
+ if (sectionId) {
260
+ const cleaned = this.cleanName(sectionId)
261
+ if (cleaned) return cleaned
262
+ }
263
+
264
+ // Priority 3: resolveChain
265
+ const resolveChain = urlObj.searchParams.get('resolveChain')
266
+ if (resolveChain) {
267
+ try {
268
+ const chain = JSON.parse(decodeURIComponent(resolveChain))
269
+ if (Array.isArray(chain)) {
270
+ for (const item of chain) {
271
+ if (item.type === 'resolvable' && item.value) {
272
+ const val = String(item.value)
273
+ if (val.includes('sections/') || val.includes('Section')) {
274
+ const cleaned = this.cleanName(val)
275
+ if (cleaned) return cleaned
276
+ }
277
+ }
278
+ }
279
+ }
280
+ } catch {}
281
+ }
282
+
283
+ // Priority 4: href parameter
284
+ const href = urlObj.searchParams.get('href')
285
+ if (href) {
286
+ try {
287
+ const hrefUrl = new URL(href, 'http://localhost')
288
+ const match = hrefUrl.pathname.match(/\/section\/([^/]+)/)
289
+ if (match) {
290
+ const cleaned = this.cleanName(decodeURIComponent(match[1]))
291
+ if (cleaned) return cleaned
292
+ }
293
+ } catch {}
294
+ }
295
+
296
+ // Priority 5: pathname
297
+ const pathMatch = urlObj.pathname.match(/\/deco\/render\/([^/?]+)/)
298
+ if (pathMatch) {
299
+ const cleaned = this.cleanName(decodeURIComponent(pathMatch[1]))
300
+ if (cleaned) return cleaned
301
+ }
302
+
303
+ // Fallback: renderSalt
304
+ const renderSalt = urlObj.searchParams.get('renderSalt')
305
+ if (renderSalt) {
306
+ return `Section #${renderSalt}`
307
+ }
308
+
309
+ return 'Unknown Section'
310
+ } catch {
311
+ return 'Unknown Section'
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Extract a meaningful name from section props
317
+ */
318
+ private extractNameFromProps(props: Record<string, unknown>): string | null {
319
+ const section = props.section as Record<string, unknown> | undefined
320
+ const target = section || props
321
+
322
+ const resolveType = (target.__resolveType || target['__resolveType']) as string | undefined
323
+ const componentName = resolveType ? this.cleanName(resolveType) : null
324
+
325
+ const title = (target.title || target.name || target.label) as string | undefined
326
+
327
+ const nestedTitle = (
328
+ (target.props as Record<string, unknown>)?.title ||
329
+ (target.content as Record<string, unknown>)?.title ||
330
+ (target.header as Record<string, unknown>)?.title
331
+ ) as string | undefined
332
+
333
+ const displayTitle = title || nestedTitle
334
+
335
+ if (componentName && displayTitle) {
336
+ const shortTitle = displayTitle.length > 20 ? displayTitle.substring(0, 17) + '...' : displayTitle
337
+ return `${componentName}: ${shortTitle}`
338
+ }
339
+
340
+ if (displayTitle) {
341
+ return displayTitle.length > 30 ? displayTitle.substring(0, 27) + '...' : displayTitle
342
+ }
343
+
344
+ if (componentName) {
345
+ return componentName
346
+ }
347
+
348
+ const collection = (
349
+ (target.products as Record<string, unknown>)?.props as Record<string, unknown>
350
+ )?.collection as string | undefined
351
+
352
+ if (collection) {
353
+ return `Products: ${collection.substring(0, 20)}`
354
+ }
355
+
356
+ return null
357
+ }
358
+
359
+ private cleanName(name: string): string {
360
+ if (name.includes('Rendering/Lazy') || name.includes('Rendering/Deferred')) {
361
+ return ''
362
+ }
363
+
364
+ return name
365
+ .replace(/^site\//, '')
366
+ .replace(/^website\//, '')
367
+ .replace(/\.tsx$/, '')
368
+ .replace(/^sections\//, '')
369
+ .replace(/^islands\//, '')
370
+ .replace(/Rendering\//, '')
371
+ }
372
+
373
+ startMeasurement(): void {
374
+ this.requests.clear()
375
+ this.errors = []
376
+ this.serverTimingHeader = null
377
+ this.pageCacheControl = null
378
+ this.lazyRenders = []
379
+ this.pendingLazyRenders = 0
380
+ this.startTime = Date.now()
381
+ this.decoPageHeader = null
382
+ this.decoRouteHeader = null
383
+ this.decoPlatformHeader = null
384
+ }
385
+
386
+ /**
387
+ * Dismiss any popups/modals that might block scrolling
388
+ */
389
+ private async dismissPopups(): Promise<void> {
390
+ await this.page.evaluate(() => {
391
+ document.querySelectorAll('[class*="pushnews"], [id*="pushnews"], [class*="pn-"]').forEach(el => el.remove())
392
+
393
+ document.querySelectorAll('button').forEach(btn => {
394
+ const text = btn.textContent || ''
395
+ if (text.includes('obrigado') || text.includes('quero') || text.includes('Aceitar')) {
396
+ let parent = btn.parentElement
397
+ for (let i = 0; i < 10 && parent; i++) {
398
+ const style = window.getComputedStyle(parent)
399
+ if (style.position === 'fixed' || style.position === 'absolute') {
400
+ parent.remove()
401
+ break
402
+ }
403
+ parent = parent.parentElement
404
+ }
405
+ }
406
+ })
407
+ }).catch(() => {})
408
+ }
409
+
410
+ /**
411
+ * Scroll down the page to trigger lazy loading
412
+ * STRICT QUEUE: Only one /deco/render at a time
413
+ */
414
+ async scrollPage(options: { full?: boolean; footerSelector?: string; maxTime?: number } = {}): Promise<number> {
415
+ const { full = false, footerSelector = 'footer', maxTime = 30000 } = options
416
+ const initialRenders = this.lazyRenders.length
417
+ const startTime = Date.now()
418
+
419
+ await this.page.waitForTimeout(1000)
420
+ await this.dismissPopups()
421
+
422
+ if (!full) {
423
+ for (let i = 0; i < 3; i++) {
424
+ await this.page.evaluate(() => window.scrollBy(0, 500)).catch(() => {})
425
+ await this.page.waitForTimeout(300)
426
+ }
427
+ return this.lazyRenders.length - initialRenders
428
+ }
429
+
430
+ const scrollStep = 200
431
+ let stuckSectionCount = 0
432
+ const maxStuckSections = 2
433
+
434
+ for (let i = 0; i < 300; i++) {
435
+ if (this.page.isClosed()) break
436
+
437
+ const elapsed = Date.now() - startTime
438
+ if (elapsed > maxTime) {
439
+ console.log(` ⏱️ Scroll timeout (${Math.round(elapsed / 1000)}s) - stopping`)
440
+ break
441
+ }
442
+
443
+ if (this.pendingLazyRenders > 0) {
444
+ console.log(` ⏳ Waiting for ${this.pendingLazyRenders} pending render before next scroll...`)
445
+ await this.waitForPendingRenders(8000)
446
+
447
+ if (this.pendingLazyRenders > 0) {
448
+ stuckSectionCount++
449
+ console.log(` ⚠️ Section STUCK (${stuckSectionCount}/${maxStuckSections}) - will skip`)
450
+ this.pendingLazyRenders = 0
451
+
452
+ if (stuckSectionCount >= maxStuckSections) {
453
+ console.log(` 🛑 Too many stuck sections - stopping scroll`)
454
+ break
455
+ }
456
+
457
+ await this.page.waitForTimeout(1000)
458
+ }
459
+
460
+ await this.page.waitForTimeout(500)
461
+ continue
462
+ }
463
+
464
+ const footerVisible = await this.page.locator(footerSelector).first().isVisible().catch(() => false)
465
+ if (footerVisible) {
466
+ console.log(` ✅ Footer visible after ${i} scrolls`)
467
+ break
468
+ }
469
+
470
+ if (i < 5) await this.dismissPopups()
471
+
472
+ await this.page.evaluate((step) => window.scrollBy(0, step), scrollStep).catch(() => {})
473
+ await this.page.waitForTimeout(100)
474
+
475
+ const atBottom = await this.page.evaluate(() => {
476
+ return window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 20
477
+ }).catch(() => false)
478
+
479
+ if (atBottom) {
480
+ if (this.pendingLazyRenders > 0) {
481
+ await this.waitForPendingRenders(3000)
482
+ this.pendingLazyRenders = 0
483
+ }
484
+
485
+ const footerNow = await this.page.locator(footerSelector).first().isVisible().catch(() => false)
486
+ console.log(footerNow ? ` ✅ Footer visible at bottom` : ` ⚠️ At bottom, no footer`)
487
+ break
488
+ }
489
+ }
490
+
491
+ return this.lazyRenders.length - initialRenders
492
+ }
493
+
494
+ private async waitForPendingRenders(maxWait: number): Promise<void> {
495
+ const startTime = Date.now()
496
+
497
+ while (this.pendingLazyRenders > 0 && Date.now() - startTime < maxWait) {
498
+ await this.page.waitForTimeout(100)
499
+ }
500
+
501
+ if (this.pendingLazyRenders > 0) {
502
+ console.log(` ⚠️ Timeout waiting for ${this.pendingLazyRenders} render(s)`)
503
+ }
504
+ }
505
+
506
+ async collectPageMetrics(pageName: string): Promise<PageMetrics> {
507
+ if (this.page.isClosed()) {
508
+ console.log(' ⚠️ Page was closed before collecting metrics')
509
+ return this.getEmptyMetrics(pageName)
510
+ }
511
+
512
+ await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
513
+ if (this.page.isClosed()) return this.getEmptyMetrics(pageName)
514
+ await this.page.waitForTimeout(500).catch(() => {})
515
+
516
+ const serverTiming = this.parseServerTiming()
517
+ const cacheAnalysis = this.analyzeCaching(serverTiming)
518
+
519
+ return {
520
+ url: this.page.url(),
521
+ pageName,
522
+ timestamp: new Date().toISOString(),
523
+ performance: await this.getPerformance(),
524
+ network: this.getNetwork(),
525
+ serverTiming,
526
+ cacheAnalysis,
527
+ renderTime: Date.now() - this.startTime,
528
+ errors: [...this.errors],
529
+ decoHeaders: {
530
+ page: this.decoPageHeader,
531
+ route: this.decoRouteHeader,
532
+ platform: this.decoPlatformHeader,
533
+ },
534
+ }
535
+ }
536
+
537
+ private analyzeCaching(serverTiming: ServerTimingMetrics): CacheAnalysis {
538
+ const warnings: string[] = []
539
+ const pageCached = this.isCached(this.pageCacheControl, 200)
540
+
541
+ const lazyRendersCached = this.lazyRenders.filter(r => r.cached).length
542
+ const lazyRendersUncached = this.lazyRenders.filter(r => !r.cached).length
543
+
544
+ const serverSideLoaders = serverTiming.slowestLoaders.filter(l => l.duration > 0)
545
+
546
+ if (!pageCached && serverSideLoaders.length > 0) {
547
+ const slowLoaders = serverSideLoaders.filter(l => l.duration > 50)
548
+ if (slowLoaders.length > 0) {
549
+ warnings.push(
550
+ `⚠️ Page is NOT cached but has ${slowLoaders.length} slow loader(s) on SSR. ` +
551
+ `Consider moving loaders to lazy sections or adding cache.`
552
+ )
553
+ }
554
+ }
555
+
556
+ if (serverSideLoaders.length > 10) {
557
+ warnings.push(
558
+ `⚠️ ${serverSideLoaders.length} loaders running on SSR. ` +
559
+ `Consider lazy loading more sections.`
560
+ )
561
+ }
562
+
563
+ if (lazyRendersUncached > 0) {
564
+ warnings.push(
565
+ `⚠️ ${lazyRendersUncached} lazy render(s) without cache. ` +
566
+ `Add cache-control headers.`
567
+ )
568
+ }
569
+
570
+ const verySlowLoaders = serverSideLoaders.filter(l => l.duration > 200)
571
+ if (verySlowLoaders.length > 0) {
572
+ warnings.push(
573
+ `🐢 ${verySlowLoaders.length} very slow loader(s) (>200ms) on SSR: ` +
574
+ verySlowLoaders.map(l => `${l.name} (${l.duration}ms)`).join(', ')
575
+ )
576
+ }
577
+
578
+ return {
579
+ pageUrl: this.page.url(),
580
+ pageCached,
581
+ pageCacheControl: this.pageCacheControl,
582
+ lazyRenders: [...this.lazyRenders],
583
+ lazyRendersCached,
584
+ lazyRendersUncached,
585
+ serverSideLoaders,
586
+ warnings,
587
+ }
588
+ }
589
+
590
+ private parseServerTiming(): ServerTimingMetrics {
591
+ return this.parseServerTimingToMetrics(this.serverTimingHeader)
592
+ }
593
+
594
+ private parseServerTimingToMetrics(header: string | null): ServerTimingMetrics {
595
+ const loaders: LoaderTiming[] = []
596
+ const cacheStats = { total: 0, bypass: 0, hit: 0, miss: 0, stale: 0 }
597
+
598
+ if (!header) {
599
+ return {
600
+ loaders: [],
601
+ totalServerTime: 0,
602
+ slowestLoaders: [],
603
+ cacheStats,
604
+ }
605
+ }
606
+
607
+ const entries = header.split(/,\s*/)
608
+
609
+ for (const entry of entries) {
610
+ const parsed = this.parseServerTimingEntry(entry.trim())
611
+ if (parsed) {
612
+ loaders.push(parsed)
613
+ cacheStats.total++
614
+
615
+ if (parsed.status) {
616
+ const statusLower = parsed.status.toLowerCase()
617
+ if (statusLower === 'bypass') cacheStats.bypass++
618
+ else if (statusLower === 'hit') cacheStats.hit++
619
+ else if (statusLower === 'miss') cacheStats.miss++
620
+ else if (statusLower === 'stale') cacheStats.stale++
621
+ }
622
+ }
623
+ }
624
+
625
+ const totalServerTime = loaders
626
+ .filter(l => l.name !== 'render-to-string')
627
+ .reduce((sum, l) => sum + l.duration, 0)
628
+
629
+ const slowestLoaders = [...loaders]
630
+ .filter(l => !['router', 'render-to-string', 'load-data', 'cfExtPri'].includes(l.name))
631
+ .sort((a, b) => b.duration - a.duration)
632
+ .slice(0, 10)
633
+
634
+ return {
635
+ loaders,
636
+ totalServerTime,
637
+ slowestLoaders,
638
+ cacheStats,
639
+ }
640
+ }
641
+
642
+ private parseServerTimingString(header: string): LoaderTiming[] {
643
+ if (!header) return []
644
+
645
+ const entries = header.split(/,\s*/)
646
+ return entries
647
+ .map(entry => this.parseServerTimingEntry(entry.trim()))
648
+ .filter((e): e is LoaderTiming => e !== null)
649
+ }
650
+
651
+ private parseServerTimingEntry(entry: string): LoaderTiming | null {
652
+ if (!entry) return null
653
+
654
+ const parts = entry.split(';')
655
+ if (parts.length === 0) return null
656
+
657
+ const rawName = parts[0]
658
+ const name = this.decodeLoaderName(rawName)
659
+
660
+ let duration = 0
661
+ let status: string | null = null
662
+
663
+ for (let i = 1; i < parts.length; i++) {
664
+ const part = parts[i].trim()
665
+ if (part.startsWith('dur=')) {
666
+ duration = parseFloat(part.substring(4)) || 0
667
+ } else if (part.startsWith('desc=')) {
668
+ status = part.substring(5).replace(/^"|"$/g, '')
669
+ }
670
+ }
671
+
672
+ return { name, duration, status }
673
+ }
674
+
675
+ private decodeLoaderName(raw: string): string {
676
+ try {
677
+ let decoded = decodeURIComponent(raw)
678
+ try {
679
+ decoded = decodeURIComponent(decoded)
680
+ } catch {}
681
+
682
+ decoded = decoded
683
+ .replace(/@/g, ' → ')
684
+ .replace(/\.variants\.\d+\.value\./g, '.')
685
+ .replace(/\.section\./g, '.')
686
+ .replace(/pages-/g, '')
687
+ .replace(/-[a-f0-9]{8,}/g, '')
688
+
689
+ return decoded
690
+ } catch {
691
+ return raw
692
+ }
693
+ }
694
+
695
+ private async getPerformance(): Promise<PerformanceMetrics> {
696
+ const data = await this.page.evaluate(() => {
697
+ const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
698
+ const fcp = performance.getEntriesByType('paint').find(e => e.name === 'first-contentful-paint')
699
+ const lcpEntries = performance.getEntriesByType('largest-contentful-paint')
700
+ const lcp = lcpEntries[lcpEntries.length - 1] as PerformanceEntry & { startTime: number }
701
+
702
+ const layoutShifts = performance.getEntriesByType('layout-shift') as Array<PerformanceEntry & { value: number; hadRecentInput: boolean }>
703
+ const cls = layoutShifts.filter(e => !e.hadRecentInput).reduce((sum, e) => sum + e.value, 0)
704
+
705
+ return {
706
+ TTFB: nav?.responseStart - nav?.requestStart || null,
707
+ domContentLoaded: nav?.domContentLoadedEventEnd - nav?.startTime || null,
708
+ FCP: fcp?.startTime || null,
709
+ LCP: lcp?.startTime || null,
710
+ CLS: cls,
711
+ }
712
+ })
713
+
714
+ return {
715
+ TTFB: data.TTFB,
716
+ FCP: data.FCP,
717
+ LCP: data.LCP,
718
+ CLS: data.CLS,
719
+ domContentLoaded: data.domContentLoaded,
720
+ }
721
+ }
722
+
723
+ private getNetwork(): NetworkMetrics {
724
+ const entries = [...this.requests.values()]
725
+ const totalBytes = entries.reduce((sum, e) => sum + e.size, 0)
726
+
727
+ const slowest = entries
728
+ .filter(e => e.endTime)
729
+ .map(e => ({
730
+ url: e.url.slice(0, 80),
731
+ duration: (e.endTime || 0) - e.startTime,
732
+ type: e.type,
733
+ }))
734
+ .sort((a, b) => b.duration - a.duration)
735
+ .slice(0, 5)
736
+
737
+ return {
738
+ totalRequests: entries.length,
739
+ totalBytes,
740
+ totalBytesFormatted: this.formatBytes(totalBytes),
741
+ slowestRequests: slowest,
742
+ failedRequests: entries.filter(e => e.status === 0).length,
743
+ }
744
+ }
745
+
746
+ private formatBytes(bytes: number): string {
747
+ if (bytes === 0) return '0 B'
748
+ const k = 1024
749
+ const sizes = ['B', 'KB', 'MB']
750
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
751
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
752
+ }
753
+
754
+ async cleanup(): Promise<void> {
755
+ await this.cdp?.detach().catch(() => {})
756
+ }
757
+
758
+ private getEmptyMetrics(pageName: string): PageMetrics {
759
+ return {
760
+ url: 'page-closed',
761
+ pageName,
762
+ timestamp: new Date().toISOString(),
763
+ performance: { LCP: null, FCP: null, CLS: null, TTFB: null, domContentLoaded: null },
764
+ network: { totalRequests: 0, totalBytes: 0, totalBytesFormatted: '0 B', slowestRequests: [], failedRequests: 0 },
765
+ serverTiming: { loaders: [], totalServerTime: 0, slowestLoaders: [], cacheStats: { total: 0, bypass: 0, hit: 0, miss: 0, stale: 0 } },
766
+ cacheAnalysis: { pageUrl: 'page-closed', pageCached: false, pageCacheControl: null, lazyRenders: [], lazyRendersCached: 0, lazyRendersUncached: 0, serverSideLoaders: [], warnings: ['Page was closed before metrics collection'] },
767
+ renderTime: 0,
768
+ errors: ['Page was closed'],
769
+ decoHeaders: { page: null, route: null, platform: null },
770
+ }
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Format loader timings for beautiful console output
776
+ */
777
+ export function formatLoaderTimings(serverTiming: ServerTimingMetrics): string[] {
778
+ const lines: string[] = []
779
+
780
+ if (serverTiming.loaders.length === 0) {
781
+ lines.push(' ⚡ Server Timing: 0ms total (1 loaders)')
782
+ return lines
783
+ }
784
+
785
+ const { bypass, hit, miss, stale } = serverTiming.cacheStats
786
+ const cacheInfo: string[] = []
787
+ if (hit > 0) cacheInfo.push(`💾${hit}`)
788
+ if (miss > 0) cacheInfo.push(`❌${miss}`)
789
+ if (stale > 0) cacheInfo.push(`⏳${stale}`)
790
+ if (bypass > 0) cacheInfo.push(`⏭️${bypass}`)
791
+
792
+ const cacheStr = cacheInfo.length > 0 ? ` [${cacheInfo.join(' ')}]` : ''
793
+ lines.push(` ⚡ Server Timing: ${serverTiming.totalServerTime.toFixed(0)}ms total (${serverTiming.loaders.length} loaders)${cacheStr}`)
794
+
795
+ if (serverTiming.loaders.length > 0) {
796
+ lines.push(' ┌───────────────────────────────────────────────────────────')
797
+
798
+ const sorted = [...serverTiming.loaders].sort((a, b) => b.duration - a.duration)
799
+
800
+ for (const loader of sorted.slice(0, 12)) {
801
+ const speedIcon = loader.duration < 50 ? '🟢' : loader.duration < 200 ? '🟡' : '🔴'
802
+ let cacheIcon = ' '
803
+ if (loader.status === 'HIT') cacheIcon = '💾'
804
+ else if (loader.status === 'MISS') cacheIcon = '❌'
805
+ else if (loader.status === 'STALE') cacheIcon = '⏳'
806
+ else if (loader.status === 'bypass') cacheIcon = '⏭️'
807
+
808
+ const name = loader.name.length > 30 ? loader.name.substring(0, 27) + '...' : loader.name.padEnd(30)
809
+ const status = loader.status ? `[${loader.status}]` : ''
810
+
811
+ lines.push(` │ ${speedIcon} ${name} ${loader.duration.toFixed(0).padStart(5)}ms ${cacheIcon} ${status}`)
812
+ }
813
+
814
+ if (serverTiming.loaders.length > 12) {
815
+ lines.push(` │ ... and ${serverTiming.loaders.length - 12} more loaders`)
816
+ }
817
+
818
+ lines.push(' └───────────────────────────────────────────────────────────')
819
+ }
820
+
821
+ return lines
822
+ }
823
+
824
+ /**
825
+ * Format lazy render analysis for console output
826
+ */
827
+ export function formatLazyRenderAnalysis(cacheAnalysis: CacheAnalysis): string[] {
828
+ const lines: string[] = []
829
+
830
+ const pageIcon = cacheAnalysis.pageCached ? '✅' : '❌'
831
+ lines.push(` ${pageIcon} Page Cache: ${cacheAnalysis.pageCached ? 'CACHED' : 'NOT CACHED'}`)
832
+ if (cacheAnalysis.pageCacheControl && cacheAnalysis.pageCached) {
833
+ lines.push(` Cache-Control: ${cacheAnalysis.pageCacheControl.substring(0, 60)}...`)
834
+ }
835
+
836
+ if (cacheAnalysis.serverSideLoaders.length > 0) {
837
+ lines.push('')
838
+ lines.push(` 🖥️ SSR Loaders (${cacheAnalysis.serverSideLoaders.length}):`)
839
+ lines.push(' ┌───────────────────────────────────────────────────────────')
840
+ for (const loader of cacheAnalysis.serverSideLoaders.slice(0, 10)) {
841
+ const speedIcon = loader.duration < 50 ? '🟢' : loader.duration < 200 ? '🟡' : '🔴'
842
+ const cacheIcon = loader.status === 'HIT' ? '💾' : loader.status === 'STALE' ? '⏳' : loader.status === 'MISS' ? '❌' : '⏭️'
843
+ const name = loader.name.length > 30 ? loader.name.substring(0, 27) + '...' : loader.name.padEnd(30)
844
+ const status = loader.status ? `[${loader.status}]` : ''
845
+ lines.push(` │ ${speedIcon} ${name} ${loader.duration.toString().padStart(4)}ms ${cacheIcon} ${status}`)
846
+ }
847
+ if (cacheAnalysis.serverSideLoaders.length > 10) {
848
+ lines.push(` │ ... and ${cacheAnalysis.serverSideLoaders.length - 10} more loaders`)
849
+ }
850
+ lines.push(' └───────────────────────────────────────────────────────────')
851
+ }
852
+
853
+ if (cacheAnalysis.lazyRenders.length > 0) {
854
+ lines.push('')
855
+ lines.push(` 🔄 Lazy Sections (${cacheAnalysis.lazyRenders.length}):`)
856
+ lines.push(' ┌───────────────────────────────────────────────────────────')
857
+
858
+ const sorted = [...cacheAnalysis.lazyRenders].sort((a, b) => b.duration - a.duration)
859
+
860
+ for (const render of sorted.slice(0, 15)) {
861
+ const speedIcon = render.duration < 100 ? '🟢' : render.duration < 500 ? '🟡' : '🔴'
862
+ let cacheIcon = ' '
863
+ let cacheText = ''
864
+ if (render.cacheStatus) {
865
+ cacheText = render.cacheStatus
866
+ if (render.cacheStatus === 'HIT') cacheIcon = '💾'
867
+ else if (render.cacheStatus === 'MISS') cacheIcon = '❌'
868
+ else if (render.cacheStatus === 'STALE') cacheIcon = '⏳'
869
+ else if (render.cacheStatus === 'DYNAMIC') cacheIcon = '🔄'
870
+ } else if (render.cached) {
871
+ cacheIcon = '💾'
872
+ cacheText = 'cached'
873
+ }
874
+ const name = render.sectionName.length > 26 ? render.sectionName.substring(0, 23) + '...' : render.sectionName.padEnd(26)
875
+
876
+ lines.push(` │ ${speedIcon} ${name} ${render.duration.toString().padStart(5)}ms ${cacheIcon} ${cacheText}`)
877
+ }
878
+
879
+ if (cacheAnalysis.lazyRenders.length > 15) {
880
+ lines.push(` │ ... and ${cacheAnalysis.lazyRenders.length - 15} more sections`)
881
+ }
882
+ lines.push(' └───────────────────────────────────────────────────────────')
883
+
884
+ const fast = cacheAnalysis.lazyRenders.filter(r => r.duration < 100).length
885
+ const medium = cacheAnalysis.lazyRenders.filter(r => r.duration >= 100 && r.duration < 500).length
886
+ const slow = cacheAnalysis.lazyRenders.filter(r => r.duration >= 500).length
887
+ const totalTime = cacheAnalysis.lazyRenders.reduce((sum, r) => sum + r.duration, 0)
888
+
889
+ lines.push(` 📊 Summary: ${fast} fast, ${medium} medium, ${slow} slow │ Total: ${totalTime}ms`)
890
+
891
+ if (cacheAnalysis.lazyRendersUncached > 0) {
892
+ lines.push(` ⚠️ ${cacheAnalysis.lazyRendersUncached} uncached - add cache headers!`)
893
+ }
894
+ }
895
+
896
+ if (cacheAnalysis.warnings.length > 0) {
897
+ lines.push('')
898
+ lines.push(' ⚠️ WARNINGS:')
899
+ for (const warning of cacheAnalysis.warnings) {
900
+ lines.push(` ${warning}`)
901
+ }
902
+ }
903
+
904
+ return lines
905
+ }
906
+
907
+ /**
908
+ * Get a summary of loader timings for compact display
909
+ */
910
+ export function getLoaderSummary(serverTiming: ServerTimingMetrics): string {
911
+ if (serverTiming.loaders.length === 0) return 'No timing data'
912
+
913
+ const count = serverTiming.loaders.length
914
+ const slowest = serverTiming.slowestLoaders[0]
915
+ const slowestInfo = slowest ? `slowest: ${slowest.name.substring(0, 20)}... (${slowest.duration}ms)` : ''
916
+
917
+ return `${count} loaders, ${serverTiming.totalServerTime}ms total${slowestInfo ? ', ' + slowestInfo : ''}`
918
+ }