@decocms/start 0.31.1 → 0.32.1
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 +3 -1
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/phase-analyze.ts +402 -0
- package/scripts/migrate/phase-cleanup.ts +212 -0
- package/scripts/migrate/phase-report.ts +171 -0
- package/scripts/migrate/phase-scaffold.ts +133 -0
- package/scripts/migrate/phase-transform.ts +102 -0
- package/scripts/migrate/phase-verify.ts +308 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/package-json.ts +98 -0
- package/scripts/migrate/templates/routes.ts +280 -0
- package/scripts/migrate/templates/server-entry.ts +163 -0
- package/scripts/migrate/templates/setup.ts +30 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/vite-config.ts +108 -0
- package/scripts/migrate/templates/wrangler.ts +25 -0
- package/scripts/migrate/transforms/deno-isms.ts +59 -0
- package/scripts/migrate/transforms/fresh-apis.ts +218 -0
- package/scripts/migrate/transforms/imports.ts +217 -0
- package/scripts/migrate/transforms/jsx.ts +184 -0
- package/scripts/migrate/transforms/tailwind.ts +409 -0
- package/scripts/migrate/types.ts +141 -0
- package/scripts/migrate.ts +135 -0
- package/scripts/tailwind-lint.ts +518 -0
- package/src/types/widgets.ts +1 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { TransformResult } from "../types.ts";
|
|
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
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Removes or replaces Fresh-specific APIs:
|
|
53
|
+
*
|
|
54
|
+
* - asset("/path") → "/path"
|
|
55
|
+
* - <Head>...</Head> → content extracted or removed
|
|
56
|
+
* - defineApp wrapper → unwrap
|
|
57
|
+
* - IS_BROWSER → typeof window !== "undefined"
|
|
58
|
+
* - Context.active().release?.revision() → "" (Vite handles cache busting)
|
|
59
|
+
*/
|
|
60
|
+
export function transformFreshApis(content: string): TransformResult {
|
|
61
|
+
const notes: string[] = [];
|
|
62
|
+
let changed = false;
|
|
63
|
+
let result = content;
|
|
64
|
+
|
|
65
|
+
// asset("/path") → "/path" and asset(`/path`) → `/path`
|
|
66
|
+
if (/\basset\(/.test(result)) {
|
|
67
|
+
result = result.replace(
|
|
68
|
+
/\basset\(\s*(`[^`]+`|"[^"]+"|'[^']+')\s*\)/g,
|
|
69
|
+
(_match, path) => {
|
|
70
|
+
// For template literals with revision, simplify
|
|
71
|
+
const inner = path.slice(1, -1);
|
|
72
|
+
if (inner.includes("${revision}") || inner.includes("?revision=")) {
|
|
73
|
+
// Remove cache-busting query — Vite handles it
|
|
74
|
+
const clean = inner
|
|
75
|
+
.replace(/\?revision=\$\{revision\}/, "")
|
|
76
|
+
.replace(/\$\{revision\}/, "");
|
|
77
|
+
return `"${clean}"`;
|
|
78
|
+
}
|
|
79
|
+
return path;
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
changed = true;
|
|
83
|
+
notes.push("Replaced asset() calls with direct paths");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove import { asset, Head } from "$fresh/runtime.ts"
|
|
87
|
+
// (the imports transform handles the specifier, but we also need to handle
|
|
88
|
+
// cases where the import line wasn't fully removed)
|
|
89
|
+
result = result.replace(
|
|
90
|
+
/^import\s+\{[^}]*\b(?:asset|Head)\b[^}]*\}\s+from\s+["']\$fresh\/runtime\.ts["'];?\s*\n?/gm,
|
|
91
|
+
"",
|
|
92
|
+
);
|
|
93
|
+
result = result.replace(
|
|
94
|
+
/^import\s+\{[^}]*\}\s+from\s+["']\$fresh\/server\.ts["'];?\s*\n?/gm,
|
|
95
|
+
"",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// IS_BROWSER → typeof window !== "undefined"
|
|
99
|
+
if (result.includes("IS_BROWSER")) {
|
|
100
|
+
result = result.replace(
|
|
101
|
+
/\bIS_BROWSER\b/g,
|
|
102
|
+
'(typeof window !== "undefined")',
|
|
103
|
+
);
|
|
104
|
+
// Remove the import
|
|
105
|
+
result = result.replace(
|
|
106
|
+
/^import\s+\{[^}]*\bIS_BROWSER\b[^}]*\}\s+from\s+["'][^"']+["'];?\s*\n?/gm,
|
|
107
|
+
"",
|
|
108
|
+
);
|
|
109
|
+
changed = true;
|
|
110
|
+
notes.push('Replaced IS_BROWSER with typeof window !== "undefined"');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Context.active().release?.revision() → "" or remove the entire await line
|
|
114
|
+
if (result.includes("Context.active()")) {
|
|
115
|
+
result = result.replace(
|
|
116
|
+
/(?:const|let)\s+\w+\s*=\s*await\s+Context\.active\(\)\.release\?\.revision\(\);?\s*\n?/g,
|
|
117
|
+
"",
|
|
118
|
+
);
|
|
119
|
+
result = result.replace(
|
|
120
|
+
/Context\.active\(\)\.release\?\.revision\(\)/g,
|
|
121
|
+
'""',
|
|
122
|
+
);
|
|
123
|
+
result = result.replace(
|
|
124
|
+
/^import\s+\{\s*Context\s*\}\s+from\s+["']@deco\/deco["'];?\s*\n?/gm,
|
|
125
|
+
"",
|
|
126
|
+
);
|
|
127
|
+
changed = true;
|
|
128
|
+
notes.push("Removed Context.active().release?.revision()");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// defineApp wrapper → unwrap to a plain function
|
|
132
|
+
// Matches: export default defineApp(async (_req, ctx) => { ... });
|
|
133
|
+
if (result.includes("defineApp")) {
|
|
134
|
+
result = result.replace(
|
|
135
|
+
/export\s+default\s+defineApp\(\s*(?:async\s+)?\([^)]*\)\s*=>\s*\{/,
|
|
136
|
+
"// NOTE: defineApp removed — this file needs manual conversion to a route\nexport default function AppLayout() {",
|
|
137
|
+
);
|
|
138
|
+
// Remove trailing ); that closed defineApp
|
|
139
|
+
// This is tricky — we'll flag for manual review instead of guessing
|
|
140
|
+
changed = true;
|
|
141
|
+
notes.push(
|
|
142
|
+
"MANUAL: defineApp wrapper partially unwrapped — verify closing brackets",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
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
|
|
148
|
+
if (result.includes("<Head>") || result.includes("<Head ")) {
|
|
149
|
+
notes.push(
|
|
150
|
+
"MANUAL: <Head> component found — move contents to route head() config",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
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
|
+
|
|
214
|
+
// Clean up blank lines
|
|
215
|
+
result = result.replace(/\n{3,}/g, "\n\n");
|
|
216
|
+
|
|
217
|
+
return { content: result, changed, notes };
|
|
218
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { TransformResult } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Import rewriting rules: from (Deno/Fresh/Preact) → to (Node/TanStack/React)
|
|
5
|
+
*
|
|
6
|
+
* Order matters: more specific rules should come first.
|
|
7
|
+
*/
|
|
8
|
+
const IMPORT_RULES: Array<[RegExp, string | null]> = [
|
|
9
|
+
// Fresh — remove entirely (handled by fresh-apis transform)
|
|
10
|
+
[/^"\$fresh\/runtime\.ts"/, null],
|
|
11
|
+
[/^"\$fresh\/server\.ts"/, null],
|
|
12
|
+
|
|
13
|
+
// Preact → React
|
|
14
|
+
[/^"preact\/hooks"$/, `"react"`],
|
|
15
|
+
[/^"preact\/jsx-runtime"$/, null],
|
|
16
|
+
[/^"preact\/compat"$/, `"react"`],
|
|
17
|
+
[/^"preact"$/, `"react"`],
|
|
18
|
+
[/^"@preact\/signals-core"$/, null],
|
|
19
|
+
[/^"@preact\/signals"$/, null],
|
|
20
|
+
|
|
21
|
+
// Deco framework
|
|
22
|
+
[/^"@deco\/deco\/hooks"$/, `"@decocms/start/sdk/useScript"`],
|
|
23
|
+
[/^"@deco\/deco\/blocks"$/, `"@decocms/start/types"`],
|
|
24
|
+
[/^"@deco\/deco\/web"$/, null], // runtime.ts is rewritten
|
|
25
|
+
[/^"@deco\/deco"$/, `"@decocms/start"`],
|
|
26
|
+
|
|
27
|
+
// Apps — widgets & components
|
|
28
|
+
[/^"apps\/admin\/widgets\.ts"$/, `"@decocms/start/types/widgets"`],
|
|
29
|
+
[/^"apps\/website\/components\/Image\.tsx"$/, `"@decocms/apps/commerce/components/Image"`],
|
|
30
|
+
[/^"apps\/website\/components\/Picture\.tsx"$/, `"@decocms/apps/commerce/components/Picture"`],
|
|
31
|
+
[/^"apps\/website\/components\/Video\.tsx"$/, `"@decocms/apps/commerce/components/Video"`],
|
|
32
|
+
[/^"apps\/commerce\/types\.ts"$/, `"@decocms/apps/commerce/types"`],
|
|
33
|
+
|
|
34
|
+
// Apps — catch-all (things like apps/website/mod.ts, apps/vtex/mod.ts, etc.)
|
|
35
|
+
[/^"apps\/([^"]+)"$/, null], // Remove — site.ts is rewritten
|
|
36
|
+
|
|
37
|
+
// Deco old CDN imports
|
|
38
|
+
[/^"deco\/([^"]+)"$/, null],
|
|
39
|
+
|
|
40
|
+
// Std lib — not needed in Node (Deno std lib)
|
|
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],
|
|
50
|
+
|
|
51
|
+
// site/ → ~/
|
|
52
|
+
[/^"site\/(.+)"$/, `"~/$1"`],
|
|
53
|
+
];
|
|
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
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Rewrites import specifiers in a file.
|
|
77
|
+
*
|
|
78
|
+
* Handles:
|
|
79
|
+
* - import X from "old" → import X from "new"
|
|
80
|
+
* - import { X } from "old" → import { X } from "new"
|
|
81
|
+
* - import type { X } from "old" → import type { X } from "new"
|
|
82
|
+
* - export { X } from "old" → export { X } from "new"
|
|
83
|
+
* - import "old" → import "new"
|
|
84
|
+
*
|
|
85
|
+
* When a rule maps to null, the entire import line is removed.
|
|
86
|
+
*/
|
|
87
|
+
export function transformImports(content: string): TransformResult {
|
|
88
|
+
const notes: string[] = [];
|
|
89
|
+
let changed = false;
|
|
90
|
+
|
|
91
|
+
// Match import/export lines with their specifiers
|
|
92
|
+
const importLineRegex =
|
|
93
|
+
/^(import\s+(?:type\s+)?(?:\{[^}]*\}|[\w*]+(?:\s*,\s*\{[^}]*\})?)\s+from\s+)("[^"]+"|'[^']+')(;?\s*)$/gm;
|
|
94
|
+
const reExportLineRegex =
|
|
95
|
+
/^(export\s+(?:type\s+)?\{[^}]*\}\s+from\s+)("[^"]+"|'[^']+')(;?\s*)$/gm;
|
|
96
|
+
const sideEffectImportRegex = /^(import\s+)("[^"]+"|'[^']+')(;?\s*)$/gm;
|
|
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
|
+
|
|
128
|
+
function rewriteSpecifier(specifier: string): string | null {
|
|
129
|
+
// Remove quotes for matching
|
|
130
|
+
const inner = specifier.slice(1, -1);
|
|
131
|
+
|
|
132
|
+
for (const [pattern, replacement] of IMPORT_RULES) {
|
|
133
|
+
if (pattern.test(`"${inner}"`)) {
|
|
134
|
+
if (replacement === null) return null;
|
|
135
|
+
// Apply regex 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
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// npm: prefix removal
|
|
160
|
+
if (inner.startsWith("npm:")) {
|
|
161
|
+
const cleaned = inner
|
|
162
|
+
.slice(4)
|
|
163
|
+
.replace(/@[\d^~>=<.*]+$/, ""); // strip version
|
|
164
|
+
return `"${cleaned}"`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Strip .ts/.tsx extensions from relative imports
|
|
168
|
+
if (
|
|
169
|
+
(inner.startsWith("./") || inner.startsWith("../") ||
|
|
170
|
+
inner.startsWith("~/")) &&
|
|
171
|
+
(inner.endsWith(".ts") || inner.endsWith(".tsx"))
|
|
172
|
+
) {
|
|
173
|
+
const stripped = inner.replace(/\.tsx?$/, "");
|
|
174
|
+
return `"${stripped}"`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return specifier;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function processLine(
|
|
181
|
+
_match: string,
|
|
182
|
+
prefix: string,
|
|
183
|
+
specifier: string,
|
|
184
|
+
suffix: string,
|
|
185
|
+
): string {
|
|
186
|
+
const newSpec = rewriteSpecifier(specifier);
|
|
187
|
+
if (newSpec === null) {
|
|
188
|
+
changed = true;
|
|
189
|
+
notes.push(`Removed import: ${specifier}`);
|
|
190
|
+
return ""; // Remove the line
|
|
191
|
+
}
|
|
192
|
+
if (newSpec !== specifier) {
|
|
193
|
+
changed = true;
|
|
194
|
+
notes.push(`Rewrote: ${specifier} → ${newSpec}`);
|
|
195
|
+
return `${prefix}${newSpec}${suffix}`;
|
|
196
|
+
}
|
|
197
|
+
return `${prefix}${specifier}${suffix}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let result = content;
|
|
201
|
+
result = result.replace(importLineRegex, processLine);
|
|
202
|
+
result = result.replace(reExportLineRegex, processLine);
|
|
203
|
+
result = result.replace(sideEffectImportRegex, processLine);
|
|
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
|
+
|
|
213
|
+
// Clean up blank lines left by removed imports (collapse multiple to one)
|
|
214
|
+
result = result.replace(/\n{3,}/g, "\n\n");
|
|
215
|
+
|
|
216
|
+
return { content: result, changed, notes };
|
|
217
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { TransformResult } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transforms Preact JSX patterns to React JSX patterns.
|
|
5
|
+
*
|
|
6
|
+
* - class= → className= (in JSX context)
|
|
7
|
+
* - onInput= → onChange= (React's onChange fires on every keystroke)
|
|
8
|
+
* - ComponentChildren → React.ReactNode
|
|
9
|
+
* - JSX.SVGAttributes → React.SVGAttributes
|
|
10
|
+
* - JSX.GenericEventHandler → React.FormEventHandler / React.EventHandler
|
|
11
|
+
* - type { JSX } from "preact" → (removed, use React types)
|
|
12
|
+
*/
|
|
13
|
+
export function transformJsx(content: string): TransformResult {
|
|
14
|
+
const notes: string[] = [];
|
|
15
|
+
let changed = false;
|
|
16
|
+
let result = content;
|
|
17
|
+
|
|
18
|
+
// class= → className= in JSX attributes
|
|
19
|
+
// Match class= that's preceded by whitespace and inside a JSX tag
|
|
20
|
+
if (/(<[a-zA-Z][^>]*?\s)class(\s*=)/.test(result)) {
|
|
21
|
+
result = result.replace(
|
|
22
|
+
/(<[a-zA-Z][^>]*?\s)class(\s*=)/g,
|
|
23
|
+
"$1className$2",
|
|
24
|
+
);
|
|
25
|
+
changed = true;
|
|
26
|
+
notes.push("Replaced class= with className=");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Also handle class= at the start of a line in JSX (multi-line attributes)
|
|
30
|
+
if (/^(\s+)class(\s*=)/m.test(result)) {
|
|
31
|
+
result = result.replace(/^(\s+)class(\s*=)/gm, "$1className$2");
|
|
32
|
+
changed = true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// onInput= → onChange=
|
|
36
|
+
if (result.includes("onInput=")) {
|
|
37
|
+
result = result.replace(/onInput=/g, "onChange=");
|
|
38
|
+
changed = true;
|
|
39
|
+
notes.push("Replaced onInput= with onChange=");
|
|
40
|
+
}
|
|
41
|
+
|
|
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)
|
|
58
|
+
if (result.includes("ComponentChildren")) {
|
|
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
|
+
}
|
|
79
|
+
}
|
|
80
|
+
changed = true;
|
|
81
|
+
notes.push("Replaced ComponentChildren with ReactNode");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// JSX.SVGAttributes<SVGSVGElement> → React.SVGAttributes<SVGSVGElement>
|
|
85
|
+
if (result.includes("JSX.SVGAttributes")) {
|
|
86
|
+
result = result.replace(/\bJSX\.SVGAttributes/g, "React.SVGAttributes");
|
|
87
|
+
changed = true;
|
|
88
|
+
notes.push("Replaced JSX.SVGAttributes with React.SVGAttributes");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// JSX.GenericEventHandler<X> → React.FormEventHandler<X>
|
|
92
|
+
if (result.includes("JSX.GenericEventHandler")) {
|
|
93
|
+
result = result.replace(
|
|
94
|
+
/\bJSX\.GenericEventHandler/g,
|
|
95
|
+
"React.FormEventHandler",
|
|
96
|
+
);
|
|
97
|
+
changed = true;
|
|
98
|
+
notes.push(
|
|
99
|
+
"Replaced JSX.GenericEventHandler with React.FormEventHandler",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// JSX.HTMLAttributes<X> → React.HTMLAttributes<X>
|
|
104
|
+
if (result.includes("JSX.HTMLAttributes")) {
|
|
105
|
+
result = result.replace(
|
|
106
|
+
/\bJSX\.HTMLAttributes/g,
|
|
107
|
+
"React.HTMLAttributes",
|
|
108
|
+
);
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// JSX.IntrinsicElements → React.JSX.IntrinsicElements
|
|
113
|
+
if (result.includes("JSX.IntrinsicElements")) {
|
|
114
|
+
result = result.replace(
|
|
115
|
+
/\bJSX\.IntrinsicElements/g,
|
|
116
|
+
"React.JSX.IntrinsicElements",
|
|
117
|
+
);
|
|
118
|
+
changed = true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Remove standalone "import type { JSX } from 'preact'" if JSX no longer used
|
|
122
|
+
// (it was already removed by imports transform, but double check)
|
|
123
|
+
result = result.replace(
|
|
124
|
+
/^import\s+type\s+\{\s*JSX\s*\}\s+from\s+["']preact["'];?\s*\n?/gm,
|
|
125
|
+
"",
|
|
126
|
+
);
|
|
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
|
+
|
|
174
|
+
// Ensure React import exists if we introduced React.* references
|
|
175
|
+
if (
|
|
176
|
+
(result.includes("React.") || result.includes("React,")) &&
|
|
177
|
+
!result.match(/^import\s.*React/m)
|
|
178
|
+
) {
|
|
179
|
+
result = `import React from "react";\n${result}`;
|
|
180
|
+
changed = true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { content: result, changed, notes };
|
|
184
|
+
}
|