@decocms/blocks-cli 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,782 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type {
4
+ DetectedPattern,
5
+ FileRecord,
6
+ MigrationContext,
7
+ Platform,
8
+ } from "./types";
9
+ import { log, logPhase } from "./types";
10
+ import { extractSectionMetadata } from "./analyzers/section-metadata";
11
+ import { classifyIslands } from "./analyzers/island-classifier";
12
+ import { inventoryLoaders } from "./analyzers/loader-inventory";
13
+
14
+ const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
15
+ ["preact-hooks", /from\s+["']preact\/hooks["']/],
16
+ ["preact-signals", /from\s+["']@preact\/signals/],
17
+ ["fresh-runtime", /from\s+["']\$fresh\/runtime/],
18
+ ["fresh-server", /from\s+["']\$fresh\/server/],
19
+ ["deco-hooks", /from\s+["']@deco\/deco\/hooks["']/],
20
+ ["deco-context", /Context\.active\(\)/],
21
+ ["deco-web", /from\s+["']@deco\/deco\/web["']/],
22
+ ["deco-blocks", /from\s+["']@deco\/deco\/blocks["']/],
23
+ ["apps-imports", /from\s+["']apps\//],
24
+ ["site-imports", /from\s+["']site\//],
25
+ ["class-attr", /<[a-zA-Z][^>]*\sclass\s*=/],
26
+ ["onInput-handler", /onInput\s*=/],
27
+ ["deno-lint-ignore", /deno-lint-ignore/],
28
+ ["npm-prefix", /from\s+["']npm:/],
29
+ ["component-children", /ComponentChildren/],
30
+ ["jsx-types", /JSX\.(?:SVG|HTML|Generic)/],
31
+ ["asset-function", /\basset\(/],
32
+ ["head-component", /<Head[\s>]/],
33
+ ["define-app", /defineApp\(/],
34
+ ["invoke-proxy", /proxy<Manifest/],
35
+ // Bagaggio-style HTMX dynamic-section loader. Both the source file and
36
+ // every call site need manual conversion to React state / createServerFn.
37
+ ["sections-component-loader", /sections\/Component\.tsx?$/m],
38
+ ["use-component", /import\s*\{[^}]*\buseComponent\b[^}]*\}\s*from\s*["'][^"']*sections\/Component(?:\.tsx?)?["']/],
39
+ ];
40
+
41
+ /** Files/dirs that should be completely skipped during scanning */
42
+ const SKIP_DIRS = new Set([
43
+ "node_modules",
44
+ ".git",
45
+ ".github",
46
+ ".deco",
47
+ ".devcontainer",
48
+ ".vscode",
49
+ ".claude",
50
+ ".cursor",
51
+ "_fresh",
52
+ "static",
53
+ ".context",
54
+ "scripts",
55
+ "src",
56
+ "public",
57
+ ".tanstack",
58
+ "tests",
59
+ "bin",
60
+ "fonts",
61
+ ".pilot",
62
+ ]);
63
+
64
+ const SKIP_FILES = new Set([
65
+ "deno.lock",
66
+ ".gitignore",
67
+ "README.md",
68
+ "AGENTS.md",
69
+ "LICENSE",
70
+ "browserslist",
71
+ "bw_stats.json",
72
+ "biome.json",
73
+ "package.json",
74
+ "package-lock.json",
75
+ "yarn.lock",
76
+ "bun.lock",
77
+ "bun.lockb",
78
+ ]);
79
+
80
+ /** Files that are generated and should be deleted */
81
+ const GENERATED_FILES = new Set([
82
+ "fresh.gen.ts",
83
+ "manifest.gen.ts",
84
+ "fresh.config.ts",
85
+ ]);
86
+
87
+ /** SDK files that have framework equivalents or are scaffolded fresh */
88
+ const SDK_DELETE = new Set([
89
+ "sdk/clx.ts",
90
+ "sdk/useId.ts",
91
+ // sdk/useOffer.ts — kept: sites often customize offer logic
92
+ // sdk/useVariantPossiblities.ts — kept: sites often customize variant logic
93
+ "sdk/usePlatform.tsx",
94
+ "sdk/signal.ts",
95
+ "sdk/format.ts",
96
+ ]);
97
+
98
+ /** Component files that are scaffolded fresh (old versions must not overwrite) */
99
+ const COMPONENT_DELETE = new Set([
100
+ "components/ui/Image.tsx",
101
+ "components/ui/Picture.tsx",
102
+ "components/ui/Video.tsx",
103
+ ]);
104
+
105
+ /** Loaders that depend on deleted admin tooling */
106
+ const LOADER_DELETE = new Set([
107
+ "loaders/availableIcons.ts",
108
+ "loaders/icons.ts",
109
+ ]);
110
+
111
+ /** Root config/infra files to delete */
112
+ const ROOT_DELETE = new Set([
113
+ "main.ts",
114
+ "dev.ts",
115
+ "deno.json",
116
+ "deno.lock",
117
+ "tailwind.css",
118
+ "tailwind.config.ts",
119
+ "runtime.ts",
120
+ "constants.ts",
121
+ "fresh.gen.ts",
122
+ "manifest.gen.ts",
123
+ "fresh.config.ts",
124
+ "browserslist",
125
+ "bw_stats.json",
126
+ "islands.ts",
127
+ ]);
128
+
129
+ /** Static files that are code/tooling, not assets — should be deleted */
130
+ const STATIC_DELETE = new Set([
131
+ "static/adminIcons.ts",
132
+ "static/generate-icons.ts",
133
+ "static/tailwind.css",
134
+ ]);
135
+
136
+ /**
137
+ * Scan file content for inline npm: imports and return { name: version } pairs.
138
+ * Matches patterns like: from "npm:fuse.js@7.0.0"
139
+ */
140
+ function extractInlineNpmDeps(content: string): Record<string, string> {
141
+ const deps: Record<string, string> = {};
142
+ const regex = /from\s+["']npm:(@?[^@"']+)(?:@([^"']+))?["']/g;
143
+ let match;
144
+ while ((match = regex.exec(content)) !== null) {
145
+ const name = match[1];
146
+ const version = match[2] || "*";
147
+ if (name.startsWith("preact") || name.startsWith("@preact/")) continue;
148
+ deps[name] = `^${version}`;
149
+ }
150
+
151
+ // Detect well-known third-party packages imported without npm: prefix
152
+ const KNOWN_THIRD_PARTY: Record<string, string> = {
153
+ "@sentry/react": "^10.43.0",
154
+ "dompurify": "^3.3.3",
155
+ "fuse.js": "^7.0.0",
156
+ "swiper": "^11.2.6",
157
+ "lottie-web": "^5.12.2",
158
+ "class-variance-authority": "^0.7.1",
159
+ "clsx": "^2.1.1",
160
+ };
161
+ for (const [pkg, version] of Object.entries(KNOWN_THIRD_PARTY)) {
162
+ const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
163
+ if (new RegExp(`from\\s+["']${escaped}`, "m").test(content)) {
164
+ deps[pkg] = version;
165
+ }
166
+ }
167
+
168
+ return deps;
169
+ }
170
+
171
+ function detectPatterns(content: string): DetectedPattern[] {
172
+ const patterns: DetectedPattern[] = [];
173
+ for (const [name, regex] of PATTERN_DETECTORS) {
174
+ if (regex.test(content)) {
175
+ patterns.push(name);
176
+ }
177
+ }
178
+ return patterns;
179
+ }
180
+
181
+ function isReExport(content: string): { is: boolean; target?: string } {
182
+ const match = content.match(
183
+ /^export\s+\{\s*default\s*\}\s+from\s+["']([^"']+)["']/m,
184
+ );
185
+ if (match) return { is: true, target: match[1] };
186
+ return { is: false };
187
+ }
188
+
189
+ function categorizeFile(
190
+ relPath: string,
191
+ ): FileRecord["category"] {
192
+ if (relPath.startsWith("sections/")) return "section";
193
+ if (relPath.startsWith("islands/")) return "island";
194
+ if (relPath.startsWith("components/")) return "component";
195
+ if (relPath.startsWith("sdk/")) return "sdk";
196
+ if (relPath.startsWith("loaders/")) return "loader";
197
+ if (relPath.startsWith("actions/")) return "action";
198
+ if (relPath.startsWith("routes/")) return "route";
199
+ if (relPath.startsWith("apps/")) return "app";
200
+ if (relPath.startsWith("static/") || relPath.startsWith("static-")) return "static";
201
+ if (GENERATED_FILES.has(relPath)) return "generated";
202
+ if (
203
+ relPath === "deno.json" || relPath === "tsconfig.json" ||
204
+ relPath === "tailwind.config.ts"
205
+ ) {
206
+ return "config";
207
+ }
208
+ return "other";
209
+ }
210
+
211
+ function decideAction(
212
+ record: FileRecord,
213
+ ): { action: FileRecord["action"]; targetPath?: string; notes?: string } {
214
+ const { path: relPath, category, isReExport: isReExp } = record;
215
+
216
+ // Generated files → delete
217
+ if (category === "generated") {
218
+ return { action: "delete" };
219
+ }
220
+
221
+ // Root config/infra → delete (will be scaffolded)
222
+ if (ROOT_DELETE.has(relPath)) {
223
+ return { action: "delete", notes: "Replaced by scaffolded config" };
224
+ }
225
+
226
+ // Routes → delete (will be scaffolded)
227
+ if (category === "route") {
228
+ return { action: "delete", notes: "Routes are scaffolded fresh" };
229
+ }
230
+
231
+ // Apps deco/ dir → delete
232
+ if (relPath.startsWith("apps/deco/")) {
233
+ return { action: "delete", notes: "Deco apps not needed in TanStack" };
234
+ }
235
+
236
+ // apps/site.ts → delete (will be scaffolded)
237
+ if (relPath === "apps/site.ts") {
238
+ return { action: "delete", notes: "Rewritten from scratch" };
239
+ }
240
+
241
+ // All remaining apps/ files → delete (platform config is in setup.ts now)
242
+ if (relPath.startsWith("apps/")) {
243
+ return { action: "delete", notes: "Old app config — platform setup is in setup.ts" };
244
+ }
245
+
246
+ // Loaders that depend on deleted admin tooling
247
+ if (LOADER_DELETE.has(relPath)) {
248
+ return {
249
+ action: "delete",
250
+ notes: "Admin icon loader — depends on deleted static/adminIcons.ts",
251
+ };
252
+ }
253
+
254
+ // SDK files to delete (replaced by scaffolded or framework equivalents)
255
+ if (SDK_DELETE.has(relPath)) {
256
+ return {
257
+ action: "delete",
258
+ notes: "Use framework equivalent from @decocms/start or @decocms/apps",
259
+ };
260
+ }
261
+
262
+ // Component files replaced by scaffolded versions
263
+ if (COMPONENT_DELETE.has(relPath)) {
264
+ return {
265
+ action: "delete",
266
+ notes: "Scaffolded fresh from @decocms/apps re-exports",
267
+ };
268
+ }
269
+
270
+ // cart/ directory → delete
271
+ if (relPath.startsWith("sdk/cart/")) {
272
+ return { action: "delete", notes: "Use @decocms/apps cart hooks" };
273
+ }
274
+
275
+ // Non-platform cleanup is done in analyze() post-processing (needs ctx.platform)
276
+
277
+ // Islands — classify and route to appropriate target
278
+ if (category === "island") {
279
+ const classification = (record as any).__islandClassification;
280
+ if (classification?.type === "wrapper") {
281
+ return { action: "delete", notes: "Island wrapper — imports repointed to component" };
282
+ }
283
+ // Standalone islands go to components/, not sections/
284
+ const componentPath = relPath.replace("islands/", "components/");
285
+ return {
286
+ action: "transform",
287
+ targetPath: `src/${componentPath}`,
288
+ notes: "Standalone island moved to components",
289
+ };
290
+ }
291
+
292
+ // Sections that re-export from islands/ → TRANSFORM (rewrite to ~/components/)
293
+ // The CMS still references these section keys, so the file must exist.
294
+ // The island was moved to components/, so we rewrite the re-export target.
295
+ if (category === "section" && isReExp) {
296
+ const target = record.reExportTarget || "";
297
+ const isIslandReExport = target.includes("islands/") ||
298
+ target.includes("islands\\");
299
+ if (isIslandReExport) {
300
+ return {
301
+ action: "transform",
302
+ targetPath: `src/${relPath}`,
303
+ notes: "Section re-export — island target rewritten to ~/components/",
304
+ };
305
+ }
306
+ // Section re-exports from components/ — keep and transform
307
+ }
308
+
309
+ // Session component → delete (analytics moves to __root.tsx)
310
+ if (
311
+ relPath === "components/Session.tsx" || relPath === "sections/Session.tsx"
312
+ ) {
313
+ return {
314
+ action: "delete",
315
+ notes: "Analytics SDK moved to __root.tsx scaffold",
316
+ };
317
+ }
318
+
319
+ // Bagaggio-style HTMX dynamic-section loader → delete and flag.
320
+ // The file uses Deno-only APIs (`toFileUrl(Deno.cwd())`, `import.meta.resolve`)
321
+ // and the `useComponent(component, props)` HTMX render-and-swap pattern, none
322
+ // of which work on TanStack Start / Cloudflare Workers. The site author must
323
+ // refactor every `useComponent(...)` call site to React state, `createServerFn`
324
+ // + `useMutation`, or a direct `~/server/invoke` call BEFORE this file is
325
+ // safe to remove.
326
+ if (
327
+ relPath === "sections/Component.tsx" ||
328
+ relPath === "sections/Component.ts"
329
+ ) {
330
+ return {
331
+ action: "delete",
332
+ notes:
333
+ "HTMX dynamic-section loader (useComponent) — incompatible with TanStack Start. " +
334
+ "Migrate every useComponent(...) call site to React state / createServerFn before deploy.",
335
+ };
336
+ }
337
+
338
+ // Static code/tooling files → delete
339
+ if (STATIC_DELETE.has(relPath)) {
340
+ return { action: "delete", notes: "Code/tooling file, not an asset" };
341
+ }
342
+
343
+ // Static files → move
344
+ if (category === "static") {
345
+ const publicPath = relPath.replace("static/", "public/");
346
+ return { action: "move", targetPath: publicPath };
347
+ }
348
+
349
+ // Non-code root files that shouldn't go into src/
350
+ const ext = path.extname(relPath);
351
+ const nonCodeExts = new Set([".md", ".csv", ".json", ".sh", ".lock", ".yml", ".yaml", ".xml", ".html", ".txt", ".log"]);
352
+ const keepRootFiles = new Set(["account.json"]);
353
+ if (!relPath.includes("/") && nonCodeExts.has(ext) && !keepRootFiles.has(relPath)) {
354
+ return { action: "delete", notes: "Root-level non-code file" };
355
+ }
356
+
357
+ // Root-level tooling files — not app code
358
+ const rootToolingFiles = new Set(["islands.ts", "sync.sh"]);
359
+ if (!relPath.includes("/") && rootToolingFiles.has(relPath)) {
360
+ return { action: "delete", notes: "Root-level tooling file" };
361
+ }
362
+
363
+ // Everything else → transform into src/
364
+ return { action: "transform", targetPath: `src/${relPath}` };
365
+ }
366
+
367
+ function scanDir(
368
+ dir: string,
369
+ baseDir: string,
370
+ files: FileRecord[],
371
+ ) {
372
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
373
+
374
+ for (const entry of entries) {
375
+ const fullPath = path.join(dir, entry.name);
376
+ const relPath = path.relative(baseDir, fullPath);
377
+
378
+ if (entry.isDirectory()) {
379
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") || entry.name.startsWith("static-")) continue;
380
+ scanDir(fullPath, baseDir, files);
381
+ continue;
382
+ }
383
+
384
+ // Skip dotfiles and known non-code files
385
+ if (SKIP_FILES.has(entry.name) || entry.name.startsWith(".")) continue;
386
+
387
+ // Only process .ts, .tsx, .css, .json files for transforms
388
+ const ext = path.extname(entry.name);
389
+ const isCode = [".ts", ".tsx", ".css", ".json"].includes(ext);
390
+
391
+ let content = "";
392
+ let patterns: DetectedPattern[] = [];
393
+ let reExport: { is: boolean; target?: string } = { is: false };
394
+
395
+ if (isCode) {
396
+ content = fs.readFileSync(fullPath, "utf-8");
397
+ patterns = detectPatterns(content);
398
+ reExport = isReExport(content);
399
+ }
400
+
401
+ const record: FileRecord = {
402
+ path: relPath,
403
+ absPath: fullPath,
404
+ category: categorizeFile(relPath),
405
+ isReExport: reExport.is,
406
+ reExportTarget: reExport.target,
407
+ patterns,
408
+ action: "transform", // placeholder
409
+ };
410
+
411
+ const decision = decideAction(record);
412
+ record.action = decision.action;
413
+ record.targetPath = decision.targetPath;
414
+ record.notes = decision.notes;
415
+
416
+ files.push(record);
417
+ }
418
+ }
419
+
420
+ function extractGtmId(sourceDir: string): string | null {
421
+ const appPath = path.join(sourceDir, "routes", "_app.tsx");
422
+ if (!fs.existsSync(appPath)) return null;
423
+
424
+ const content = fs.readFileSync(appPath, "utf-8");
425
+ const match = content.match(/GTM-[A-Z0-9]+/);
426
+ return match ? match[0] : null;
427
+ }
428
+
429
+ function extractVtexAccount(sourceDir: string): string | null {
430
+ // Strategy 1: utils/sitename.ts — DICT_VTEX_AN mapping
431
+ for (const candidate of ["utils/sitename.ts", "sdk/sitename.ts"]) {
432
+ const fp = path.join(sourceDir, candidate);
433
+ if (!fs.existsSync(fp)) continue;
434
+ const content = fs.readFileSync(fp, "utf-8");
435
+ // Match patterns like: casaevideo: "casaevideonewio"
436
+ const match = content.match(/vtexAn|VTEX_AN/i);
437
+ if (match) {
438
+ const dictMatch = content.match(/["'](\w+)["']\s*:\s*["'](\w+myvtex|\w+newio|\w+)["']/);
439
+ if (dictMatch) return dictMatch[2];
440
+ }
441
+ }
442
+
443
+ // Strategy 2: .myvtex.com references in loaders/routes
444
+ const candidates = [
445
+ "routes/_app.tsx",
446
+ "loaders/reviews/productReviews.ts",
447
+ "apps/vtex.ts",
448
+ ];
449
+ for (const candidate of candidates) {
450
+ const fp = path.join(sourceDir, candidate);
451
+ if (!fs.existsSync(fp)) continue;
452
+ const content = fs.readFileSync(fp, "utf-8");
453
+ const match = content.match(/["'](\w+)\.myvtex\.com/);
454
+ if (match) return match[1];
455
+ const match2 = content.match(/account\s*[:=]\s*["'](\w+)["']/);
456
+ if (match2) return match2[1];
457
+ }
458
+
459
+ // Strategy 3: deno.json import map or apps/ config
460
+ const appsVtexPath = path.join(sourceDir, "apps", "vtex.ts");
461
+ if (fs.existsSync(appsVtexPath)) {
462
+ const content = fs.readFileSync(appsVtexPath, "utf-8");
463
+ const match = content.match(/account\s*:\s*["'](\w+)["']/);
464
+ if (match) return match[1];
465
+ }
466
+
467
+ return null;
468
+ }
469
+
470
+ export function extractPlatform(sourceDir: string): Platform {
471
+ // Strategy 2 (apps/{p}.ts existence) is the most reliable signal, but it
472
+ // only matches when the platform name appears here — so a site whose only
473
+ // app file is `apps/magento.ts` previously fell through to Strategy 3
474
+ // and got mis-detected as "vtex" because site.ts imports
475
+ // `apps/vtex/mod.ts` for color palettes. See #211.
476
+ const platforms: Platform[] = [
477
+ "vtex",
478
+ "shopify",
479
+ "wake",
480
+ "vnda",
481
+ "linx",
482
+ "nuvemshop",
483
+ "magento",
484
+ ];
485
+
486
+ // Strategy 1: Check deno.json imports for platform-specific app imports
487
+ const denoPath = path.join(sourceDir, "deno.json");
488
+ if (fs.existsSync(denoPath)) {
489
+ try {
490
+ const deno = JSON.parse(fs.readFileSync(denoPath, "utf-8"));
491
+ const imports = deno.imports || {};
492
+ for (const p of platforms) {
493
+ // e.g. "apps/vtex/" or direct app import containing the platform name
494
+ const hasAppImport = Object.keys(imports).some(
495
+ (k) => k === `apps/${p}/` || k.includes(`/${p}/mod.ts`) || k.includes(`deco-apps`) && imports[k].includes(`/${p}/`),
496
+ );
497
+ const hasAppValue = Object.values(imports).some(
498
+ (v) => typeof v === "string" && (v as string).includes(`/${p}/`),
499
+ );
500
+ if (hasAppImport || hasAppValue) return p;
501
+ }
502
+ // Check if the import map value for "apps/" contains a platform hint
503
+ const appsUrl = imports["apps/"];
504
+ if (typeof appsUrl === "string") {
505
+ for (const p of platforms) {
506
+ // The apps/ URL itself doesn't indicate platform, but let's check apps/vtex.ts
507
+ const vtexAppPath = path.join(sourceDir, "apps", `${p}.ts`);
508
+ if (fs.existsSync(vtexAppPath)) return p;
509
+ }
510
+ }
511
+ } catch {}
512
+ }
513
+
514
+ // Strategy 2: Check for apps/{platform}.ts file existence
515
+ for (const p of platforms) {
516
+ if (fs.existsSync(path.join(sourceDir, "apps", `${p}.ts`))) return p;
517
+ }
518
+
519
+ // Strategy 3: Check apps/site.ts for platform type and default value
520
+ const sitePath = path.join(sourceDir, "apps", "site.ts");
521
+ if (fs.existsSync(sitePath)) {
522
+ const content = fs.readFileSync(sitePath, "utf-8");
523
+ // Look for platform default in state or props: state.platform || "vtex"
524
+ const defaultMatch = content.match(/(?:state\.platform|props\.platform)\s*\|\|\s*["'](\w+)["']/);
525
+ if (defaultMatch) {
526
+ const p = defaultMatch[1] as Platform;
527
+ if (platforms.includes(p)) return p;
528
+ }
529
+ // Look for platform in the Props type
530
+ for (const p of platforms) {
531
+ if (content.includes(`"${p}"`) && (content.includes("Platform") || content.includes("platform"))) {
532
+ return p;
533
+ }
534
+ }
535
+ }
536
+
537
+ // Strategy 4: Check .deco/blocks for platform-specific block files
538
+ const blocksDir = path.join(sourceDir, ".deco", "blocks");
539
+ if (fs.existsSync(blocksDir)) {
540
+ const blockFiles = fs.readdirSync(blocksDir);
541
+ for (const p of platforms) {
542
+ if (blockFiles.some((f) => f.includes(`deco-${p}`) || f === `${p}.json`)) return p;
543
+ }
544
+ }
545
+
546
+ return "custom";
547
+ }
548
+
549
+ function extractSiteName(sourceDir: string): string {
550
+ // Try to extract from .deco or directory name
551
+ const dirName = path.basename(path.resolve(sourceDir));
552
+
553
+ // Try deno.json
554
+ const denoPath = path.join(sourceDir, "deno.json");
555
+ if (fs.existsSync(denoPath)) {
556
+ const deno = JSON.parse(fs.readFileSync(denoPath, "utf-8"));
557
+ if (deno.name) return deno.name;
558
+ }
559
+
560
+ return dirName;
561
+ }
562
+
563
+ function extractThemeFromCms(sourceDir: string): { colors: Record<string, string>; fontFamily: string | null } {
564
+ const colors: Record<string, string> = {};
565
+ let fontFamily: string | null = null;
566
+
567
+ // Look for Theme config in .deco/blocks/
568
+ const blocksDir = path.join(sourceDir, ".deco", "blocks");
569
+ if (!fs.existsSync(blocksDir)) return { colors, fontFamily };
570
+
571
+ const files = fs.readdirSync(blocksDir);
572
+ for (const file of files) {
573
+ if (!file.endsWith(".json")) continue;
574
+ try {
575
+ const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
576
+ if (!content.includes("mainColors") && !content.includes("Theme")) continue;
577
+
578
+ const data = JSON.parse(content);
579
+ // Direct Theme block (e.g. Deco.json with __resolveType: "site/sections/Theme/Theme.tsx")
580
+ if (data.mainColors) {
581
+ Object.assign(colors, data.mainColors);
582
+ if (data.complementaryColors) {
583
+ Object.assign(colors, data.complementaryColors);
584
+ }
585
+ }
586
+ // Font
587
+ if (data.font?.fonts?.[0]?.family) {
588
+ fontFamily = data.font.fonts[0].family;
589
+ }
590
+ // Check sections array (page blocks may contain Theme)
591
+ if (data.sections) {
592
+ for (const section of data.sections) {
593
+ if (section.__resolveType?.includes("Theme") && section.mainColors) {
594
+ Object.assign(colors, section.mainColors);
595
+ if (section.complementaryColors) {
596
+ Object.assign(colors, section.complementaryColors);
597
+ }
598
+ if (section.font?.fonts?.[0]?.family) {
599
+ fontFamily = section.font.fonts[0].family;
600
+ }
601
+ }
602
+ }
603
+ }
604
+ } catch {}
605
+ }
606
+
607
+ return { colors, fontFamily };
608
+ }
609
+
610
+ export function analyze(ctx: MigrationContext): void {
611
+ logPhase("Analyze");
612
+
613
+ // Parse deno.json for import map
614
+ const denoJsonPath = path.join(ctx.sourceDir, "deno.json");
615
+ if (fs.existsSync(denoJsonPath)) {
616
+ const denoJson = JSON.parse(fs.readFileSync(denoJsonPath, "utf-8"));
617
+ ctx.importMap = denoJson.imports || {};
618
+ log(
619
+ ctx,
620
+ `Found ${Object.keys(ctx.importMap).length} import map entries`,
621
+ );
622
+ }
623
+
624
+ // Extract metadata
625
+ ctx.siteName = extractSiteName(ctx.sourceDir);
626
+ ctx.platform = extractPlatform(ctx.sourceDir);
627
+ ctx.vtexAccount = ctx.platform === "vtex" ? extractVtexAccount(ctx.sourceDir) : null;
628
+ ctx.gtmId = extractGtmId(ctx.sourceDir);
629
+
630
+ // Extract theme colors and font from CMS
631
+ const theme = extractThemeFromCms(ctx.sourceDir);
632
+ ctx.themeColors = theme.colors;
633
+ ctx.fontFamily = theme.fontFamily;
634
+
635
+ console.log(` Site: ${ctx.siteName}`);
636
+ console.log(` Platform: ${ctx.platform}${ctx.vtexAccount ? ` (account: ${ctx.vtexAccount})` : ""}`);
637
+ console.log(` GTM ID: ${ctx.gtmId || "none"}`);
638
+ if (Object.keys(ctx.themeColors).length > 0) {
639
+ console.log(` Theme: ${Object.keys(ctx.themeColors).length} colors from CMS`);
640
+ }
641
+ if (ctx.fontFamily) {
642
+ console.log(` Font: ${ctx.fontFamily}`);
643
+ }
644
+
645
+ // Scan all files
646
+ scanDir(ctx.sourceDir, ctx.sourceDir, ctx.files);
647
+
648
+ // Summary
649
+ const byAction = { transform: 0, delete: 0, move: 0, scaffold: 0, "manual-review": 0 };
650
+ const byCategory: Record<string, number> = {};
651
+
652
+ for (const f of ctx.files) {
653
+ byAction[f.action]++;
654
+ byCategory[f.category] = (byCategory[f.category] || 0) + 1;
655
+ }
656
+
657
+ // Scan all source files for inline npm: imports
658
+ for (const f of ctx.files) {
659
+ if (f.action === "delete") continue;
660
+ const ext = path.extname(f.path);
661
+ if (![".ts", ".tsx"].includes(ext)) continue;
662
+ try {
663
+ const content = fs.readFileSync(f.absPath, "utf-8");
664
+ const deps = extractInlineNpmDeps(content);
665
+ Object.assign(ctx.discoveredNpmDeps, deps);
666
+ } catch {}
667
+ }
668
+ if (Object.keys(ctx.discoveredNpmDeps).length > 0) {
669
+ log(ctx, `Discovered npm deps from source: ${JSON.stringify(ctx.discoveredNpmDeps)}`);
670
+ }
671
+
672
+ console.log(`\n Files found: ${ctx.files.length}`);
673
+ console.log(` By category: ${JSON.stringify(byCategory)}`);
674
+ console.log(` By action: ${JSON.stringify(byAction)}`);
675
+
676
+ // Surface the HTMX dynamic-section loader and every `useComponent` call site
677
+ // up-front. These need manual conversion to React state / createServerFn before
678
+ // the migrated site will run on Cloudflare Workers — the analyzer cannot do
679
+ // it automatically, so it must be loud enough to land in the report.
680
+ const useComponentSites = ctx.files.filter(
681
+ (f) => f.patterns.includes("use-component"),
682
+ );
683
+ const componentLoaderFile = ctx.files.find(
684
+ (f) =>
685
+ f.path === "sections/Component.tsx" ||
686
+ f.path === "sections/Component.ts",
687
+ );
688
+ if (componentLoaderFile || useComponentSites.length > 0) {
689
+ console.log("\n ⚠ HTMX dynamic-section loader detected (Bagaggio-style)");
690
+ if (componentLoaderFile) {
691
+ console.log(` • ${componentLoaderFile.path} (will be deleted)`);
692
+ }
693
+ if (useComponentSites.length > 0) {
694
+ console.log(` • ${useComponentSites.length} useComponent(...) call site(s) need manual conversion:`);
695
+ for (const f of useComponentSites.slice(0, 10)) {
696
+ console.log(` - ${f.path}`);
697
+ }
698
+ if (useComponentSites.length > 10) {
699
+ console.log(` ... and ${useComponentSites.length - 10} more`);
700
+ }
701
+ }
702
+ console.log(
703
+ " See: deco-to-tanstack-migration skill, 'useComponent / partial sections' section",
704
+ );
705
+ }
706
+
707
+ // Run analyzers
708
+ extractSectionMetadata(ctx);
709
+ classifyIslands(ctx);
710
+ inventoryLoaders(ctx);
711
+
712
+ // Apply island classifications to file records
713
+ const classMap = new Map(ctx.islandClassifications.map((c) => [c.path, c]));
714
+ for (const f of ctx.files) {
715
+ if (f.category !== "island") continue;
716
+ const classification = classMap.get(f.path);
717
+ if (!classification) continue;
718
+
719
+ if (classification.type === "wrapper") {
720
+ f.action = "delete";
721
+ f.notes = "Island wrapper — imports repointed to component";
722
+ // Build redirect map: island path → wrapped component's migrated import path
723
+ if (classification.wrapsComponent) {
724
+ let wrappedImport = classification.wrapsComponent;
725
+ if (wrappedImport.startsWith("./") || wrappedImport.startsWith("../")) {
726
+ // Resolve relative to the island's directory
727
+ const islandDir = path.dirname(f.path);
728
+ wrappedImport = path.posix.normalize(path.posix.join(islandDir, wrappedImport));
729
+ wrappedImport = "~/" + wrappedImport;
730
+ } else {
731
+ wrappedImport = wrappedImport.replace(/^(\$store|site)\//, "~/");
732
+ }
733
+ wrappedImport = wrappedImport.replace(/\.tsx?$/, "");
734
+ ctx.islandWrapperTargets.set(f.path, wrappedImport);
735
+ }
736
+ } else {
737
+ f.action = "transform";
738
+ f.targetPath = classification.suggestedTarget;
739
+ f.notes = "Standalone island moved to components";
740
+ }
741
+ }
742
+
743
+ // Fix section re-exports from wrapper islands — rewrite to the wrapper's target component
744
+ if (ctx.islandWrapperTargets.size > 0) {
745
+ for (const f of ctx.files) {
746
+ if (f.category !== "section" || !f.isReExport || !f.reExportTarget) continue;
747
+ let target = f.reExportTarget;
748
+ // Resolve relative paths (../../islands/X) against section's directory
749
+ if (target.startsWith("./") || target.startsWith("../")) {
750
+ target = path.posix.normalize(path.posix.join(path.dirname(f.path), target));
751
+ }
752
+ target = target.replace(/^(\$store|site)\//, "").replace(/\.tsx?$/, "");
753
+ if (!target.startsWith("islands/")) continue;
754
+ const wrappedImport = ctx.islandWrapperTargets.get(target + ".tsx") ||
755
+ ctx.islandWrapperTargets.get(target);
756
+ if (wrappedImport) {
757
+ (f as any).__resolvedReExportTarget = wrappedImport;
758
+ f.notes = `Section re-export — island wrapper resolved to ${wrappedImport}`;
759
+ }
760
+ }
761
+ }
762
+
763
+ // Delete non-platform files (e.g. shopify/linx/vnda/wake/nuvemshop on VTEX sites)
764
+ const NON_PLATFORM_PATTERNS: Record<string, RegExp> = {
765
+ vtex: /\/(shopify|linx|vnda|wake|nuvemshop)\b/i,
766
+ shopify: /\/(vtex|linx|vnda|wake|nuvemshop)\b/i,
767
+ vnda: /\/(vtex|shopify|linx|wake|nuvemshop)\b/i,
768
+ wake: /\/(vtex|shopify|linx|vnda|nuvemshop)\b/i,
769
+ linx: /\/(vtex|shopify|vnda|wake|nuvemshop)\b/i,
770
+ nuvemshop: /\/(vtex|shopify|linx|vnda|wake)\b/i,
771
+ };
772
+ const nonPlatformRe = NON_PLATFORM_PATTERNS[ctx.platform];
773
+ if (nonPlatformRe) {
774
+ for (const f of ctx.files) {
775
+ if (f.action === "delete") continue;
776
+ if (nonPlatformRe.test("/" + f.path)) {
777
+ f.action = "delete";
778
+ f.notes = `Non-${ctx.platform} platform file`;
779
+ }
780
+ }
781
+ }
782
+ }