@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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/scripts/deco-migrate-cli.ts +444 -0
  3. package/scripts/migrate/analyzers/island-classifier.ts +73 -0
  4. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  5. package/scripts/migrate/analyzers/section-metadata.ts +91 -0
  6. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  7. package/scripts/migrate/phase-analyze.ts +147 -17
  8. package/scripts/migrate/phase-cleanup.ts +124 -2
  9. package/scripts/migrate/phase-report.ts +44 -16
  10. package/scripts/migrate/phase-scaffold.ts +38 -132
  11. package/scripts/migrate/phase-transform.ts +28 -3
  12. package/scripts/migrate/phase-verify.ts +127 -5
  13. package/scripts/migrate/templates/app-css.ts +204 -0
  14. package/scripts/migrate/templates/cache-config.ts +26 -0
  15. package/scripts/migrate/templates/commerce-loaders.ts +124 -0
  16. package/scripts/migrate/templates/hooks.ts +358 -0
  17. package/scripts/migrate/templates/package-json.ts +29 -6
  18. package/scripts/migrate/templates/routes.ts +41 -136
  19. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  20. package/scripts/migrate/templates/section-loaders.ts +108 -0
  21. package/scripts/migrate/templates/server-entry.ts +174 -67
  22. package/scripts/migrate/templates/setup.ts +64 -55
  23. package/scripts/migrate/templates/types-gen.ts +119 -0
  24. package/scripts/migrate/templates/ui-components.ts +113 -0
  25. package/scripts/migrate/templates/vite-config.ts +18 -1
  26. package/scripts/migrate/templates/wrangler.ts +4 -1
  27. package/scripts/migrate/transforms/dead-code.ts +23 -2
  28. package/scripts/migrate/transforms/imports.ts +40 -10
  29. package/scripts/migrate/transforms/jsx.ts +9 -0
  30. package/scripts/migrate/transforms/section-conventions.ts +83 -0
  31. package/scripts/migrate/types.ts +74 -0
  32. package/src/cms/resolve.ts +10 -0
  33. package/src/routes/cmsRoute.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -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
+ }