@decocms/blocks-cli 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,1588 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { MigrationContext } from "./types";
4
+ import { log, logPhase } from "./types";
5
+ import {
6
+ LIB_TEMPLATES,
7
+ selectImportedLibTemplates,
8
+ } from "./templates/lib-utils";
9
+
10
+ /** Directories to remove entirely after migration */
11
+ const DIRS_TO_DELETE = [
12
+ "islands",
13
+ "routes",
14
+ "apps/deco",
15
+ "sdk/cart",
16
+ ];
17
+
18
+ /** Individual root files to delete */
19
+ const ROOT_FILES_TO_DELETE = [
20
+ "main.ts",
21
+ "dev.ts",
22
+ "deno.json",
23
+ "deno.lock",
24
+ "tailwind.css",
25
+ "tailwind.config.ts",
26
+ "runtime.ts",
27
+ // NOTE: `constants.ts` is intentionally NOT deleted here — it holds
28
+ // site-specific UI constants (form/drawer IDs, header heights, etc.)
29
+ // that components reference via `~/constants` or `../../constants`.
30
+ // We move it to `src/constants.ts` instead — see `moveRootConstantsToSrc`.
31
+ "fresh.gen.ts",
32
+ "manifest.gen.ts",
33
+ "fresh.config.ts",
34
+ "browserslist",
35
+ "bw_stats.json",
36
+ ];
37
+
38
+ /** SDK files that have framework equivalents */
39
+ const SDK_FILES_TO_DELETE = [
40
+ "sdk/clx.ts",
41
+ "sdk/useId.ts",
42
+ "sdk/usePlatform.tsx",
43
+ ];
44
+
45
+ /** Section/component wrappers that are no longer needed */
46
+ const WRAPPER_FILES_TO_DELETE = [
47
+ "components/Session.tsx",
48
+ "sections/Session.tsx",
49
+ ];
50
+
51
+ /** Loaders that depend on deleted admin tooling or are replaced by commerce-loaders wrappers */
52
+ const LOADER_FILES_TO_DELETE = [
53
+ "loaders/availableIcons.ts",
54
+ "loaders/icons.ts",
55
+ "loaders/getUserGeolocation.ts",
56
+ "loaders/smartShelfForYou.ts",
57
+ // NOTE: intelligenseSearch.ts is intentionally KEPT — it's the autocomplete
58
+ // loader referenced by Searchbar, useSuggestions, and CMS blocks.
59
+ ];
60
+
61
+ function deleteFileIfExists(ctx: MigrationContext, relPath: string) {
62
+ const fullPath = path.join(ctx.sourceDir, relPath);
63
+ if (!fs.existsSync(fullPath)) return;
64
+
65
+ if (ctx.dryRun) {
66
+ log(ctx, `[DRY] Would delete: ${relPath}`);
67
+ ctx.deletedFiles.push(relPath);
68
+ return;
69
+ }
70
+
71
+ fs.unlinkSync(fullPath);
72
+ ctx.deletedFiles.push(relPath);
73
+ log(ctx, `Deleted: ${relPath}`);
74
+ }
75
+
76
+ function deleteDirIfExists(ctx: MigrationContext, relPath: string) {
77
+ const fullPath = path.join(ctx.sourceDir, relPath);
78
+ if (!fs.existsSync(fullPath)) return;
79
+
80
+ if (ctx.dryRun) {
81
+ log(ctx, `[DRY] Would delete dir: ${relPath}/`);
82
+ ctx.deletedFiles.push(`${relPath}/`);
83
+ return;
84
+ }
85
+
86
+ fs.rmSync(fullPath, { recursive: true, force: true });
87
+ ctx.deletedFiles.push(`${relPath}/`);
88
+ log(ctx, `Deleted dir: ${relPath}/`);
89
+ }
90
+
91
+ function moveStaticFiles(ctx: MigrationContext) {
92
+ const staticDir = path.join(ctx.sourceDir, "static");
93
+ if (!fs.existsSync(staticDir)) return;
94
+
95
+ const publicDir = path.join(ctx.sourceDir, "public");
96
+
97
+ function moveRecursive(dir: string) {
98
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
99
+ for (const entry of entries) {
100
+ const srcPath = path.join(dir, entry.name);
101
+ const relFromStatic = path.relative(staticDir, srcPath);
102
+ const destPath = path.join(publicDir, relFromStatic);
103
+
104
+ // Skip generated files
105
+ if (
106
+ entry.name === "tailwind.css" || entry.name === "adminIcons.ts" ||
107
+ entry.name === "generate-icons.ts"
108
+ ) {
109
+ continue;
110
+ }
111
+
112
+ if (entry.isDirectory()) {
113
+ moveRecursive(srcPath);
114
+ continue;
115
+ }
116
+
117
+ if (ctx.dryRun) {
118
+ log(ctx, `[DRY] Would move: static/${relFromStatic} → public/${relFromStatic}`);
119
+ ctx.movedFiles.push({
120
+ from: `static/${relFromStatic}`,
121
+ to: `public/${relFromStatic}`,
122
+ });
123
+ continue;
124
+ }
125
+
126
+ // Ensure dest dir exists
127
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
128
+ fs.copyFileSync(srcPath, destPath);
129
+ ctx.movedFiles.push({
130
+ from: `static/${relFromStatic}`,
131
+ to: `public/${relFromStatic}`,
132
+ });
133
+ log(ctx, `Moved: static/${relFromStatic} → public/${relFromStatic}`);
134
+ }
135
+ }
136
+
137
+ moveRecursive(staticDir);
138
+
139
+ // Now delete static/ dir
140
+ if (!ctx.dryRun) {
141
+ fs.rmSync(staticDir, { recursive: true, force: true });
142
+ log(ctx, "Deleted dir: static/");
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Handle multi-brand static directories (static-cv/, static-lb/, etc.).
148
+ * The "primary" brand's assets go to public/.
149
+ */
150
+ function moveMultiBrandStaticFiles(ctx: MigrationContext) {
151
+ const entries = fs.readdirSync(ctx.sourceDir, { withFileTypes: true });
152
+ const staticDirs = entries.filter(
153
+ (e) => e.isDirectory() && e.name.startsWith("static-"),
154
+ );
155
+
156
+ if (staticDirs.length === 0) return;
157
+
158
+ // Use the first one as primary (or match by site name)
159
+ const primaryDir = staticDirs[0];
160
+ const primaryPath = path.join(ctx.sourceDir, primaryDir.name);
161
+ const publicDir = path.join(ctx.sourceDir, "public");
162
+
163
+ log(ctx, `Found multi-brand static dirs: ${staticDirs.map((d) => d.name).join(", ")}`);
164
+ log(ctx, `Using ${primaryDir.name} as primary → public/`);
165
+
166
+ function copyRecursive(dir: string, base: string) {
167
+ const items = fs.readdirSync(dir, { withFileTypes: true });
168
+ for (const item of items) {
169
+ const srcPath = path.join(dir, item.name);
170
+ const relFromBase = path.relative(base, srcPath);
171
+ const destPath = path.join(publicDir, relFromBase);
172
+
173
+ if (item.name === "tailwind.css" || item.name === "adminIcons.ts") continue;
174
+ // Skip partytown (not needed in Workers)
175
+ if (item.name === "~partytown" || item.name === "partytown") continue;
176
+
177
+ if (item.isDirectory()) {
178
+ copyRecursive(srcPath, base);
179
+ continue;
180
+ }
181
+
182
+ if (ctx.dryRun) {
183
+ log(ctx, `[DRY] Would copy: ${primaryDir.name}/${relFromBase} → public/${relFromBase}`);
184
+ ctx.movedFiles.push({ from: `${primaryDir.name}/${relFromBase}`, to: `public/${relFromBase}` });
185
+ continue;
186
+ }
187
+
188
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
189
+ fs.copyFileSync(srcPath, destPath);
190
+ ctx.movedFiles.push({ from: `${primaryDir.name}/${relFromBase}`, to: `public/${relFromBase}` });
191
+ }
192
+ }
193
+
194
+ copyRecursive(primaryPath, primaryPath);
195
+
196
+ // Clean up all static-* dirs (both root and src/)
197
+ if (!ctx.dryRun) {
198
+ for (const d of staticDirs) {
199
+ const rootDir = path.join(ctx.sourceDir, d.name);
200
+ if (fs.existsSync(rootDir)) {
201
+ fs.rmSync(rootDir, { recursive: true, force: true });
202
+ log(ctx, `Deleted: ${d.name}/`);
203
+ }
204
+ const srcDir = path.join(ctx.sourceDir, "src", d.name);
205
+ if (fs.existsSync(srcDir)) {
206
+ fs.rmSync(srcDir, { recursive: true, force: true });
207
+ log(ctx, `Deleted: src/${d.name}/`);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ function moveRootDirToSrc(ctx: MigrationContext, dir: string) {
214
+ const oldDir = path.join(ctx.sourceDir, dir);
215
+ const newDir = path.join(ctx.sourceDir, "src", dir);
216
+ if (!fs.existsSync(oldDir)) return;
217
+
218
+ if (ctx.dryRun) {
219
+ log(ctx, `[DRY] Would move: ${dir}/ → src/${dir}/`);
220
+ ctx.movedFiles.push({ from: `${dir}/`, to: `src/${dir}/` });
221
+ return;
222
+ }
223
+
224
+ if (fs.existsSync(newDir)) {
225
+ // Merge: copy files from old into new (don't overwrite existing)
226
+ copyRecursiveNoOverwrite(oldDir, newDir);
227
+ } else {
228
+ fs.mkdirSync(path.dirname(newDir), { recursive: true });
229
+ fs.cpSync(oldDir, newDir, { recursive: true });
230
+ }
231
+ fs.rmSync(oldDir, { recursive: true, force: true });
232
+ ctx.deletedFiles.push(`${dir}/`);
233
+ log(ctx, `Moved: ${dir}/ → src/${dir}/`);
234
+ }
235
+
236
+ function copyRecursiveNoOverwrite(src: string, dest: string) {
237
+ const entries = fs.readdirSync(src, { withFileTypes: true });
238
+ for (const entry of entries) {
239
+ const srcPath = path.join(src, entry.name);
240
+ const destPath = path.join(dest, entry.name);
241
+ if (entry.isDirectory()) {
242
+ fs.mkdirSync(destPath, { recursive: true });
243
+ copyRecursiveNoOverwrite(srcPath, destPath);
244
+ } else if (!fs.existsSync(destPath)) {
245
+ fs.copyFileSync(srcPath, destPath);
246
+ }
247
+ }
248
+ }
249
+
250
+ function cleanupOldSourceDirs(ctx: MigrationContext) {
251
+ // Dirs that the scaffold/transform phases already created under src/.
252
+ // Delete root copies when both exist.
253
+ const dirsToClean = [
254
+ "sections",
255
+ "components",
256
+ "sdk",
257
+ "loaders",
258
+ "actions",
259
+ "apps",
260
+ ];
261
+
262
+ for (const dir of dirsToClean) {
263
+ const oldDir = path.join(ctx.sourceDir, dir);
264
+ const newDir = path.join(ctx.sourceDir, "src", dir);
265
+ if (fs.existsSync(oldDir) && fs.existsSync(newDir)) {
266
+ if (ctx.dryRun) {
267
+ log(ctx, `[DRY] Would delete old dir: ${dir}/ (moved to src/${dir}/)`);
268
+ ctx.deletedFiles.push(`${dir}/`);
269
+ } else {
270
+ fs.rmSync(oldDir, { recursive: true, force: true });
271
+ ctx.deletedFiles.push(`${dir}/`);
272
+ log(ctx, `Deleted old dir: ${dir}/ (now at src/${dir}/)`);
273
+ }
274
+ }
275
+ }
276
+
277
+ // Dirs that need to be MOVED (not just deleted) because scaffold doesn't
278
+ // create them under src/ but code references them via ~/utils, ~/types, etc.
279
+ const dirsToMove = ["utils", "types", "hooks", "contexts"];
280
+ for (const dir of dirsToMove) {
281
+ moveRootDirToSrc(ctx, dir);
282
+ }
283
+ }
284
+
285
+ /** Delete sections that were re-export wrappers (their islands are now sections) */
286
+ function cleanupReExportSections(ctx: MigrationContext) {
287
+ const reExports = ctx.files.filter(
288
+ (f) => f.category === "section" && f.isReExport && f.action === "delete",
289
+ );
290
+ for (const f of reExports) {
291
+ // These were already not transformed, just make sure we note them
292
+ log(ctx, `Skipped re-export wrapper: ${f.path}`);
293
+ }
294
+ }
295
+
296
+ /** Remove non-code files and directories that shouldn't be under src/ */
297
+ function cleanupJunkFromSrc(ctx: MigrationContext) {
298
+ const srcDir = path.join(ctx.sourceDir, "src");
299
+ if (!fs.existsSync(srcDir)) return;
300
+
301
+ // Remove dirs that don't belong in src/
302
+ const junkDirs = ["bin", "fonts", "tests", ".pilot", ".deco"];
303
+ for (const dir of junkDirs) {
304
+ const dirPath = path.join(srcDir, dir);
305
+ if (fs.existsSync(dirPath)) {
306
+ if (ctx.dryRun) {
307
+ log(ctx, `[DRY] Would delete junk dir: src/${dir}/`);
308
+ } else {
309
+ fs.rmSync(dirPath, { recursive: true, force: true });
310
+ log(ctx, `Deleted junk from src/: ${dir}/`);
311
+ }
312
+ }
313
+ }
314
+
315
+ // Remove static-* dirs from src/
316
+ if (fs.existsSync(srcDir)) {
317
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
318
+ if (entry.isDirectory() && entry.name.startsWith("static-")) {
319
+ const dirPath = path.join(srcDir, entry.name);
320
+ if (ctx.dryRun) {
321
+ log(ctx, `[DRY] Would delete: src/${entry.name}/`);
322
+ } else {
323
+ fs.rmSync(dirPath, { recursive: true, force: true });
324
+ log(ctx, `Deleted from src/: ${entry.name}/`);
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ // Remove non-code root files from src/
331
+ const junkFiles = [
332
+ "AGENTS.md", "biome.json", "blockedQs.ts", "islands.ts",
333
+ "lint-changed.sh", "redirects-vtex.csv", "search-urls-cvlb.csv",
334
+ "search.csv", "sync.sh", "yarn.lock",
335
+ ];
336
+ for (const file of junkFiles) {
337
+ const filePath = path.join(srcDir, file);
338
+ if (fs.existsSync(filePath)) {
339
+ if (ctx.dryRun) {
340
+ log(ctx, `[DRY] Would delete: src/${file}`);
341
+ } else {
342
+ fs.unlinkSync(filePath);
343
+ log(ctx, `Deleted from src/: ${file}`);
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Remove empty ({}) block stubs from .deco/blocks/.
351
+ * Some source repos have both `pages-Foo%20bar.json` (empty) and
352
+ * `pages-Foo%2520bar.json` (real data). generate-blocks.ts deduplicates
353
+ * by decoded key, and the empty stub can shadow the real file.
354
+ */
355
+ function removeEmptyBlockStubs(ctx: MigrationContext) {
356
+ const blocksDir = path.join(ctx.sourceDir, ".deco", "blocks");
357
+ if (!fs.existsSync(blocksDir)) return;
358
+
359
+ const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
360
+ for (const file of files) {
361
+ const fullPath = path.join(blocksDir, file);
362
+ const stat = fs.statSync(fullPath);
363
+ if (stat.size > 4) continue; // only target tiny files
364
+ const content = fs.readFileSync(fullPath, "utf-8").trim();
365
+ if (content === "{}" || content === "") {
366
+ if (ctx.dryRun) {
367
+ log(ctx, `[DRY] Would delete empty block stub: .deco/blocks/${file}`);
368
+ } else {
369
+ fs.unlinkSync(fullPath);
370
+ log(ctx, `Deleted empty block stub: .deco/blocks/${file}`);
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ function overrideDeviceContext(ctx: MigrationContext) {
377
+ const target = path.join(ctx.sourceDir, "src", "contexts", "device.tsx");
378
+ const dir = path.dirname(target);
379
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
380
+ const content = `import { useSyncExternalStore } from "react";
381
+
382
+ const MOBILE_QUERY = "(max-width: 767px)";
383
+
384
+ function subscribe(cb: () => void) {
385
+ if (typeof window === "undefined") return () => {};
386
+ const mql = window.matchMedia(MOBILE_QUERY);
387
+ mql.addEventListener("change", cb);
388
+ return () => mql.removeEventListener("change", cb);
389
+ }
390
+
391
+ function getSnapshot(): boolean {
392
+ return window.matchMedia(MOBILE_QUERY).matches;
393
+ }
394
+
395
+ function getServerSnapshot(): boolean {
396
+ return false;
397
+ }
398
+
399
+ /**
400
+ * Reactive mobile detection based on viewport width via matchMedia.
401
+ * SSR defaults to desktop (false); hydrates to the real value on mount.
402
+ *
403
+ * For server-side device detection (UA-based), use the section loader
404
+ * pattern: registerSectionLoaders injects \`isMobile\` as a prop.
405
+ */
406
+ export const useDevice = () => {
407
+ const isMobile = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
408
+ return { isMobile };
409
+ };
410
+ `;
411
+ if (ctx.dryRun) {
412
+ log(ctx, "[DRY] Would override: src/contexts/device.tsx");
413
+ } else {
414
+ fs.writeFileSync(target, content);
415
+ log(ctx, "Overrode src/contexts/device.tsx with useSyncExternalStore implementation");
416
+ }
417
+ }
418
+
419
+ function rewriteRetryUtil(ctx: MigrationContext) {
420
+ const target = path.join(ctx.sourceDir, "src", "utils", "retry.ts");
421
+ if (!fs.existsSync(target)) return;
422
+
423
+ const content = `export const CONNECTION_CLOSED_MESSAGE = "connection closed before message completed";
424
+
425
+ function sleep(ms: number): Promise<void> {
426
+ return new Promise((resolve) => setTimeout(resolve, ms));
427
+ }
428
+
429
+ /**
430
+ * Simple retry utility — replaces cockatiel to avoid module-level AbortController
431
+ * (cockatiel's abort.js creates new AbortController() at module scope, which is
432
+ * forbidden in Cloudflare Workers global scope).
433
+ *
434
+ * Retries up to maxAttempts when the error matches the predicate.
435
+ * Uses exponential backoff: delay = min(initialDelay * exponent^attempt, maxDelay).
436
+ */
437
+ export function retryExceptionOr500() {
438
+ return {
439
+ execute: async <T>(fn: () => Promise<T>): Promise<T> => {
440
+ const maxAttempts = 3;
441
+ const initialDelay = 100;
442
+ const maxDelay = 5000;
443
+ const exponent = 2;
444
+
445
+ let lastErr: unknown;
446
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
447
+ try {
448
+ return await fn();
449
+ } catch (err) {
450
+ const message = err instanceof Error ? err.message : String(err);
451
+ if (!message.includes(CONNECTION_CLOSED_MESSAGE)) {
452
+ throw err;
453
+ }
454
+ lastErr = err;
455
+ try {
456
+ console.error("retrying...", err);
457
+ } catch (_) {}
458
+ if (attempt < maxAttempts - 1) {
459
+ const delay = Math.min(initialDelay * Math.pow(exponent, attempt), maxDelay);
460
+ await sleep(delay);
461
+ }
462
+ }
463
+ }
464
+ throw lastErr;
465
+ },
466
+ };
467
+ }
468
+ `;
469
+ if (ctx.dryRun) {
470
+ log(ctx, "[DRY] Would rewrite: src/utils/retry.ts");
471
+ } else {
472
+ fs.writeFileSync(target, content);
473
+ log(ctx, "Rewrote src/utils/retry.ts (replaced cockatiel with Workers-safe version)");
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Add safety guards for common runtime patterns that crash in React strict mode
479
+ * or in Cloudflare Workers but worked silently in the old Deno/Preact stack.
480
+ *
481
+ * These are useEffect-level errors that React error boundaries catch and
482
+ * propagate, killing the entire section (e.g. the Header).
483
+ */
484
+ function addRuntimeSafetyGuards(ctx: MigrationContext) {
485
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
486
+ let result = content;
487
+ let changed = false;
488
+
489
+ // 1. Guard: `event.params.X = Y` → `if (event.params) event.params.X = Y`
490
+ const paramsAssignRe = /^(\s*)(event\.params\.(\w+)\s*=\s*.+;)$/gm;
491
+ const paramsRepl = result.replace(paramsAssignRe, (_m, indent, assignment) => {
492
+ return `${indent}if (event.params) ${assignment}`;
493
+ });
494
+ if (paramsRepl !== result) {
495
+ result = paramsRepl;
496
+ changed = true;
497
+ log(ctx, ` Added event.params guard: src/${relPath}`);
498
+ }
499
+
500
+ // 2. Guard: `.find(...).params` → `.find(...)?.params`
501
+ // Uses paren-counting to handle nested parens in callbacks like
502
+ // `.find((item) => item?.name === "deco").params`
503
+ result = addOptionalChainAfterFind(result, (msg) => {
504
+ changed = true;
505
+ log(ctx, ` ${msg}: src/${relPath}`);
506
+ });
507
+
508
+ // 3. Guard: undeclared variables used in if-conditions (ReferenceError).
509
+ // In the old Preact stack, some global signals/variables silently
510
+ // resolved to undefined. In React strict mode, bare references to
511
+ // undeclared variables throw ReferenceError which error boundaries catch.
512
+ // We detect variables referenced in the file that are never declared
513
+ // (const/let/var/param/import) and add typeof guards.
514
+ result = guardUndeclaredVariables(result, (msg) => {
515
+ changed = true;
516
+ log(ctx, ` ${msg}: src/${relPath}`);
517
+ });
518
+
519
+ if (!changed) return null;
520
+ return result;
521
+ });
522
+ }
523
+
524
+ /**
525
+ * Find `.find(...)` calls followed by `.params` (without `?.`) and insert
526
+ * optional chaining. Handles nested parentheses correctly.
527
+ */
528
+ function addOptionalChainAfterFind(src: string, onFix: (msg: string) => void): string {
529
+ let result = src;
530
+ let searchFrom = 0;
531
+
532
+ while (true) {
533
+ const findIdx = result.indexOf(".find(", searchFrom);
534
+ if (findIdx === -1) break;
535
+
536
+ // Walk forward from the opening paren, counting depth
537
+ let depth = 1;
538
+ let i = findIdx + 6; // past ".find("
539
+ while (i < result.length && depth > 0) {
540
+ if (result[i] === "(") depth++;
541
+ if (result[i] === ")") depth--;
542
+ i++;
543
+ }
544
+ // i is now right after the matching ")"
545
+ // Check for `.params` without `?.`
546
+ if (result.slice(i, i + 7) === ".params" && result.slice(i - 1, i + 8) !== ")?.params") {
547
+ result = result.slice(0, i) + "?" + result.slice(i);
548
+ onFix("Added optional chain after .find()");
549
+ searchFrom = i + 8; // skip past the inserted "?.params"
550
+ } else {
551
+ searchFrom = i;
552
+ }
553
+ }
554
+
555
+ return result;
556
+ }
557
+
558
+ /**
559
+ * Detect variables used in `if (varName ...` or `if (varName && ...` that are
560
+ * never declared with const/let/var/function/import/param in the file, and
561
+ * wrap with `typeof varName !== "undefined"`.
562
+ *
563
+ * This prevents ReferenceError in React strict mode — the old Preact/Deno
564
+ * stack had more lenient scoping or these variables were injected by the runtime.
565
+ */
566
+ function guardUndeclaredVariables(src: string, onFix: (msg: string) => void): string {
567
+ let result = src;
568
+
569
+ // Find all `if (someVar &&` or `if (someVar)` patterns where someVar
570
+ // is a bare identifier (not a property access, not a function call)
571
+ const ifBareVarRe = /\bif\s*\(\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:&&|\))/g;
572
+ const candidates = new Set<string>();
573
+ let match;
574
+
575
+ while ((match = ifBareVarRe.exec(result)) !== null) {
576
+ candidates.add(match[1]);
577
+ }
578
+
579
+ // Filter to only truly undeclared variables
580
+ const reserved = new Set([
581
+ "true", "false", "null", "undefined", "this", "window", "document",
582
+ "globalThis", "console", "navigator", "location", "localStorage",
583
+ "sessionStorage", "fetch", "JSON", "Array", "Object", "Math",
584
+ "Date", "Error", "Promise", "Map", "Set", "RegExp", "Symbol",
585
+ "parseInt", "parseFloat", "isNaN", "isFinite", "NaN", "Infinity",
586
+ "setTimeout", "clearTimeout", "setInterval", "clearInterval",
587
+ "requestAnimationFrame", "cancelAnimationFrame", "event",
588
+ ]);
589
+
590
+ for (const varName of candidates) {
591
+ if (reserved.has(varName)) continue;
592
+
593
+ // Check if the variable is declared anywhere in the file
594
+ const declPatterns = [
595
+ new RegExp(`\\b(?:const|let|var|function)\\s+${varName}\\b`),
596
+ new RegExp(`\\bimport\\b[^;]*\\b${varName}\\b`),
597
+ // Function parameter: `function foo(varName)` or `(varName) =>`
598
+ new RegExp(`\\(\\s*(?:[^)]*,\\s*)?${varName}\\s*(?:[:,][^)]*)?\\)\\s*(?:=>|\\{)`),
599
+ // Destructuring declaration: `const { varName }` or `let { x: varName }`
600
+ new RegExp(`(?:const|let|var)\\s+\\{[^}]*\\b${varName}\\b[^}]*\\}\\s*=`),
601
+ // For-of/for-in: `for (const varName of/in ...)`
602
+ new RegExp(`for\\s*\\(\\s*(?:const|let|var)\\s+${varName}\\b`),
603
+ ];
604
+
605
+ const isDeclared = declPatterns.some((p) => p.test(result));
606
+ if (isDeclared) continue;
607
+
608
+ // This variable is used in an if-condition but never declared — wrap with typeof
609
+ const unsafePat = new RegExp(
610
+ `\\bif\\s*\\(\\s*${varName}\\b`,
611
+ "g",
612
+ );
613
+ const guardedResult = result.replace(
614
+ unsafePat,
615
+ `if (typeof ${varName} !== "undefined"`,
616
+ );
617
+
618
+ if (guardedResult !== result) {
619
+ result = guardedResult;
620
+ onFix(`Added typeof guard for undeclared variable "${varName}"`);
621
+ }
622
+ }
623
+
624
+ return result;
625
+ }
626
+
627
+ /**
628
+ * Migrate account.json → src/constants/account.ts and rewrite imports.
629
+ *
630
+ * Old stack: root-level `account.json` containing e.g. `"casaevideo"`
631
+ * New stack: `src/constants/account.ts` exporting `accountName`
632
+ *
633
+ * Also rewrites every file that imports from `account.json` (via
634
+ * `$store/account.json`, `site/account.json`, or `~/account.json`)
635
+ * to use `import { accountName } from "~/constants/account"` instead.
636
+ */
637
+ function migrateAccountJson(ctx: MigrationContext) {
638
+ // 1. Read the site name from account.json (check root, then src/)
639
+ let siteName: string | null = null;
640
+ for (const candidate of ["account.json", "src/account.json"]) {
641
+ const fullPath = path.join(ctx.sourceDir, candidate);
642
+ if (fs.existsSync(fullPath)) {
643
+ try {
644
+ const raw = fs.readFileSync(fullPath, "utf-8").trim();
645
+ siteName = JSON.parse(raw);
646
+ if (typeof siteName !== "string") siteName = null;
647
+ } catch { /* ignore parse errors */ }
648
+ // Delete the old JSON file
649
+ if (!ctx.dryRun) {
650
+ fs.unlinkSync(fullPath);
651
+ }
652
+ log(ctx, `Deleted: ${candidate}`);
653
+ break;
654
+ }
655
+ }
656
+
657
+ if (!siteName) {
658
+ // Fallback: try to infer from deco blocks or directory name
659
+ const decofilePath = path.join(ctx.sourceDir, ".deco", "blocks", "vtex.json");
660
+ if (fs.existsSync(decofilePath)) {
661
+ try {
662
+ const vtexBlock = JSON.parse(fs.readFileSync(decofilePath, "utf-8"));
663
+ if (vtexBlock.account && typeof vtexBlock.account === "string") {
664
+ siteName = vtexBlock.account.replace(/newio$/, "").replace(/io$/, "");
665
+ }
666
+ } catch { /* ignore */ }
667
+ }
668
+ if (!siteName) {
669
+ siteName = path.basename(ctx.sourceDir).replace(/-migrated$/, "");
670
+ }
671
+ log(ctx, `Inferred site name: "${siteName}" (no account.json found)`);
672
+ }
673
+
674
+ // 2. Create src/constants/account.ts
675
+ const constantsDir = path.join(ctx.sourceDir, "src", "constants");
676
+ const accountTsPath = path.join(constantsDir, "account.ts");
677
+
678
+ if (ctx.dryRun) {
679
+ log(ctx, `[DRY] Would create: src/constants/account.ts with accountName="${siteName}"`);
680
+ } else {
681
+ fs.mkdirSync(constantsDir, { recursive: true });
682
+ fs.writeFileSync(
683
+ accountTsPath,
684
+ `export const accountName = "${siteName}" as const;\nexport type AccountName = typeof accountName;\n`,
685
+ );
686
+ log(ctx, `Created: src/constants/account.ts (accountName="${siteName}")`);
687
+ }
688
+
689
+ // 3. Rewrite all files that import from account.json or ~/constants/account
690
+ // The transform phase may have already rewritten the specifier from
691
+ // `$store/account.json` → `~/constants/account`, but it only changes the
692
+ // specifier, not the binding style (default → named). We must fix both.
693
+ const accountJsonPattern =
694
+ /import\s+([\w{},\s*]+)\s+from\s+["'](?:\$store|site|~)\/account\.json["']\s*(?:(?:with|assert)\s*\{[^}]*\}\s*)?;?/g;
695
+ const accountTsDefaultPattern =
696
+ /import\s+(\w+)\s+from\s+["']~\/constants\/account["']\s*(?:(?:with|assert)\s*\{[^}]*\}\s*)?;?/g;
697
+
698
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
699
+ if (!content.includes("account.json") && !content.includes("constants/account")) return null;
700
+
701
+ let changed = false;
702
+ let result = content;
703
+
704
+ // Capture old variable name before any replacements
705
+ const defaultImportMatch = content.match(
706
+ /import\s+(\w+)\s+from\s+["'](?:\$store|site|~)\/account\.json["']/,
707
+ ) || content.match(
708
+ /import\s+(\w+)\s+from\s+["']~\/constants\/account["']/,
709
+ );
710
+ const oldVarName = defaultImportMatch?.[1];
711
+
712
+ // Fix account.json imports (pre-transform)
713
+ result = result.replace(accountJsonPattern, (_match, importName) => {
714
+ changed = true;
715
+ const trimmed = importName.trim();
716
+ if (trimmed.startsWith("{")) {
717
+ return `import ${trimmed} from "~/constants/account";`;
718
+ }
719
+ return `import { accountName } from "~/constants/account";`;
720
+ });
721
+
722
+ // Fix default imports from ~/constants/account (post-transform)
723
+ result = result.replace(accountTsDefaultPattern, (_match, varName) => {
724
+ if (varName.startsWith("{")) return _match; // already named
725
+ changed = true;
726
+ return `import { accountName } from "~/constants/account";`;
727
+ });
728
+
729
+ if (!changed) return null;
730
+
731
+ // Rename old variable references if the import used a different name
732
+ if (oldVarName && oldVarName !== "accountName" && oldVarName !== "{") {
733
+ result = result.replace(
734
+ new RegExp(`\\b${oldVarName}\\b`, "g"),
735
+ "accountName",
736
+ );
737
+ }
738
+
739
+ log(ctx, ` Rewrote account import: src/${relPath}`);
740
+ return result;
741
+ });
742
+ }
743
+
744
+ function rewriteFilesRecursive(
745
+ ctx: MigrationContext,
746
+ dir: string,
747
+ transformer: (content: string, relPath: string) => string | null,
748
+ ) {
749
+ if (!fs.existsSync(dir)) return;
750
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
751
+ for (const entry of entries) {
752
+ const fullPath = path.join(dir, entry.name);
753
+ if (entry.isDirectory()) {
754
+ if (entry.name === "node_modules" || entry.name === ".deco") continue;
755
+ rewriteFilesRecursive(ctx, fullPath, transformer);
756
+ } else if (/\.(tsx?|jsx?|mts|mjs)$/.test(entry.name)) {
757
+ const content = fs.readFileSync(fullPath, "utf-8");
758
+ const relPath = path.relative(path.join(ctx.sourceDir, "src"), fullPath);
759
+ const newContent = transformer(content, relPath);
760
+ if (newContent !== null && newContent !== content) {
761
+ if (!ctx.dryRun) {
762
+ fs.writeFileSync(fullPath, newContent);
763
+ }
764
+ }
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Auto-fix section barrel files that re-export `default` but miss `LoadingFallback`.
771
+ * If the target component defines `LoadingFallback`, the section file should re-export it.
772
+ */
773
+ function fixLoadingFallbackReExports(ctx: MigrationContext) {
774
+ const sectionsDir = path.join(ctx.sourceDir, "src", "sections");
775
+ if (!fs.existsSync(sectionsDir)) return;
776
+
777
+ function walk(dir: string) {
778
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
779
+ if (entry.isDirectory()) {
780
+ walk(path.join(dir, entry.name));
781
+ continue;
782
+ }
783
+ if (!entry.name.endsWith(".tsx") && !entry.name.endsWith(".ts")) continue;
784
+
785
+ const filePath = path.join(dir, entry.name);
786
+ const content = fs.readFileSync(filePath, "utf-8");
787
+
788
+ // Match: `export { default } from "../../some/path"` (no LoadingFallback)
789
+ const reExportMatch = content.match(
790
+ /^export\s*\{\s*default\s*\}\s*from\s*["']([^"']+)["']\s*;?\s*$/m,
791
+ );
792
+ if (!reExportMatch) continue;
793
+ if (content.includes("LoadingFallback")) continue; // already has it
794
+
795
+ // Resolve the target module and check if it exports LoadingFallback
796
+ const targetRelPath = reExportMatch[1];
797
+ const resolved = path.resolve(path.dirname(filePath), targetRelPath);
798
+ const candidates = [
799
+ resolved + ".tsx", resolved + ".ts",
800
+ path.join(resolved, "index.tsx"), path.join(resolved, "index.ts"),
801
+ ];
802
+
803
+ for (const candidate of candidates) {
804
+ if (!fs.existsSync(candidate)) continue;
805
+ const targetContent = fs.readFileSync(candidate, "utf-8");
806
+ if (/export\s+(?:function|const)\s+LoadingFallback\b/.test(targetContent)) {
807
+ // Add LoadingFallback to the re-export
808
+ const newContent = content.replace(
809
+ /export\s*\{\s*default\s*\}\s*from/,
810
+ "export { default, LoadingFallback } from",
811
+ );
812
+ if (newContent !== content) {
813
+ if (!ctx.dryRun) fs.writeFileSync(filePath, newContent);
814
+ const rel = path.relative(ctx.sourceDir, filePath);
815
+ log(ctx, ` Added LoadingFallback re-export: ${rel}`);
816
+ }
817
+ break;
818
+ }
819
+ }
820
+ }
821
+ }
822
+
823
+ walk(sectionsDir);
824
+ }
825
+
826
+ /**
827
+ * Find the end index of a self-closing JSX tag starting from a position
828
+ * inside the tag body. Handles nested braces, brackets, and parens.
829
+ * Returns the index of `>` in `/>`, or -1 if not found.
830
+ */
831
+ function findSelfClosingEnd(src: string, startIdx: number): number {
832
+ let i = startIdx;
833
+ while (i < src.length) {
834
+ const ch = src[i];
835
+ if (ch === "{" || ch === "[" || ch === "(") {
836
+ const close = ch === "{" ? "}" : ch === "[" ? "]" : ")";
837
+ let depth = 1;
838
+ i++;
839
+ while (i < src.length && depth > 0) {
840
+ if (src[i] === ch) depth++;
841
+ if (src[i] === close) depth--;
842
+ if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
843
+ const q = src[i];
844
+ i++;
845
+ while (i < src.length && src[i] !== q) {
846
+ if (src[i] === "\\" && q !== "`") i++;
847
+ i++;
848
+ }
849
+ }
850
+ i++;
851
+ }
852
+ continue;
853
+ }
854
+ if (ch === "/" && i + 1 < src.length && src[i + 1] === ">") {
855
+ return i + 1;
856
+ }
857
+ i++;
858
+ }
859
+ return -1;
860
+ }
861
+
862
+ /**
863
+ * Extract JSX prop assignments from a string like:
864
+ * items={[...]} offers={product.offers}
865
+ * Returns an array of { name, value } with balanced brace extraction.
866
+ */
867
+ function extractJsxProps(src: string): Array<{ name: string; value: string }> {
868
+ const props: Array<{ name: string; value: string }> = [];
869
+ const propRe = /(\w+)\s*=\s*\{/g;
870
+ let match;
871
+ while ((match = propRe.exec(src)) !== null) {
872
+ const name = match[1];
873
+ let depth = 1;
874
+ let i = match.index + match[0].length;
875
+ while (i < src.length && depth > 0) {
876
+ if (src[i] === "{") depth++;
877
+ if (src[i] === "}") depth--;
878
+ if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
879
+ const q = src[i];
880
+ i++;
881
+ while (i < src.length && src[i] !== q) {
882
+ if (src[i] === "\\" && q !== "`") i++;
883
+ i++;
884
+ }
885
+ }
886
+ if (depth > 0) i++;
887
+ }
888
+ // i is at the closing }
889
+ const value = src.slice(match.index + match[0].length, i);
890
+ props.push({ name, value });
891
+ propRe.lastIndex = i + 1;
892
+ }
893
+ return props;
894
+ }
895
+
896
+ /**
897
+ * Convert `<varName.Component {...varName?.props} prop1={val1} />` patterns
898
+ * to `<RenderSection section={{...varName, prop1: val1}} />`.
899
+ *
900
+ * Handles multi-line JSX with nested braces (e.g. items={[{...}]}).
901
+ */
902
+ function convertDirectComponentCalls(src: string, onFix: (msg: string) => void): string {
903
+ const componentCallRe = /<(\w+)\.Component\b/g;
904
+ let result = src;
905
+ let offset = 0;
906
+ let match;
907
+
908
+ // Reset lastIndex
909
+ componentCallRe.lastIndex = 0;
910
+ const replacements: Array<{ start: number; end: number; replacement: string }> = [];
911
+
912
+ while ((match = componentCallRe.exec(src)) !== null) {
913
+ const varName = match[1];
914
+ const tagStart = match.index;
915
+
916
+ // Find the self-closing end
917
+ const bodyStart = tagStart + match[0].length;
918
+ const endIdx = findSelfClosingEnd(src, bodyStart);
919
+ if (endIdx === -1) continue;
920
+
921
+ const fullTag = src.slice(tagStart, endIdx + 1);
922
+ const body = src.slice(bodyStart, endIdx - 1).trim(); // between <X.Component and />
923
+
924
+ // Verify there's a spread: {...varName?.props} or {...varName.props}
925
+ const spreadRe = new RegExp(`\\{\\.\\.\\.${varName}\\??\\.(props)\\}`, "g");
926
+ if (!spreadRe.test(body)) continue;
927
+
928
+ // Remove the spread from body and extract remaining props
929
+ const bodyWithoutSpread = body.replace(spreadRe, "").trim();
930
+ const additionalProps = extractJsxProps(bodyWithoutSpread);
931
+
932
+ let sectionExpr: string;
933
+ if (additionalProps.length === 0) {
934
+ sectionExpr = varName;
935
+ } else {
936
+ const propEntries = additionalProps
937
+ .map((p) => `${p.name}: ${p.value}`)
938
+ .join(", ");
939
+ sectionExpr = `{...${varName}, ${propEntries}}`;
940
+ }
941
+
942
+ const replacement = `<RenderSection section={${sectionExpr}} />`;
943
+ replacements.push({ start: tagStart, end: endIdx + 1, replacement });
944
+ }
945
+
946
+ // Apply replacements in reverse order to preserve indices
947
+ for (let i = replacements.length - 1; i >= 0; i--) {
948
+ const r = replacements[i];
949
+ result = result.slice(0, r.start) + r.replacement + result.slice(r.end);
950
+ onFix(`Converted .Component direct call → RenderSection`);
951
+ }
952
+
953
+ return result;
954
+ }
955
+
956
+ /**
957
+ * Replace SectionRenderer with RenderSection in components.
958
+ *
959
+ * SectionRenderer (from DecoPageRenderer) requires section.Component to be a
960
+ * resolved function/string. RenderSection also handles bare { __resolveType }
961
+ * objects, which is how CMS blocks pass nested sections.
962
+ *
963
+ * Also converts direct `<section.Component {...props}/>` patterns to use
964
+ * RenderSection for robustness.
965
+ */
966
+ function upgradeSectionRenderer(ctx: MigrationContext) {
967
+ rewriteFilesRecursive(ctx, path.join(ctx.sourceDir, "src"), (content, relPath) => {
968
+ if (!relPath.endsWith(".tsx") && !relPath.endsWith(".ts")) return null;
969
+
970
+ let result = content;
971
+ let changed = false;
972
+
973
+ // 1. Replace `import { SectionRenderer } from "@decocms/start/hooks"`
974
+ // with `import { RenderSection } from "@decocms/start/hooks"`
975
+ const sectionRendererImport =
976
+ /import\s*\{([^}]*)\bSectionRenderer\b([^}]*)\}\s*from\s*["']@decocms\/start\/hooks["']/g;
977
+ const newContent = result.replace(sectionRendererImport, (_m, before, after) => {
978
+ changed = true;
979
+ return `import {${before}RenderSection${after}} from "@decocms/start/hooks"`;
980
+ });
981
+ if (newContent !== result) {
982
+ result = newContent;
983
+ log(ctx, ` Replaced SectionRenderer import → RenderSection: src/${relPath}`);
984
+ }
985
+
986
+ // 2. Replace JSX: <SectionRenderer section={x} /> → <RenderSection section={x} />
987
+ const sectionRendererJsx = /<SectionRenderer\b/g;
988
+ if (sectionRendererJsx.test(result)) {
989
+ result = result.replace(/<SectionRenderer\b/g, "<RenderSection");
990
+ changed = true;
991
+ log(ctx, ` Replaced <SectionRenderer → <RenderSection: src/${relPath}`);
992
+ }
993
+
994
+ // 3. Convert <varName.Component {...varName?.props} additionalProp={value} ... />
995
+ // to <RenderSection section={{...varName, additionalProp: value, ...}} />
996
+ result = convertDirectComponentCalls(result, (msg) => {
997
+ changed = true;
998
+ log(ctx, ` ${msg}: src/${relPath}`);
999
+ });
1000
+
1001
+ // 4. Add RenderSection import if we introduced usages but no import exists
1002
+ if (changed && result.includes("<RenderSection") && !result.includes("RenderSection")) {
1003
+ result = `import { RenderSection } from "@decocms/start/hooks";\n` + result;
1004
+ }
1005
+
1006
+ if (!changed) return null;
1007
+ return result;
1008
+ });
1009
+ }
1010
+
1011
+ /**
1012
+ * Cleanup pass for VTEX-style transform / signature workarounds.
1013
+ *
1014
+ * Historical context:
1015
+ * Earlier versions of this script rewrote `@decocms/apps/vtex/utils/*` imports
1016
+ * to local `~/lib/vtex-*` shims because those modules did not exist yet in
1017
+ * `@decocms/apps`. They now do (apps-start exports `./vtex/utils/*` and
1018
+ * `./vtex/client` directly), so any further rewrite would actively REGRESS
1019
+ * working direct imports back into NO-OP shims and silently break runtime
1020
+ * behavior on every migrated site.
1021
+ *
1022
+ * Scope kept here:
1023
+ * - `@decocms/apps/vtex/loaders/intelligentSearch/*` → inline stubs
1024
+ * (loaders moved under `inline-loaders/` in apps-start; this is still a
1025
+ * real path-mismatch fixup)
1026
+ * - `LabelledFuzzy` type + `mapLabelledFuzzyToFuzzy` helper inlined when
1027
+ * `intelligentSearch/productListingPage` is imported
1028
+ * - `createHttpClient<Type>(...)` → `createHttpClient(...)` (Proxy handles types)
1029
+ * - `fetcher: fetchSafe,` parameter strip (Proxy uses native fetch)
1030
+ * - Inline stub hoisting: when a Fresh loader declares
1031
+ * `const getSegmentFromBag = ... = ({})` etc, hoist them into imports from
1032
+ * the corresponding `~/lib/*` shim so multiple loaders stay in sync
1033
+ *
1034
+ * What was removed:
1035
+ * The four `@decocms/apps/vtex/utils/* → ~/lib/vtex-*` rewrites that the
1036
+ * first-pass `transforms/imports.ts` (lines 50-52) already produces in the
1037
+ * correct, direct form. See discovery notes in MIGRATION_TOOLING_PLAN.md.
1038
+ */
1039
+ function rewriteVtexUtilImports(ctx: MigrationContext) {
1040
+ // Intentionally empty — see docstring. First-pass `transforms/imports.ts`
1041
+ // already maps `apps/vtex/utils/*` and `apps/vtex/client` directly to the
1042
+ // `@decocms/apps` equivalents; rewriting them again here would dead-shim them.
1043
+ const importRewrites: Array<{ pattern: RegExp; replacement: string; desc: string }> = [];
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
+
1381
+ export function cleanup(ctx: MigrationContext): void {
1382
+ logPhase("Cleanup");
1383
+
1384
+ // 0. Remove empty block stubs that shadow real data
1385
+ console.log(" Removing empty block stubs...");
1386
+ removeEmptyBlockStubs(ctx);
1387
+
1388
+ // 1. Move static → public (handles static/, static-cv/, static-lb/, etc.)
1389
+ console.log(" Moving static assets → public/...");
1390
+ moveStaticFiles(ctx);
1391
+ moveMultiBrandStaticFiles(ctx);
1392
+
1393
+ // 2. Delete specific files
1394
+ console.log(" Deleting old files...");
1395
+ for (const file of ROOT_FILES_TO_DELETE) {
1396
+ deleteFileIfExists(ctx, file);
1397
+ }
1398
+ for (const file of SDK_FILES_TO_DELETE) {
1399
+ deleteFileIfExists(ctx, file);
1400
+ deleteFileIfExists(ctx, `src/${file}`);
1401
+ }
1402
+ for (const file of WRAPPER_FILES_TO_DELETE) {
1403
+ deleteFileIfExists(ctx, file);
1404
+ deleteFileIfExists(ctx, `src/${file}`);
1405
+ }
1406
+ for (const file of LOADER_FILES_TO_DELETE) {
1407
+ deleteFileIfExists(ctx, file);
1408
+ deleteFileIfExists(ctx, `src/${file}`);
1409
+ }
1410
+
1411
+ // 3. Delete directories
1412
+ console.log(" Deleting old directories...");
1413
+ for (const dir of DIRS_TO_DELETE) {
1414
+ deleteDirIfExists(ctx, dir);
1415
+ }
1416
+
1417
+ // 4. Clean up old source directories
1418
+ console.log(" Cleaning up old source dirs...");
1419
+ cleanupOldSourceDirs(ctx);
1420
+ cleanupReExportSections(ctx);
1421
+ cleanupJunkFromSrc(ctx);
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
+
1486
+ // 14. LAZY: write only the src/lib/* shim files that the migrated
1487
+ // codebase actually imports. Run after every previous step so we
1488
+ // observe the final import graph (transforms + cleanup rewrites
1489
+ // + inline-stub hoisting all settled).
1490
+ console.log(" Writing src/lib/* shims (lazy)...");
1491
+ writeImportedLibShims(ctx);
1492
+
1493
+ console.log(
1494
+ ` Deleted ${ctx.deletedFiles.length} files/dirs, moved ${ctx.movedFiles.length} files`,
1495
+ );
1496
+ }
1497
+
1498
+ /**
1499
+ * Walk `src/**\/*.{ts,tsx}` looking for `from "~/lib/<name>"` imports.
1500
+ * For each unique `<name>` that has a registered template in
1501
+ * `LIB_TEMPLATES`, write `src/lib/<name>.ts` with the template content.
1502
+ *
1503
+ * Sites that import none of the shims end up with no `src/lib/` directory
1504
+ * at all, instead of 11 dead files.
1505
+ *
1506
+ * Exported (rather than file-local) for unit testability — see
1507
+ * `phase-cleanup.test.ts`.
1508
+ */
1509
+ export function writeImportedLibShims(ctx: MigrationContext): void {
1510
+ const srcRoot = path.join(ctx.sourceDir, "src");
1511
+ if (!fs.existsSync(srcRoot)) return;
1512
+
1513
+ const importedSpecifiers = new Set<string>();
1514
+ // Match `from "~/lib/<name>"` or `from '~/lib/<name>'`. We don't care
1515
+ // about default vs named imports here — only the path matters.
1516
+ const importRe = /from\s+["']~\/lib\/([^"']+?)(?:\.ts)?["']/g;
1517
+
1518
+ /** Recursively scan a directory for .ts/.tsx files and collect specifiers. */
1519
+ const walk = (dir: string) => {
1520
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1521
+ const full = path.join(dir, entry.name);
1522
+ if (entry.isDirectory()) {
1523
+ // Skip the lib dir itself so we don't count internal cross-refs
1524
+ // when we later add them.
1525
+ if (full === path.join(srcRoot, "lib")) continue;
1526
+ walk(full);
1527
+ continue;
1528
+ }
1529
+ if (!entry.isFile()) continue;
1530
+ if (!/\.(ts|tsx)$/.test(entry.name)) continue;
1531
+ const content = fs.readFileSync(full, "utf-8");
1532
+ let m: RegExpExecArray | null;
1533
+ while ((m = importRe.exec(content)) !== null) {
1534
+ importedSpecifiers.add(m[1]);
1535
+ }
1536
+ }
1537
+ };
1538
+ walk(srcRoot);
1539
+
1540
+ if (importedSpecifiers.size === 0) {
1541
+ log(ctx, " No ~/lib/* imports detected — no shims written.");
1542
+ return;
1543
+ }
1544
+
1545
+ const known = new Set(
1546
+ Object.keys(LIB_TEMPLATES).map((k) =>
1547
+ k.replace(/^src\/lib\//, "").replace(/\.ts$/, ""),
1548
+ ),
1549
+ );
1550
+ const unknown: string[] = [];
1551
+ for (const spec of importedSpecifiers) {
1552
+ if (!known.has(spec)) unknown.push(spec);
1553
+ }
1554
+ if (unknown.length > 0) {
1555
+ log(
1556
+ ctx,
1557
+ ` Warning: ~/lib/{${unknown.join(", ")}} imported but no template registered. ` +
1558
+ `Write the file by hand or add a template to scripts/migrate/templates/lib-utils.ts.`,
1559
+ );
1560
+ }
1561
+
1562
+ const toWrite = selectImportedLibTemplates(importedSpecifiers);
1563
+ if (Object.keys(toWrite).length === 0) return;
1564
+
1565
+ if (ctx.dryRun) {
1566
+ for (const relPath of Object.keys(toWrite)) {
1567
+ log(ctx, ` [DRY] Would write: ${relPath}`);
1568
+ }
1569
+ return;
1570
+ }
1571
+
1572
+ const libDir = path.join(srcRoot, "lib");
1573
+ if (!fs.existsSync(libDir)) {
1574
+ fs.mkdirSync(libDir, { recursive: true });
1575
+ }
1576
+
1577
+ for (const [relPath, content] of Object.entries(toWrite)) {
1578
+ const fullPath = path.join(ctx.sourceDir, relPath);
1579
+ fs.writeFileSync(fullPath, content, "utf-8");
1580
+ log(ctx, ` Wrote: ${relPath}`);
1581
+ }
1582
+
1583
+ log(
1584
+ ctx,
1585
+ ` Generated ${Object.keys(toWrite).length} src/lib/* shim(s) ` +
1586
+ `(out of ${Object.keys(LIB_TEMPLATES).length} available templates).`,
1587
+ );
1588
+ }