@decocms/start 0.42.2 → 0.42.3

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.
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: run-migration
3
+ description: Run the deco-start migration script against a target site workspace. Resets the target to its Fresh/Deno state (origin/main), then runs the local migration script. Use for testing migration on real sites.
4
+ ---
5
+
6
+ # Run Deco Migration
7
+
8
+ Runs the migration script from the local `@decocms/start` repo against a target site.
9
+
10
+ ## How to use
11
+
12
+ 1. **Identify the target site workspace.** The user will specify which site to migrate. Common targets:
13
+ - miess-01-tanstack: `/Users/jonasjesus/conductor/workspaces/miess-01-tanstack/istanbul`
14
+
15
+ 2. **Reset the target to Fresh/Deno state:**
16
+ ```bash
17
+ cd <target-dir>
18
+ git checkout origin/main -- .
19
+ git checkout -- .
20
+ git clean -fd
21
+ ```
22
+ This restores the original Fresh/Deno source code.
23
+
24
+ 3. **Remove old node_modules** (migration generates a new package.json):
25
+ ```bash
26
+ rm -rf node_modules package-lock.json
27
+ ```
28
+
29
+ 4. **Run the migration script from local deco-start:**
30
+ ```bash
31
+ cd <target-dir>
32
+ npx tsx /Users/jonasjesus/conductor/workspaces/deco-start/london/scripts/migrate.ts --verbose
33
+ ```
34
+ The script uses `--source .` by default (current directory).
35
+
36
+ 5. **Check the output** for errors and review `MIGRATION_REPORT.md`.
37
+
38
+ 6. **If the user wants to test locally** after migration:
39
+ ```bash
40
+ cd <target-dir>
41
+ # Ensure @decocms/start points to local repo (not npm)
42
+ npm link /Users/jonasjesus/conductor/workspaces/deco-start/london 2>/dev/null || true
43
+ npm install
44
+ npm run dev # or bun run dev
45
+ ```
46
+
47
+ ## Important notes
48
+
49
+ - The migration script is at the repo root: `scripts/migrate.ts`
50
+ - Changes to the script are immediately reflected when running (no build needed, tsx runs TS directly)
51
+ - The target site's `origin/main` branch has the original Fresh/Deno code
52
+ - NEVER push migration results to the target site's remote without explicit user confirmation
53
+ - After running, check for runtime errors and update the migration script if needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.42.2",
3
+ "version": "0.42.3",
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",
@@ -65,6 +65,15 @@ export function scaffold(ctx: MigrationContext): void {
65
65
  // Styles
66
66
  writeFile(ctx, "src/styles/app.css", generateAppCss(ctx));
67
67
 
68
+ // SDK — signal shim (replaces @preact/signals)
69
+ writeFile(ctx, "src/sdk/signal.ts", generateSignalShim());
70
+
71
+ // SDK — clx (class name joiner, with default export for compat)
72
+ writeFile(ctx, "src/sdk/clx.ts", generateClxShim());
73
+
74
+ // SDK — debounce (replaces Deno std/async/debounce)
75
+ writeFile(ctx, "src/sdk/debounce.ts", generateDebounceShim());
76
+
68
77
  // Apps
69
78
  writeFile(ctx, "src/apps/site.ts", generateSiteApp(ctx));
70
79
 
@@ -224,6 +233,112 @@ vite.config.timestamp_*
224
233
  `;
225
234
  }
226
235
 
236
+ function generateClxShim(): string {
237
+ return `/** Filter out nullable values, join and minify class names */
238
+ export const clx = (...args: (string | null | undefined | false)[]) =>
239
+ args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");
240
+
241
+ /** Alias for compat — some files import as clsx */
242
+ export const clsx = clx;
243
+
244
+ export default clx;
245
+ `;
246
+ }
247
+
248
+ function generateDebounceShim(): string {
249
+ return `/** Debounce a function call — drop-in replacement for Deno std/async/debounce */
250
+ export function debounce<T extends (...args: any[]) => any>(
251
+ fn: T,
252
+ delay = 250,
253
+ ): T & { clear(): void } {
254
+ let timer: ReturnType<typeof setTimeout> | undefined;
255
+
256
+ const debounced = ((...args: Parameters<T>) => {
257
+ if (timer !== undefined) clearTimeout(timer);
258
+ timer = setTimeout(() => {
259
+ timer = undefined;
260
+ fn(...args);
261
+ }, delay);
262
+ }) as T & { clear(): void };
263
+
264
+ debounced.clear = () => {
265
+ if (timer !== undefined) {
266
+ clearTimeout(timer);
267
+ timer = undefined;
268
+ }
269
+ };
270
+
271
+ return debounced;
272
+ }
273
+
274
+ export default debounce;
275
+ `;
276
+ }
277
+
278
+ function generateSignalShim(): string {
279
+ return `import { Store } from "@tanstack/store";
280
+ import { useSyncExternalStore, useMemo, useEffect } from "react";
281
+
282
+ export interface Signal<T> {
283
+ readonly store: Store<T>;
284
+ value: T;
285
+ peek(): T;
286
+ subscribe(fn: () => void): () => void;
287
+ }
288
+
289
+ export function signal<T>(initialValue: T): Signal<T> {
290
+ const store = new Store<T>(initialValue);
291
+ return {
292
+ store,
293
+ get value() { return store.state; },
294
+ set value(v: T) { store.setState(() => v); },
295
+ peek() { return store.state; },
296
+ subscribe(fn) {
297
+ // @tanstack/store@0.9.x returns { unsubscribe: Function },
298
+ // NOT a plain function. React's useSyncExternalStore cleanup
299
+ // expects a bare function — unwrap it.
300
+ const sub = store.subscribe(() => fn());
301
+ return typeof sub === "function" ? sub : sub.unsubscribe;
302
+ },
303
+ };
304
+ }
305
+
306
+ export function useSignal<T>(initialValue: T): Signal<T> {
307
+ const sig = useMemo(() => signal(initialValue), []);
308
+ useSyncExternalStore(
309
+ (cb) => sig.subscribe(cb),
310
+ () => sig.value,
311
+ () => sig.value,
312
+ );
313
+ return sig;
314
+ }
315
+
316
+ export function useComputed<T>(fn: () => T): Signal<T> {
317
+ const sig = useMemo(() => signal(fn()), [fn]);
318
+ return sig;
319
+ }
320
+
321
+ export function computed<T>(fn: () => T): Signal<T> {
322
+ return signal(fn());
323
+ }
324
+
325
+ export function effect(fn: () => void | (() => void)): () => void {
326
+ const cleanup = fn();
327
+ return typeof cleanup === "function" ? cleanup : () => {};
328
+ }
329
+
330
+ export function batch(fn: () => void): void {
331
+ fn();
332
+ }
333
+
334
+ export function useSignalEffect(fn: () => void | (() => void)): void {
335
+ useEffect(fn);
336
+ }
337
+
338
+ export type { Signal as ReadonlySignal };
339
+ `;
340
+ }
341
+
227
342
  function generateSiteApp(ctx: MigrationContext): string {
228
343
  return `export type Platform =
229
344
  | "vtex"
@@ -119,4 +119,89 @@ export function transform(ctx: MigrationContext): void {
119
119
  }
120
120
 
121
121
  console.log(` Transformed ${ctx.transformedFiles.length} files`);
122
+
123
+ // Post-transform: resolve ~/islands/ imports to actual file locations.
124
+ // Islands are moved to src/sections/ during migration, but components
125
+ // import them via ~/islands/X which no longer exists. Scan src/ for
126
+ // the actual file and rewrite the import.
127
+ if (!ctx.dryRun) {
128
+ fixIslandImports(ctx);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Scan all transformed files for ~/islands/ imports and rewrite them
134
+ * to the actual path where the file was placed (sections/, components/, etc.).
135
+ */
136
+ function fixIslandImports(ctx: MigrationContext): void {
137
+ const srcDir = path.join(ctx.sourceDir, "src");
138
+ if (!fs.existsSync(srcDir)) return;
139
+
140
+ // Build a lookup: filename → relative path from src/
141
+ const fileLookup = new Map<string, string[]>();
142
+ function scanDir(dir: string) {
143
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
144
+ if (entry.isDirectory()) {
145
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
146
+ scanDir(path.join(dir, entry.name));
147
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
148
+ const relPath = path.relative(srcDir, path.join(dir, entry.name)).replace(/\\/g, "/");
149
+ const base = entry.name.replace(/\.tsx?$/, "");
150
+ if (!fileLookup.has(base)) fileLookup.set(base, []);
151
+ fileLookup.get(base)!.push(relPath);
152
+ }
153
+ }
154
+ }
155
+ scanDir(srcDir);
156
+
157
+ // Scan all .ts/.tsx files in src/ for ~/islands/ imports
158
+ const islandImportRe = /from\s+["'](~\/islands\/([^"']+))["']/g;
159
+ let fixCount = 0;
160
+
161
+ function walkAndFix(dir: string) {
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ if (entry.isDirectory()) {
164
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
165
+ walkAndFix(path.join(dir, entry.name));
166
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
167
+ const filePath = path.join(dir, entry.name);
168
+ let content = fs.readFileSync(filePath, "utf-8");
169
+ let modified = false;
170
+
171
+ content = content.replace(islandImportRe, (match, fullImport, islandPath) => {
172
+ // islandPath = "Cart/Indicator" or "SliderJS" or "Searchbar"
173
+ const basename = islandPath.replace(/\.tsx?$/, "").split("/").pop()!;
174
+
175
+ // Try to find the file — prefer components/ over sections/
176
+ const candidates = fileLookup.get(basename) || [];
177
+ // Exclude islands/ paths themselves and routes/
178
+ const valid = candidates.filter(
179
+ (c) => !c.startsWith("islands/") && !c.startsWith("routes/"),
180
+ );
181
+
182
+ if (valid.length === 0) return match; // can't resolve, leave as-is
183
+
184
+ // Prefer components/ over sections/
185
+ const preferred =
186
+ valid.find((c) => c.startsWith("components/")) ??
187
+ valid.find((c) => c.startsWith("sections/")) ??
188
+ valid[0];
189
+
190
+ const newPath = "~/" + preferred.replace(/\.tsx?$/, "");
191
+ modified = true;
192
+ return match.replace(fullImport, newPath);
193
+ });
194
+
195
+ if (modified) {
196
+ fs.writeFileSync(filePath, content, "utf-8");
197
+ fixCount++;
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ walkAndFix(srcDir);
204
+ if (fixCount > 0) {
205
+ console.log(` Fixed ~/islands/ imports in ${fixCount} files`);
206
+ }
122
207
  }
@@ -261,14 +261,14 @@ const checks: Check[] = [
261
261
  },
262
262
  },
263
263
  {
264
- name: "No dead cache/cacheKey/loader exports",
264
+ name: "No dead cache/cacheKey exports",
265
265
  severity: "warning",
266
266
  fn: (ctx) => {
267
267
  const srcDir = path.join(ctx.sourceDir, "src");
268
268
  if (!fs.existsSync(srcDir)) return true;
269
- const bad = findFilesWithPattern(srcDir, /^export\s+const\s+(?:cache|cacheKey|loader)\s*=/m);
269
+ const bad = findFilesWithPattern(srcDir, /^export\s+const\s+(?:cache|cacheKey)\s*=/m);
270
270
  if (bad.length > 0) {
271
- console.log(` Dead exports found (old cache/loader system): ${bad.join(", ")}`);
271
+ console.log(` Dead exports found (old cache system): ${bad.join(", ")}`);
272
272
  return false;
273
273
  }
274
274
  return true;
@@ -6,13 +6,57 @@ import type { TransformResult } from "../types.ts";
6
6
  *
7
7
  * - `export const cache = "stale-while-revalidate"` (old cache system)
8
8
  * - `export const cacheKey = ...` (old cache key generation)
9
- * - `export const loader = (props, req, ctx) => ...` (old section loader pattern)
10
9
  * - `crypto.subtle.digestSync(...)` (Deno-only sync API)
11
10
  *
11
+ * NOTE: `export const loader` is kept — it's a server-side function the CMS calls.
12
12
  * NOTE: invoke.* calls are NOT migrated — they are RPC calls to the server
13
13
  * where the CMS config (API keys, etc.) is available. The runtime.ts invoke
14
14
  * proxy handles routing them to /deco/invoke/*.
15
15
  */
16
+ /**
17
+ * Remove an `export const <name> = ...` block using brace-counting
18
+ * so nested `{}` (for loops, if/else) don't cause premature truncation.
19
+ */
20
+ function removeExportConstBlock(src: string, name: string): string {
21
+ const pattern = new RegExp(`^export\\s+const\\s+${name}\\s*=`, "m");
22
+ const match = pattern.exec(src);
23
+ if (!match) return src;
24
+
25
+ // Find the arrow `=>` first, then the opening `{` of the body.
26
+ // This avoids matching destructuring braces in parameters like
27
+ // `export const loader = ({ groups }: Props) => { ... }`
28
+ let pos = match.index + match[0].length;
29
+ // Look for `=>`
30
+ const arrowIdx = src.indexOf("=>", pos);
31
+ if (arrowIdx === -1) {
32
+ // No arrow function — try simple brace from current position
33
+ while (pos < src.length && src[pos] !== "{") pos++;
34
+ } else {
35
+ // Start searching for `{` after the arrow
36
+ pos = arrowIdx + 2;
37
+ while (pos < src.length && src[pos] !== "{") pos++;
38
+ }
39
+ if (pos >= src.length) return src; // no brace body, skip
40
+
41
+ // Count braces to find the matching closing brace
42
+ let depth = 0;
43
+ const start = match.index;
44
+ for (; pos < src.length; pos++) {
45
+ if (src[pos] === "{") depth++;
46
+ else if (src[pos] === "}") {
47
+ depth--;
48
+ if (depth === 0) {
49
+ // Skip optional semicolon and trailing newline
50
+ let end = pos + 1;
51
+ if (end < src.length && src[end] === ";") end++;
52
+ if (end < src.length && src[end] === "\n") end++;
53
+ return src.slice(0, start) + src.slice(end);
54
+ }
55
+ }
56
+ }
57
+ return src; // unbalanced braces, don't touch
58
+ }
59
+
16
60
  export function transformDeadCode(content: string): TransformResult {
17
61
  const notes: string[] = [];
18
62
  let changed = false;
@@ -28,13 +72,9 @@ export function transformDeadCode(content: string): TransformResult {
28
72
  notes.push("Removed dead `export const cache` (old caching system)");
29
73
  }
30
74
 
31
- // Remove old cacheKey export (can be multiline)
75
+ // Remove old cacheKey export (can be multiline with brace-counting)
32
76
  if (/^export\s+const\s+cacheKey\s*=/m.test(result)) {
33
- // Try to remove the entire cacheKey function — find matching closing brace/semicolon
34
- result = result.replace(
35
- /^export\s+const\s+cacheKey\s*=\s*\([^)]*\)\s*(?::\s*\w+\s*)?=>\s*\{[\s\S]*?\n\};\s*\n?/gm,
36
- "",
37
- );
77
+ result = removeExportConstBlock(result, "cacheKey");
38
78
  // Also handle simpler inline forms
39
79
  result = result.replace(
40
80
  /^export\s+const\s+cacheKey\s*=[^;]*;\s*\n?/gm,
@@ -44,22 +84,8 @@ export function transformDeadCode(content: string): TransformResult {
44
84
  notes.push("Removed dead `export const cacheKey` (old caching system)");
45
85
  }
46
86
 
47
- // Remove old section loader export: export const loader = (props, req, ctx) => { ... };
48
- // This is the old pattern where sections had co-located loaders.
49
- // In TanStack Start, section loaders are handled differently.
50
- if (/^export\s+const\s+loader\s*=\s*\(/m.test(result)) {
51
- result = result.replace(
52
- /^export\s+const\s+loader\s*=\s*\([^)]*\)\s*(?::\s*[\w<>[\]|&\s]+)?\s*=>\s*\{[\s\S]*?\n\};\s*\n?/gm,
53
- "",
54
- );
55
- // Also handle simpler inline forms
56
- result = result.replace(
57
- /^export\s+const\s+loader\s*=\s*\([^)]*\)\s*=>[^;]*;\s*\n?/gm,
58
- "",
59
- );
60
- changed = true;
61
- notes.push("Removed dead `export const loader` (old section loader — use section loaders in @decocms/start)");
62
- }
87
+ // NOTE: `export const loader` is kept these are server-side functions
88
+ // that the CMS calls to modify section props before rendering.
63
89
 
64
90
  // Replace crypto.subtle.digestSync (Deno-only) with a note
65
91
  if (result.includes("digestSync")) {
@@ -143,12 +143,17 @@ export function transformFreshApis(content: string): TransformResult {
143
143
  );
144
144
  }
145
145
 
146
- // Remove <Head> wrapper — its children should go into route head() config
147
- // This is complex to do with regex, so we flag it
146
+ // Replace <Head>...</Head> with <>...</>
147
+ // React 19 auto-hoists <title>, <meta>, <link> tags to document <head>.
148
148
  if (result.includes("<Head>") || result.includes("<Head ")) {
149
- notes.push(
150
- "MANUAL: <Head> component found — move contents to route head() config",
151
- );
149
+ // Handle self-closing <Head ... /> first so it becomes <></> (not just <>)
150
+ result = result.replace(/<Head\s[^>]*\/>/g, "<></>");
151
+ result = result.replace(/<Head\s*\/>/g, "<></>");
152
+ result = result.replace(/<Head>/g, "<>");
153
+ result = result.replace(/<Head\s[^>]*>/g, "<>");
154
+ result = result.replace(/<\/Head>/g, "</>");
155
+ changed = true;
156
+ notes.push("Replaced <Head> with fragment — React 19 hoists head tags automatically");
152
157
  }
153
158
 
154
159
  // scriptAsDataURI → useScript with dangerouslySetInnerHTML
@@ -15,8 +15,8 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
15
15
  [/^"preact\/jsx-runtime"$/, null],
16
16
  [/^"preact\/compat"$/, `"react"`],
17
17
  [/^"preact"$/, `"react"`],
18
- [/^"@preact\/signals-core"$/, null],
19
- [/^"@preact\/signals"$/, null],
18
+ [/^"@preact\/signals-core"$/, `"~/sdk/signal"`],
19
+ [/^"@preact\/signals"$/, `"~/sdk/signal"`],
20
20
 
21
21
  // Deco framework
22
22
  [/^"@deco\/deco\/hooks"$/, `"@decocms/start/sdk/useScript"`],
@@ -32,25 +32,41 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
32
32
  [/^"apps\/website\/components\/Theme\.tsx"$/, `"~/components/ui/Theme"`],
33
33
  [/^"apps\/commerce\/types\.ts"$/, `"@decocms/apps/commerce/types"`],
34
34
 
35
- // Apps — catch-all (things like apps/website/mod.ts, apps/vtex/mod.ts, etc.)
35
+ // Apps — VTEX (hooks, utils, actions, loaders, types)
36
+ [/^"apps\/vtex\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/hooks/$1"`],
37
+ [/^"apps\/vtex\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/utils/$1"`],
38
+ [/^"apps\/vtex\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/actions/$1"`],
39
+ [/^"apps\/vtex\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/loaders/$1"`],
40
+ [/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps/vtex/types"`],
41
+ // Apps — Shopify (hooks, utils, actions, loaders)
42
+ [/^"apps\/shopify\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/hooks/$1"`],
43
+ [/^"apps\/shopify\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/utils/$1"`],
44
+ [/^"apps\/shopify\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/actions/$1"`],
45
+ [/^"apps\/shopify\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/shopify/loaders/$1"`],
46
+ // Apps — commerce (types, SDK, utils)
47
+ [/^"apps\/commerce\/sdk\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/commerce/sdk/$1"`],
48
+ [/^"apps\/commerce\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/commerce/utils/$1"`],
49
+
50
+ // Apps — catch-all (things like apps/website/mod.ts, apps/analytics/mod.ts, etc.)
36
51
  [/^"apps\/([^"]+)"$/, null], // Remove — site.ts is rewritten
37
52
 
38
53
  // Deco old CDN imports
39
54
  [/^"deco\/([^"]+)"$/, null],
40
55
 
41
- // Std lib — not needed in Node (Deno std lib)
56
+ // Std lib — redirect useful utils, remove the rest
57
+ [/^"std\/async\/debounce(?:\.ts)?"$/, `"~/sdk/debounce"`],
42
58
  [/^"std\/([^"]+)"$/, null],
43
59
  [/^"@std\/crypto"$/, null], // Use globalThis.crypto instead
44
60
 
45
61
  // site/sdk/* → framework equivalents (before the catch-all site/ → ~/ rule)
46
- [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`],
62
+ [/^"site\/sdk\/clx(?:\.tsx?)?.*"$/, `"~/sdk/clx"`],
47
63
  [/^"site\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`],
48
64
  [/^"site\/sdk\/useOffer(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useOffer"`],
49
65
  [/^"site\/sdk\/useVariantPossiblities(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useVariantPossibilities"`],
50
66
  [/^"site\/sdk\/usePlatform(?:\.tsx?)?.*"$/, null],
51
67
 
52
68
  // $store/ → ~/ (common Deno import map alias for project root)
53
- [/^"\$store\/sdk\/clx(?:\.tsx?)?.*"$/, `"@decocms/start/sdk/clx"`],
69
+ [/^"\$store\/sdk\/clx(?:\.tsx?)?.*"$/, `"~/sdk/clx"`],
54
70
  [/^"\$store\/sdk\/useId(?:\.tsx?)?.*"$/, `"react"`],
55
71
  [/^"\$store\/sdk\/useOffer(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useOffer"`],
56
72
  [/^"\$store\/sdk\/useVariantPossiblities(?:\.tsx?)?.*"$/, `"@decocms/apps/commerce/sdk/useVariantPossibilities"`],
@@ -67,8 +83,8 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
67
83
  * The key is the ending of the import path, the value is the replacement specifier.
68
84
  */
69
85
  const RELATIVE_SDK_REWRITES: Array<[RegExp, string]> = [
70
- // sdk/clx → @decocms/start/sdk/clx
71
- [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "@decocms/start/sdk/clx"],
86
+ // sdk/clx → ~/sdk/clx (scaffolded locally with default export)
87
+ [/(?:\.\.\/)*sdk\/clx(?:\.tsx?)?$/, "~/sdk/clx"],
72
88
  // sdk/useId → react (useId is built-in in React 19)
73
89
  [/(?:\.\.\/)*sdk\/useId(?:\.tsx?)?$/, "react"],
74
90
  // sdk/useOffer → @decocms/apps/commerce/sdk/useOffer
@@ -219,6 +235,25 @@ export function transformImports(content: string): TransformResult {
219
235
  notes.push("Split useDevice into separate import from @decocms/start/sdk/useDevice");
220
236
  }
221
237
 
238
+ // Rewrite dynamic imports: route through rewriteSpecifier so sdk-specific
239
+ // rules (e.g. site/sdk/useId → react) are applied consistently.
240
+ const dynamicImportRe = /\bimport\(\s*(["'])([^"']+)\1\s*\)/g;
241
+ result = result.replace(dynamicImportRe, (_match, quote, specifier) => {
242
+ const quoted = `"${specifier}"`;
243
+ const rewritten = rewriteSpecifier(quoted);
244
+ if (rewritten === null) {
245
+ // Rule says remove — leave the dynamic import as-is (caller must fix manually)
246
+ return _match;
247
+ }
248
+ if (rewritten !== quoted) {
249
+ const newSpecifier = rewritten.slice(1, -1);
250
+ changed = true;
251
+ notes.push(`Rewrote dynamic import: ${specifier} → ${newSpecifier}`);
252
+ return `import(${quote}${newSpecifier}${quote})`;
253
+ }
254
+ return _match;
255
+ });
256
+
222
257
  // Clean up blank lines left by removed imports (collapse multiple to one)
223
258
  result = result.replace(/\n{3,}/g, "\n\n");
224
259
 
package/src/sdk/clx.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  /** Filter out nullable values, join and minify class names */
2
2
  export const clx = (...args: (string | null | undefined | false)[]) =>
3
3
  args.filter(Boolean).join(" ").replace(/\s\s+/g, " ");
4
+
5
+ export default clx;
package/src/sdk/signal.ts CHANGED
@@ -39,3 +39,4 @@ export function signal<T>(initialValue: T): ReactiveSignal<T> {
39
39
  },
40
40
  };
41
41
  }
42
+