@decocms/start 1.6.2 → 1.7.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 (37) hide show
  1. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +85 -12
  2. package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +98 -0
  3. package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +45 -25
  4. package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +56 -39
  5. package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +122 -141
  6. package/.releaserc.json +1 -0
  7. package/package.json +1 -1
  8. package/scripts/generate-blocks.ts +8 -5
  9. package/scripts/generate-loaders.ts +79 -12
  10. package/scripts/migrate/analyzers/island-classifier.ts +23 -0
  11. package/scripts/migrate/analyzers/section-metadata.ts +63 -7
  12. package/scripts/migrate/phase-analyze.ts +190 -11
  13. package/scripts/migrate/phase-cleanup.ts +1162 -7
  14. package/scripts/migrate/phase-scaffold.ts +294 -5
  15. package/scripts/migrate/phase-transform.ts +56 -3
  16. package/scripts/migrate/templates/app-css.ts +149 -2
  17. package/scripts/migrate/templates/commerce-loaders.ts +174 -69
  18. package/scripts/migrate/templates/lib-utils.ts +255 -0
  19. package/scripts/migrate/templates/package-json.ts +30 -22
  20. package/scripts/migrate/templates/routes.ts +81 -11
  21. package/scripts/migrate/templates/section-loaders.ts +369 -33
  22. package/scripts/migrate/templates/server-entry.ts +350 -80
  23. package/scripts/migrate/templates/setup.ts +78 -8
  24. package/scripts/migrate/templates/types-gen.ts +58 -0
  25. package/scripts/migrate/templates/ui-components.ts +47 -16
  26. package/scripts/migrate/templates/vite-config.ts +17 -6
  27. package/scripts/migrate/templates/wrangler.ts +3 -1
  28. package/scripts/migrate/transforms/dead-code.ts +330 -4
  29. package/scripts/migrate/transforms/deno-isms.ts +19 -0
  30. package/scripts/migrate/transforms/imports.ts +93 -30
  31. package/scripts/migrate/transforms/jsx.ts +79 -4
  32. package/scripts/migrate/transforms/section-conventions.ts +105 -3
  33. package/scripts/migrate/types.ts +9 -1
  34. package/src/cms/resolve.ts +12 -1
  35. package/src/sdk/useScript.ts +27 -6
  36. package/src/sdk/workerEntry.ts +11 -2
  37. package/src/setup.ts +1 -1
@@ -34,17 +34,25 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
34
34
  [/^"apps\/website\/components\/Picture\.tsx"$/, `"~/components/ui/Picture"`],
35
35
  [/^"apps\/website\/components\/Video\.tsx"$/, `"~/components/ui/Video"`],
36
36
  [/^"apps\/website\/components\/Theme\.tsx"$/, `"~/components/ui/Theme"`],
