@decocms/blocks-cli 7.0.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 (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,317 @@
1
+ import type { TransformResult } from "../types";
2
+
3
+ /**
4
+ * Convert JSX attribute syntax to object literal entries.
5
+ * e.g. `page={page}` → `page`, `foo={bar.baz}` → `foo: bar.baz`, `label="hi"` → `label: "hi"`
6
+ */
7
+ function jsxAttrsToObjectEntries(jsxAttrs: string): string {
8
+ return jsxAttrs
9
+ .replace(/(\w+)=\{(\1)\}/g, "$1") // shorthand: name={name} → name
10
+ .replace(/(\w+)=\{([^}]+)\}/g, "$1: $2") // name={expr} → name: expr
11
+ .replace(/(\w+)="([^"]*)"/g, '$1: "$2"') // name="str" → name: "str"
12
+ .replace(/(\w+)='([^']*)'/g, "$1: '$2'"); // name='str' → name: 'str'
13
+ }
14
+
15
+ /**
16
+ * Transforms Preact JSX patterns to React JSX patterns.
17
+ *
18
+ * - class= → className= (in JSX context)
19
+ * - onInput= → onChange= (React's onChange fires on every keystroke)
20
+ * - ComponentChildren → React.ReactNode
21
+ * - JSX.SVGAttributes → React.SVGAttributes
22
+ * - JSX.GenericEventHandler → React.FormEventHandler / React.EventHandler
23
+ * - type { JSX } from "preact" → (removed, use React types)
24
+ */
25
+ export function transformJsx(content: string): TransformResult {
26
+ const notes: string[] = [];
27
+ let changed = false;
28
+ let result = content;
29
+
30
+ // class= → className= in JSX attributes
31
+ // Match class= that's preceded by whitespace and inside a JSX tag
32
+ if (/(<[a-zA-Z][^>]*?\s)class(\s*=)/.test(result)) {
33
+ result = result.replace(
34
+ /(<[a-zA-Z][^>]*?\s)class(\s*=)/g,
35
+ "$1className$2",
36
+ );
37
+ changed = true;
38
+ notes.push("Replaced class= with className=");
39
+ }
40
+
41
+ // Also handle class= at the start of a line in JSX (multi-line attributes)
42
+ if (/^(\s+)class(\s*=)/m.test(result)) {
43
+ result = result.replace(/^(\s+)class(\s*=)/gm, "$1className$2");
44
+ changed = true;
45
+ }
46
+
47
+ // onInput= → onChange= (React onChange fires on every keystroke, like Preact onInput)
48
+ if (result.includes("onInput=")) {
49
+ if (result.includes("onChange=") && result.includes("onInput=")) {
50
+ // Both exist — remove onInput blocks to avoid duplicate JSX attributes.
51
+ // onInput is redundant in React since onChange already fires on every keystroke.
52
+ // Match onInput={...handler...} including multi-line arrow functions
53
+ result = result.replace(
54
+ /\s*onInput=\{[^{}]*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}[^{}]*)*\}/g,
55
+ ""
56
+ );
57
+ changed = true;
58
+ notes.push("Removed onInput= (redundant with existing onChange= in React)");
59
+ } else {
60
+ result = result.replace(/onInput=/g, "onChange=");
61
+ changed = true;
62
+ notes.push("Replaced onInput= with onChange=");
63
+ }
64
+ }
65
+
66
+ // for= → htmlFor= in JSX (label elements)
67
+ if (/(<(?:label|Label)[^>]*?\s)for(\s*=)/.test(result)) {
68
+ result = result.replace(
69
+ /(<(?:label|Label)[^>]*?\s)for(\s*=)/g,
70
+ "$1htmlFor$2",
71
+ );
72
+ changed = true;
73
+ notes.push("Replaced for= with htmlFor= on label elements");
74
+ }
75
+ // Also handle for= at the start of a line in multi-line JSX attributes
76
+ if (/^\s+for\s*=\s*\{/m.test(result)) {
77
+ result = result.replace(/^(\s+)for(\s*=\s*\{)/gm, "$1htmlFor$2");
78
+ changed = true;
79
+ }
80
+
81
+ // ComponentChildren → ReactNode (named import, not React.ReactNode)
82
+ if (result.includes("ComponentChildren")) {
83
+ result = result.replace(/\bComponentChildren\b/g, "ReactNode");
84
+ // Add ReactNode import if not already imported
85
+ if (
86
+ !result.match(/\bReactNode\b.*from\s+["']react["']/) &&
87
+ !result.match(/from\s+["']react["'].*\bReactNode\b/)
88
+ ) {
89
+ // Check if there's already a react import we can extend
90
+ const reactImportMatch = result.match(
91
+ /^(import\s+(?:type\s+)?\{)([^}]*?)(\}\s+from\s+["']react["'];?)$/m,
92
+ );
93
+ if (reactImportMatch) {
94
+ const [fullMatch, prefix, existing, suffix] = reactImportMatch;
95
+ const items = existing.trim();
96
+ result = result.replace(
97
+ fullMatch,
98
+ `${prefix}${items ? `${items}, ` : ""}type ReactNode${suffix}`,
99
+ );
100
+ } else {
101
+ result = `import type { ReactNode } from "react";\n${result}`;
102
+ }
103
+ }
104
+ changed = true;
105
+ notes.push("Replaced ComponentChildren with ReactNode");
106
+ }
107
+
108
+ // JSX.SVGAttributes<SVGSVGElement> → React.SVGAttributes<SVGSVGElement>
109
+ if (result.includes("JSX.SVGAttributes")) {
110
+ result = result.replace(/\bJSX\.SVGAttributes/g, "React.SVGAttributes");
111
+ changed = true;
112
+ notes.push("Replaced JSX.SVGAttributes with React.SVGAttributes");
113
+ }
114
+
115
+ // JSX.GenericEventHandler<X> → React.FormEventHandler<X>
116
+ if (result.includes("JSX.GenericEventHandler")) {
117
+ result = result.replace(
118
+ /\bJSX\.GenericEventHandler/g,
119
+ "React.FormEventHandler",
120
+ );
121
+ changed = true;
122
+ notes.push(
123
+ "Replaced JSX.GenericEventHandler with React.FormEventHandler",
124
+ );
125
+ }
126
+
127
+ // JSX.HTMLAttributes<X> → React.HTMLAttributes<X>
128
+ if (result.includes("JSX.HTMLAttributes")) {
129
+ result = result.replace(
130
+ /\bJSX\.HTMLAttributes/g,
131
+ "React.HTMLAttributes",
132
+ );
133
+ changed = true;
134
+ }
135
+
136
+ // JSX.IntrinsicElements → React.JSX.IntrinsicElements
137
+ if (result.includes("JSX.IntrinsicElements")) {
138
+ result = result.replace(
139
+ /\bJSX\.IntrinsicElements/g,
140
+ "React.JSX.IntrinsicElements",
141
+ );
142
+ changed = true;
143
+ }
144
+
145
+ // Remove standalone "import type { JSX } from 'preact'" if JSX no longer used
146
+ // (it was already removed by imports transform, but double check)
147
+ result = result.replace(
148
+ /^import\s+type\s+\{\s*JSX\s*\}\s+from\s+["']preact["'];?\s*\n?/gm,
149
+ "",
150
+ );
151
+
152
+ // tabindex → tabIndex in JSX
153
+ if (/\btabindex\s*=/.test(result)) {
154
+ result = result.replace(/\btabindex(\s*=)/g, "tabIndex$1");
155
+ changed = true;
156
+ notes.push("Replaced tabindex with tabIndex");
157
+ }
158
+
159
+ // frameBorder → frameBorder (already camelCase, but just in case)
160
+ // referrerpolicy → referrerPolicy
161
+ if (result.includes("referrerpolicy=")) {
162
+ result = result.replace(/referrerpolicy=/g, "referrerPolicy=");
163
+ changed = true;
164
+ notes.push("Replaced referrerpolicy with referrerPolicy");
165
+ }
166
+
167
+ // allowFullScreen={true} is fine in React, but allowfullscreen is not
168
+ if (result.includes("allowfullscreen")) {
169
+ result = result.replace(/\ballowfullscreen\b/g, "allowFullScreen");
170
+ changed = true;
171
+ }
172
+
173
+ // `class` as a prop name in destructuring patterns → `className`
174
+ // Matches: { class: someVar } or { class, } or { ..., class: x } in function params
175
+ if (/[{,]\s*class\s*[,}:]/.test(result)) {
176
+ // class: varName → className: varName (anywhere in destructuring)
177
+ result = result.replace(
178
+ /([{,]\s*)class(\s*:\s*\w+)/g,
179
+ "$1className$2",
180
+ );
181
+ // class, → className, (shorthand, anywhere in destructuring)
182
+ result = result.replace(
183
+ /([{,]\s*)class(\s*[,}])/g,
184
+ "$1className$2",
185
+ );
186
+ changed = true;
187
+ notes.push("Replaced 'class' prop in destructuring with 'className'");
188
+ }
189
+
190
+ // `class` in interface/type definitions → className
191
+ // Matches: class?: string; or class: string;
192
+ if (/^\s+class\??\s*:/m.test(result)) {
193
+ result = result.replace(/^(\s+)class(\??\s*:)/gm, "$1className$2");
194
+ changed = true;
195
+ notes.push("Replaced 'class' in interface definitions with 'className'");
196
+ }
197
+
198
+ // Remove `alt` prop from non-img elements (<a>, <iframe>, <div>, etc.)
199
+ // In React, `alt` is only valid on <img>, <input type="image">, <area>
200
+ const altOnNonImgRegex = /(<(?:a|iframe|div|span|button|section)\s[^>]*?)\s+alt=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g;
201
+ if (altOnNonImgRegex.test(result)) {
202
+ result = result.replace(
203
+ /(<(?:a|iframe|div|span|button|section)\s[^>]*?)\s+alt=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g,
204
+ "$1",
205
+ );
206
+ changed = true;
207
+ notes.push("Removed invalid alt prop from non-img elements");
208
+ }
209
+
210
+ // Remove `type` prop from non-form elements (<span>, <div>, etc.)
211
+ // In React, `type` is only valid on <input>, <button>, <select>, <textarea>, <script>, <style>, <link>
212
+ const typeOnInvalidRegex = /(<(?:span|div|p|section|header|footer|nav|main|article|aside)\s[^>]*?)\s+type=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g;
213
+ if (typeOnInvalidRegex.test(result)) {
214
+ result = result.replace(
215
+ /(<(?:span|div|p|section|header|footer|nav|main|article|aside)\s[^>]*?)\s+type=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g,
216
+ "$1",
217
+ );
218
+ changed = true;
219
+ notes.push("Removed invalid type prop from non-form elements");
220
+ }
221
+
222
+ // setTimeout/setInterval return type: use window.setTimeout for correct typing
223
+ // In Node/CF Workers, setTimeout returns Timeout object, not number
224
+ // window.setTimeout always returns number
225
+ if (/\bsetTimeout\b/.test(result) && /:\s*number/.test(result)) {
226
+ // Only replace bare setTimeout when it's assigned to a typed variable
227
+ result = result.replace(
228
+ /\b(?<!window\.)setTimeout\(/g,
229
+ "window.setTimeout(",
230
+ );
231
+ result = result.replace(
232
+ /\b(?<!window\.)setInterval\(/g,
233
+ "window.setInterval(",
234
+ );
235
+ result = result.replace(
236
+ /\b(?<!window\.)clearTimeout\(/g,
237
+ "window.clearTimeout(",
238
+ );
239
+ result = result.replace(
240
+ /\b(?<!window\.)clearInterval\(/g,
241
+ "window.clearInterval(",
242
+ );
243
+ changed = true;
244
+ notes.push("Prefixed setTimeout/setInterval with window. for correct typing");
245
+ }
246
+
247
+ // Strip data-fresh-disable-lock (Fresh-specific, not needed in React/TanStack)
248
+ if (result.includes("data-fresh-disable-lock")) {
249
+ result = result.replace(/\s*data-fresh-disable-lock=\{[^}]*\}/g, "");
250
+ result = result.replace(/\s*data-fresh-disable-lock="[^"]*"/g, "");
251
+ result = result.replace(/\s*data-fresh-disable-lock/g, "");
252
+ changed = true;
253
+ notes.push("Removed data-fresh-disable-lock attribute (Fresh-specific)");
254
+ }
255
+
256
+ // Replace <x.Component {...x.props} /> with <SectionRenderer section={x} />
257
+ // In TanStack Start, nested sections have Component as a string key, not a function.
258
+ // SectionRenderer from @decocms/start/hooks handles the lazy registry lookup.
259
+ //
260
+ // Gate on ANY variant of the .Component/.props pattern (simple, extra props, or multi-line).
261
+ const sectionPatternGate = /\.\s*Component[\s\n]+\{\.\.\.(\w+)\.props\}/;
262
+ if (sectionPatternGate.test(result)) {
263
+ // 1. Simple: <x.Component {...x.props} />
264
+ result = result.replace(
265
+ /<(\w+)\.Component\s+\{\.\.\.(\w+)\.props\}\s*\/>/g,
266
+ (_, v1) => `<SectionRenderer section={${v1}} />`,
267
+ );
268
+
269
+ // 2. Extra props: <x.Component {...x.props} page={page} />
270
+ // Convert JSX attrs to object entries: name={expr} → name: expr
271
+ result = result.replace(
272
+ /<(\w+)\.Component\s+\{\.\.\.(\w+)\.props\}\s+([^/]+?)\s*\/>/g,
273
+ (_, varName, _v2, extraJsx) => {
274
+ const objEntries = jsxAttrsToObjectEntries(extraJsx.trim());
275
+ return `<SectionRenderer section={{ ...${varName}, props: { ...${varName}.props, ${objEntries} } }} />`;
276
+ },
277
+ );
278
+
279
+ // 3. Multi-line: <x.Component\n {...x.props}\n/>
280
+ result = result.replace(
281
+ /<(\w+)\.Component\s*\n\s*\{\.\.\.(\w+)\.props\}\s*\n\s*\/>/g,
282
+ (_, v1) => `<SectionRenderer section={${v1}} />`,
283
+ );
284
+
285
+ // 4. Multi-line with extra props: <x.Component\n {...x.props}\n extra={val}\n/>
286
+ result = result.replace(
287
+ /<(\w+)\.Component\s*\n\s*\{\.\.\.(\w+)\.props\}\s*\n\s*([^/]+?)\s*\n\s*\/>/g,
288
+ (_, varName, _v2, extraJsx) => {
289
+ const objEntries = jsxAttrsToObjectEntries(extraJsx.trim());
290
+ return `<SectionRenderer section={{ ...${varName}, props: { ...${varName}.props, ${objEntries} } }} />`;
291
+ },
292
+ );
293
+
294
+ if (!result.match(/^import\s.*SectionRenderer/m)) {
295
+ const hooksImportRe = /^import\s+\{([^}]*)\}\s+from\s+["']@decocms\/start\/hooks["'];?$/m;
296
+ const hooksMatch = result.match(hooksImportRe);
297
+ if (hooksMatch) {
298
+ result = result.replace(hooksImportRe, `import { ${hooksMatch[1].trim()}, SectionRenderer } from "@decocms/start/hooks";`);
299
+ } else {
300
+ result = `import { SectionRenderer } from "@decocms/start/hooks";\n${result}`;
301
+ }
302
+ }
303
+ changed = true;
304
+ notes.push("Replaced .Component/.props section pattern with SectionRenderer");
305
+ }
306
+
307
+ // Ensure React import exists if we introduced React.* references
308
+ if (
309
+ (result.includes("React.") || result.includes("React,")) &&
310
+ !result.match(/^import\s.*React/m)
311
+ ) {
312
+ result = `import React from "react";\n${result}`;
313
+ changed = true;
314
+ }
315
+
316
+ return { content: result, changed, notes };
317
+ }
@@ -0,0 +1,210 @@
1
+ import type {
2
+ SectionConventionSets,
3
+ } from "../config";
4
+ import { resolveSectionConventions } from "../config";
5
+ import type { SectionMeta, TransformResult } from "../types";
6
+
7
+ /**
8
+ * Adds section convention exports (sync, eager, layout, cache)
9
+ * to section files based on metadata extracted during analysis.
10
+ *
11
+ * These exports are read by generate-sections.ts in @decocms/start
12
+ * to build the sections.gen.ts registry.
13
+ *
14
+ * The set of section *names* that get hints applied is configurable
15
+ * via `.deco-migrate.config.json` (see `migrate/config.ts`). The exported
16
+ * `transformSectionConventions` keeps a back-compat signature using the
17
+ * baked-in defaults; new callers should prefer
18
+ * `createSectionConventionsTransform(sets)` so config can drive the lists.
19
+ */
20
+
21
+ function getSectionBasename(filePath: string): string {
22
+ return filePath.split("/").pop()?.replace(/\.\w+$/, "") || "";
23
+ }
24
+
25
+ /**
26
+ * Build a `transformSectionConventions` closure bound to the given
27
+ * resolved sets. This is the preferred entry point — the caller (usually
28
+ * `phase-transform`) loads config once and passes the sets in.
29
+ */
30
+ export function createSectionConventionsTransform(
31
+ sets: SectionConventionSets,
32
+ ) {
33
+ return (
34
+ content: string,
35
+ sectionMeta: SectionMeta | undefined,
36
+ ): TransformResult => transformWithSets(content, sectionMeta, sets);
37
+ }
38
+
39
+ /** Default-configured transform. Uses the baked-in defaults from `config.ts`. */
40
+ const defaultSets = resolveSectionConventions(null);
41
+
42
+ export function transformSectionConventions(
43
+ content: string,
44
+ sectionMeta: SectionMeta | undefined,
45
+ ): TransformResult {
46
+ return transformWithSets(content, sectionMeta, defaultSets);
47
+ }
48
+
49
+ function transformWithSets(
50
+ content: string,
51
+ sectionMeta: SectionMeta | undefined,
52
+ sets: SectionConventionSets,
53
+ ): TransformResult {
54
+ if (!sectionMeta) {
55
+ return { content, changed: false, notes: [] };
56
+ }
57
+
58
+ const notes: string[] = [];
59
+ let result = content;
60
+ let changed = false;
61
+ const basename = getSectionBasename(sectionMeta.path);
62
+
63
+ // Header, footer, theme → eager + sync + layout
64
+ if (sectionMeta.isHeader || sectionMeta.isFooter || sectionMeta.isTheme) {
65
+ if (!result.includes("export const eager")) {
66
+ result += "\nexport const eager = true;\n";
67
+ notes.push("Added: export const eager = true");
68
+ changed = true;
69
+ }
70
+ if (!result.includes("export const sync")) {
71
+ result += "export const sync = true;\n";
72
+ notes.push("Added: export const sync = true");
73
+ changed = true;
74
+ }
75
+ // Header in golden does NOT have layout=true; only footer+theme do
76
+ if (
77
+ (sectionMeta.isFooter || sectionMeta.isTheme) &&
78
+ !result.includes("export const layout")
79
+ ) {
80
+ result += "export const layout = true;\n";
81
+ notes.push("Added: export const layout = true");
82
+ changed = true;
83
+ }
84
+ }
85
+
86
+ // Known eager+sync sections (non-layout)
87
+ if (sets.eagerSync.has(basename)) {
88
+ if (!result.includes("export const eager")) {
89
+ result += "\nexport const eager = true;\n";
90
+ notes.push(`Added: export const eager = true (${basename})`);
91
+ changed = true;
92
+ }
93
+ if (!result.includes("export const sync")) {
94
+ result += "export const sync = true;\n";
95
+ notes.push(`Added: export const sync = true (${basename})`);
96
+ changed = true;
97
+ }
98
+ }
99
+
100
+ // Known sync-only sections
101
+ if (sets.sync.has(basename) && !result.includes("export const sync")) {
102
+ result += "\nexport const sync = true;\n";
103
+ notes.push(`Added: export const sync = true (${basename})`);
104
+ changed = true;
105
+ }
106
+
107
+ // Listing cache sections
108
+ if (
109
+ sets.listingCache.has(basename) &&
110
+ !result.includes("export const cache")
111
+ ) {
112
+ result += '\nexport const cache = "listing";\n';
113
+ notes.push(`Added: export const cache = "listing" (${basename})`);
114
+ changed = true;
115
+ }
116
+
117
+ // Static cache sections
118
+ if (
119
+ sets.staticCache.has(basename) &&
120
+ !result.includes("export const cache")
121
+ ) {
122
+ result += '\nexport const cache = "static";\n';
123
+ notes.push(`Added: export const cache = "static" (${basename})`);
124
+ changed = true;
125
+ }
126
+
127
+ // Generic: listing sections not already matched above
128
+ if (sectionMeta.isListing && !result.includes("export const cache")) {
129
+ result += '\nexport const cache = "listing";\n';
130
+ notes.push('Added: export const cache = "listing"');
131
+ changed = true;
132
+ }
133
+
134
+ // Sections with loaders that use device → add sync (needs SSR device detection)
135
+ if (
136
+ sectionMeta.hasLoader &&
137
+ sectionMeta.loaderUsesDevice &&
138
+ !result.includes("export const sync")
139
+ ) {
140
+ result += "\nexport const sync = true;\n";
141
+ notes.push("Added: export const sync = true (loader uses device)");
142
+ changed = true;
143
+ }
144
+
145
+ // Sections that render nested Section children need sync so they're in
146
+ // the syncComponents registry (SectionRenderer resolves the string key).
147
+ const hasNestedSections =
148
+ /children:\s*Section\b/.test(result) ||
149
+ /fallback:\s*Section\b/.test(result);
150
+ if (hasNestedSections && !result.includes("export const sync")) {
151
+ result += "\nexport const sync = true;\n";
152
+ notes.push(
153
+ "Added: export const sync = true (renders nested Section children)",
154
+ );
155
+ changed = true;
156
+ }
157
+
158
+ // Re-export sections that wrap PDP/nested content need sync too.
159
+ // Detect: file is a re-export AND the target component renders nested Sections
160
+ const isReExport = /^export\s+\{[^}]*default[^}]*\}\s+from\s+/.test(
161
+ result.trim(),
162
+ );
163
+ if (
164
+ isReExport &&
165
+ (basename === "MountedPDP" || basename === "NotFoundChallenge")
166
+ ) {
167
+ if (!result.includes("export const sync")) {
168
+ result += "\nexport const sync = true;\n";
169
+ notes.push(`Added: export const sync = true (re-export: ${basename})`);
170
+ changed = true;
171
+ }
172
+ }
173
+
174
+ // Don't add LoadingFallback re-exports to thin section files —
175
+ // we can't guarantee the target component exports it.
176
+ // Instead, if it's a listing section, a generic skeleton will be added below.
177
+
178
+ // Generate a basic LoadingFallback if the section doesn't have one
179
+ // and it's a listing section (visible skeleton improvement)
180
+ if (
181
+ sectionMeta.isListing &&
182
+ !sectionMeta.hasLoadingFallback &&
183
+ !result.includes("LoadingFallback")
184
+ ) {
185
+ result += `
186
+ export function LoadingFallback() {
187
+ return (
188
+ <div className="w-full py-8">
189
+ <div className="container mx-auto px-4">
190
+ <div className="h-6 w-48 bg-base-200 animate-pulse rounded mb-4" />
191
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
192
+ {Array.from({ length: 4 }).map((_, i) => (
193
+ <div key={i} className="flex flex-col gap-2">
194
+ <div className="aspect-square bg-base-200 animate-pulse rounded" />
195
+ <div className="h-4 bg-base-200 animate-pulse rounded w-3/4" />
196
+ <div className="h-4 bg-base-200 animate-pulse rounded w-1/2" />
197
+ </div>
198
+ ))}
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
204
+ `;
205
+ notes.push("Added: LoadingFallback skeleton for listing section");
206
+ changed = true;
207
+ }
208
+
209
+ return { content: result, changed, notes };
210
+ }