@decocms/start 1.6.2 → 1.6.3

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