37
+ [/^"apps\/website\/components\/_seo\/[^"]+?"$/, null], // SEO preview — framework-only, remove
37
38
  [/^"apps\/website\/components\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`],
38
39
  [/^"apps\/commerce\/types\.ts"$/, `"@decocms/apps/commerce/types"`],
39
40
  [/^"apps\/commerce\/mod\.ts"$/, `"~/types/commerce-app"`],
40
41
  [/^"apps\/commerce\/types"$/, `"@decocms/apps/commerce/types"`],
41
42
 
42
- // Apps — VTEX (hooks, utils, actions, loaders, types)
43
+ // Apps — VTEX hooks: useUser/useCart/useWishlist → local hooks (react-query based @decocms/apps hooks crash Workers SSR)
44
+ [/^"apps\/vtex\/hooks\/useUser(?:\.ts)?"$/, `"~/hooks/useUser"`],
45
+ [/^"apps\/vtex\/hooks\/useCart(?:\.ts)?"$/, `"~/hooks/useCart"`],
46
+ [/^"apps\/vtex\/hooks\/useWishlist(?:\.ts)?"$/, `"~/hooks/useWishlist"`],
43
47
  [/^"apps\/vtex\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/hooks/$1"`],
48
+ // Specific VTEX utils that moved to different paths in @decocms/apps
49
+ [/^"apps\/vtex\/utils\/fetchVTEX(?:\.ts)?"$/, `"@decocms/apps/vtex/client"`],
50
+ [/^"apps\/vtex\/utils\/client(?:\.ts)?"$/, `"@decocms/apps/vtex/client"`],
44
51
  [/^"apps\/vtex\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/utils/$1"`],
45
52
  [/^"apps\/vtex\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/actions/$1"`],
46
53
  [/^"apps\/vtex\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/loaders/$1"`],
47
54
  [/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps/vtex/types"`],
55
+ [/^"apps\/vtex\/mod(?:\.ts)?"$/, `"~/types/vtex-app"`],
48
56
  // Apps — Shopify (hooks, utils, actions, loaders)
49
57
  [/^"apps\/shopify\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/hooks/$1"`],
50
58
  [/^"apps\/shopify\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/utils/$1"`],
@@ -54,48 +62,72 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
54
62
  [/^"apps\/commerce\/sdk\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/commerce/sdk/$1"`],
55
63
  [/^"apps\/commerce\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/commerce/utils/$1"`],
56
64
 
65
+ // Apps — shared utils (STALE, fetchSafe, createHttpClient, etc.)
66
+ [/^"apps\/utils\/fetch(?:\.ts)?"$/, `"~/lib/fetch-utils"`],
67
+ [/^"apps\/utils\/http(?:\.ts)?"$/, `"~/lib/http-utils"`],
68
+ [/^"apps\/utils\/graphql(?:\.ts)?"$/, `"~/lib/graphql-utils"`],
69
+
57
70
  // Apps — catch-all (things like apps/website/mod.ts, apps/analytics/mod.ts, etc.)
58
71
  [/^"apps\/([^"]+)"$/, null], // Remove — site.ts is rewritten
59
72
 
60
73
  // Deco old CDN imports
61
74
  [/^"deco\/([^"]+)"$/, null],
62
75
 
76
+ // Remote URL imports (esm.sh, cdn.esm.sh, skypack, etc.) — remove
77
+ [/^"https?:\/\/esm\.sh\/[^"]*"$/, null],
78
+ [/^"https?:\/\/cdn\.esm\.sh\/[^"]*"$/, null],
79
+ [/^"https?:\/\/cdn\.skypack\.dev\/[^"]*"$/, null],
80
+ [/^"https?:\/\/deno\.land\/[^"]*"$/, null],
81
+
63
82
  // Std lib — redirect useful utils, remove the rest
64
83
  [/^"std\/async\/debounce(?:\.ts)?"$/, `"~/sdk/debounce"`],
65
84
  [/^"std\/([^"]+)"$/, null],
66
85
  [/^"@std\/crypto"$/, null], // Use globalThis.crypto instead
67
86
 
68
87
  // site/sdk/* → framework equivalents (before the catch-all site/ → ~/ rule)
