@decocms/start 0.32.0 → 0.32.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.32.0",
3
+ "version": "0.32.2",
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",
@@ -43,6 +43,9 @@ const SKIP_DIRS = new Set([
43
43
  "static",
44
44
  ".context",
45
45
  "scripts",
46
+ "src",
47
+ "public",
48
+ ".tanstack",
46
49
  ]);
47
50
 
48
51
  const SKIP_FILES = new Set([
@@ -52,6 +55,8 @@ const SKIP_FILES = new Set([
52
55
  "LICENSE",
53
56
  "browserslist",
54
57
  "bw_stats.json",
58
+ "package.json",
59
+ "package-lock.json",
55
60
  ]);
56
61
 
57
62
  /** Files that are generated and should be deleted */
@@ -70,6 +75,12 @@ const SDK_DELETE = new Set([
70
75
  "sdk/usePlatform.tsx",
71
76
  ]);
72
77
 
78
+ /** Loaders that depend on deleted admin tooling */
79
+ const LOADER_DELETE = new Set([
80
+ "loaders/availableIcons.ts",
81
+ "loaders/icons.ts",
82
+ ]);
83
+
73
84
  /** Root config/infra files to delete */
74
85
  const ROOT_DELETE = new Set([
75
86
  "main.ts",
@@ -79,11 +90,39 @@ const ROOT_DELETE = new Set([
79
90
  "tailwind.css",
80
91
  "tailwind.config.ts",
81
92
  "runtime.ts",
93
+ "constants.ts",
82
94
  "fresh.gen.ts",
83
95
  "manifest.gen.ts",
84
96
  "fresh.config.ts",
97
+ "browserslist",
98
+ "bw_stats.json",
99
+ ]);
100
+
101
+ /** Static files that are code/tooling, not assets — should be deleted */
102
+ const STATIC_DELETE = new Set([
103
+ "static/adminIcons.ts",
104
+ "static/generate-icons.ts",
105
+ "static/tailwind.css",
85
106
  ]);
86
107
 
108
+ /**
109
+ * Scan file content for inline npm: imports and return { name: version } pairs.
110
+ * Matches patterns like: from "npm:fuse.js@7.0.0"
111
+ */
112
+ function extractInlineNpmDeps(content: string): Record<string, string> {
113
+ const deps: Record<string, string> = {};
114
+ const regex = /from\s+["']npm:(@?[^@"']+)(?:@([^"']+))?["']/g;
115
+ let match;
116
+ while ((match = regex.exec(content)) !== null) {
117
+ const name = match[1];
118
+ const version = match[2] || "*";
119
+ // Skip framework deps
120
+ if (name.startsWith("preact") || name.startsWith("@preact/")) continue;
121
+ deps[name] = `^${version}`;
122
+ }
123
+ return deps;
124
+ }
125
+
87
126
  function detectPatterns(content: string): DetectedPattern[] {
88
127
  const patterns: DetectedPattern[] = [];
89
128
  for (const [name, regex] of PATTERN_DETECTORS) {
@@ -154,6 +193,14 @@ function decideAction(
154
193
  return { action: "delete", notes: "Rewritten from scratch" };
155
194
  }
156
195
 
196
+ // Loaders that depend on deleted admin tooling
197
+ if (LOADER_DELETE.has(relPath)) {
198
+ return {
199
+ action: "delete",
200
+ notes: "Admin icon loader — depends on deleted static/adminIcons.ts",
201
+ };
202
+ }
203
+
157
204
  // SDK files to delete
158
205
  if (SDK_DELETE.has(relPath)) {
159
206
  return {
@@ -192,6 +239,11 @@ function decideAction(
192
239
  };
193
240
  }
194
241
 
242
+ // Static code/tooling files → delete
243
+ if (STATIC_DELETE.has(relPath)) {
244
+ return { action: "delete", notes: "Code/tooling file, not an asset" };
245
+ }
246
+
195
247
  // Static files → move
196
248
  if (category === "static") {
197
249
  const publicPath = relPath.replace("static/", "public/");
@@ -329,6 +381,21 @@ export function analyze(ctx: MigrationContext): void {
329
381
  byCategory[f.category] = (byCategory[f.category] || 0) + 1;
330
382
  }
331
383
 
384
+ // Scan all source files for inline npm: imports
385
+ for (const f of ctx.files) {
386
+ if (f.action === "delete") continue;
387
+ const ext = path.extname(f.path);
388
+ if (![".ts", ".tsx"].includes(ext)) continue;
389
+ try {
390
+ const content = fs.readFileSync(f.absPath, "utf-8");
391
+ const deps = extractInlineNpmDeps(content);
392
+ Object.assign(ctx.discoveredNpmDeps, deps);
393
+ } catch {}
394
+ }
395
+ if (Object.keys(ctx.discoveredNpmDeps).length > 0) {
396
+ log(ctx, `Discovered npm deps from source: ${JSON.stringify(ctx.discoveredNpmDeps)}`);
397
+ }
398
+
332
399
  console.log(`\n Files found: ${ctx.files.length}`);
333
400
  console.log(` By category: ${JSON.stringify(byCategory)}`);
334
401
  console.log(` By action: ${JSON.stringify(byAction)}`);
@@ -43,6 +43,12 @@ const WRAPPER_FILES_TO_DELETE = [
43
43
  "sections/Session.tsx",
44
44
  ];
45
45
 
46
+ /** Loaders that depend on deleted admin tooling */
47
+ const LOADER_FILES_TO_DELETE = [
48
+ "loaders/availableIcons.ts",
49
+ "loaders/icons.ts",
50
+ ];
51
+
46
52
  function deleteFileIfExists(ctx: MigrationContext, relPath: string) {
47
53
  const fullPath = path.join(ctx.sourceDir, relPath);
48
54
  if (!fs.existsSync(fullPath)) return;
@@ -185,6 +191,9 @@ export function cleanup(ctx: MigrationContext): void {
185
191
  for (const file of WRAPPER_FILES_TO_DELETE) {
186
192
  deleteFileIfExists(ctx, file);
187
193
  }
194
+ for (const file of LOADER_FILES_TO_DELETE) {
195
+ deleteFileIfExists(ctx, file);
196
+ }
188
197
 
189
198
  // 3. Delete directories
190
199
  console.log(" Deleting old directories...");
@@ -186,23 +186,83 @@ const checks: Check[] = [
186
186
  return true;
187
187
  },
188
188
  },
189
+ {
190
+ name: "No .ts/.tsx extensions in relative import paths",
191
+ severity: "warning",
192
+ fn: (ctx) => {
193
+ const srcDir = path.join(ctx.sourceDir, "src");
194
+ if (!fs.existsSync(srcDir)) return true;
195
+ // Match relative imports with .ts/.tsx extensions
196
+ const bad = findFilesWithPattern(srcDir, /from\s+["'](?:\.\.?\/|~\/)[^"']*\.tsx?["']/);
197
+ if (bad.length > 0) {
198
+ console.log(` Still has .ts/.tsx extensions in imports: ${bad.join(", ")}`);
199
+ return false;
200
+ }
201
+ return true;
202
+ },
203
+ },
204
+ {
205
+ name: "No for= in JSX (should be htmlFor=)",
206
+ severity: "warning",
207
+ fn: (ctx) => {
208
+ const srcDir = path.join(ctx.sourceDir, "src");
209
+ if (!fs.existsSync(srcDir)) return true;
210
+ const bad = findFilesWithPattern(srcDir, /<label[^>]*\sfor\s*=/);
211
+ if (bad.length > 0) {
212
+ console.log(` Still has for= in JSX: ${bad.join(", ")}`);
213
+ return false;
214
+ }
215
+ return true;
216
+ },
217
+ },
218
+ {
219
+ name: "No relative imports to deleted SDK files",
220
+ severity: "error",
221
+ fn: (ctx) => {
222
+ const srcDir = path.join(ctx.sourceDir, "src");
223
+ if (!fs.existsSync(srcDir)) return true;
224
+ // Only match relative imports (../ or ./) to deleted SDK files, not @decocms/* package imports
225
+ const bad = findFilesWithPattern(srcDir, /from\s+["'](?:\.\.?\/)[^"']*\/sdk\/(?:clx|useId|useOffer|useVariantPossiblities|usePlatform)["']/);
226
+ if (bad.length > 0) {
227
+ console.log(` Still has relative imports to deleted SDK files: ${bad.join(", ")}`);
228
+ return false;
229
+ }
230
+ return true;
231
+ },
232
+ },
233
+ {
234
+ name: "No imports to deleted static files",
235
+ severity: "error",
236
+ fn: (ctx) => {
237
+ const srcDir = path.join(ctx.sourceDir, "src");
238
+ if (!fs.existsSync(srcDir)) return true;
239
+ const bad = findFilesWithPattern(srcDir, /from\s+["'][^"']*static\/adminIcons/);
240
+ if (bad.length > 0) {
241
+ console.log(` Still has imports to static/adminIcons: ${bad.join(", ")}`);
242
+ return false;
243
+ }
244
+ return true;
245
+ },
246
+ },
189
247
  ];
190
248
 
191
249
  function findFilesWithPattern(
192
250
  dir: string,
193
251
  pattern: RegExp,
194
252
  results: string[] = [],
253
+ baseDir?: string,
195
254
  ): string[] {
255
+ const root = baseDir ?? dir;
196
256
  const entries = fs.readdirSync(dir, { withFileTypes: true });
197
257
  for (const entry of entries) {
198
258
  const fullPath = path.join(dir, entry.name);
199
259
  if (entry.isDirectory()) {
200
- if (entry.name === "node_modules" || entry.name === ".git") continue;
201
- findFilesWithPattern(fullPath, pattern, results);
260
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue;
261
+ findFilesWithPattern(fullPath, pattern, results, root);
202
262
  } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
203
263
  const content = fs.readFileSync(fullPath, "utf-8");
204
264
  if (pattern.test(content)) {
205
- results.push(path.relative(dir, fullPath));
265
+ results.push(path.basename(fullPath));
206
266
  }
207
267
  }
208
268
  }
@@ -1,6 +1,37 @@
1
1
  import type { MigrationContext } from "../types.ts";
2
2
 
3
+ /**
4
+ * Extract npm dependencies from deno.json import map.
5
+ * Entries like `"fuse.js": "npm:fuse.js@7.0.0"` become `"fuse.js": "^7.0.0"`.
6
+ */
7
+ function extractNpmDeps(importMap: Record<string, string>): Record<string, string> {
8
+ const deps: Record<string, string> = {};
9
+ for (const [key, value] of Object.entries(importMap)) {
10
+ if (!value.startsWith("npm:")) continue;
11
+ // Skip preact, deco, and other framework deps we handle ourselves
12
+ if (key.startsWith("preact") || key.startsWith("@preact/")) continue;
13
+ if (key.startsWith("@deco/")) continue;
14
+ if (key === "daisyui") continue; // we pin our own version
15
+
16
+ const raw = value.slice(4); // remove "npm:"
17
+ const atIdx = raw.lastIndexOf("@");
18
+ if (atIdx <= 0) {
19
+ deps[raw] = "*";
20
+ } else {
21
+ const name = raw.slice(0, atIdx);
22
+ const version = raw.slice(atIdx + 1);
23
+ deps[name] = `^${version}`;
24
+ }
25
+ }
26
+ return deps;
27
+ }
28
+
3
29
  export function generatePackageJson(ctx: MigrationContext): string {
30
+ const siteDeps = {
31
+ ...extractNpmDeps(ctx.importMap),
32
+ ...ctx.discoveredNpmDeps,
33
+ };
34
+
4
35
  const pkg = {
5
36
  name: ctx.siteName,
6
37
  version: "0.1.0",
@@ -21,12 +52,18 @@ export function generatePackageJson(ctx: MigrationContext): string {
21
52
  format: 'prettier --write "src/**/*.{ts,tsx}"',
22
53
  "format:check": 'prettier --check "src/**/*.{ts,tsx}"',
23
54
  knip: "knip",
55
+ clean:
56
+ "rm -rf node_modules .cache dist .wrangler/state node_modules/.vite && npm install",
57
+ "tailwind:lint":
58
+ "tsx node_modules/@decocms/start/scripts/tailwind-lint.ts",
59
+ "tailwind:fix":
60
+ "tsx node_modules/@decocms/start/scripts/tailwind-lint.ts --fix",
24
61
  },
25
62
  author: "deco.cx",
26
63
  license: "MIT",
27
64
  dependencies: {
28
65
  "@decocms/apps": "^0.25.2",
29
- "@decocms/start": "^0.31.1",
66
+ "@decocms/start": "^0.32.0",
30
67
  "@tanstack/react-query": "5.90.21",
31
68
  "@tanstack/react-router": "1.166.7",
32
69
  "@tanstack/react-start": "1.166.8",
@@ -35,6 +72,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
35
72
  "colorjs.io": "^0.6.1",
36
73
  react: "^19.2.4",
37
74
  "react-dom": "^19.2.4",
75
+ ...siteDeps,
38
76
  },
39
77
  devDependencies: {
40
78
  "@cloudflare/vite-plugin": "^1.27.0",
@@ -48,6 +86,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
48
86
  knip: "^5.61.2",
49
87
  prettier: "^3.5.3",
50
88
  tailwindcss: "^4.2.1",
89
+ "ts-morph": "^27.0.2",
51
90
  tsx: "^4.19.4",
52
91
  typescript: "^5.9.3",
53
92
  vite: "^7.3.1",
@@ -71,7 +71,7 @@ function NavigationProgress() {
71
71
  function StableOutlet() {
72
72
  const isLoading = useRouterState({ select: (s) => s.isLoading });
73
73
  const ref = useRef<HTMLDivElement>(null);
74
- const savedHeight = useRef<number | undefined>();
74
+ const savedHeight = useRef<number | undefined>(undefined);
75
75
 
76
76
  useEffect(() => {
77
77
  if (isLoading && ref.current) {
@@ -22,7 +22,7 @@ export default createStartHandler(defaultStreamHandler);
22
22
  `;
23
23
  }
24
24
 
25
- function generateWorkerEntry(ctx: MigrationContext): string {
25
+ function generateWorkerEntry(_ctx: MigrationContext): string {
26
26
  return `/**
27
27
  * Cloudflare Worker entry point.
28
28
  *
@@ -30,13 +30,28 @@ function generateWorkerEntry(ctx: MigrationContext): string {
30
30
  * around the TanStack Start handler. Add proxy logic, security headers, or
31
31
  * A/B testing as needed.
32
32
  */
33
- import { createDecoWorkerEntry } from "@decocms/start/worker";
34
-
35
- const handler = createDecoWorkerEntry({
36
- siteName: "${ctx.siteName}",
33
+ import "./setup";
34
+ import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
35
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
36
+ import {
37
+ handleMeta,
38
+ handleDecofileRead,
39
+ handleDecofileReload,
40
+ handleRender,
41
+ corsHeaders,
42
+ } from "@decocms/start/admin";
43
+
44
+ const serverEntry = createServerEntry({ fetch: handler.fetch });
45
+
46
+ export default createDecoWorkerEntry(serverEntry, {
47
+ admin: {
48
+ handleMeta,
49
+ handleDecofileRead,
50
+ handleDecofileReload,
51
+ handleRender,
52
+ corsHeaders,
53
+ },
37
54
  });
38
-
39
- export default handler;
40
55
  `;
41
56
  }
42
57
 
@@ -7,8 +7,18 @@ export function generateSetup(_ctx: MigrationContext): string {
7
7
  * This file is imported by router.tsx at startup.
8
8
  * It uses import.meta.glob to lazily discover all section components.
9
9
  */
10
- import { registerSections } from "@decocms/start/cms";
11
- import { registerMatcher } from "@decocms/start/matchers";
10
+ import { blocks as generatedBlocks } from "./server/cms/blocks.gen";
11
+ import {
12
+ registerSections,
13
+ setBlocks,
14
+ } from "@decocms/start/cms";
15
+ import { registerBuiltinMatchers } from "@decocms/start/matchers/builtins";
16
+
17
+ // -- CMS Blocks --
18
+ // The Vite plugin intercepts the blocks.gen import and injects .deco/blocks/ data.
19
+ if (typeof document === "undefined") {
20
+ setBlocks(generatedBlocks);
21
+ }
12
22
 
13
23
  // -- Section Registry --
14
24
  // Discovers all .tsx files under src/sections/ and registers them as CMS blocks.
@@ -16,17 +26,6 @@ const sectionModules = import.meta.glob("./sections/**/*.tsx");
16
26
  registerSections(sectionModules);
17
27
 
18
28
  // -- Matchers --
19
- // Register any custom matchers here.
20
- // Example: registerMatcher("device", deviceMatcher);
21
-
22
- // -- Loader Cache --
23
- // Register cached loaders here if needed.
24
- // Example:
25
- // import { createCachedLoader } from "@decocms/start/loaders";
26
- // registerLoader("productList", createCachedLoader(vtexProductList, { ttl: 60_000 }));
27
-
28
- // -- CMS Blocks --
29
- // Load generated blocks at module level so they're available for resolution.
30
- import "./server/cms/blocks.gen";
29
+ registerBuiltinMatchers();
31
30
  `;
32
31
  }
@@ -14,33 +14,28 @@ export function transformDenoIsms(content: string): TransformResult {
14
14
  let result = content;
15
15
 
16
16
  // Remove deno-lint-ignore comments (single line and file-level)
17
- const denoLintRegex = /^\s*\/\/\s*deno-lint-ignore[^\n]*\n?/gm;
18
- if (denoLintRegex.test(result)) {
19
- result = result.replace(denoLintRegex, "");
17
+ // Also handle JSX comment form: {/* deno-lint-ignore ... */}
18
+ if (/deno-lint-ignore/.test(result)) {
19
+ result = result.replace(/^\s*\/\/\s*deno-lint-ignore[^\n]*\n?/gm, "");
20
+ result = result.replace(/\s*\{\/\*\s*deno-lint-ignore[^*]*\*\/\}\s*/g, " ");
20
21
  changed = true;
21
22
  notes.push("Removed deno-lint-ignore comments");
22
23
  }
23
24
 
24
25
  // Remove npm: prefix in import specifiers that weren't caught by imports transform
25
- const npmPrefixRegex = /(from\s+["'])npm:([^"'@][^"']*)(["'])/g;
26
- if (npmPrefixRegex.test(result)) {
26
+ if (/from\s+["']npm:/.test(result)) {
27
+ // npm:pkg@version → pkg (strip version)
27
28
  result = result.replace(
28
- /(from\s+["'])npm:([^"'@][^"']*)(["'])/g,
29
+ /(from\s+["'])npm:(@?[^@"']+)@[^"']*(["'])/g,
29
30
  "$1$2$3",
30
31
  );
31
- changed = true;
32
- notes.push("Removed npm: prefix from imports");
33
- }
34
-
35
- // npm:pkg@version → pkg (strip version too)
36
- const npmVersionRegex = /(from\s+["'])npm:(@?[^@"']+)@[^"']*(["'])/g;
37
- if (npmVersionRegex.test(result)) {
32
+ // npm:pkg → pkg (no version)
38
33
  result = result.replace(
39
- /(from\s+["'])npm:(@?[^@"']+)@[^"']*(["'])/g,
34
+ /(from\s+["'])npm:([^"'@][^"']*)(["'])/g,
40
35
  "$1$2$3",
41
36
  );
42
37
  changed = true;
43
- notes.push("Removed npm: prefix and version from imports");
38
+ notes.push("Removed npm: prefix from imports");
44
39
  }
45
40
 
46
41
  // Remove Deno.* API usages — flag for manual review
@@ -1,5 +1,53 @@
1
1
  import type { TransformResult } from "../types.ts";
2
2
 
3
+ /**
4
+ * Fix JSX after scriptAsDataURI → useScript replacement.
5
+ *
6
+ * Transforms patterns like:
7
+ * <script dangerouslySetInnerHTML={{ __html: useScript(fn, { ...props, x })}
8
+ * defer
9
+ * />
10
+ * Into:
11
+ * <script dangerouslySetInnerHTML={{ __html: useScript(fn, { ...props, x }) }}
12
+ * />
13
+ *
14
+ * The key issue: the original `src={scriptAsDataURI(...)}` has one closing `}`,
15
+ * but `dangerouslySetInnerHTML={{ __html: useScript(...) }}` needs two closing `}}`.
16
+ * We also need to remove stray attrs like `defer` that sit between the call and `/>`.
17
+ */
18
+ function rebalanceScriptDataUri(code: string): string {
19
+ const marker = "dangerouslySetInnerHTML={{ __html: useScript(";
20
+ let idx = code.indexOf(marker);
21
+
22
+ while (idx !== -1) {
23
+ const start = idx + marker.length;
24
+ // Find the balanced closing paren for useScript(
25
+ let depth = 1;
26
+ let i = start;
27
+ while (i < code.length && depth > 0) {
28
+ if (code[i] === "(") depth++;
29
+ else if (code[i] === ")") depth--;
30
+ i++;
31
+ }
32
+ // i is now right after the matching ) of useScript(...)
33
+ // We expect `}` next (closing the old src={...})
34
+ // We need to replace everything from ) to /> with `) }} />`
35
+ // and remove any stray attributes like `defer`, `type="module"`, etc.
36
+ const afterParen = code.substring(i);
37
+ const closingMatch = afterParen.match(/^\s*\}\s*\n?\s*([\s\S]*?)\s*\/>/);
38
+ if (closingMatch) {
39
+ const endOffset = i + closingMatch[0].length;
40
+ // i is already past the closing ), so just add the }} and />
41
+ const replacement = ` }}\n />`;
42
+ code = code.substring(0, i) + replacement + code.substring(endOffset);
43
+ }
44
+
45
+ idx = code.indexOf(marker, idx + 1);
46
+ }
47
+
48
+ return code;
49
+ }
50
+
3
51
  /**
4
52
  * Removes or replaces Fresh-specific APIs:
5
53
  *
@@ -15,8 +63,7 @@ export function transformFreshApis(content: string): TransformResult {
15
63
  let result = content;
16
64
 
17
65
  // asset("/path") → "/path" and asset(`/path`) → `/path`
18
- const assetCallRegex = /\basset\(\s*(`[^`]+`|"[^"]+"|'[^']+')\s*\)/g;
19
- if (assetCallRegex.test(result)) {
66
+ if (/\basset\(/.test(result)) {
20
67
  result = result.replace(
21
68
  /\basset\(\s*(`[^`]+`|"[^"]+"|'[^']+')\s*\)/g,
22
69
  (_match, path) => {
@@ -104,6 +151,66 @@ export function transformFreshApis(content: string): TransformResult {
104
151
  );
105
152
  }
106
153
 
154
+ // scriptAsDataURI → useScript with dangerouslySetInnerHTML
155
+ // scriptAsDataURI is a Fresh pattern that returns a data: URI for <script src=...>.
156
+ // In React/TanStack, useScript returns a string for dangerouslySetInnerHTML.
157
+ //
158
+ // Before: <script src={scriptAsDataURI(fn, arg1, arg2)} defer />
159
+ // After: <script dangerouslySetInnerHTML={{ __html: useScript(fn, arg1, arg2) }} />
160
+ if (result.includes("scriptAsDataURI")) {
161
+ // Ensure useScript is imported
162
+ if (
163
+ !result.includes('"@decocms/start/sdk/useScript"') &&
164
+ !result.includes("'@decocms/start/sdk/useScript'")
165
+ ) {
166
+ result = `import { useScript } from "@decocms/start/sdk/useScript";\n${result}`;
167
+ }
168
+
169
+ // Transform src={scriptAsDataURI(...)} into dangerouslySetInnerHTML={{ __html: useScript(...) }}
170
+ // We need to match balanced parens to capture the full argument list.
171
+ result = result.replace(
172
+ /\bsrc=\{scriptAsDataURI\(/g,
173
+ "dangerouslySetInnerHTML={{ __html: useScript(",
174
+ );
175
+
176
+ // Now close the pattern: find the matching )} and replace with ) }}
177
+ // The pattern after replacement is: dangerouslySetInnerHTML={{ __html: useScript(...)}<maybe whitespace and other attrs>
178
+ // We need to find the closing )} that ends the JSX expression
179
+ result = rebalanceScriptDataUri(result);
180
+
181
+ // Replace any remaining standalone scriptAsDataURI references
182
+ result = result.replace(/\bscriptAsDataURI\b/g, "useScript");
183
+
184
+ changed = true;
185
+ notes.push("Replaced scriptAsDataURI with useScript + dangerouslySetInnerHTML");
186
+ }
187
+
188
+ // allowCorsFor — not available in @decocms/start, remove usage
189
+ if (result.includes("allowCorsFor")) {
190
+ result = result.replace(
191
+ /^import\s+\{[^}]*\ballowCorsFor\b[^}]*\}\s+from\s+["'][^"']+["'];?\s*\n?/gm,
192
+ "",
193
+ );
194
+ // Remove allowCorsFor calls
195
+ result = result.replace(/\ballowCorsFor\b\([^)]*\);?\s*\n?/g, "");
196
+ changed = true;
197
+ notes.push("Removed allowCorsFor (not needed in TanStack)");
198
+ }
199
+
200
+ // ctx.response.headers → not available, flag
201
+ if (result.includes("ctx.response")) {
202
+ notes.push("MANUAL: ctx.response usage found — FnContext in @decocms/start does not have response object");
203
+ }
204
+
205
+ // { crypto } from "@std/crypto" → use globalThis.crypto (Web Crypto API)
206
+ // The import is already removed by imports transform, but `crypto` references
207
+ // need to be prefixed with globalThis if they'd shadow the global
208
+ if (result.match(/^import\s+\{[^}]*\bcrypto\b/m)) {
209
+ // Import already removed by imports transform, so just ensure bare `crypto` works
210
+ // No action needed — globalThis.crypto is available in Workers + Node 20+
211
+ notes.push("INFO: @std/crypto replaced with globalThis.crypto (Web Crypto API)");
212
+ }
213
+
107
214
  // Clean up blank lines
108
215
  result = result.replace(/\n{3,}/g, "\n\n");
109
216
 
@@ -25,7 +25,7 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
25
25
  [/^"@deco\/deco"$/, `"@decocms/start"`],
26
26
 
27
27
  // Apps — widgets & components
28
- [/^"apps\/admin\/widgets\.ts"$/, `"@decocms/start/admin/widgets"`],
28
+ [/^"apps\/admin\/widgets\.ts"$/, `"@decocms/start/types/widgets"`],
29
29
  [/^"apps\/website\/components\/Image\.tsx"$/, `"@decocms/apps/commerce/components/Image"`],
30
30
  [/^"apps\/website\/components\/Picture\.tsx"$/, `"@decocms/apps/commerce/components/Picture"`],
31
31
  [/^"apps\/website\/components\/Video\.tsx"$/, `"@decocms/apps/commerce/components/Video"`],
@@ -37,13 +37,41 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
37
37
  // Deco old CDN imports
38
38
  [/^"deco\/([^"]+)"$/, null],
39
39
 
40
- // Std lib — not needed in Node
40
+ // Std lib — not needed in Node (Deno std lib)
41
41
  [/^"std\/([^"]+)"$/, null],
42
+ [/^"@std\/crypto"$/, null], // Use globalThis.crypto instead
43
+
44
+ // site/sdk/* → framework equivalents (before the catch-all site/ → ~/ rule)
45
+ [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`],
46
+ [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`],
47
+ [/^"site\/sdk\/useOffer(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useOffer"`],
48
+ [/^"site\/sdk\/useVariantPossiblities(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useVariantPossibilities"`],
49
+ [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null],
42
50
 
43
51
  // site/ → ~/
44
52
  [/^"site\/(.+)"$/, `"~/$1"`],
45
53
  ];
46
54
 
55
+ /**
56
+ * Relative import rewrites for SDK files that are deleted during migration.
57
+ * These are matched against the resolved import path (after ../.. resolution).
58
+ * The key is the ending of the import path, the value is the replacement specifier.
59
+ */
60
+ const RELATIVE_SDK_REWRITES: Array<[RegExp, string]> = [
61
+ // sdk/clx → @decocms/start/sdk/clx
62
+ [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "@decocms/start/sdk/clx"],
63
+ // sdk/useId → react (useId is built-in in React 19)
64
+ [/(?:\.\.\/)*sdk\/useId(?:\.tsx?)?$/, "react"],
65
+ // sdk/useOffer → @decocms/apps/commerce/sdk/useOffer
66
+ [/(?:\.\.\/)*sdk\/useOffer(?:\.tsx?)?$/, "@decocms/apps/commerce/sdk/useOffer"],
67
+ // sdk/useVariantPossiblities → @decocms/apps/commerce/sdk/useVariantPossibilities
68
+ [/(?:\.\.\/)*sdk\/useVariantPossiblities(?:\.tsx?)?$/, "@decocms/apps/commerce/sdk/useVariantPossibilities"],
69
+ // sdk/usePlatform → remove entirely
70
+ [/(?:\.\.\/)*sdk\/usePlatform(?:\.tsx?)?$/, ""],
71
+ // static/adminIcons → deleted (icon loaders need rewriting)
72
+ [/(?:\.\.\/)*static\/adminIcons(?:\.ts)?$/, ""],
73
+ ];
74
+
47
75
  /**
48
76
  * Rewrites import specifiers in a file.
49
77
  *
@@ -67,6 +95,36 @@ export function transformImports(content: string): TransformResult {
67
95
  /^(export\s+(?:type\s+)?\{[^}]*\}\s+from\s+)("[^"]+"|'[^']+')(;?\s*)$/gm;
68
96
  const sideEffectImportRegex = /^(import\s+)("[^"]+"|'[^']+')(;?\s*)$/gm;
69
97
 
98
+ /**
99
+ * Post-process: split @deco/deco/hooks imports.
100
+ * In the old stack, @deco/deco/hooks exported useDevice, useScript, useSection, etc.
101
+ * In @decocms/start, useDevice is at @decocms/start/sdk/useDevice.
102
+ * After import rewriting, we need to split lines like:
103
+ * import { useDevice, useScript } from "@decocms/start/sdk/useScript"
104
+ * into:
105
+ * import { useDevice } from "@decocms/start/sdk/useDevice"
106
+ * import { useScript } from "@decocms/start/sdk/useScript"
107
+ */
108
+ function splitDecoHooksImports(code: string): string {
109
+ return code.replace(
110
+ /^(import\s+(?:type\s+)?\{)([^}]*\buseDevice\b[^}]*)(\}\s+from\s+["']@decocms\/start\/sdk\/useScript["'];?)$/gm,
111
+ (_match, _prefix, importList, _suffix) => {
112
+ const items = importList.split(",").map((s: string) => s.trim()).filter(Boolean);
113
+ const deviceItems = items.filter((s: string) => s.includes("useDevice"));
114
+ const otherItems = items.filter((s: string) => !s.includes("useDevice"));
115
+
116
+ const lines: string[] = [];
117
+ if (deviceItems.length > 0) {
118
+ lines.push(`import { ${deviceItems.join(", ")} } from "@decocms/start/sdk/useDevice";`);
119
+ }
120
+ if (otherItems.length > 0) {
121
+ lines.push(`import { ${otherItems.join(", ")} } from "@decocms/start/sdk/useScript";`);
122
+ }
123
+ return lines.join("\n");
124
+ },
125
+ );
126
+ }
127
+
70
128
  function rewriteSpecifier(specifier: string): string | null {
71
129
  // Remove quotes for matching
72
130
  const inner = specifier.slice(1, -1);
@@ -75,7 +133,26 @@ export function transformImports(content: string): TransformResult {
75
133
  if (pattern.test(`"${inner}"`)) {
76
134
  if (replacement === null) return null;
77
135
  // Apply regex replacement
78
- return `"${inner}"`.replace(pattern, replacement);
136
+ let result = `"${inner}"`.replace(pattern, replacement);
137
+ // Strip .ts/.tsx extensions from the rewritten path if it's a relative/alias import
138
+ const resultInner = result.slice(1, -1);
139
+ if (
140
+ (resultInner.startsWith("~/") || resultInner.startsWith("./") || resultInner.startsWith("../")) &&
141
+ (resultInner.endsWith(".ts") || resultInner.endsWith(".tsx"))
142
+ ) {
143
+ result = `"${resultInner.replace(/\.tsx?$/, "")}"`;
144
+ }
145
+ return result;
146
+ }
147
+ }
148
+
149
+ // Relative imports pointing to deleted SDK files → framework equivalents
150
+ if (inner.startsWith("./") || inner.startsWith("../")) {
151
+ for (const [pattern, replacement] of RELATIVE_SDK_REWRITES) {
152
+ if (pattern.test(inner)) {
153
+ if (replacement === "") return null; // remove the import
154
+ return `"${replacement}"`;
155
+ }
79
156
  }
80
157
  }
81
158
 
@@ -125,6 +202,14 @@ export function transformImports(content: string): TransformResult {
125
202
  result = result.replace(reExportLineRegex, processLine);
126
203
  result = result.replace(sideEffectImportRegex, processLine);
127
204
 
205
+ // Split @deco/deco/hooks imports that contain useDevice
206
+ const afterSplit = splitDecoHooksImports(result);
207
+ if (afterSplit !== result) {
208
+ result = afterSplit;
209
+ changed = true;
210
+ notes.push("Split useDevice into separate import from @decocms/start/sdk/useDevice");
211
+ }
212
+
128
213
  // Clean up blank lines left by removed imports (collapse multiple to one)
129
214
  result = result.replace(/\n{3,}/g, "\n\n");
130
215
 
@@ -17,8 +17,7 @@ export function transformJsx(content: string): TransformResult {
17
17
 
18
18
  // class= → className= in JSX attributes
19
19
  // Match class= that's preceded by whitespace and inside a JSX tag
20
- const classAttrRegex = /(<[a-zA-Z][^>]*?\s)class(\s*=)/g;
21
- if (classAttrRegex.test(result)) {
20
+ if (/(<[a-zA-Z][^>]*?\s)class(\s*=)/.test(result)) {
22
21
  result = result.replace(
23
22
  /(<[a-zA-Z][^>]*?\s)class(\s*=)/g,
24
23
  "$1className$2",
@@ -28,9 +27,8 @@ export function transformJsx(content: string): TransformResult {
28
27
  }
29
28
 
30
29
  // Also handle class= at the start of a line in JSX (multi-line attributes)
31
- const standaloneClassRegex = /^(\s+)class(\s*=)/gm;
32
- if (standaloneClassRegex.test(result)) {
33
- result = result.replace(standaloneClassRegex, "$1className$2");
30
+ if (/^(\s+)class(\s*=)/m.test(result)) {
31
+ result = result.replace(/^(\s+)class(\s*=)/gm, "$1className$2");
34
32
  changed = true;
35
33
  }
36
34
 
@@ -41,15 +39,46 @@ export function transformJsx(content: string): TransformResult {
41
39
  notes.push("Replaced onInput= with onChange=");
42
40
  }
43
41
 
44
- // ComponentChildrenReact.ReactNode
42
+ // for=htmlFor= in JSX (label elements)
43
+ if (/(<(?:label|Label)[^>]*?\s)for(\s*=)/.test(result)) {
44
+ result = result.replace(
45
+ /(<(?:label|Label)[^>]*?\s)for(\s*=)/g,
46
+ "$1htmlFor$2",
47
+ );
48
+ changed = true;
49
+ notes.push("Replaced for= with htmlFor= on label elements");
50
+ }
51
+ // Also handle for= at the start of a line in multi-line JSX attributes
52
+ if (/^\s+for\s*=\s*\{/m.test(result)) {
53
+ result = result.replace(/^(\s+)for(\s*=\s*\{)/gm, "$1htmlFor$2");
54
+ changed = true;
55
+ }
56
+
57
+ // ComponentChildren → ReactNode (named import, not React.ReactNode)
45
58
  if (result.includes("ComponentChildren")) {
46
- result = result.replace(/\bComponentChildren\b/g, "React.ReactNode");
47
- // Add React import if not present
48
- if (!result.includes('from "react"') && !result.includes("from 'react'")) {
49
- result = `import React from "react";\n${result}`;
59
+ result = result.replace(/\bComponentChildren\b/g, "ReactNode");
60
+ // Add ReactNode import if not already imported
61
+ if (
62
+ !result.match(/\bReactNode\b.*from\s+["']react["']/) &&
63
+ !result.match(/from\s+["']react["'].*\bReactNode\b/)
64
+ ) {
65
+ // Check if there's already a react import we can extend
66
+ const reactImportMatch = result.match(
67
+ /^(import\s+(?:type\s+)?\{)([^}]*?)(\}\s+from\s+["']react["'];?)$/m,
68
+ );
69
+ if (reactImportMatch) {
70
+ const [fullMatch, prefix, existing, suffix] = reactImportMatch;
71
+ const items = existing.trim();
72
+ result = result.replace(
73
+ fullMatch,
74
+ `${prefix}${items ? `${items}, ` : ""}type ReactNode${suffix}`,
75
+ );
76
+ } else {
77
+ result = `import type { ReactNode } from "react";\n${result}`;
78
+ }
50
79
  }
51
80
  changed = true;
52
- notes.push("Replaced ComponentChildren with React.ReactNode");
81
+ notes.push("Replaced ComponentChildren with ReactNode");
53
82
  }
54
83
 
55
84
  // JSX.SVGAttributes<SVGSVGElement> → React.SVGAttributes<SVGSVGElement>
@@ -96,6 +125,52 @@ export function transformJsx(content: string): TransformResult {
96
125
  "",
97
126
  );
98
127
 
128
+ // tabindex → tabIndex in JSX
129
+ if (/\btabindex\s*=/.test(result)) {
130
+ result = result.replace(/\btabindex(\s*=)/g, "tabIndex$1");
131
+ changed = true;
132
+ notes.push("Replaced tabindex with tabIndex");
133
+ }
134
+
135
+ // frameBorder → frameBorder (already camelCase, but just in case)
136
+ // referrerpolicy → referrerPolicy
137
+ if (result.includes("referrerpolicy=")) {
138
+ result = result.replace(/referrerpolicy=/g, "referrerPolicy=");
139
+ changed = true;
140
+ notes.push("Replaced referrerpolicy with referrerPolicy");
141
+ }
142
+
143
+ // allowFullScreen={true} is fine in React, but allowfullscreen is not
144
+ if (result.includes("allowfullscreen")) {
145
+ result = result.replace(/\ballowfullscreen\b/g, "allowFullScreen");
146
+ changed = true;
147
+ }
148
+
149
+ // `class` as a prop name in destructuring patterns → `className`
150
+ // Matches: { class: someVar } or { class, } or { ..., class: x } in function params
151
+ if (/[{,]\s*class\s*[,}:]/.test(result)) {
152
+ // class: varName → className: varName (anywhere in destructuring)
153
+ result = result.replace(
154
+ /([{,]\s*)class(\s*:\s*\w+)/g,
155
+ "$1className$2",
156
+ );
157
+ // class, → className, (shorthand, anywhere in destructuring)
158
+ result = result.replace(
159
+ /([{,]\s*)class(\s*[,}])/g,
160
+ "$1className$2",
161
+ );
162
+ changed = true;
163
+ notes.push("Replaced 'class' prop in destructuring with 'className'");
164
+ }
165
+
166
+ // `class` in interface/type definitions → className
167
+ // Matches: class?: string; or class: string;
168
+ if (/^\s+class\??\s*:/m.test(result)) {
169
+ result = result.replace(/^(\s+)class(\??\s*:)/gm, "$1className$2");
170
+ changed = true;
171
+ notes.push("Replaced 'class' in interface definitions with 'className'");
172
+ }
173
+
99
174
  // Ensure React import exists if we introduced React.* references
100
175
  if (
101
176
  (result.includes("React.") || result.includes("React,")) &&
@@ -72,6 +72,9 @@ export interface MigrationContext {
72
72
  /** deno.json import map entries */
73
73
  importMap: Record<string, string>;
74
74
 
75
+ /** npm dependencies discovered from inline npm: imports in source files */
76
+ discoveredNpmDeps: Record<string, string>;
77
+
75
78
  /** All categorized source files */
76
79
  files: FileRecord[];
77
80
 
@@ -114,6 +117,7 @@ export function createContext(
114
117
  platform: "custom",
115
118
  gtmId: null,
116
119
  importMap: {},
120
+ discoveredNpmDeps: {},
117
121
  files: [],
118
122
  scaffoldedFiles: [],
119
123
  transformedFiles: [],
@@ -11,3 +11,4 @@ export type RichText = string;
11
11
  export type Secret = string;
12
12
  export type Color = string;
13
13
  export type ButtonWidget = string;
14
+ export type TextArea = string;