@decocms/start 1.6.2 → 1.7.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 (37) hide show
  1. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +85 -12
  2. package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +98 -0
  3. package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +45 -25
  4. package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +56 -39
  5. package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +122 -141
  6. package/.releaserc.json +1 -0
  7. package/package.json +1 -1
  8. package/scripts/generate-blocks.ts +8 -5
  9. package/scripts/generate-loaders.ts +79 -12
  10. package/scripts/migrate/analyzers/island-classifier.ts +23 -0
  11. package/scripts/migrate/analyzers/section-metadata.ts +63 -7
  12. package/scripts/migrate/phase-analyze.ts +190 -11
  13. package/scripts/migrate/phase-cleanup.ts +1162 -7
  14. package/scripts/migrate/phase-scaffold.ts +294 -5
  15. package/scripts/migrate/phase-transform.ts +56 -3
  16. package/scripts/migrate/templates/app-css.ts +149 -2
  17. package/scripts/migrate/templates/commerce-loaders.ts +174 -69
  18. package/scripts/migrate/templates/lib-utils.ts +255 -0
  19. package/scripts/migrate/templates/package-json.ts +30 -22
  20. package/scripts/migrate/templates/routes.ts +81 -11
  21. package/scripts/migrate/templates/section-loaders.ts +369 -33
  22. package/scripts/migrate/templates/server-entry.ts +350 -80
  23. package/scripts/migrate/templates/setup.ts +78 -8
  24. package/scripts/migrate/templates/types-gen.ts +58 -0
  25. package/scripts/migrate/templates/ui-components.ts +47 -16
  26. package/scripts/migrate/templates/vite-config.ts +17 -6
  27. package/scripts/migrate/templates/wrangler.ts +3 -1
  28. package/scripts/migrate/transforms/dead-code.ts +330 -4
  29. package/scripts/migrate/transforms/deno-isms.ts +19 -0
  30. package/scripts/migrate/transforms/imports.ts +93 -30
  31. package/scripts/migrate/transforms/jsx.ts +79 -4
  32. package/scripts/migrate/transforms/section-conventions.ts +105 -3
  33. package/scripts/migrate/types.ts +9 -1
  34. package/src/cms/resolve.ts +12 -1
  35. package/src/sdk/useScript.ts +27 -6
  36. package/src/sdk/workerEntry.ts +11 -2
  37. package/src/setup.ts +1 -1