69
- [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"~/sdk/clx"`],
88
+ [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`],
70
89
  [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`],
71
- [/^"site\/sdk\/useOffer(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useOffer"`],
72
- [/^"site\/sdk\/useVariantPossiblities(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useVariantPossibilities"`],
90
+ // useOffer and useVariantPossiblities kept as site files (~/sdk/)
73
91
  [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null],
74
92
 
75
- // $store/account.json → ~/account.json (JSON import with assertion)
76
- [/^"\$store\/account\.json"$/, `"~/account.json"`],
77
- [/^"site\/account\.json"$/, `"~/account.json"`],
93
+ // account.json → constants/account (JSON file replaced with TS module in cleanup)
94
+ [/^"\$store\/account\.json"$/, `"~/constants/account"`],
95
+ [/^"site\/account\.json"$/, `"~/constants/account"`],
96
+ [/^"~\/account\.json"$/, `"~/constants/account"`],
78
97
 
79
98
  // $store/ → ~/ (common Deno import map alias for project root)
80
- [/^"\$store\/sdk\/clx(?:\.tsx?)?.*"$/, `"~/sdk/clx"`],
99
+ [/^"\$store\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`],
81
100
  [/^"\$store\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`],
82
- [/^"\$store\/sdk\/useOffer(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useOffer"`],
101
+ // useOffer and useVariantPossiblities kept as site files (~/sdk/)
83
102
  [/^"\$store\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/formatPrice"`],
84
- [/^"\$store\/sdk\/useVariantPossiblities(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useVariantPossibilities"`],
85
103
  [/^"\$store\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null],
104
+ // islands → components (must be before $store catch-all)
105
+ [/^"\$store\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`],
106
+ [/^"\$store\/islands\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/$1"`],
86
107
  [/^"\$store\/(.+)"$/, `"~/$1"`],
87
108
 
88
109
  // $home/ → ~/ (another common alias)
89
110
  [/^"\$home\/(.+)"$/, `"~/$1"`],
90
111
 
91
112
  // site/ → ~/
92
- [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"~/sdk/clx"`],
113
+ [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`],
93
114
  [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`],
94
- [/^"site\/sdk\/useOffer(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useOffer"`],
115
+ // useOffer and useVariantPossiblities kept as site files (~/sdk/)
95
116
  [/^"site\/sdk\/format(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/formatPrice"`],
96
- [/^"site\/sdk\/useVariantPossiblities(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useVariantPossibilities"`],
97
117
  [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null],
118
+ // islands → components (must be before site/ catch-all)
119
+ [/^"site\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`],
120
+ [/^"site\/islands\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/$1"`],
98
121
  [/^"site\/(.+)"$/, `"~/$1"`],
122
+
123
+ // ~/islands/* → ~/components/* (catch any that slipped through)
124
+ [/^"~\/islands\/ui\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/ui/$1"`],
125
+ [/^"~\/islands\/([^"]+?)(?:\.tsx?)?"$/, `"~/components/$1"`],
126
+
127
+ // @decocms/apps hooks → local hooks (react-query hooks crash Workers SSR at module eval)
128
+ [/^"@decocms\/apps\/vtex\/hooks\/useUser"$/, `"~/hooks/useUser"`],
129
+ [/^"@decocms\/apps\/vtex\/hooks\/useCart"$/, `"~/hooks/useCart"`],
130
+ [/^"@decocms\/apps\/vtex\/hooks\/useWishlist"$/, `"~/hooks/useWishlist"`],
99
131
  ];
100
132
 
101
133
  /**
@@ -104,20 +136,21 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
104
136
  * The key is the ending of the import path, the value is the replacement specifier.
105
137
  */
