@decocms/start 1.2.5 → 1.2.7
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/package.json +1 -1
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/migrate/analyzers/island-classifier.ts +73 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +91 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/phase-analyze.ts +147 -17
- package/scripts/migrate/phase-cleanup.ts +124 -2
- package/scripts/migrate/phase-report.ts +44 -16
- package/scripts/migrate/phase-scaffold.ts +38 -132
- package/scripts/migrate/phase-transform.ts +28 -3
- package/scripts/migrate/phase-verify.ts +127 -5
- package/scripts/migrate/templates/app-css.ts +204 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +124 -0
- package/scripts/migrate/templates/hooks.ts +358 -0
- package/scripts/migrate/templates/package-json.ts +29 -6
- package/scripts/migrate/templates/routes.ts +41 -136
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +108 -0
- package/scripts/migrate/templates/server-entry.ts +174 -67
- package/scripts/migrate/templates/setup.ts +64 -55
- package/scripts/migrate/templates/types-gen.ts +119 -0
- package/scripts/migrate/templates/ui-components.ts +113 -0
- package/scripts/migrate/templates/vite-config.ts +18 -1
- package/scripts/migrate/templates/wrangler.ts +4 -1
- package/scripts/migrate/transforms/dead-code.ts +23 -2
- package/scripts/migrate/transforms/imports.ts +40 -10
- package/scripts/migrate/transforms/jsx.ts +9 -0
- package/scripts/migrate/transforms/section-conventions.ts +83 -0
- package/scripts/migrate/types.ts +74 -0
- package/src/cms/resolve.ts +10 -0
- package/src/routes/cmsRoute.ts +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* deco-migrate CLI — one command to clone, migrate, and verify a Deco site.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx tsx scripts/deco-migrate-cli.ts <repo-or-dir> [options]
|
|
7
|
+
*
|
|
8
|
+
* Examples:
|
|
9
|
+
* # Clone from GitHub, migrate, compare against golden reference:
|
|
10
|
+
* npx tsx scripts/deco-migrate-cli.ts https://github.com/org/my-site \
|
|
11
|
+
* --output ~/work/my-site-migrated \
|
|
12
|
+
* --ref ~/work/my-site-storefront
|
|
13
|
+
*
|
|
14
|
+
* # Migrate from local directory:
|
|
15
|
+
* npx tsx scripts/deco-migrate-cli.ts ./old-site --output ./migrated-site
|
|
16
|
+
*
|
|
17
|
+
* # Quick re-run (wipe + re-migrate):
|
|
18
|
+
* npx tsx scripts/deco-migrate-cli.ts ./old-site --output ./migrated-site --clean
|
|
19
|
+
*
|
|
20
|
+
* # Dry run:
|
|
21
|
+
* npx tsx scripts/deco-migrate-cli.ts ./old-site --dry-run --verbose
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
28
|
+
import { banner, stat, red, green, yellow, cyan, bold, dim, icons } from "./migrate/colors.ts";
|
|
29
|
+
|
|
30
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
31
|
+
const __dirname = path.dirname(__filename);
|
|
32
|
+
|
|
33
|
+
interface CliOpts {
|
|
34
|
+
source: string;
|
|
35
|
+
output: string | null;
|
|
36
|
+
ref: string | null;
|
|
37
|
+
dryRun: boolean;
|
|
38
|
+
verbose: boolean;
|
|
39
|
+
clean: boolean;
|
|
40
|
+
skipBootstrap: boolean;
|
|
41
|
+
help: boolean;
|
|
42
|
+
branch: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseArgs(args: string[]): CliOpts {
|
|
46
|
+
const opts: CliOpts = {
|
|
47
|
+
source: "",
|
|
48
|
+
output: null,
|
|
49
|
+
ref: null,
|
|
50
|
+
dryRun: false,
|
|
51
|
+
verbose: false,
|
|
52
|
+
clean: false,
|
|
53
|
+
skipBootstrap: false,
|
|
54
|
+
help: false,
|
|
55
|
+
branch: null,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const positional: string[] = [];
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const arg = args[i];
|
|
62
|
+
switch (arg) {
|
|
63
|
+
case "--output":
|
|
64
|
+
case "-o":
|
|
65
|
+
opts.output = args[++i];
|
|
66
|
+
break;
|
|
67
|
+
case "--ref":
|
|
68
|
+
case "--reference":
|
|
69
|
+
opts.ref = args[++i];
|
|
70
|
+
break;
|
|
71
|
+
case "--branch":
|
|
72
|
+
case "-b":
|
|
73
|
+
opts.branch = args[++i];
|
|
74
|
+
break;
|
|
75
|
+
case "--dry-run":
|
|
76
|
+
opts.dryRun = true;
|
|
77
|
+
break;
|
|
78
|
+
case "--verbose":
|
|
79
|
+
case "-v":
|
|
80
|
+
opts.verbose = true;
|
|
81
|
+
break;
|
|
82
|
+
case "--clean":
|
|
83
|
+
opts.clean = true;
|
|
84
|
+
break;
|
|
85
|
+
case "--skip-bootstrap":
|
|
86
|
+
opts.skipBootstrap = true;
|
|
87
|
+
break;
|
|
88
|
+
case "--help":
|
|
89
|
+
case "-h":
|
|
90
|
+
opts.help = true;
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
if (!arg.startsWith("-")) positional.push(arg);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (positional.length > 0) opts.source = positional[0];
|
|
98
|
+
return opts;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function showHelp() {
|
|
102
|
+
console.log(`
|
|
103
|
+
${bold("deco-migrate")} — Clone, migrate, and verify a Deco storefront
|
|
104
|
+
|
|
105
|
+
${bold("Usage:")}
|
|
106
|
+
npx tsx scripts/deco-migrate-cli.ts <repo-url-or-dir> [options]
|
|
107
|
+
|
|
108
|
+
${bold("Arguments:")}
|
|
109
|
+
<repo-url-or-dir> Git repo URL or local directory path
|
|
110
|
+
|
|
111
|
+
${bold("Options:")}
|
|
112
|
+
-o, --output <dir> Output directory (default: <name>-migrated)
|
|
113
|
+
--ref <dir> Golden reference directory to diff against
|
|
114
|
+
-b, --branch <name> Git branch to clone (default: main)
|
|
115
|
+
--dry-run Preview changes without writing files
|
|
116
|
+
-v, --verbose Show detailed output for every file
|
|
117
|
+
--clean Wipe output dir before migrating (for re-runs)
|
|
118
|
+
--skip-bootstrap Skip npm install + codegen after migration
|
|
119
|
+
-h, --help Show this help message
|
|
120
|
+
|
|
121
|
+
${bold("Examples:")}
|
|
122
|
+
${dim("# Clone from GitHub and migrate:")}
|
|
123
|
+
npx tsx scripts/deco-migrate-cli.ts https://github.com/org/my-site
|
|
124
|
+
|
|
125
|
+
${dim("# Migrate local dir, compare against golden reference:")}
|
|
126
|
+
npx tsx scripts/deco-migrate-cli.ts ./casaevideo \\
|
|
127
|
+
--ref ./casaevideo-storefront
|
|
128
|
+
|
|
129
|
+
${dim("# Quick re-run (wipe previous output first):")}
|
|
130
|
+
npx tsx scripts/deco-migrate-cli.ts ./casaevideo \\
|
|
131
|
+
-o ./casaevideo-migrated --clean
|
|
132
|
+
|
|
133
|
+
${dim("# Dry run to preview what would change:")}
|
|
134
|
+
npx tsx scripts/deco-migrate-cli.ts ./casaevideo --dry-run -v
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isGitUrl(source: string): boolean {
|
|
139
|
+
return (
|
|
140
|
+
source.startsWith("https://") ||
|
|
141
|
+
source.startsWith("git@") ||
|
|
142
|
+
source.startsWith("http://") ||
|
|
143
|
+
source.endsWith(".git")
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractRepoName(source: string): string {
|
|
148
|
+
// https://github.com/org/my-site.git → my-site
|
|
149
|
+
// https://github.com/org/my-site → my-site
|
|
150
|
+
// ./path/to/my-site → my-site
|
|
151
|
+
const base = path.basename(source.replace(/\.git$/, ""));
|
|
152
|
+
return base || "site";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function run(cmd: string, cwd?: string, label?: string): boolean {
|
|
156
|
+
if (label) console.log(` ${dim("$")} ${dim(cmd)}`);
|
|
157
|
+
try {
|
|
158
|
+
execSync(cmd, {
|
|
159
|
+
cwd,
|
|
160
|
+
stdio: label ? "pipe" : "inherit",
|
|
161
|
+
timeout: 120_000,
|
|
162
|
+
});
|
|
163
|
+
if (label) console.log(` ${icons.success} ${label}`);
|
|
164
|
+
return true;
|
|
165
|
+
} catch (e: any) {
|
|
166
|
+
if (label) {
|
|
167
|
+
console.log(` ${icons.error} ${label}: ${e.message?.split("\n")[0] || "failed"}`);
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function cloneRepo(source: string, dest: string, branch: string | null): boolean {
|
|
174
|
+
console.log(`\n Cloning ${cyan(source)}...`);
|
|
175
|
+
const branchArg = branch ? ` --branch ${branch}` : "";
|
|
176
|
+
const depthArg = " --depth 1";
|
|
177
|
+
const ok = run(
|
|
178
|
+
`git clone${depthArg}${branchArg} "${source}" "${dest}"`,
|
|
179
|
+
undefined,
|
|
180
|
+
"Clone repository",
|
|
181
|
+
);
|
|
182
|
+
if (!ok) return false;
|
|
183
|
+
|
|
184
|
+
// Strip remote to prevent accidental pushes
|
|
185
|
+
run(`git remote remove origin`, dest, "Remove git remote");
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function copyLocal(source: string, dest: string): boolean {
|
|
190
|
+
console.log(`\n Copying ${cyan(source)} → ${cyan(dest)}...`);
|
|
191
|
+
try {
|
|
192
|
+
// Use cp -r, excluding .git and node_modules
|
|
193
|
+
execSync(
|
|
194
|
+
`rsync -a --exclude='.git' --exclude='node_modules' --exclude='_fresh' --exclude='.wrangler' "${source}/" "${dest}/"`,
|
|
195
|
+
{ stdio: "pipe", timeout: 120_000 },
|
|
196
|
+
);
|
|
197
|
+
console.log(` ${icons.success} Copied source directory`);
|
|
198
|
+
|
|
199
|
+
// Init fresh git so the migration has a clean baseline
|
|
200
|
+
run(`git init`, dest);
|
|
201
|
+
run(`git add -A && git commit -m "pre-migration snapshot" --allow-empty`, dest);
|
|
202
|
+
return true;
|
|
203
|
+
} catch (e: any) {
|
|
204
|
+
console.log(` ${icons.error} Copy failed: ${e.message?.split("\n")[0]}`);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function runMigration(
|
|
210
|
+
dest: string,
|
|
211
|
+
scriptDir: string,
|
|
212
|
+
opts: { dryRun: boolean; verbose: boolean; skipBootstrap: boolean },
|
|
213
|
+
): boolean {
|
|
214
|
+
const migrateScript = path.join(scriptDir, "migrate.ts");
|
|
215
|
+
const args = ["tsx", migrateScript, "--source", dest];
|
|
216
|
+
if (opts.dryRun) args.push("--dry-run");
|
|
217
|
+
if (opts.verbose) args.push("--verbose");
|
|
218
|
+
|
|
219
|
+
console.log("");
|
|
220
|
+
const result = spawnSync("npx", args, {
|
|
221
|
+
cwd: scriptDir.replace(/\/scripts$/, ""),
|
|
222
|
+
stdio: "inherit",
|
|
223
|
+
env: {
|
|
224
|
+
...process.env,
|
|
225
|
+
SKIP_BOOTSTRAP: opts.skipBootstrap ? "1" : "",
|
|
226
|
+
},
|
|
227
|
+
timeout: 300_000,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return result.status === 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function diffAgainstRef(migrated: string, ref: string): void {
|
|
234
|
+
banner("Comparing against golden reference");
|
|
235
|
+
stat("Migrated", migrated);
|
|
236
|
+
stat("Reference", ref);
|
|
237
|
+
|
|
238
|
+
if (!fs.existsSync(ref)) {
|
|
239
|
+
console.log(`\n ${icons.error} Reference dir does not exist: ${ref}`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const migratedSrc = path.join(migrated, "src");
|
|
244
|
+
const refSrc = path.join(ref, "src");
|
|
245
|
+
|
|
246
|
+
if (!fs.existsSync(migratedSrc) || !fs.existsSync(refSrc)) {
|
|
247
|
+
console.log(`\n ${icons.error} One or both src/ directories missing`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 1. File count comparison
|
|
252
|
+
console.log(`\n ${bold("File counts:")}`);
|
|
253
|
+
const countFiles = (dir: string, ext: string): number => {
|
|
254
|
+
try {
|
|
255
|
+
const result = execSync(
|
|
256
|
+
`find "${dir}" -name "*${ext}" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/server/*" | wc -l`,
|
|
257
|
+
{ encoding: "utf-8" },
|
|
258
|
+
);
|
|
259
|
+
return parseInt(result.trim(), 10);
|
|
260
|
+
} catch {
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
for (const [label, subdir] of [
|
|
266
|
+
["sections", "sections"],
|
|
267
|
+
["components", "components"],
|
|
268
|
+
["loaders", "loaders"],
|
|
269
|
+
["hooks", "hooks"],
|
|
270
|
+
["sdk", "sdk"],
|
|
271
|
+
["types", "types"],
|
|
272
|
+
] as const) {
|
|
273
|
+
const mDir = path.join(migratedSrc, subdir);
|
|
274
|
+
const rDir = path.join(refSrc, subdir);
|
|
275
|
+
const mCount = fs.existsSync(mDir) ? countFiles(mDir, ".tsx") + countFiles(mDir, ".ts") : 0;
|
|
276
|
+
const rCount = fs.existsSync(rDir) ? countFiles(rDir, ".tsx") + countFiles(rDir, ".ts") : 0;
|
|
277
|
+
const delta = mCount - rCount;
|
|
278
|
+
const deltaStr = delta === 0 ? green("=") : delta > 0 ? yellow(`+${delta}`) : red(`${delta}`);
|
|
279
|
+
console.log(` ${label.padEnd(14)} migrated: ${String(mCount).padStart(4)} ref: ${String(rCount).padStart(4)} (${deltaStr})`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 2. Key import pattern checks
|
|
283
|
+
console.log(`\n ${bold("Remaining old imports (migrated):")}`);
|
|
284
|
+
const grepCount = (dir: string, pattern: string): number => {
|
|
285
|
+
try {
|
|
286
|
+
const result = execSync(
|
|
287
|
+
`grep -rl '${pattern}' "${dir}" --include='*.ts' --include='*.tsx' 2>/dev/null | grep -v node_modules | grep -v '/server/' | wc -l`,
|
|
288
|
+
{ encoding: "utf-8" },
|
|
289
|
+
);
|
|
290
|
+
return parseInt(result.trim(), 10);
|
|
291
|
+
} catch {
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const patterns = [
|
|
297
|
+
["from \"preact", "preact imports"],
|
|
298
|
+
["from \"@preact/", "@preact/* imports"],
|
|
299
|
+
["from \"@deco/deco", "@deco/deco imports"],
|
|
300
|
+
["from \"$fresh/", "$fresh imports"],
|
|
301
|
+
['from "apps/', "apps/* imports"],
|
|
302
|
+
['from "site/', "site/* imports"],
|
|
303
|
+
['from "$store/', "$store/* imports"],
|
|
304
|
+
["export const cache", "old cache exports"],
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
for (const [pattern, label] of patterns) {
|
|
308
|
+
const count = grepCount(migratedSrc, pattern);
|
|
309
|
+
const icon = count === 0 ? icons.success : icons.warning;
|
|
310
|
+
console.log(` ${icon} ${label}: ${count} files`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 3. Missing scaffolded files
|
|
314
|
+
console.log(`\n ${bold("Scaffolded file parity:")}`);
|
|
315
|
+
const checkFiles = [
|
|
316
|
+
"setup.ts",
|
|
317
|
+
"cache-config.ts",
|
|
318
|
+
"worker-entry.ts",
|
|
319
|
+
"server.ts",
|
|
320
|
+
"router.tsx",
|
|
321
|
+
"runtime.ts",
|
|
322
|
+
"setup/commerce-loaders.ts",
|
|
323
|
+
"setup/section-loaders.ts",
|
|
324
|
+
"hooks/useCart.ts",
|
|
325
|
+
"hooks/useUser.ts",
|
|
326
|
+
"hooks/useWishlist.ts",
|
|
327
|
+
"types/widgets.ts",
|
|
328
|
+
"types/deco.ts",
|
|
329
|
+
"components/ui/Image.tsx",
|
|
330
|
+
"components/ui/Picture.tsx",
|
|
331
|
+
"styles/app.css",
|
|
332
|
+
"routes/__root.tsx",
|
|
333
|
+
"routes/$.tsx",
|
|
334
|
+
"routes/index.tsx",
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
for (const file of checkFiles) {
|
|
338
|
+
const mExists = fs.existsSync(path.join(migratedSrc, file));
|
|
339
|
+
const rExists = fs.existsSync(path.join(refSrc, file));
|
|
340
|
+
if (mExists && rExists) {
|
|
341
|
+
console.log(` ${icons.success} ${file}`);
|
|
342
|
+
} else if (!mExists && rExists) {
|
|
343
|
+
console.log(` ${icons.error} ${file} — ${red("missing in migrated")}`);
|
|
344
|
+
} else if (mExists && !rExists) {
|
|
345
|
+
console.log(` ${icons.info} ${file} — ${dim("extra in migrated (not in ref)")}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 4. public/ assets
|
|
350
|
+
console.log(`\n ${bold("public/ assets:")}`);
|
|
351
|
+
const mPublic = path.join(migrated, "public");
|
|
352
|
+
const rPublic = path.join(ref, "public");
|
|
353
|
+
const mPubCount = fs.existsSync(mPublic) ? countFiles(mPublic, "") : 0;
|
|
354
|
+
const rPubCount = fs.existsSync(rPublic) ? countFiles(rPublic, "") : 0;
|
|
355
|
+
console.log(` migrated: ${mPubCount} files, ref: ${rPubCount} files`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function main() {
|
|
359
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
360
|
+
|
|
361
|
+
if (opts.help || !opts.source) {
|
|
362
|
+
showHelp();
|
|
363
|
+
process.exit(opts.help ? 0 : 1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const scriptDir = path.resolve(__dirname, ".");
|
|
367
|
+
const repoName = extractRepoName(opts.source);
|
|
368
|
+
const outputDir = path.resolve(opts.output || `${repoName}-migrated`);
|
|
369
|
+
|
|
370
|
+
banner("deco-migrate CLI");
|
|
371
|
+
stat("Source", opts.source);
|
|
372
|
+
stat("Output", outputDir);
|
|
373
|
+
if (opts.ref) stat("Reference", path.resolve(opts.ref));
|
|
374
|
+
stat("Mode", opts.dryRun ? yellow("DRY RUN") : green("EXECUTE"));
|
|
375
|
+
|
|
376
|
+
// Clean output dir if requested
|
|
377
|
+
if (opts.clean && fs.existsSync(outputDir)) {
|
|
378
|
+
console.log(`\n Cleaning ${outputDir}...`);
|
|
379
|
+
fs.rmSync(outputDir, { recursive: true, force: true });
|
|
380
|
+
console.log(` ${icons.success} Cleaned output directory`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Check if output already exists
|
|
384
|
+
if (fs.existsSync(outputDir) && !opts.dryRun) {
|
|
385
|
+
const srcDir = path.join(outputDir, "src");
|
|
386
|
+
if (fs.existsSync(srcDir)) {
|
|
387
|
+
console.log(`\n ${icons.error} Output directory already exists and has src/: ${outputDir}`);
|
|
388
|
+
console.log(` ${dim("Use --clean to wipe it first, or choose a different --output")}`);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Step 1: Get the source code
|
|
394
|
+
let acquired = false;
|
|
395
|
+
if (isGitUrl(opts.source)) {
|
|
396
|
+
acquired = cloneRepo(opts.source, outputDir, opts.branch);
|
|
397
|
+
} else {
|
|
398
|
+
const sourceDir = path.resolve(opts.source);
|
|
399
|
+
if (!fs.existsSync(sourceDir)) {
|
|
400
|
+
console.log(`\n ${icons.error} Source directory not found: ${sourceDir}`);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
acquired = copyLocal(sourceDir, outputDir);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!acquired) {
|
|
407
|
+
console.log(`\n ${red("Failed to acquire source. Aborting.")}`);
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Step 2: Run migration
|
|
412
|
+
const migrationOk = runMigration(outputDir, scriptDir, {
|
|
413
|
+
dryRun: opts.dryRun,
|
|
414
|
+
verbose: opts.verbose,
|
|
415
|
+
skipBootstrap: opts.skipBootstrap || opts.dryRun,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Step 3: Compare against reference (if provided)
|
|
419
|
+
if (opts.ref && !opts.dryRun) {
|
|
420
|
+
diffAgainstRef(outputDir, path.resolve(opts.ref));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Final status
|
|
424
|
+
console.log("");
|
|
425
|
+
if (migrationOk) {
|
|
426
|
+
banner("Migration complete");
|
|
427
|
+
console.log(`\n ${green("Output:")} ${outputDir}`);
|
|
428
|
+
if (!opts.dryRun) {
|
|
429
|
+
console.log(`\n ${bold("Next steps:")}`);
|
|
430
|
+
console.log(` cd ${outputDir}`);
|
|
431
|
+
console.log(` npm install`);
|
|
432
|
+
console.log(` npm run generate:blocks`);
|
|
433
|
+
console.log(` npm run generate:schema`);
|
|
434
|
+
console.log(` npx tsr generate`);
|
|
435
|
+
console.log(` npm run dev`);
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
console.log(` ${yellow("Migration completed with issues.")} Check the report above.`);
|
|
439
|
+
console.log(` ${dim("Output:")} ${outputDir}`);
|
|
440
|
+
}
|
|
441
|
+
console.log("");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
main();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { MigrationContext, IslandClassification } from "../types.ts";
|
|
4
|
+
import { log } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
const REEXPORT_RE = /^export\s+\{\s*default\s*\}\s+from\s+["']([^"']+)["']/m;
|
|
7
|
+
const THIN_WRAPPER_RE = /^import\s+(\w+)\s+from\s+["']([^"']+)["']/m;
|
|
8
|
+
const RETURN_COMPONENT_RE = /return\s+<\s*\w+\s+\{\.\.\.props\}/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Classify each island file as either a thin wrapper (re-export or
|
|
12
|
+
* trivial bridge component) or a standalone file with real logic.
|
|
13
|
+
*
|
|
14
|
+
* Wrappers are deleted — their imports are repointed to the target component.
|
|
15
|
+
* Standalone islands are moved to src/components/.
|
|
16
|
+
*/
|
|
17
|
+
export function classifyIslands(ctx: MigrationContext): void {
|
|
18
|
+
const islandFiles = ctx.files.filter((f) => f.category === "island");
|
|
19
|
+
|
|
20
|
+
for (const file of islandFiles) {
|
|
21
|
+
let content: string;
|
|
22
|
+
try {
|
|
23
|
+
content = fs.readFileSync(file.absPath, "utf-8");
|
|
24
|
+
} catch {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lines = content.split("\n");
|
|
29
|
+
const nonEmptyLines = lines.filter((l) => l.trim().length > 0);
|
|
30
|
+
const lineCount = nonEmptyLines.length;
|
|
31
|
+
|
|
32
|
+
// Check for single-line re-export: export { default } from "..."
|
|
33
|
+
const reExportMatch = content.match(REEXPORT_RE);
|
|
34
|
+
if (reExportMatch) {
|
|
35
|
+
ctx.islandClassifications.push({
|
|
36
|
+
path: file.path,
|
|
37
|
+
type: "wrapper",
|
|
38
|
+
wrapsComponent: reExportMatch[1],
|
|
39
|
+
suggestedTarget: `src/${file.path.replace("islands/", "components/")}`,
|
|
40
|
+
lineCount,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check for thin wrapper pattern: import X from "...", return <X {...props} />
|
|
46
|
+
if (lineCount <= 15) {
|
|
47
|
+
const importMatch = content.match(THIN_WRAPPER_RE);
|
|
48
|
+
const hasSpreadReturn = RETURN_COMPONENT_RE.test(content);
|
|
49
|
+
if (importMatch && hasSpreadReturn) {
|
|
50
|
+
ctx.islandClassifications.push({
|
|
51
|
+
path: file.path,
|
|
52
|
+
type: "wrapper",
|
|
53
|
+
wrapsComponent: importMatch[2],
|
|
54
|
+
suggestedTarget: `src/${file.path.replace("islands/", "components/")}`,
|
|
55
|
+
lineCount,
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Everything else is standalone
|
|
62
|
+
ctx.islandClassifications.push({
|
|
63
|
+
path: file.path,
|
|
64
|
+
type: "standalone",
|
|
65
|
+
suggestedTarget: `src/${file.path.replace("islands/", "components/")}`,
|
|
66
|
+
lineCount,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const wrappers = ctx.islandClassifications.filter((c) => c.type === "wrapper").length;
|
|
71
|
+
const standalone = ctx.islandClassifications.filter((c) => c.type === "standalone").length;
|
|
72
|
+
log(ctx, `Islands classified: ${wrappers} wrappers, ${standalone} standalone`);
|
|
73
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { MigrationContext, LoaderInfo, Platform } from "../types.ts";
|
|
4
|
+
import { log } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
/** Well-known loaders that map directly to @decocms/apps equivalents */
|
|
7
|
+
const APPS_EQUIVALENTS: Record<string, string> = {
|
|
8
|
+
"loaders/availableIcons.ts": "", // deleted
|
|
9
|
+
"loaders/icons.ts": "", // deleted
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const VTEX_LOADERS: Record<string, string> = {
|
|
13
|
+
"loaders/search/intelligenseSearch.ts": "vtex/autocomplete",
|
|
14
|
+
"loaders/search/intelligentSearchEvents.ts": "",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CACHE_RE = /^export\s+const\s+cache\s*=/m;
|
|
18
|
+
const CACHE_KEY_RE = /^export\s+const\s+cacheKey\s*=/m;
|
|
19
|
+
|
|
20
|
+
function detectPlatformRelevance(content: string, filePath: string): Platform | null {
|
|
21
|
+
if (filePath.includes("vtex") || content.includes("vtex") || content.includes("VTEX")) return "vtex";
|
|
22
|
+
if (filePath.includes("shopify") || content.includes("shopify")) return "shopify";
|
|
23
|
+
if (filePath.includes("wake") || content.includes("wake")) return "wake";
|
|
24
|
+
if (filePath.includes("vnda") || content.includes("vnda")) return "vnda";
|
|
25
|
+
if (filePath.includes("linx") || content.includes("linx")) return "linx";
|
|
26
|
+
if (filePath.includes("nuvemshop") || content.includes("nuvemshop")) return "nuvemshop";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function inventoryLoaders(ctx: MigrationContext): void {
|
|
31
|
+
const loaderFiles = ctx.files.filter(
|
|
32
|
+
(f) => f.category === "loader" && f.action !== "delete",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
for (const file of loaderFiles) {
|
|
36
|
+
let content: string;
|
|
37
|
+
try {
|
|
38
|
+
content = fs.readFileSync(file.absPath, "utf-8");
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const appsEquiv = APPS_EQUIVALENTS[file.path] ?? VTEX_LOADERS[file.path] ?? null;
|
|
44
|
+
const isDeleted = appsEquiv === "";
|
|
45
|
+
|
|
46
|
+
if (isDeleted) continue;
|
|
47
|
+
|
|
48
|
+
const info: LoaderInfo = {
|
|
49
|
+
path: file.path,
|
|
50
|
+
hasCache: CACHE_RE.test(content),
|
|
51
|
+
hasCacheKey: CACHE_KEY_RE.test(content),
|
|
52
|
+
appsEquivalent: appsEquiv,
|
|
53
|
+
isCustom: appsEquiv === null,
|
|
54
|
+
platformRelevance: detectPlatformRelevance(content, file.path),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
ctx.loaderInventory.push(info);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const custom = ctx.loaderInventory.filter((l) => l.isCustom).length;
|
|
61
|
+
const mapped = ctx.loaderInventory.filter((l) => l.appsEquivalent).length;
|
|
62
|
+
log(ctx, `Loaders inventoried: ${ctx.loaderInventory.length} total, ${mapped} mapped, ${custom} custom`);
|
|
63
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { MigrationContext, SectionMeta } from "../types.ts";
|
|
4
|
+
import { log } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
const HEADER_RE = /\bheader\b/i;
|
|
7
|
+
const FOOTER_RE = /\bfooter\b/i;
|
|
8
|
+
const THEME_RE = /\btheme\b/i;
|
|
9
|
+
const LISTING_RE = /\b(?:shelf|carousel|slider|product\s*list|search\s*result)\b/i;
|
|
10
|
+
|
|
11
|
+
const LOADER_CONST_RE = /^export\s+const\s+loader\b/m;
|
|
12
|
+
const LOADER_FN_RE = /^export\s+(?:async\s+)?function\s+loader\b/m;
|
|
13
|
+
const LOADING_FALLBACK_RE = /^export\s+(?:const|function)\s+LoadingFallback\b/m;
|
|
14
|
+
const JSDOC_TITLE_RE = /@title\b/;
|
|
15
|
+
const JSDOC_DESC_RE = /@description\b/;
|
|
16
|
+
const CTX_DEVICE_RE = /ctx\.device|useDevice|device.*(?:mobile|desktop)/i;
|
|
17
|
+
const CTX_URL_RE = /ctx\.url|req\.url|ctx\.request|searchParam|pathname/i;
|
|
18
|
+
const ASYNC_RE = /^export\s+async\s+function\s+loader\b/m;
|
|
19
|
+
const STATUS_ONLY_RE = /ctx\.response\.status\s*=/;
|
|
20
|
+
const IS_MOBILE_RE = /isMobile|is_mobile|ctx\.device\s*===?\s*["']mobile["']/i;
|
|
21
|
+
const DEVICE_PROP_RE = /device\s*:\s*ctx\.device/;
|
|
22
|
+
|
|
23
|
+
function isStatusOnlyLoader(content: string): boolean {
|
|
24
|
+
const loaderMatch = content.match(
|
|
25
|
+
/(?:export\s+const\s+loader\s*=|export\s+(?:async\s+)?function\s+loader)\s*[\s\S]*?\n(?=export\s|\z)/m,
|
|
26
|
+
);
|
|
27
|
+
if (!loaderMatch) return false;
|
|
28
|
+
const loaderBody = loaderMatch[0];
|
|
29
|
+
if (!STATUS_ONLY_RE.test(loaderBody)) return false;
|
|
30
|
+
const meaningful = loaderBody
|
|
31
|
+
.replace(/\/\/.*$/gm, "")
|
|
32
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
33
|
+
.replace(/ctx\.response\.status\s*=\s*\d+;?/g, "")
|
|
34
|
+
.replace(/return\s+props;?/g, "")
|
|
35
|
+
.replace(/if\s*\(props\.\w+\s*===?\s*null\)/g, "")
|
|
36
|
+
.replace(/export\s+(const|async\s+)?function\s+loader[^{]*\{/g, "")
|
|
37
|
+
.replace(/\};\s*$/g, "")
|
|
38
|
+
.trim();
|
|
39
|
+
return meaningful.replace(/[\s{}();,]/g, "").length < 30;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractSectionMetadata(ctx: MigrationContext): void {
|
|
43
|
+
const sectionFiles = ctx.files.filter(
|
|
44
|
+
(f) => f.category === "section" && f.action !== "delete",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
for (const file of sectionFiles) {
|
|
48
|
+
let content: string;
|
|
49
|
+
try {
|
|
50
|
+
content = fs.readFileSync(file.absPath, "utf-8");
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const basename = path.basename(file.path, path.extname(file.path));
|
|
56
|
+
const dirName = path.dirname(file.path).split("/").pop() || "";
|
|
57
|
+
const parentDirs = path.dirname(file.path).split("/");
|
|
58
|
+
|
|
59
|
+
const hasLoaderConst = LOADER_CONST_RE.test(content);
|
|
60
|
+
const hasLoaderFn = LOADER_FN_RE.test(content);
|
|
61
|
+
const hasLoader = hasLoaderConst || hasLoaderFn;
|
|
62
|
+
|
|
63
|
+
const isAccountSection = parentDirs.some((d) => d.toLowerCase() === "account");
|
|
64
|
+
|
|
65
|
+
const meta: SectionMeta = {
|
|
66
|
+
path: file.path,
|
|
67
|
+
hasLoader,
|
|
68
|
+
loaderIsAsync: hasLoader && ASYNC_RE.test(content),
|
|
69
|
+
hasLoadingFallback: LOADING_FALLBACK_RE.test(content),
|
|
70
|
+
isHeader: HEADER_RE.test(basename) || HEADER_RE.test(dirName),
|
|
71
|
+
isFooter: FOOTER_RE.test(basename) || FOOTER_RE.test(dirName),
|
|
72
|
+
isTheme: THEME_RE.test(basename) || THEME_RE.test(dirName),
|
|
73
|
+
isListing: LISTING_RE.test(basename) || LISTING_RE.test(dirName),
|
|
74
|
+
hasTitle: JSDOC_TITLE_RE.test(content),
|
|
75
|
+
hasDescription: JSDOC_DESC_RE.test(content),
|
|
76
|
+
loaderUsesDevice: hasLoader && CTX_DEVICE_RE.test(content),
|
|
77
|
+
loaderUsesUrl: hasLoader && CTX_URL_RE.test(content),
|
|
78
|
+
isAccountSection,
|
|
79
|
+
isStatusOnly: hasLoader && isStatusOnlyLoader(content),
|
|
80
|
+
usesMobileBoolean: hasLoader && IS_MOBILE_RE.test(content) && !DEVICE_PROP_RE.test(content),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
ctx.sectionMetas.push(meta);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const withLoader = ctx.sectionMetas.filter((m) => m.hasLoader).length;
|
|
87
|
+
const layouts = ctx.sectionMetas.filter((m) => m.isHeader || m.isFooter || m.isTheme).length;
|
|
88
|
+
const accounts = ctx.sectionMetas.filter((m) => m.isAccountSection).length;
|
|
89
|
+
const statusOnly = ctx.sectionMetas.filter((m) => m.isStatusOnly).length;
|
|
90
|
+
log(ctx, `Sections analyzed: ${ctx.sectionMetas.length} total, ${withLoader} with loader, ${layouts} layout, ${accounts} account, ${statusOnly} status-only`);
|
|
91
|
+
}
|