@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.
- package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +85 -12
- package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +98 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +45 -25
- package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +56 -39
- package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +122 -141
- package/package.json +1 -1
- package/scripts/generate-blocks.ts +8 -5
- package/scripts/migrate/analyzers/island-classifier.ts +23 -0
- package/scripts/migrate/analyzers/section-metadata.ts +63 -7
- package/scripts/migrate/phase-analyze.ts +136 -11
- package/scripts/migrate/phase-cleanup.ts +1057 -6
- package/scripts/migrate/phase-scaffold.ts +294 -5
- package/scripts/migrate/phase-transform.ts +14 -3
- package/scripts/migrate/templates/app-css.ts +149 -2
- package/scripts/migrate/templates/commerce-loaders.ts +173 -68
- package/scripts/migrate/templates/lib-utils.ts +255 -0
- package/scripts/migrate/templates/package-json.ts +30 -22
- package/scripts/migrate/templates/routes.ts +81 -11
- package/scripts/migrate/templates/section-loaders.ts +365 -32
- package/scripts/migrate/templates/server-entry.ts +350 -80
- package/scripts/migrate/templates/setup.ts +78 -8
- package/scripts/migrate/templates/types-gen.ts +58 -0
- package/scripts/migrate/templates/ui-components.ts +47 -16
- package/scripts/migrate/templates/vite-config.ts +17 -6
- package/scripts/migrate/templates/wrangler.ts +3 -1
- package/scripts/migrate/transforms/dead-code.ts +330 -4
- package/scripts/migrate/transforms/deno-isms.ts +19 -0
- package/scripts/migrate/transforms/imports.ts +93 -30
- package/scripts/migrate/transforms/jsx.ts +79 -4
- package/scripts/migrate/transforms/section-conventions.ts +105 -3
- 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
|
-
//
|
|
206
|
-
// Delete
|
|
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", "
|
|
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
|
);
|