106
138
  const RELATIVE_SDK_REWRITES: Array<[RegExp, string]> = [
107
- // sdk/clx → ~/sdk/clx (scaffolded locally with default export)
108
- [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "~/sdk/clx"],
139
+ // sdk/clx → @decocms/start/sdk/clx (framework utility)
140
+ [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "@decocms/start/sdk/clx"],
109
141
  // sdk/useId → react (useId is built-in in React 19)
110
142
  [/(?:\.\.\/)*sdk\/useId(?:\.tsx?)?$/, "react"],
111
- // sdk/useOffer @decocms/apps/commerce/sdk/useOffer
112
- [/(?:\.\.\/)*sdk\/useOffer(?:\.tsx?)?$/, "@decocms/apps/commerce/sdk/useOffer"],
143
+ // sdk/useOffer kept as-is (sites customize offer logic)
144
+ // sdk/useVariantPossiblities — kept as-is (sites customize variant logic)
113
145
  // sdk/format → @decocms/apps/commerce/sdk/formatPrice
114
146
  [/(?:\.\.\/)*sdk\/format(?:\.tsx?)?$/, "@decocms/apps/commerce/sdk/formatPrice"],
115
- // sdk/useVariantPossiblities → @decocms/apps/commerce/sdk/useVariantPossibilities
116
- [/(?:\.\.\/)*sdk\/useVariantPossiblities(?:\.tsx?)?$/, "@decocms/apps/commerce/sdk/useVariantPossibilities"],
117
147
  // sdk/usePlatform → remove entirely
118
148
  [/(?:\.\.\/)*sdk\/usePlatform(?:\.tsx?)?$/, ""],
119
149
  // static/adminIcons → deleted (icon loaders need rewriting)
120
150
  [/(?:\.\.\/)*static\/adminIcons(?:\.ts)?$/, ""],
151
+ // islands/ui/* → components/ui/* (islands are merged into components)
152
+ [/(?:\.\.\/)*islands\/ui\/([^"]+?)(?:\.tsx?)?$/, "~/components/ui/$1"],
153
+ [/(?:\.\.\/)*islands\/([^"]+?)(?:\.tsx?)?$/, "~/components/$1"],
121
154
  ];
122
155
 
123
156
  /**
@@ -132,10 +165,27 @@ const RELATIVE_SDK_REWRITES: Array<[RegExp, string]> = [
132
165
  *
133
166
  * When a rule maps to null, the entire import line is removed.
134
167
  */
135
- export function transformImports(content: string): TransformResult {
168
+ export function transformImports(
169
+ content: string,
170
+ islandWrapperTargets?: Map<string, string>,
171
+ ): TransformResult {
136
172
  const notes: string[] = [];
137
173
  let changed = false;
138
174
 
175
+ // Build dynamic rules from island wrapper targets (wrapper island → actual component)
176
+ const dynamicRules: Array<[RegExp, string]> = [];
177
+ if (islandWrapperTargets) {
178
+ for (const [islandPath, targetImport] of islandWrapperTargets) {
179
+ const componentPath = islandPath.replace("islands/", "components/").replace(/\.tsx?$/, "");
180
+ // Match ~/components/X and rewrite to the wrapper's actual target
181
+ const escaped = componentPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
182
+ dynamicRules.push([
183
+ new RegExp(`^"~/${escaped}"$`),
184
+ `"${targetImport}"`,
185
+ ]);
186
+ }
187
+ }
188
+
139
189
  // Strip BOM that prevents ^ matching on the first line
140
190
  if (content.charCodeAt(0) === 0xfeff) {
141
191
  content = content.slice(1);
@@ -180,6 +230,13 @@ export function transformImports(content: string): TransformResult {
180
230
  );
181
231
  }
182
232
 
233
+ function applyDynamicRules(result: string): string {
234
+ for (const [dynPattern, dynReplacement] of dynamicRules) {
235
+ if (dynPattern.test(result)) return dynReplacement;
236
+ }
237
+ return result;
238
+ }
239
+
183
240
  function rewriteSpecifier(specifier: string): string | null {
184
241
  // Remove quotes for matching
185
242
  const inner = specifier.slice(1, -1);
@@ -187,17 +244,16 @@ export function transformImports(content: string): TransformResult {
187
244
  for (const [pattern, replacement] of IMPORT_RULES) {
188
245
  if (pattern.test(`"${inner}"`)) {
189
246
  if (replacement === null) return null;
190
- // Apply regex replacement
191
247
  let result = `"${inner}"`.replace(pattern, replacement);
192
- // Strip .ts/.tsx extensions from the rewritten path if it's a relative/alias import
193
- const resultInner = result.slice(1, -1);
248
+ let resultInner = result.slice(1, -1);
194
249
  if (
195
250
  (resultInner.startsWith("~/") || resultInner.startsWith("./") || resultInner.startsWith("../")) &&
196
251
  (resultInner.endsWith(".ts") || resultInner.endsWith(".tsx"))
197
252
  ) {
198
- result = `"${resultInner.replace(/\.tsx?$/, "")}"`;
253
+ resultInner = resultInner.replace(/\.tsx?$/, "");
254
+ result = `"${resultInner}"`;
199
255
  }
200
- return result;
256
+ return applyDynamicRules(result);
201
257
  }
202
258
  }
203
259
 
@@ -205,8 +261,9 @@ export function transformImports(content: string): TransformResult {
205
261
  if (inner.startsWith("./") || inner.startsWith("../")) {
206
262
  for (const [pattern, replacement] of RELATIVE_SDK_REWRITES) {
207
263
  if (pattern.test(inner)) {
208
- if (replacement === "") return null; // remove the import
209
- return `"${replacement}"`;
264
+ if (replacement === "") return null;
265
+ const resolved = inner.replace(pattern, replacement);
266
+ return applyDynamicRules(`"${resolved}"`);
210
267
  }
211
268
  }
212
269
  }
@@ -215,7 +272,7 @@ export function transformImports(content: string): TransformResult {
215
272
  if (inner.startsWith("npm:")) {
216
273
  const cleaned = inner
217
274
  .slice(4)
218
- .replace(/@[\d^~>=<.*]+$/, ""); // strip version
275
+ .replace(/@[\d^~>=<.*]+$/, "");
219
276
  return `"${cleaned}"`;
220
277
  }
221
278
 
@@ -226,7 +283,7 @@ export function transformImports(content: string): TransformResult {
226
283
  (inner.endsWith(".ts") || inner.endsWith(".tsx"))
227
284
  ) {
228
285
  const stripped = inner.replace(/\.tsx?$/, "");
229
- return `"${stripped}"`;
286
+ return applyDynamicRules(`"${stripped}"`);
230
287
  }
231
288
 
232
289
  return specifier;
@@ -247,7 +304,13 @@ export function transformImports(content: string): TransformResult {
247
304
  if (newSpec !== specifier) {
248
305
  changed = true;
249
306
  notes.push(`Rewrote: ${specifier} → ${newSpec}`);
250
- return `${prefix}${newSpec}${suffix}`;
307
+ // Strip import assertions (with/assert { type: "json" }) when the
308
+ // specifier no longer points to a JSON file (e.g. account.json → constants/account)
309
+ let cleanSuffix = suffix;
310
+ if (specifier.includes(".json") && !newSpec.includes(".json")) {
311
+ cleanSuffix = cleanSuffix.replace(/\s*(?:with|assert)\s*\{[^}]*\}\s*/, "");
312
+ }
313
+ return `${prefix}${newSpec}${cleanSuffix}`;
251
314
  }
252
315
  return `${prefix}${specifier}${suffix}`;
253
316
  }
@@ -1,5 +1,17 @@
1
1
  import type { TransformResult } from "../types.ts";
2
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
+
3
15
  /**
4
16
  * Transforms Preact JSX patterns to React JSX patterns.
5
17
  *
@@ -32,11 +44,23 @@ export function transformJsx(content: string): TransformResult {
32
44
  changed = true;
33
45
  }
34
46
 
35
- // onInput= → onChange=
47
+ // onInput= → onChange= (React onChange fires on every keystroke, like Preact onInput)
36
48
  if (result.includes("onInput=")) {
37
- result = result.replace(/onInput=/g, "onChange=");
38
- changed = true;
39
- notes.push("Replaced onInput= with onChange=");
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
+ }
40
64
  }
41
65
 
42
66
  // for= → htmlFor= in JSX (label elements)
@@ -229,6 +253,57 @@ export function transformJsx(content: string): TransformResult {
229
253
  notes.push("Removed data-fresh-disable-lock attribute (Fresh-specific)");
230
254
  }
231
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
+
232
307
  // Ensure React import exists if we introduced React.* references
233
308
  if (
234
309
  (result.includes("React.") || result.includes("React,")) &&
@@ -7,6 +7,46 @@ import type { TransformResult, SectionMeta } from "../types.ts";
7
7
  * These exports are read by generate-sections.ts in @decocms/start
8
8
  * to build the sections.gen.ts registry.
9
9
  */
10
+
11
+ const EAGER_SYNC_SECTIONS = new Set([
12
+ "UtilLinks",
13
+ "DepartamentList",
14
+ "ImageGallery",
15
+ "BannersGrid",
16
+ "Carousel",
17
+ "Tipbar",
18
+ "Live",
19
+ ]);
20
+
21
+ const SYNC_SECTIONS = new Set([
22
+ "ProductShelf",
23
+ "ProductShelfTabbed",
24
+ "ProductShelfGroup",
25
+ "ProductShelfTopSort",
26
+ "CouponList",
27
+ "NotFoundChallenge",
28
+ "MountedPDP",
29
+ "BackgroundWrapper",
30
+ "SearchResult",
31
+ "LpCartao",
32
+ ]);
33
+
34
+ const LISTING_CACHE_SECTIONS = new Set([
35
+ "ProductShelf",
36
+ "ProductShelfTabbed",
37
+ "ProductShelfGroup",
38
+ "ProductShelfTimedOffers",
39
+ ]);
40
+
41
+ const STATIC_CACHE_SECTIONS = new Set([
42
+ "InstagramPosts",
43
+ "Faq",
44
+ ]);
45
+
46
+ function getSectionBasename(filePath: string): string {
47
+ return filePath.split("/").pop()?.replace(/\.\w+$/, "") || "";
48
+ }
49
+
10
50
  export function transformSectionConventions(
11
51
  content: string,
12
52
  sectionMeta: SectionMeta | undefined,
@@ -18,6 +58,7 @@ export function transformSectionConventions(
18
58
  const notes: string[] = [];
19
59
  let result = content;
20
60
  let changed = false;
61
+ const basename = getSectionBasename(sectionMeta.path);
21
62
 
22
63
  // Header, footer, theme → eager + sync + layout
23
64
  if (sectionMeta.isHeader || sectionMeta.isFooter || sectionMeta.isTheme) {
@@ -31,14 +72,50 @@ export function transformSectionConventions(
31
72
  notes.push("Added: export const sync = true");
32
73
  changed = true;
33
74
  }
34
- if (!result.includes("export const layout")) {
75
+ // Header in golden does NOT have layout=true; only footer+theme do
76
+ if ((sectionMeta.isFooter || sectionMeta.isTheme) && !result.includes("export const layout")) {
35
77
  result += "export const layout = true;\n";
36
78
  notes.push("Added: export const layout = true");
37
79
  changed = true;
38
80
  }
39
81
  }
40
82
 
41
- // Listing sections → cache = "listing"
83
+ // Known eager+sync sections (non-layout)
84
+ if (EAGER_SYNC_SECTIONS.has(basename)) {
85
+ if (!result.includes("export const eager")) {
86
+ result += "\nexport const eager = true;\n";
87
+ notes.push(`Added: export const eager = true (${basename})`);
88
+ changed = true;
89
+ }
90
+ if (!result.includes("export const sync")) {
91
+ result += "export const sync = true;\n";
92
+ notes.push(`Added: export const sync = true (${basename})`);
93
+ changed = true;
94
+ }
95
+ }
96
+
97
+ // Known sync-only sections
98
+ if (SYNC_SECTIONS.has(basename) && !result.includes("export const sync")) {
99
+ result += "\nexport const sync = true;\n";
100
+ notes.push(`Added: export const sync = true (${basename})`);
101
+ changed = true;
102
+ }
103
+
104
+ // Listing cache sections
105
+ if (LISTING_CACHE_SECTIONS.has(basename) && !result.includes("export const cache")) {
106
+ result += '\nexport const cache = "listing";\n';
107
+ notes.push(`Added: export const cache = "listing" (${basename})`);
108
+ changed = true;
109
+ }
110
+
111
+ // Static cache sections
112
+ if (STATIC_CACHE_SECTIONS.has(basename) && !result.includes("export const cache")) {
113
+ result += '\nexport const cache = "static";\n';
114
+ notes.push(`Added: export const cache = "static" (${basename})`);
115
+ changed = true;
116
+ }
117
+
118
+ // Generic: listing sections not already matched above
42
119
  if (sectionMeta.isListing && !result.includes("export const cache")) {
43
120
  result += '\nexport const cache = "listing";\n';
44
121
  notes.push('Added: export const cache = "listing"');
@@ -52,9 +129,34 @@ export function transformSectionConventions(
52
129
  changed = true;
53
130
  }
54
131
 
132
+ // Sections that render nested Section children need sync so they're in
133
+ // the syncComponents registry (SectionRenderer resolves the string key).
134
+ const hasNestedSections =
135
+ /children:\s*Section\b/.test(result) || /fallback:\s*Section\b/.test(result);
136
+ if (hasNestedSections && !result.includes("export const sync")) {
137
+ result += "\nexport const sync = true;\n";
138
+ notes.push("Added: export const sync = true (renders nested Section children)");
139
+ changed = true;
140
+ }
141
+
142
+ // Re-export sections that wrap PDP/nested content need sync too.
143
+ // Detect: file is a re-export AND the target component renders nested Sections
144
+ const isReExport = /^export\s+\{[^}]*default[^}]*\}\s+from\s+/.test(result.trim());
145
+ if (isReExport && (basename === "MountedPDP" || basename === "NotFoundChallenge")) {
146
+ if (!result.includes("export const sync")) {
147
+ result += "\nexport const sync = true;\n";
148
+ notes.push(`Added: export const sync = true (re-export: ${basename})`);
149
+ changed = true;
150
+ }
151
+ }
152
+
153
+ // Don't add LoadingFallback re-exports to thin section files —
154
+ // we can't guarantee the target component exports it.
155
+ // Instead, if it's a listing section, a generic skeleton will be added below.
156
+
55
157
  // Generate a basic LoadingFallback if the section doesn't have one
56
158
  // and it's a listing section (visible skeleton improvement)
57
- if (sectionMeta.isListing && !sectionMeta.hasLoadingFallback) {
159
+ if (sectionMeta.isListing && !sectionMeta.hasLoadingFallback && !result.includes("LoadingFallback")) {
58
160
  result += `
59
161
  export function LoadingFallback() {
60
162
  return (
@@ -61,7 +61,9 @@ export type DetectedPattern =
61
61
  | "asset-function"
62
62
  | "head-component"
63
63
  | "define-app"
64
- | "invoke-proxy";
64
+ | "invoke-proxy"
65
+ | "use-component"
66
+ | "sections-component-loader";
65
67
 
66
68
  /** Metadata extracted from a section file during analysis */
67
69
  export interface SectionMeta {
@@ -131,6 +133,8 @@ export interface MigrationContext {
131
133
  sourceDir: string;
132
134
  siteName: string;
133
135
  platform: Platform;
136
+ /** VTEX account name (e.g. "casaevideonewio") — extracted from source code */
137
+ vtexAccount: string | null;
134
138
  gtmId: string | null;
135
139
 
136
140
  /** deno.json import map entries */
@@ -151,6 +155,8 @@ export interface MigrationContext {
151
155
  sectionMetas: SectionMeta[];
152
156
  /** Island classifications */
153
157
  islandClassifications: IslandClassification[];
158
+ /** Map from island path → wrapped component import path (for wrapper islands) */
159
+ islandWrapperTargets: Map<string, string>;
154
160
  /** Loader inventory */
155
161
  loaderInventory: LoaderInfo[];
156
162
 
@@ -191,6 +197,7 @@ export function createContext(
191
197
  sourceDir,
192
198
  siteName: "",
193
199
  platform: "custom",
200
+ vtexAccount: null,
194
201
  gtmId: null,
195
202
  importMap: {},
196
203
  discoveredNpmDeps: {},
@@ -199,6 +206,7 @@ export function createContext(
199
206
  files: [],
200
207
  sectionMetas: [],
201
208
  islandClassifications: [],
209
+ islandWrapperTargets: new Map(),
202
210
  loaderInventory: [],
203
211
  scaffoldedFiles: [],
204
212
  transformedFiles: [],
@@ -228,7 +228,18 @@ function isBot(userAgent?: string): boolean {
228
228
  return botPatterns.some((re) => re.test(userAgent));
229
229
  }
230
230
 
231
- export type CommerceLoader = (props: any) => Promise<any>;
231
+ /**
232
+ * A loader registered against a `__resolveType` key. The runtime invokes it
233
+ * through two paths:
234
+ *
235
+ * 1. CMS resolution (`commerceLoader(resolvedProps)`) — 1-arg call.
236
+ * 2. `/deco/invoke/...` endpoint — `(props, request)` 2-arg call.
237
+ *
238
+ * Loaders that need the `Request` (cookies, geo, headers) declare the second
239
+ * parameter; pure loaders ignore it. This shape lets a single registry serve
240
+ * both invocation paths without `as any` casts at every wrapper.
241
+ */
242
+ export type CommerceLoader = (props: any, request?: Request) => Promise<any>;
232
243
 
233
244
  /**
234
245
  * Context passed through the resolution pipeline.
@@ -137,13 +137,34 @@ export function inlineScript(js: string) {
137
137
  }
138
138
 
139
139
  /**
140
- * Stub -- Deco partial sections don't apply in TanStack Start.
141
- * Returns the provided props as-is.
140
+ * @deprecated Removed in TanStack Start.
141
+ *
142
+ * The Fresh/Deno HTMX-based partial-section pattern (`useSection` /
143
+ * `usePartialSection` + `sections/Component.tsx`) does not apply on
144
+ * Cloudflare Workers and React. Replace call-sites with one of:
145
+ *
146
+ * 1. Local React state (`useState` + event handlers) for client-side toggles.
147
+ * 2. `createServerFn` + `useMutation` for server actions.
148
+ * 3. Direct `invoke` calls (`~/server/invoke`) for ad-hoc loaders.
149
+ *
150
+ * See: deco-to-tanstack-migration skill, "useComponent / partial sections"
151
+ * section, for the per-pattern recipes.
152
+ *
153
+ * Both stubs throw at runtime (and at import time, if you call them at
154
+ * module top level) so legacy code surfaces a clear error instead of a
155
+ * silent no-op.
142
156
  */
143
- export function usePartialSection(props?: Record<string, unknown>) {
144
- return props || {};
157
+ const DEPRECATION_MESSAGE =
158
+ "[@decocms/start] useSection / usePartialSection were removed. " +
159
+ "The Fresh/Deno HTMX partial-section pattern does not apply on " +
160
+ "TanStack Start / Cloudflare Workers. Replace call-sites with " +
161
+ "createServerFn + useMutation, or local React state. See the " +
162
+ "deco-to-tanstack-migration skill for per-pattern recipes.";
163
+
164
+ export function usePartialSection(_props?: Record<string, unknown>): never {
165
+ throw new Error(DEPRECATION_MESSAGE);
145
166
  }
146
167
 
147
- export function useSection(_props?: Record<string, unknown>) {
148
- return "";
168
+ export function useSection(_props?: Record<string, unknown>): never {
169
+ throw new Error(DEPRECATION_MESSAGE);
149
170
  }
@@ -34,7 +34,7 @@ import {
34
34
  } from "./cacheHeaders";
35
35
  import { buildHtmlShell } from "./htmlShell";
36
36
  import { cleanPathForCacheKey } from "./urlUtils";
37
- import { isMobileUA } from "./useDevice";
37
+ import { type Device, isMobileUA } from "./useDevice";
38
38
  import { getRenderShellConfig } from "../admin/setup";
39
39
  import { RequestContext } from "./requestContext";
40
40
  import { getAppMiddleware } from "./setupApps";
@@ -88,7 +88,16 @@ interface ServerEntry {
88
88
  * cache entry; different segments get different cached responses.
89
89
  */
90
90
  export interface SegmentKey {
91
- device: "mobile" | "desktop";
91
+ /**
92
+ * Device class derived from the request User-Agent.
93
+ *
94
+ * Accepts the full `Device` union (`"mobile" | "desktop" | "tablet"`) so
95
+ * that callers can pass `detectDevice(...)` directly without manual
96
+ * narrowing. Sites that want to share cache entries between mobile and
97
+ * tablet can collapse the value at the call site (e.g.
98
+ * `device === "tablet" ? "mobile" : device`).
99
+ */
100
+ device: Device;
92
101
  /** Whether the user is logged in (e.g., has a valid auth cookie). */
93
102
  loggedIn?: boolean;
94
103
  /** Commerce sales channel / price list. */
package/src/setup.ts CHANGED
@@ -96,7 +96,7 @@ export interface SiteSetupOptions {
96
96
  * { getCommerceLoaders: () => COMMERCE_LOADERS }
97
97
  * ```
98
98
  */
99
- getCommerceLoaders?: () => Record<string, (props: any) => Promise<any>>;
99
+ getCommerceLoaders?: () => Record<string, (props: any, request?: Request) => Promise<any>>;
100
100
  }
101
101
 
102
102
  /**