@@ -32,6 +32,10 @@ const PATTERN_DETECTORS: Array<[DetectedPattern, RegExp]> = [
32
32
  ["head-component", /<Head[\s>]/],
33
33
  ["define-app", /defineApp\(/],
34
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?)?["']/],
35
39
  ];
36
40
 
37
41
  /** Files/dirs that should be completely skipped during scanning */
@@ -71,7 +75,6 @@ const SKIP_FILES = new Set([
71
75
  "yarn.lock",
72
76
  "bun.lock",
73
77
  "bun.lockb",
74
- "account.json",
75
78
  ]);
76
79
 
77
80
  /** Files that are generated and should be deleted */
@@ -85,8 +88,8 @@ const GENERATED_FILES = new Set([
85
88
  const SDK_DELETE = new Set([
86
89
  "sdk/clx.ts",
87
90
  "sdk/useId.ts",
88
- "sdk/useOffer.ts",
89
- "sdk/useVariantPossiblities.ts",
91
+ // sdk/useOffer.ts — kept: sites often customize offer logic
92
+ // sdk/useVariantPossiblities.ts — kept: sites often customize variant logic
90
93
  "sdk/usePlatform.tsx",
91
94
  "sdk/signal.ts",
92
95
  "sdk/format.ts",
@@ -141,10 +144,27 @@ function extractInlineNpmDeps(content: string): Record<string, string> {
141
144
  while ((match = regex.exec(content)) !== null) {
142
145
  const name = match[1];
143
146
  const version = match[2] || "*";
144
- // Skip framework deps
145
147
  if (name.startsWith("preact") || name.startsWith("@preact/")) continue;
146
148
  deps[name] = `^${version}`;
147
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
+
148
168
  return deps;
149
169
  }
150
170
 
@@ -218,6 +238,11 @@ function decideAction(
218
238
  return { action: "delete", notes: "Rewritten from scratch" };
219
239
  }
220
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
+
221
246
  // Loaders that depend on deleted admin tooling
222
247
  if (LOADER_DELETE.has(relPath)) {
223
248
  return {
@@ -247,6 +272,8 @@ function decideAction(
247
272
  return { action: "delete", notes: "Use @decocms/apps cart hooks" };
248
273
  }
249
274
 
275
+ // Non-platform cleanup is done in analyze() post-processing (needs ctx.platform)
276
+
250
277
  // Islands — classify and route to appropriate target
251
278
  if (category === "island") {
252
279
  const classification = (record as any).__islandClassification;
@@ -262,14 +289,19 @@ function decideAction(
262
289
  };
263
290
  }
264
291
 
265
- // Sections that re-export from islands/ → delete (island takes their place)
266
- // But sections that re-export from components/ or other dirs should be KEPT
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.
267
295
  if (category === "section" && isReExp) {
268
296
  const target = record.reExportTarget || "";
269
297
  const isIslandReExport = target.includes("islands/") ||
270
298
  target.includes("islands\\");
271
299
  if (isIslandReExport) {
272
- return { action: "delete", notes: "Re-export wrapper for island, island merged" };
300
+ return {
301
+ action: "transform",
302
+ targetPath: `src/${relPath}`,
303
+ notes: "Section re-export — island target rewritten to ~/components/",
304
+ };
273
305
  }
274
306
  // Section re-exports from components/ — keep and transform
275
307
  }
@@ -284,6 +316,25 @@ function decideAction(
284
316
  };
285
317
  }
286
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
+
287
338
  // Static code/tooling files → delete
288
339
  if (STATIC_DELETE.has(relPath)) {
289
340
  return { action: "delete", notes: "Code/tooling file, not an asset" };
@@ -298,12 +349,13 @@ function decideAction(
298
349
  // Non-code root files that shouldn't go into src/
299
350
  const ext = path.extname(relPath);
300
351
  const nonCodeExts = new Set([".md", ".csv", ".json", ".sh", ".lock", ".yml", ".yaml", ".xml", ".html", ".txt", ".log"]);
301
- if (!relPath.includes("/") && nonCodeExts.has(ext)) {
352
+ const keepRootFiles = new Set(["account.json"]);
353
+ if (!relPath.includes("/") && nonCodeExts.has(ext) && !keepRootFiles.has(relPath)) {
302
354
  return { action: "delete", notes: "Root-level non-code file" };
303
355
  }
304
356
 
305
- // Root-level loose TS/TSX files that are tooling, not app code
306
- const rootToolingFiles = new Set(["islands.ts", "order-status.ts", "sync.sh"]);
357
+ // Root-level tooling files not app code
358
+ const rootToolingFiles = new Set(["islands.ts", "sync.sh"]);
307
359
  if (!relPath.includes("/") && rootToolingFiles.has(relPath)) {
308
360
  return { action: "delete", notes: "Root-level tooling file" };
309
361
  }
@@ -374,6 +426,47 @@ function extractGtmId(sourceDir: string): string | null {
374
426
  return match ? match[0] : null;
375
427
  }
376
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
+
377
470
  function extractPlatform(sourceDir: string): Platform {
378
471
  const platforms: Platform[] = ["vtex", "shopify", "wake", "vnda", "linx", "nuvemshop"];
379
472
 
@@ -518,6 +611,7 @@ export function analyze(ctx: MigrationContext): void {
518
611
  // Extract metadata
519
612
  ctx.siteName = extractSiteName(ctx.sourceDir);
520
613
  ctx.platform = extractPlatform(ctx.sourceDir);
614
+ ctx.vtexAccount = ctx.platform === "vtex" ? extractVtexAccount(ctx.sourceDir) : null;
521
615
  ctx.gtmId = extractGtmId(ctx.sourceDir);
522
616
 
523
617
  // Extract theme colors and font from CMS
@@ -526,7 +620,7 @@ export function analyze(ctx: MigrationContext): void {
526
620
  ctx.fontFamily = theme.fontFamily;
527
621
 
528
622
  console.log(` Site: ${ctx.siteName}`);
529
- console.log(` Platform: ${ctx.platform}`);
623
+ console.log(` Platform: ${ctx.platform}${ctx.vtexAccount ? ` (account: ${ctx.vtexAccount})` : ""}`);
530
624
  console.log(` GTM ID: ${ctx.gtmId || "none"}`);
531
625
  if (Object.keys(ctx.themeColors).length > 0) {
532
626
  console.log(` Theme: ${Object.keys(ctx.themeColors).length} colors from CMS`);
@@ -566,6 +660,37 @@ export function analyze(ctx: MigrationContext): void {
566
660
  console.log(` By category: ${JSON.stringify(byCategory)}`);
567
661
  console.log(` By action: ${JSON.stringify(byAction)}`);
568
662
 
663
+ // Surface the HTMX dynamic-section loader and every `useComponent` call site
664
+ // up-front. These need manual conversion to React state / createServerFn before
665
+ // the migrated site will run on Cloudflare Workers — the analyzer cannot do
666
+ // it automatically, so it must be loud enough to land in the report.
667
+ const useComponentSites = ctx.files.filter(
668
+ (f) => f.patterns.includes("use-component"),
669
+ );
670
+ const componentLoaderFile = ctx.files.find(
671
+ (f) =>
672
+ f.path === "sections/Component.tsx" ||
673
+ f.path === "sections/Component.ts",
674
+ );
675
+ if (componentLoaderFile || useComponentSites.length > 0) {
676
+ console.log("\n ⚠ HTMX dynamic-section loader detected (Bagaggio-style)");
677
+ if (componentLoaderFile) {
678
+ console.log(` • ${componentLoaderFile.path} (will be deleted)`);
679
+ }
680
+ if (useComponentSites.length > 0) {
681
+ console.log(` • ${useComponentSites.length} useComponent(...) call site(s) need manual conversion:`);
682
+ for (const f of useComponentSites.slice(0, 10)) {
683
+ console.log(` - ${f.path}`);
684
+ }
685
+ if (useComponentSites.length > 10) {
686
+ console.log(` ... and ${useComponentSites.length - 10} more`);
687
+ }
688
+ }
689
+ console.log(
690
+ " See: deco-to-tanstack-migration skill, 'useComponent / partial sections' section",
691
+ );
692
+ }
693
+
569
694
  // Run analyzers
570
695
  extractSectionMetadata(ctx);
571
696
  classifyIslands(ctx);
@@ -581,10 +706,64 @@ export function analyze(ctx: MigrationContext): void {
581
706
  if (classification.type === "wrapper") {
582
707
  f.action = "delete";
583
708
  f.notes = "Island wrapper — imports repointed to component";
709
+ // Build redirect map: island path → wrapped component's migrated import path
710
+ if (classification.wrapsComponent) {
711
+ let wrappedImport = classification.wrapsComponent;
712
+ if (wrappedImport.startsWith("./") || wrappedImport.startsWith("../")) {
713
+ // Resolve relative to the island's directory
714
+ const islandDir = path.dirname(f.path);
715
+ wrappedImport = path.posix.normalize(path.posix.join(islandDir, wrappedImport));
716
+ wrappedImport = "~/" + wrappedImport;
717
+ } else {
718
+ wrappedImport = wrappedImport.replace(/^(\$store|site)\//, "~/");
719
+ }
720
+ wrappedImport = wrappedImport.replace(/\.tsx?$/, "");
721
+ ctx.islandWrapperTargets.set(f.path, wrappedImport);
722
+ }
584
723
  } else {
585
724
  f.action = "transform";
586
725
  f.targetPath = classification.suggestedTarget;
587
726
  f.notes = "Standalone island moved to components";
588
727
  }
589
728
  }
729
+
730
+ // Fix section re-exports from wrapper islands — rewrite to the wrapper's target component
731
+ if (ctx.islandWrapperTargets.size > 0) {
732
+ for (const f of ctx.files) {
733
+ if (f.category !== "section" || !f.isReExport || !f.reExportTarget) continue;
734
+ let target = f.reExportTarget;
735
+ // Resolve relative paths (../../islands/X) against section's directory
736
+ if (target.startsWith("./") || target.startsWith("../")) {
737
+ target = path.posix.normalize(path.posix.join(path.dirname(f.path), target));
738
+ }
739
+ target = target.replace(/^(\$store|site)\//, "").replace(/\.tsx?$/, "");
740
+ if (!target.startsWith("islands/")) continue;
741
+ const wrappedImport = ctx.islandWrapperTargets.get(target + ".tsx") ||
742
+ ctx.islandWrapperTargets.get(target);
743
+ if (wrappedImport) {
744
+ (f as any).__resolvedReExportTarget = wrappedImport;
745
+ f.notes = `Section re-export — island wrapper resolved to ${wrappedImport}`;
746
+ }
747
+ }
748
+ }
749
+
750
+ // Delete non-platform files (e.g. shopify/linx/vnda/wake/nuvemshop on VTEX sites)
751
+ const NON_PLATFORM_PATTERNS: Record<string, RegExp> = {
752
+ vtex: /\/(shopify|linx|vnda|wake|nuvemshop)\b/i,
753
+ shopify: /\/(vtex|linx|vnda|wake|nuvemshop)\b/i,
754
+ vnda: /\/(vtex|shopify|linx|wake|nuvemshop)\b/i,
755
+ wake: /\/(vtex|shopify|linx|vnda|nuvemshop)\b/i,
756
+ linx: /\/(vtex|shopify|vnda|wake|nuvemshop)\b/i,
757
+ nuvemshop: /\/(vtex|shopify|linx|vnda|wake)\b/i,
758
+ };
759
+ const nonPlatformRe = NON_PLATFORM_PATTERNS[ctx.platform];
760
+ if (nonPlatformRe) {
761
+ for (const f of ctx.files) {
762
+ if (f.action === "delete") continue;
763
+ if (nonPlatformRe.test("/" + f.path)) {
764
+ f.action = "delete";
765
+ f.notes = `Non-${ctx.platform} platform file`;
766
+ }
767
+ }
768
+ }
590
769
  }