@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
@@ -20,7 +20,10 @@ const ROOT_FILES_TO_DELETE = [
20
20
  "tailwind.css",
21
21
  "tailwind.config.ts",
22
22
  "runtime.ts",
23
- "constants.ts",
23
+ // NOTE: `constants.ts` is intentionally NOT deleted here — it holds
24
+ // site-specific UI constants (form/drawer IDs, header heights, etc.)
25
+ // that components reference via `~/constants` or `../../constants`.
26
+ // We move it to `src/constants.ts` instead — see `moveRootConstantsToSrc`.
24
27
  "fresh.gen.ts",
25
28
  "manifest.gen.ts",
26
29
  "fresh.config.ts",
@@ -32,8 +35,6 @@ const ROOT_FILES_TO_DELETE = [
32
35
  const SDK_FILES_TO_DELETE = [
33
36
  "sdk/clx.ts",
34
37
  "sdk/useId.ts",
35
- "sdk/useOffer.ts",
36
- "sdk/useVariantPossiblities.ts",
37
38
  "sdk/usePlatform.tsx",
38
39
  ];
39
40
 
@@ -43,10 +44,14 @@ const WRAPPER_FILES_TO_DELETE = [
43
44
  "sections/Session.tsx",
44
45
  ];
45
46
 
46
- /** Loaders that depend on deleted admin tooling */
47
+ /** Loaders that depend on deleted admin tooling or are replaced by commerce-loaders wrappers */
47
48
  const LOADER_FILES_TO_DELETE = [
48
49
  "loaders/availableIcons.ts",
49
50
  "loaders/icons.ts",
51
+ "loaders/getUserGeolocation.ts",
52
+ "loaders/smartShelfForYou.ts",
53
+ // NOTE: intelligenseSearch.ts is intentionally KEPT — it's the autocomplete
54
+ // loader referenced by Searchbar, useSuggestions, and CMS blocks.
50
55
  ];
51
56
 
52
57
  function deleteFileIfExists(ctx: MigrationContext, relPath: string) {
@@ -201,9 +206,46 @@ function moveMultiBrandStaticFiles(ctx: MigrationContext) {
201
206
  }
202
207
  }
203
208
 
209
+ function moveRootDirToSrc(ctx: MigrationContext, dir: string) {
210
+ const oldDir = path.join(ctx.sourceDir, dir);
211
+ const newDir = path.join(ctx.sourceDir, "src", dir);
212
+ if (!fs.existsSync(oldDir)) return;
213
+
214
+ if (ctx.dryRun) {
215
+ log(ctx, `[DRY] Would move: ${dir}/ → src/${dir}/`);
216
+ ctx.movedFiles.push({ from: `${dir}/`, to: `src/${dir}/` });
217
+ return;
218
+ }
219
+
220
+ if (fs.existsSync(newDir)) {
221
+ // Merge: copy files from old into new (don't overwrite existing)
222
+ copyRecursiveNoOverwrite(oldDir, newDir);
223
+ } else {
224
+ fs.mkdirSync(path.dirname(newDir), { recursive: true });
225
+ fs.cpSync(oldDir, newDir, { recursive: true });
226
+ }
227
+ fs.rmSync(oldDir, { recursive: true, force: true });
228
+ ctx.deletedFiles.push(`${dir}/`);
229
+ log(ctx, `Moved: ${dir}/ → src/${dir}/`);
230
+ }
231
+
232
+ function copyRecursiveNoOverwrite(src: string, dest: string) {
233
+ const entries = fs.readdirSync(src, { withFileTypes: true });
234
+ for (const entry of entries) {
235
+ const srcPath = path.join(src, entry.name);
236
+ const destPath = path.join(dest, entry.name);
237
+ if (entry.isDirectory()) {
238
+ fs.mkdirSync(destPath, { recursive: true });
239
+ copyRecursiveNoOverwrite(srcPath, destPath);
240
+ } else if (!fs.existsSync(destPath)) {
241
+ fs.copyFileSync(srcPath, destPath);
242
+ }
243
+ }
244
+ }
245
+
204
246
  function cleanupOldSourceDirs(ctx: MigrationContext) {
205
- // After transforms, the original top-level dirs have been copied to src/.
206
- // Delete the old top-level copies if they still exist and src/ has them.
247
+ // Dirs that the scaffold/transform phases already created under src/.
248
+ // Delete root copies when both exist.
207
249
  const dirsToClean = [
208
250
  "sections",
209
251
  "components",
@@ -227,6 +269,13 @@ function cleanupOldSourceDirs(ctx: MigrationContext) {
227
269
  }
228
270
  }
229
271
  }
272
+
273
+ // Dirs that need to be MOVED (not just deleted) because scaffold doesn't
274
+ // create them under src/ but code references them via ~/utils, ~/types, etc.
275
+ const dirsToMove = ["utils", "types", "hooks", "contexts"];
276
+ for (const dir of dirsToMove) {
277
+ moveRootDirToSrc(ctx, dir);
278
+ }
230
279
  }
231
280
 
232
281
  /** Delete sections that were re-export wrappers (their islands are now sections) */
@@ -276,7 +325,7 @@ function cleanupJunkFromSrc(ctx: MigrationContext) {
276
325
 
277
326
  // Remove non-code root files from src/
278
327
  const junkFiles = [
279
- "AGENTS.md", "account.json", "biome.json", "blockedQs.ts", "islands.ts",
328
+ "AGENTS.md", "biome.json", "blockedQs.ts", "islands.ts",
280
329
  "lint-changed.sh", "redirects-vtex.csv", "search-urls-cvlb.csv",
281
330
  "search.csv", "sync.sh", "yarn.lock",
282
331
  ];
@@ -293,9 +342,1049 @@ function cleanupJunkFromSrc(ctx: MigrationContext) {
293
342
  }
294
343
  }
295
344
 
345
+ /**
346
+ * Remove empty ({}) block stubs from .deco/blocks/.
347
+ * Some source repos have both `pages-Foo%20bar.json` (empty) and
348
+ * `pages-Foo%2520bar.json` (real data). generate-blocks.ts deduplicates
349
+ * by decoded key, and the empty stub can shadow the real file.
350
+ */
351
+ function removeEmptyBlockStubs(ctx: MigrationContext) {
352
+ const blocksDir = path.join(ctx.sourceDir, ".deco", "blocks");
353
+ if (!fs.existsSync(blocksDir)) return;
354
+
355
+ const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
356
+ for (const file of files) {
357
+ const fullPath = path.join(blocksDir, file);
358
+ const stat = fs.statSync(fullPath);
359
+ if (stat.size > 4) continue; // only target tiny files
360
+ const content = fs.readFileSync(fullPath, "utf-8").trim();
361
+ if (content === "{}" || content === "") {
362
+ if (ctx.dryRun) {
363
+ log(ctx, `[DRY] Would delete empty block stub: .deco/blocks/${file}`);
364
+ } else {
365
+ fs.unlinkSync(fullPath);
366
+ log(ctx, `Deleted empty block stub: .deco/blocks/${file}`);
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ function overrideDeviceContext(ctx: MigrationContext) {
373
+ const target = path.join(ctx.sourceDir, "src", "contexts", "device.tsx");
374
+ const dir = path.dirname(target);
375
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
376
+ const content = `import { useSyncExternalStore } from "react";
377
+
378
+ const MOBILE_QUERY = "(max-width: 767px)";
379
+
380
+ function subscribe(cb: () => void) {
381
+ if (typeof window === "undefined") return () => {};
382
+ const mql = window.matchMedia(MOBILE_QUERY);
383
+ mql.addEventListener("change", cb);
384
+ return () => mql.removeEventListener("change", cb);
385
+ }
386
+
387
+ function getSnapshot(): boolean {
388
+ return window.matchMedia(MOBILE_QUERY).matches;
389
+ }
390
+
391
+ function getServerSnapshot(): boolean {
392
+ return false;
393
+ }
394
+
395
+ /**
396
+ * Reactive mobile detection based on viewport width via matchMedia.
397
+ * SSR defaults to desktop (false); hydrates to the real value on mount.
398
+ *
399
+ * For server-side device detection (UA-based), use the section loader
400
+ * pattern: registerSectionLoaders injects \`isMobile\` as a prop.
401
+ */
402
+ export const useDevice = () => {
403
+ const isMobile = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
404
+ return { isMobile };
405
+ };
406
+ `;
407
+ if (ctx.dryRun) {
408
+ log(ctx, "[DRY] Would override: src/contexts/device.tsx");
409
+ } else {
410
+ fs.writeFileSync(target, content);
411
+ log(ctx, "Overrode src/contexts/device.tsx with useSyncExternalStore implementation");
412
+ }
413
+ }
414
+
415
+ function rewriteRetryUtil(ctx: MigrationContext) {
416
+ const target = path.join(ctx.sourceDir, "src", "utils", "retry.ts");
417
+ if (!fs.existsSync(target)) return;
418
+
419
+ const content = `export const CONNECTION_CLOSED_MESSAGE = "connection closed before message completed";
420
+
421
+ function sleep(ms: number): Promise<void> {
422
+ return new Promise((resolve) => setTimeout(resolve, ms));
423
+ }
424
+
425
+ /**
426
+ * Simple retry utility — replaces cockatiel to avoid module-level AbortController
427
+ * (cockatiel's abort.js creates new AbortController() at module scope, which is
428
+ * forbidden in Cloudflare Workers global scope).
429
+ *
430
+ * Retries up to maxAttempts when the error matches the predicate.
431
+ * Uses exponential backoff: delay = min(initialDelay * exponent^attempt, maxDelay).
432
+ */
433
+ export function retryExceptionOr500() {
434
+ return {
435
+ execute: async <T>(fn: () => Promise<T>): Promise<T> => {
436
+ const maxAttempts = 3;
437
+ const initialDelay = 100;
438
+ const maxDelay = 5000;
439
+ const exponent = 2;
440
+
441
+ let lastErr: unknown;
442
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
443
+ try {
444
+ return await fn();
445
+ } catch (err) {
446
+ const message = err instanceof Error ? err.message : String(err);
447
+ if (!message.includes(CONNECTION_CLOSED_MESSAGE)) {
448
+ throw err;
449
+ }
450
+ lastErr = err;
451
+ try {
452
+ console.error("retrying...", err);
453
+ } catch (_) {}
454
+ if (attempt < maxAttempts - 1) {
455
+ const delay = Math.min(initialDelay * Math.pow(exponent, attempt), maxDelay);
456
+ await sleep(delay);
457
+ }
458
+ }
459
+ }
460
+ throw lastErr;
461
+ },
462
+ };
463
+ }
464
+ `;
465
+ if (ctx.dryRun) {
466
+ log(ctx, "[DRY] Would rewrite: src/utils/retry.ts");
467
+ } else {
468
+ fs.writeFileSync(target, content);
469
+ log(ctx, "Rewrote src/utils/retry.ts (replaced cockatiel with Workers-safe version)");
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Add safety guards for common runtime patterns that crash in React strict mode
475
+ * or in Cloudflare Workers but worked silently in the old Deno/Preact stack.
476
+ *
477
+ * These are useEffect-level errors that React error boundaries catch and
478
+ * propagate, killing the entire section (e.g. the Header).
479
+ */
480
+ function addRuntimeSafetyGuards(ctx: MigrationContext) {
481
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
482
+ let result = content;
483
+ let changed = false;
484
+
485
+ // 1. Guard: `event.params.X = Y` → `if (event.params) event.params.X = Y`
486
+ const paramsAssignRe = /^(\s*)(event\.params\.(\w+)\s*=\s*.+;)$/gm;
487
+ const paramsRepl = result.replace(paramsAssignRe, (_m, indent, assignment) => {
488
+ return `${indent}if (event.params) ${assignment}`;
489
+ });
490
+ if (paramsRepl !== result) {
491
+ result = paramsRepl;
492
+ changed = true;
493
+ log(ctx, ` Added event.params guard: src/${relPath}`);
494
+ }
495
+
496
+ // 2. Guard: `.find(...).params` → `.find(...)?.params`
497
+ // Uses paren-counting to handle nested parens in callbacks like
498
+ // `.find((item) => item?.name === "deco").params`
499
+ result = addOptionalChainAfterFind(result, (msg) => {
500
+ changed = true;
501
+ log(ctx, ` ${msg}: src/${relPath}`);
502
+ });
503
+
504
+ // 3. Guard: undeclared variables used in if-conditions (ReferenceError).
505
+ // In the old Preact stack, some global signals/variables silently
506
+ // resolved to undefined. In React strict mode, bare references to
507
+ // undeclared variables throw ReferenceError which error boundaries catch.
508
+ // We detect variables referenced in the file that are never declared
509
+ // (const/let/var/param/import) and add typeof guards.
510
+ result = guardUndeclaredVariables(result, (msg) => {
511
+ changed = true;
512
+ log(ctx, ` ${msg}: src/${relPath}`);
513
+ });
514
+
515
+ if (!changed) return null;
516
+ return result;
517
+ });
518
+ }
519
+
520
+ /**
521
+ * Find `.find(...)` calls followed by `.params` (without `?.`) and insert
522
+ * optional chaining. Handles nested parentheses correctly.
523
+ */
524
+ function addOptionalChainAfterFind(src: string, onFix: (msg: string) => void): string {
525
+ let result = src;
526
+ let searchFrom = 0;
527
+
528
+ while (true) {
529
+ const findIdx = result.indexOf(".find(", searchFrom);
530
+ if (findIdx === -1) break;
531
+
532
+ // Walk forward from the opening paren, counting depth
533
+ let depth = 1;
534
+ let i = findIdx + 6; // past ".find("
535
+ while (i < result.length && depth > 0) {
536
+ if (result[i] === "(") depth++;
537
+ if (result[i] === ")") depth--;
538
+ i++;
539
+ }
540
+ // i is now right after the matching ")"
541
+ // Check for `.params` without `?.`
542
+ if (result.slice(i, i + 7) === ".params" && result.slice(i - 1, i + 8) !== ")?.params") {
543
+ result = result.slice(0, i) + "?" + result.slice(i);
544
+ onFix("Added optional chain after .find()");
545
+ searchFrom = i + 8; // skip past the inserted "?.params"
546
+ } else {
547
+ searchFrom = i;
548
+ }
549
+ }
550
+
551
+ return result;
552
+ }
553
+
554
+ /**
555
+ * Detect variables used in `if (varName ...` or `if (varName && ...` that are
556
+ * never declared with const/let/var/function/import/param in the file, and
557
+ * wrap with `typeof varName !== "undefined"`.
558
+ *
559
+ * This prevents ReferenceError in React strict mode — the old Preact/Deno
560
+ * stack had more lenient scoping or these variables were injected by the runtime.
561
+ */
562
+ function guardUndeclaredVariables(src: string, onFix: (msg: string) => void): string {
563
+ let result = src;
564
+
565
+ // Find all `if (someVar &&` or `if (someVar)` patterns where someVar
566
+ // is a bare identifier (not a property access, not a function call)
567
+ const ifBareVarRe = /\bif\s*\(\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:&&|\))/g;
568
+ const candidates = new Set<string>();
569
+ let match;
570
+
571
+ while ((match = ifBareVarRe.exec(result)) !== null) {
572
+ candidates.add(match[1]);
573
+ }
574
+
575
+ // Filter to only truly undeclared variables
576
+ const reserved = new Set([
577
+ "true", "false", "null", "undefined", "this", "window", "document",
578
+ "globalThis", "console", "navigator", "location", "localStorage",
579
+ "sessionStorage", "fetch", "JSON", "Array", "Object", "Math",
580
+ "Date", "Error", "Promise", "Map", "Set", "RegExp", "Symbol",
581
+ "parseInt", "parseFloat", "isNaN", "isFinite", "NaN", "Infinity",
582
+ "setTimeout", "clearTimeout", "setInterval", "clearInterval",
583
+ "requestAnimationFrame", "cancelAnimationFrame", "event",
584
+ ]);
585
+
586
+ for (const varName of candidates) {
587
+ if (reserved.has(varName)) continue;
588
+
589
+ // Check if the variable is declared anywhere in the file
590
+ const declPatterns = [
591
+ new RegExp(`\\b(?:const|let|var|function)\\s+${varName}\\b`),
592
+ new RegExp(`\\bimport\\b[^;]*\\b${varName}\\b`),
593
+ // Function parameter: `function foo(varName)` or `(varName) =>`
594
+ new RegExp(`\\(\\s*(?:[^)]*,\\s*)?${varName}\\s*(?:[:,][^)]*)?\\)\\s*(?:=>|\\{)`),
595
+ // Destructuring declaration: `const { varName }` or `let { x: varName }`
596
+ new RegExp(`(?:const|let|var)\\s+\\{[^}]*\\b${varName}\\b[^}]*\\}\\s*=`),
597
+ // For-of/for-in: `for (const varName of/in ...)`
598
+ new RegExp(`for\\s*\\(\\s*(?:const|let|var)\\s+${varName}\\b`),
599
+ ];
600
+
601
+ const isDeclared = declPatterns.some((p) => p.test(result));
602
+ if (isDeclared) continue;
603
+
604
+ // This variable is used in an if-condition but never declared — wrap with typeof
605
+ const unsafePat = new RegExp(
606
+ `\\bif\\s*\\(\\s*${varName}\\b`,
607
+ "g",
608
+ );
609
+ const guardedResult = result.replace(
610
+ unsafePat,
611
+ `if (typeof ${varName} !== "undefined"`,
612
+ );
613
+
614
+ if (guardedResult !== result) {
615
+ result = guardedResult;
616
+ onFix(`Added typeof guard for undeclared variable "${varName}"`);
617
+ }
618
+ }
619
+
620
+ return result;
621
+ }
622
+
623
+ /**
624
+ * Migrate account.json → src/constants/account.ts and rewrite imports.
625
+ *
626
+ * Old stack: root-level `account.json` containing e.g. `"casaevideo"`
627
+ * New stack: `src/constants/account.ts` exporting `accountName`
628
+ *
629
+ * Also rewrites every file that imports from `account.json` (via
630
+ * `$store/account.json`, `site/account.json`, or `~/account.json`)
631
+ * to use `import { accountName } from "~/constants/account"` instead.
632
+ */
633
+ function migrateAccountJson(ctx: MigrationContext) {
634
+ // 1. Read the site name from account.json (check root, then src/)
635
+ let siteName: string | null = null;
636
+ for (const candidate of ["account.json", "src/account.json"]) {
637
+ const fullPath = path.join(ctx.sourceDir, candidate);
638
+ if (fs.existsSync(fullPath)) {
639
+ try {
640
+ const raw = fs.readFileSync(fullPath, "utf-8").trim();
641
+ siteName = JSON.parse(raw);
642
+ if (typeof siteName !== "string") siteName = null;
643
+ } catch { /* ignore parse errors */ }
644
+ // Delete the old JSON file
645
+ if (!ctx.dryRun) {
646
+ fs.unlinkSync(fullPath);
647
+ }
648
+ log(ctx, `Deleted: ${candidate}`);
649
+ break;
650
+ }
651
+ }
652
+
653
+ if (!siteName) {
654
+ // Fallback: try to infer from deco blocks or directory name
655
+ const decofilePath = path.join(ctx.sourceDir, ".deco", "blocks", "vtex.json");
656
+ if (fs.existsSync(decofilePath)) {
657
+ try {
658
+ const vtexBlock = JSON.parse(fs.readFileSync(decofilePath, "utf-8"));
659
+ if (vtexBlock.account && typeof vtexBlock.account === "string") {
660
+ siteName = vtexBlock.account.replace(/newio$/, "").replace(/io$/, "");
661
+ }
662
+ } catch { /* ignore */ }
663
+ }
664
+ if (!siteName) {
665
+ siteName = path.basename(ctx.sourceDir).replace(/-migrated$/, "");
666
+ }
667
+ log(ctx, `Inferred site name: "${siteName}" (no account.json found)`);
668
+ }
669
+
670
+ // 2. Create src/constants/account.ts
671
+ const constantsDir = path.join(ctx.sourceDir, "src", "constants");
672
+ const accountTsPath = path.join(constantsDir, "account.ts");
673
+
674
+ if (ctx.dryRun) {
675
+ log(ctx, `[DRY] Would create: src/constants/account.ts with accountName="${siteName}"`);
676
+ } else {
677
+ fs.mkdirSync(constantsDir, { recursive: true });
678
+ fs.writeFileSync(
679
+ accountTsPath,
680
+ `export const accountName = "${siteName}" as const;\nexport type AccountName = typeof accountName;\n`,
681
+ );
682
+ log(ctx, `Created: src/constants/account.ts (accountName="${siteName}")`);
683
+ }
684
+
685
+ // 3. Rewrite all files that import from account.json or ~/constants/account
686
+ // The transform phase may have already rewritten the specifier from
687
+ // `$store/account.json` → `~/constants/account`, but it only changes the
688
+ // specifier, not the binding style (default → named). We must fix both.
689
+ const accountJsonPattern =
690
+ /import\s+([\w{},\s*]+)\s+from\s+["'](?:\$store|site|~)\/account\.json["']\s*(?:(?:with|assert)\s*\{[^}]*\}\s*)?;?/g;
691
+ const accountTsDefaultPattern =
692
+ /import\s+(\w+)\s+from\s+["']~\/constants\/account["']\s*(?:(?:with|assert)\s*\{[^}]*\}\s*)?;?/g;
693
+
694
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
695
+ if (!content.includes("account.json") && !content.includes("constants/account")) return null;
696
+
697
+ let changed = false;
698
+ let result = content;
699
+
700
+ // Capture old variable name before any replacements
701
+ const defaultImportMatch = content.match(
702
+ /import\s+(\w+)\s+from\s+["'](?:\$store|site|~)\/account\.json["']/,
703
+ ) || content.match(
704
+ /import\s+(\w+)\s+from\s+["']~\/constants\/account["']/,
705
+ );
706
+ const oldVarName = defaultImportMatch?.[1];
707
+
708
+ // Fix account.json imports (pre-transform)
709
+ result = result.replace(accountJsonPattern, (_match, importName) => {
710
+ changed = true;
711
+ const trimmed = importName.trim();
712
+ if (trimmed.startsWith("{")) {
713
+ return `import ${trimmed} from "~/constants/account";`;
714
+ }
715
+ return `import { accountName } from "~/constants/account";`;
716
+ });
717
+
718
+ // Fix default imports from ~/constants/account (post-transform)
719
+ result = result.replace(accountTsDefaultPattern, (_match, varName) => {
720
+ if (varName.startsWith("{")) return _match; // already named
721
+ changed = true;
722
+ return `import { accountName } from "~/constants/account";`;
723
+ });
724
+
725
+ if (!changed) return null;
726
+
727
+ // Rename old variable references if the import used a different name
728
+ if (oldVarName && oldVarName !== "accountName" && oldVarName !== "{") {
729
+ result = result.replace(
730
+ new RegExp(`\\b${oldVarName}\\b`, "g"),
731
+ "accountName",
732
+ );
733
+ }
734
+
735
+ log(ctx, ` Rewrote account import: src/${relPath}`);
736
+ return result;
737
+ });
738
+ }
739
+
740
+ function rewriteFilesRecursive(
741
+ ctx: MigrationContext,
742
+ dir: string,
743
+ transformer: (content: string, relPath: string) => string | null,
744
+ ) {
745
+ if (!fs.existsSync(dir)) return;
746
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
747
+ for (const entry of entries) {
748
+ const fullPath = path.join(dir, entry.name);
749
+ if (entry.isDirectory()) {
750
+ if (entry.name === "node_modules" || entry.name === ".deco") continue;
751
+ rewriteFilesRecursive(ctx, fullPath, transformer);
752
+ } else if (/\.(tsx?|jsx?|mts|mjs)$/.test(entry.name)) {
753
+ const content = fs.readFileSync(fullPath, "utf-8");
754
+ const relPath = path.relative(path.join(ctx.sourceDir, "src"), fullPath);
755
+ const newContent = transformer(content, relPath);
756
+ if (newContent !== null && newContent !== content) {
757
+ if (!ctx.dryRun) {
758
+ fs.writeFileSync(fullPath, newContent);
759
+ }
760
+ }
761
+ }
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Auto-fix section barrel files that re-export `default` but miss `LoadingFallback`.
767
+ * If the target component defines `LoadingFallback`, the section file should re-export it.
768
+ */
769
+ function fixLoadingFallbackReExports(ctx: MigrationContext) {
770
+ const sectionsDir = path.join(ctx.sourceDir, "src", "sections");
771
+ if (!fs.existsSync(sectionsDir)) return;
772
+
773
+ function walk(dir: string) {
774
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
775
+ if (entry.isDirectory()) {
776
+ walk(path.join(dir, entry.name));
777
+ continue;
778
+ }
779
+ if (!entry.name.endsWith(".tsx") && !entry.name.endsWith(".ts")) continue;
780
+
781
+ const filePath = path.join(dir, entry.name);
782
+ const content = fs.readFileSync(filePath, "utf-8");
783
+
784
+ // Match: `export { default } from "../../some/path"` (no LoadingFallback)
785
+ const reExportMatch = content.match(
786
+ /^export\s*\{\s*default\s*\}\s*from\s*["']([^"']+)["']\s*;?\s*$/m,
787
+ );
788
+ if (!reExportMatch) continue;
789
+ if (content.includes("LoadingFallback")) continue; // already has it
790
+
791
+ // Resolve the target module and check if it exports LoadingFallback
792
+ const targetRelPath = reExportMatch[1];
793
+ const resolved = path.resolve(path.dirname(filePath), targetRelPath);
794
+ const candidates = [
795
+ resolved + ".tsx", resolved + ".ts",
796
+ path.join(resolved, "index.tsx"), path.join(resolved, "index.ts"),
797
+ ];
798
+
799
+ for (const candidate of candidates) {
800
+ if (!fs.existsSync(candidate)) continue;
801
+ const targetContent = fs.readFileSync(candidate, "utf-8");
802
+ if (/export\s+(?:function|const)\s+LoadingFallback\b/.test(targetContent)) {
803
+ // Add LoadingFallback to the re-export
804
+ const newContent = content.replace(
805
+ /export\s*\{\s*default\s*\}\s*from/,
806
+ "export { default, LoadingFallback } from",
807
+ );
808
+ if (newContent !== content) {
809
+ if (!ctx.dryRun) fs.writeFileSync(filePath, newContent);
810
+ const rel = path.relative(ctx.sourceDir, filePath);
811
+ log(ctx, ` Added LoadingFallback re-export: ${rel}`);
812
+ }
813
+ break;
814
+ }
815
+ }
816
+ }
817
+ }
818
+
819
+ walk(sectionsDir);
820
+ }
821
+
822
+ /**
823
+ * Find the end index of a self-closing JSX tag starting from a position
824
+ * inside the tag body. Handles nested braces, brackets, and parens.
825
+ * Returns the index of `>` in `/>`, or -1 if not found.
826
+ */
827
+ function findSelfClosingEnd(src: string, startIdx: number): number {
828
+ let i = startIdx;
829
+ while (i < src.length) {
830
+ const ch = src[i];
831
+ if (ch === "{" || ch === "[" || ch === "(") {
832
+ const close = ch === "{" ? "}" : ch === "[" ? "]" : ")";
833
+ let depth = 1;
834
+ i++;
835
+ while (i < src.length && depth > 0) {
836
+ if (src[i] === ch) depth++;
837
+ if (src[i] === close) depth--;
838
+ if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
839
+ const q = src[i];
840
+ i++;
841
+ while (i < src.length && src[i] !== q) {
842
+ if (src[i] === "\\" && q !== "`") i++;
843
+ i++;
844
+ }
845
+ }
846
+ i++;
847
+ }
848
+ continue;
849
+ }
850
+ if (ch === "/" && i + 1 < src.length && src[i + 1] === ">") {
851
+ return i + 1;
852
+ }
853
+ i++;
854
+ }
855
+ return -1;
856
+ }
857
+
858
+ /**
859
+ * Extract JSX prop assignments from a string like:
860
+ * items={[...]} offers={product.offers}
861
+ * Returns an array of { name, value } with balanced brace extraction.
862
+ */
863
+ function extractJsxProps(src: string): Array<{ name: string; value: string }> {
864
+ const props: Array<{ name: string; value: string }> = [];
865
+ const propRe = /(\w+)\s*=\s*\{/g;
866
+ let match;
867
+ while ((match = propRe.exec(src)) !== null) {
868
+ const name = match[1];
869
+ let depth = 1;
870
+ let i = match.index + match[0].length;
871
+ while (i < src.length && depth > 0) {
872
+ if (src[i] === "{") depth++;
873
+ if (src[i] === "}") depth--;
874
+ if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
875
+ const q = src[i];
876
+ i++;
877
+ while (i < src.length && src[i] !== q) {
878
+ if (src[i] === "\\" && q !== "`") i++;
879
+ i++;
880
+ }
881
+ }
882
+ if (depth > 0) i++;
883
+ }
884
+ // i is at the closing }
885
+ const value = src.slice(match.index + match[0].length, i);
886
+ props.push({ name, value });
887
+ propRe.lastIndex = i + 1;
888
+ }
889
+ return props;
890
+ }
891
+
892
+ /**
893
+ * Convert `<varName.Component {...varName?.props} prop1={val1} />` patterns
894
+ * to `<RenderSection section={{...varName, prop1: val1}} />`.
895
+ *
896
+ * Handles multi-line JSX with nested braces (e.g. items={[{...}]}).
897
+ */
898
+ function convertDirectComponentCalls(src: string, onFix: (msg: string) => void): string {
899
+ const componentCallRe = /<(\w+)\.Component\b/g;
900
+ let result = src;
901
+ let offset = 0;
902
+ let match;
903
+
904
+ // Reset lastIndex
905
+ componentCallRe.lastIndex = 0;
906
+ const replacements: Array<{ start: number; end: number; replacement: string }> = [];
907
+
908
+ while ((match = componentCallRe.exec(src)) !== null) {
909
+ const varName = match[1];
910
+ const tagStart = match.index;
911
+
912
+ // Find the self-closing end
913
+ const bodyStart = tagStart + match[0].length;
914
+ const endIdx = findSelfClosingEnd(src, bodyStart);
915
+ if (endIdx === -1) continue;
916
+
917
+ const fullTag = src.slice(tagStart, endIdx + 1);
918
+ const body = src.slice(bodyStart, endIdx - 1).trim(); // between <X.Component and />
919
+
920
+ // Verify there's a spread: {...varName?.props} or {...varName.props}
921
+ const spreadRe = new RegExp(`\\{\\.\\.\\.${varName}\\??\\.(props)\\}`, "g");
922
+ if (!spreadRe.test(body)) continue;
923
+
924
+ // Remove the spread from body and extract remaining props
925
+ const bodyWithoutSpread = body.replace(spreadRe, "").trim();
926
+ const additionalProps = extractJsxProps(bodyWithoutSpread);
927
+
928
+ let sectionExpr: string;
929
+ if (additionalProps.length === 0) {
930
+ sectionExpr = varName;
931
+ } else {
932
+ const propEntries = additionalProps
933
+ .map((p) => `${p.name}: ${p.value}`)
934
+ .join(", ");
935
+ sectionExpr = `{...${varName}, ${propEntries}}`;
936
+ }
937
+
938
+ const replacement = `<RenderSection section={${sectionExpr}} />`;
939
+ replacements.push({ start: tagStart, end: endIdx + 1, replacement });
940
+ }
941
+
942
+ // Apply replacements in reverse order to preserve indices
943
+ for (let i = replacements.length - 1; i >= 0; i--) {
944
+ const r = replacements[i];
945
+ result = result.slice(0, r.start) + r.replacement + result.slice(r.end);
946
+ onFix(`Converted .Component direct call → RenderSection`);
947
+ }
948
+
949
+ return result;
950
+ }
951
+
952
+ /**
953
+ * Replace SectionRenderer with RenderSection in components.
954
+ *
955
+ * SectionRenderer (from DecoPageRenderer) requires section.Component to be a
956
+ * resolved function/string. RenderSection also handles bare { __resolveType }
957
+ * objects, which is how CMS blocks pass nested sections.
958
+ *
959
+ * Also converts direct `<section.Component {...props}/>` patterns to use
960
+ * RenderSection for robustness.
961
+ */
962
+ function upgradeSectionRenderer(ctx: MigrationContext) {
963
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
964
+ if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
965
+
966
+ let result = content;
967
+ let changed = false;
968
+
969
+ // 1. Replace `import { SectionRenderer } from "@decocms/start/hooks"`
970
+ // with `import { RenderSection } from "@decocms/start/hooks"`
971
+ const sectionRendererImport =
972
+ /import\s*\{([^}]*)\bSectionRenderer\b([^}]*)\}\s*from\s*["']@decocms\/start\/hooks["']/g;
973
+ const newContent = result.replace(sectionRendererImport, (_m, before, after) => {
974
+ changed = true;
975
+ return `import {${before}RenderSection${after}} from "@decocms/start/hooks"`;
976
+ });
977
+ if (newContent !== result) {
978
+ result = newContent;
979
+ log(ctx, ` Replaced SectionRenderer import → RenderSection: src/${relPath}`);
980
+ }
981
+
982
+ // 2. Replace JSX: <SectionRenderer section={x} /> → <RenderSection section={x} />
983
+ const sectionRendererJsx = /<SectionRenderer\b/g;
984
+ if (sectionRendererJsx.test(result)) {
985
+ result = result.replace(/<SectionRenderer\b/g, "<RenderSection");
986
+ changed = true;
987
+ log(ctx, ` Replaced <SectionRenderer → <RenderSection: src/${relPath}`);
988
+ }
989
+
990
+ // 3. Convert <varName.Component {...varName?.props} additionalProp={value} ... />
991
+ // to <RenderSection section={{...varName, additionalProp: value, ...}} />
992
+ result = convertDirectComponentCalls(result, (msg) => {
993
+ changed = true;
994
+ log(ctx, ` ${msg}: src/${relPath}`);
995
+ });
996
+
997
+ // 4. Add RenderSection import if we introduced usages but no import exists
998
+ if (changed && result.includes("<RenderSection") && !result.includes("RenderSection")) {
999
+ result = `import { RenderSection } from "@decocms/start/hooks";\n` + result;
1000
+ }
1001
+
1002
+ if (!changed) return null;
1003
+ return result;
1004
+ });
1005
+ }
1006
+
1007
+ /**
1008
+ * Rewrite imports from @decocms/apps/vtex/utils/* and other non-existent modules
1009
+ * to use the simplified ~/lib/ wrappers generated during scaffold.
1010
+ *
1011
+ * This handles:
1012
+ * - @decocms/apps/vtex/utils/transform → ~/lib/vtex-transform
1013
+ * - @decocms/apps/vtex/utils/intelligentSearch → ~/lib/vtex-intelligent-search
1014
+ * - @decocms/apps/vtex/utils/segment → ~/lib/vtex-segment
1015
+ * - @decocms/apps/vtex/client (VTEXCommerceStable) → ~/lib/vtex-client
1016
+ * - @decocms/apps/vtex/loaders/intelligentSearch/* → inline stubs
1017
+ * - createHttpClient from various sources → ~/lib/http-utils
1018
+ * - STALE constant → ~/lib/fetch-utils
1019
+ * - Typed HTTP client patterns → simplified fetch
1020
+ */
1021
+ function rewriteVtexUtilImports(ctx: MigrationContext) {
1022
+ const importRewrites: Array<{ pattern: RegExp; replacement: string; desc: string }> = [
1023
+ {
1024
+ pattern: /from\s+["']@decocms\/apps\/vtex\/utils\/transform["']/g,
1025
+ replacement: 'from "~/lib/vtex-transform"',
1026
+ desc: "vtex/utils/transform → ~/lib/vtex-transform",
1027
+ },
1028
+ {
1029
+ pattern: /from\s+["']@decocms\/apps\/vtex\/utils\/intelligentSearch["']/g,
1030
+ replacement: 'from "~/lib/vtex-intelligent-search"',
1031
+ desc: "vtex/utils/intelligentSearch → ~/lib/vtex-intelligent-search",
1032
+ },
1033
+ {
1034
+ pattern: /from\s+["']@decocms\/apps\/vtex\/utils\/segment["']/g,
1035
+ replacement: 'from "~/lib/vtex-segment"',
1036
+ desc: "vtex/utils/segment → ~/lib/vtex-segment",
1037
+ },
1038
+ {
1039
+ pattern: /from\s+["']@decocms\/apps\/vtex\/client["']/g,
1040
+ replacement: 'from "~/lib/vtex-client"',
1041
+ desc: "vtex/client → ~/lib/vtex-client",
1042
+ },
1043
+ ];
1044
+
1045
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
1046
+ if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
1047
+
1048
+ let result = content;
1049
+ let changed = false;
1050
+
1051
+ for (const rw of importRewrites) {
1052
+ if (rw.pattern.test(result)) {
1053
+ result = result.replace(rw.pattern, rw.replacement);
1054
+ changed = true;
1055
+ log(ctx, ` Import rewrite (${rw.desc}): src/${relPath}`);
1056
+ }
1057
+ }
1058
+
1059
+ // Replace entire import from productListingPage (module doesn't exist in @decocms/apps)
1060
+ const plpImport = /import\s*\{[^}]*\}\s*from\s*["'][^"']*intelligentSearch\/productListingPage["'];?\s*\n?/g;
1061
+ if (plpImport.test(result)) {
1062
+ result = result.replace(plpImport, `type LabelledFuzzy = "disabled" | "automatic" | "always";\nfunction mapLabelledFuzzyToFuzzy(fuzzy: LabelledFuzzy): string {\n const mapping: Record<LabelledFuzzy, string> = { disabled: "0", automatic: "auto", always: "1" };\n return mapping[fuzzy] ?? "0";\n}\n`);
1063
+ changed = true;
1064
+ log(ctx, ` Inlined LabelledFuzzy + mapLabelledFuzzyToFuzzy: src/${relPath}`);
1065
+ }
1066
+
1067
+ // Strip generic type params from createHttpClient<Type>(...) → createHttpClient(...)
1068
+ // The Proxy-based createHttpClient handles all patterns at runtime.
1069
+ const typedClient = /\bcreateHttpClient<[^>]+>/g;
1070
+ if (typedClient.test(result)) {
1071
+ result = result.replace(typedClient, "createHttpClient");
1072
+ changed = true;
1073
+ log(ctx, ` Stripped generic type param from createHttpClient: src/${relPath}`);
1074
+ }
1075
+
1076
+ // Remove `fetcher: fetchSafe,` from createHttpClient options (Proxy uses native fetch)
1077
+ const fetcherParam = /,?\s*fetcher:\s*fetchSafe\s*,?/g;
1078
+ if (fetcherParam.test(result)) {
1079
+ result = result.replace(fetcherParam, (match) => {
1080
+ // If the fetcher was between two other params, keep one comma
1081
+ if (match.startsWith(",") && match.endsWith(",")) return ",";
1082
+ return "";
1083
+ });
1084
+ changed = true;
1085
+ }
1086
+
1087
+ // Replace inline getSegmentFromBag stub with import from ~/lib/vtex-segment
1088
+ const segmentStub = /^const getSegmentFromBag = \(_ctx: any\) => \(\{ value: \{\} as any \}\);\s*\n?/gm;
1089
+ if (segmentStub.test(result)) {
1090
+ result = result.replace(segmentStub, "");
1091
+ if (!result.includes("from \"~/lib/vtex-segment\"")) {
1092
+ result = `import { getSegmentFromBag } from "~/lib/vtex-segment";\n` + result;
1093
+ }
1094
+ changed = true;
1095
+ log(ctx, ` Replaced inline getSegmentFromBag stub → ~/lib/vtex-segment: src/${relPath}`);
1096
+ }
1097
+
1098
+ // Replace inline fetchSafe stub with import from ~/lib/fetch-utils
1099
+ const fetchSafeStub = /^const fetchSafe = async \(url:.*?\n/gm;
1100
+ if (fetchSafeStub.test(result)) {
1101
+ result = result.replace(fetchSafeStub, "");
1102
+ if (!result.includes("from \"~/lib/fetch-utils\"")) {
1103
+ result = `import { fetchSafe } from "~/lib/fetch-utils";\n` + result;
1104
+ }
1105
+ changed = true;
1106
+ log(ctx, ` Replaced inline fetchSafe stub → ~/lib/fetch-utils: src/${relPath}`);
1107
+ }
1108
+
1109
+ // Replace inline getISCookiesFromBag stub with import from ~/lib/vtex-intelligent-search
1110
+ const isCookiesStub = /^const getISCookiesFromBag = \(_ctx: any\) => \(\{\}\);\s*\n?/gm;
1111
+ if (isCookiesStub.test(result)) {
1112
+ result = result.replace(isCookiesStub, "");
1113
+ if (!result.includes("from \"~/lib/vtex-intelligent-search\"")) {
1114
+ result = `import { getISCookiesFromBag } from "~/lib/vtex-intelligent-search";\n` + result;
1115
+ }
1116
+ changed = true;
1117
+ log(ctx, ` Replaced inline getISCookiesFromBag stub → ~/lib/vtex-intelligent-search: src/${relPath}`);
1118
+ }
1119
+
1120
+ // Rewrite ~/utils/retry → @decocms/start/sdk/retry
1121
+ const retryImport = /from\s+["']~\/utils\/retry["']/g;
1122
+ if (retryImport.test(result)) {
1123
+ result = result.replace(retryImport, 'from "@decocms/start/sdk/retry"');
1124
+ changed = true;
1125
+ log(ctx, ` Rewrote retry import → @decocms/start/sdk/retry: src/${relPath}`);
1126
+ }
1127
+
1128
+ // Rewrite type-only imports from productListingPage (Props type)
1129
+ const plpTypeImport = /import\s+type\s*\{[^}]*\}\s*from\s*["'][^"']*intelligentSearch\/productListingPage["'];?\s*\n?/g;
1130
+ if (plpTypeImport.test(result)) {
1131
+ result = result.replace(plpTypeImport, `import type { PLPProps as Props } from "~/types/vtex-loaders";\n`);
1132
+ changed = true;
1133
+ log(ctx, ` Rewrote type import from productListingPage → ~/types/vtex-loaders: src/${relPath}`);
1134
+ }
1135
+
1136
+ if (!changed) return null;
1137
+ return result;
1138
+ });
1139
+ }
1140
+
1141
+ /**
1142
+ * Ensure useVariantPossiblities omit set includes "modalType" and "Modal Type".
1143
+ * These VTEX variant dimensions cause broken variant selectors on PDP if not omitted.
1144
+ */
1145
+ function fixVariantOmitSet(ctx: MigrationContext) {
1146
+ const candidates = [
1147
+ path.join(ctx.sourceDir, "src", "sdk", "useVariantPossiblities.ts"),
1148
+ path.join(ctx.sourceDir, "src", "sdk", "useVariantPossibilities.ts"),
1149
+ ];
1150
+
1151
+ for (const filePath of candidates) {
1152
+ if (!fs.existsSync(filePath)) continue;
1153
+
1154
+ let content = fs.readFileSync(filePath, "utf-8");
1155
+ // Check if the omit set already has modalType
1156
+ if (content.includes('"modalType"')) continue;
1157
+
1158
+ // Add "modalType" and "Modal Type" to the Set constructor
1159
+ const omitSetRe = /new Set\(\[([^\]]*)\]\)/;
1160
+ const match = content.match(omitSetRe);
1161
+ if (!match) continue;
1162
+
1163
+ const existingItems = match[1].trim();
1164
+ const newItems = existingItems
1165
+ ? `${existingItems}, "modalType", "Modal Type"`
1166
+ : `"modalType", "Modal Type"`;
1167
+
1168
+ const newContent = content.replace(omitSetRe, `new Set([${newItems}])`);
1169
+ if (newContent !== content) {
1170
+ if (!ctx.dryRun) fs.writeFileSync(filePath, newContent);
1171
+ const rel = path.relative(ctx.sourceDir, filePath);
1172
+ log(ctx, ` Added modalType/Modal Type to omit set: ${rel}`);
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ /**
1178
+ * Normalize import path casing to match the actual filesystem.
1179
+ * On macOS (case-insensitive), `~/components/Header/` and `~/components/header/`
1180
+ * resolve to the same directory. But on Linux (CI, production builds), mismatched
1181
+ * casing causes "module not found" errors.
1182
+ *
1183
+ * This function scans all source files for `~/` imports and checks whether the
1184
+ * referenced path actually exists with the correct casing. If not, it tries to
1185
+ * find the correct-cased path on the filesystem.
1186
+ */
1187
+ function normalizeImportCasing(ctx: MigrationContext) {
1188
+ const srcDir = path.join(ctx.sourceDir, "src");
1189
+ if (!fs.existsSync(srcDir)) return;
1190
+
1191
+ // Build a map of all actual paths (with their real casing) under src/
1192
+ const realPaths = new Map<string, string>(); // lowercase → actual
1193
+ function indexDir(dir: string, prefix: string) {
1194
+ try {
1195
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1196
+ for (const entry of entries) {
1197
+ if (entry.name === "node_modules" || entry.name === ".deco") continue;
1198
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
1199
+ realPaths.set(rel.toLowerCase(), rel);
1200
+ if (entry.isDirectory()) {
1201
+ indexDir(path.join(dir, entry.name), rel);
1202
+ }
1203
+ }
1204
+ } catch {}
1205
+ }
1206
+ indexDir(srcDir, "");
1207
+
1208
+ rewriteFilesRecursive(ctx, srcDir, (content, relPath) => {
1209
+ if (!content.includes("~/")) return null;
1210
+
1211
+ let result = content;
1212
+ let changed = false;
1213
+
1214
+ // Match imports/exports from "~/" paths
1215
+ const importRe = /(?:from|import\()\s*["'](~\/[^"']+)["']/g;
1216
+ let match;
1217
+ while ((match = importRe.exec(content)) !== null) {
1218
+ const importPath = match[1]; // e.g. ~/components/Header/Buttons/Cart/vtex
1219
+ const relToSrc = importPath.slice(2); // e.g. components/Header/Buttons/Cart/vtex
1220
+
1221
+ // Check with common extensions
1222
+ const candidates = [
1223
+ relToSrc,
1224
+ relToSrc + ".tsx",
1225
+ relToSrc + ".ts",
1226
+ relToSrc + "/index.tsx",
1227
+ relToSrc + "/index.ts",
1228
+ ];
1229
+
1230
+ for (const candidate of candidates) {
1231
+ const lower = candidate.toLowerCase();
1232
+ const actual = realPaths.get(lower);
1233
+ if (actual && actual !== candidate) {
1234
+ // Casing mismatch — fix the import path
1235
+ let corrected = actual;
1236
+ // Strip extension if the original import didn't have one
1237
+ if (!relToSrc.match(/\.\w+$/)) {
1238
+ corrected = corrected.replace(/\.(tsx?|jsx?)$/, "");
1239
+ corrected = corrected.replace(/\/index$/, "");
1240
+ }
1241
+ const oldPath = importPath;
1242
+ const newPath = `~/${corrected}`;
1243
+ if (oldPath !== newPath) {
1244
+ result = result.replace(oldPath, newPath);
1245
+ changed = true;
1246
+ log(ctx, ` Fixed import casing: ${oldPath} → ${newPath} in src/${relPath}`);
1247
+ }
1248
+ break;
1249
+ }
1250
+ }
1251
+ }
1252
+
1253
+ return changed ? result : null;
1254
+ });
1255
+ }
1256
+
1257
+ /**
1258
+ * Move root-level `constants.ts` → `src/constants.ts`.
1259
+ *
1260
+ * Old stack: a root-level `constants.ts` exporting site-wide UI constants
1261
+ * (MINICART_FORM_ID, SIDEMENU_DRAWER_ID, HEADER_HEIGHT, USER_ID, etc.) that
1262
+ * components reference via `../../constants` or `~/constants`.
1263
+ *
1264
+ * Without this step, `phase-cleanup` deletes the file and the build fails
1265
+ * with `Could not resolve "../../constants"` from many components. The CMS
1266
+ * doesn't reference these IDs, so a 1:1 file move is sufficient.
1267
+ *
1268
+ * If `src/constants.ts` already exists (rare — usually means the migration
1269
+ * was re-run), we leave it alone.
1270
+ */
1271
+ function moveRootConstantsToSrc(ctx: MigrationContext) {
1272
+ const rootPath = path.join(ctx.sourceDir, "constants.ts");
1273
+ const srcPath = path.join(ctx.sourceDir, "src", "constants.ts");
1274
+
1275
+ if (!fs.existsSync(rootPath)) return;
1276
+ if (fs.existsSync(srcPath)) {
1277
+ log(ctx, `Skipped move: src/constants.ts already exists; deleting root constants.ts`);
1278
+ if (!ctx.dryRun) fs.unlinkSync(rootPath);
1279
+ ctx.deletedFiles.push("constants.ts");
1280
+ return;
1281
+ }
1282
+
1283
+ if (ctx.dryRun) {
1284
+ log(ctx, `[DRY] Would move: constants.ts → src/constants.ts`);
1285
+ ctx.movedFiles.push({ from: "constants.ts", to: "src/constants.ts" });
1286
+ return;
1287
+ }
1288
+
1289
+ fs.mkdirSync(path.dirname(srcPath), { recursive: true });
1290
+ fs.renameSync(rootPath, srcPath);
1291
+ ctx.movedFiles.push({ from: "constants.ts", to: "src/constants.ts" });
1292
+ log(ctx, `Moved: constants.ts → src/constants.ts`);
1293
+ }
1294
+
1295
+ /**
1296
+ * Rewrite the legacy multi-platform `loaders/minicart.ts` file.
1297
+ *
1298
+ * Old stack: `loaders/minicart.ts` runtime-dispatches on `usePlatform()` to
1299
+ * platform-specific loaders under `sdk/cart/{vtex,vnda,wake,linx,shopify,nuvemshop}/loader.ts`.
1300
+ * The cleanup phase already deletes `sdk/cart/` entirely, leaving the loader
1301
+ * with broken imports.
1302
+ *
1303
+ * New stack: the canonical Minicart contract + VTEX transform live in
1304
+ * `@decocms/apps/vtex/inline-loaders/minicart`. We replace the loader with a
1305
+ * thin VTEX-only re-export. Sites on Shopify/VNDA/Wake/Linx/Nuvemshop are
1306
+ * not currently in production on the new stack — when one is, swap this for
1307
+ * a runtime dispatcher again or add a platform-flagged rewrite.
1308
+ */
1309
+ function rewriteMinicartLoader(ctx: MigrationContext) {
1310
+ const candidates = [
1311
+ path.join(ctx.sourceDir, "src", "loaders", "minicart.ts"),
1312
+ path.join(ctx.sourceDir, "loaders", "minicart.ts"),
1313
+ ];
1314
+
1315
+ for (const filePath of candidates) {
1316
+ if (!fs.existsSync(filePath)) continue;
1317
+
1318
+ const content = fs.readFileSync(filePath, "utf-8");
1319
+ // Only rewrite if it actually imports the legacy multi-platform sdk/cart layout.
1320
+ const isLegacyLoader = /from\s+["'](?:~|\.\.?)\/sdk\/cart\/(?:vtex|vnda|wake|linx|shopify|nuvemshop)\/loader["']/
1321
+ .test(content);
1322
+ if (!isLegacyLoader) continue;
1323
+
1324
+ const newContent = `// VTEX-only minicart loader.
1325
+ //
1326
+ // The legacy site shipped per-platform loaders behind a \`usePlatform()\`
1327
+ // switch (vnda, wake, linx, shopify, nuvemshop). The canonical minicart
1328
+ // contract now lives in \`@decocms/apps\`. Until a non-VTEX customer comes
1329
+ // online on the new stack, we re-export the framework loader directly.
1330
+ // TODO: when adding another platform, replace this with a runtime
1331
+ // dispatcher and import the matching framework loader.
1332
+ export { default } from "@decocms/apps/vtex/inline-loaders/minicart";
1333
+ export type { MinicartProps } from "@decocms/apps/vtex/inline-loaders/minicart";
1334
+ `;
1335
+
1336
+ if (ctx.dryRun) {
1337
+ log(ctx, `[DRY] Would rewrite: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export)`);
1338
+ } else {
1339
+ fs.writeFileSync(filePath, newContent);
1340
+ log(ctx, `Rewrote: ${path.relative(ctx.sourceDir, filePath)} (VTEX-only re-export of @decocms/apps/vtex/inline-loaders/minicart)`);
1341
+ }
1342
+ }
1343
+ }
1344
+
1345
+ /**
1346
+ * Fix APIs that don't exist in Cloudflare Workers:
1347
+ * - window.setTimeout → setTimeout
1348
+ * - window.clearTimeout → clearTimeout
1349
+ * - window.setInterval → setInterval
1350
+ * - window.clearInterval → clearInterval
1351
+ */
1352
+ function fixWorkerIncompatibleApis(ctx: MigrationContext) {
1353
+ const replacements: Array<{ pattern: RegExp; replacement: string }> = [
1354
+ { pattern: /\bwindow\.setTimeout\b/g, replacement: "setTimeout" },
1355
+ { pattern: /\bwindow\.clearTimeout\b/g, replacement: "clearTimeout" },
1356
+ { pattern: /\bwindow\.setInterval\b/g, replacement: "setInterval" },
1357
+ { pattern: /\bwindow\.clearInterval\b/g, replacement: "clearInterval" },
1358
+ ];
1359
+
1360
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
1361
+ if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
1362
+
1363
+ let result = content;
1364
+ let changed = false;
1365
+
1366
+ for (const rp of replacements) {
1367
+ if (rp.pattern.test(result)) {
1368
+ result = result.replace(rp.pattern, rp.replacement);
1369
+ changed = true;
1370
+ }
1371
+ }
1372
+
1373
+ if (changed) {
1374
+ log(ctx, ` Fixed Worker-incompatible APIs: src/${relPath}`);
1375
+ }
1376
+
1377
+ return changed ? result : null;
1378
+ });
1379
+ }
1380
+
296
1381
  export function cleanup(ctx: MigrationContext): void {
297
1382
  logPhase("Cleanup");
298
1383
 
1384
+ // 0. Remove empty block stubs that shadow real data
1385
+ console.log(" Removing empty block stubs...");
1386
+ removeEmptyBlockStubs(ctx);
1387
+
299
1388
  // 1. Move static → public (handles static/, static-cv/, static-lb/, etc.)
300
1389
  console.log(" Moving static assets → public/...");
301
1390
  moveStaticFiles(ctx);
@@ -308,12 +1397,15 @@ export function cleanup(ctx: MigrationContext): void {
308
1397
  }
309
1398
  for (const file of SDK_FILES_TO_DELETE) {
310
1399
  deleteFileIfExists(ctx, file);
1400
+ deleteFileIfExists(ctx, `src/${file}`);
311
1401
  }
312
1402
  for (const file of WRAPPER_FILES_TO_DELETE) {
313
1403
  deleteFileIfExists(ctx, file);
1404
+ deleteFileIfExists(ctx, `src/${file}`);
314
1405
  }
315
1406
  for (const file of LOADER_FILES_TO_DELETE) {
316
1407
  deleteFileIfExists(ctx, file);
1408
+ deleteFileIfExists(ctx, `src/${file}`);
317
1409
  }
318
1410
 
319
1411
  // 3. Delete directories
@@ -328,6 +1420,69 @@ export function cleanup(ctx: MigrationContext): void {
328
1420
  cleanupReExportSections(ctx);
329
1421
  cleanupJunkFromSrc(ctx);
330
1422
 
1423
+ // 5. Override contexts/device.tsx with SSR-safe useSyncExternalStore version.
1424
+ // The transform phase copies and transforms the source file (createContext-based),
1425
+ // but @decocms/start shell-renders sections without a Device.Provider, so we
1426
+ // must replace it with a standalone implementation.
1427
+ console.log(" Overriding contexts/device.tsx...");
1428
+ overrideDeviceContext(ctx);
1429
+
1430
+ // 6. Rewrite retry.ts to remove cockatiel (creates AbortController at module scope)
1431
+ console.log(" Rewriting utils/retry.ts...");
1432
+ rewriteRetryUtil(ctx);
1433
+
1434
+ // 7. Add safety guards for common runtime errors in migrated code
1435
+ console.log(" Adding runtime safety guards...");
1436
+ addRuntimeSafetyGuards(ctx);
1437
+
1438
+ // 8. Fix section barrel files missing LoadingFallback re-export
1439
+ console.log(" Fixing LoadingFallback re-exports...");
1440
+ fixLoadingFallbackReExports(ctx);
1441
+
1442
+ // 9. Replace SectionRenderer with RenderSection for nested sections
1443
+ console.log(" Upgrading SectionRenderer → RenderSection...");
1444
+ upgradeSectionRenderer(ctx);
1445
+
1446
+ // 10. Migrate account.json → src/constants/account.ts
1447
+ // Old stack has a root-level account.json containing the site name as a JSON string.
1448
+ // New stack uses a TS module `src/constants/account.ts` exporting `accountName`.
1449
+ // We also rewrite all imports that reference account.json.
1450
+ console.log(" Migrating account.json → src/constants/account.ts...");
1451
+ migrateAccountJson(ctx);
1452
+
1453
+ // 11. Rewrite VTEX utility imports to use ~/lib/ wrappers
1454
+ // The old stack imports from apps/vtex/utils/* which get rewritten to
1455
+ // @decocms/apps/vtex/utils/* — but the signatures are incompatible
1456
+ // and some types (VTEXCommerceStable) don't exist. Replace with
1457
+ // simplified ~/lib/ wrappers generated during scaffold.
1458
+ console.log(" Rewriting VTEX utility imports → ~/lib/ wrappers...");
1459
+ rewriteVtexUtilImports(ctx);
1460
+
1461
+ // 11a. Preserve root-level constants.ts (site-wide UI IDs/heights) by
1462
+ // moving it to src/constants.ts. The cleanup phase used to delete it
1463
+ // unconditionally, breaking every component that imports `~/constants`.
1464
+ console.log(" Moving root constants.ts → src/constants.ts...");
1465
+ moveRootConstantsToSrc(ctx);
1466
+
1467
+ // 11b. Rewrite legacy multi-platform minicart loader → VTEX-only re-export.
1468
+ // `sdk/cart/` is deleted by DIRS_TO_DELETE, leaving loaders/minicart.ts
1469
+ // with broken imports. Replace it with a thin re-export of the
1470
+ // framework's @decocms/apps/vtex/inline-loaders/minicart loader.
1471
+ console.log(" Rewriting loaders/minicart.ts → VTEX-only re-export...");
1472
+ rewriteMinicartLoader(ctx);
1473
+
1474
+ // 12. Fix useVariantPossiblities omit set
1475
+ console.log(" Fixing useVariantPossiblities omit set...");
1476
+ fixVariantOmitSet(ctx);
1477
+
1478
+ // 13. Normalize component import path casing
1479
+ console.log(" Normalizing component import casing...");
1480
+ normalizeImportCasing(ctx);
1481
+
1482
+ // 13. Fix Worker-incompatible APIs (window.setTimeout, etc.)
1483
+ console.log(" Fixing Worker-incompatible APIs...");
1484
+ fixWorkerIncompatibleApis(ctx);
1485
+
331
1486
  console.log(
332
1487
  ` Deleted ${ctx.deletedFiles.length} files/dirs, moved ${ctx.movedFiles.length} files`,
333
1488
  );