@pyreon/compiler 0.11.5 → 0.11.7
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/README.md +13 -10
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +4 -4
- package/package.json +10 -10
- package/src/index.ts +6 -11
- package/src/jsx.ts +104 -104
- package/src/project-scanner.ts +21 -21
- package/src/react-intercept.ts +213 -213
- package/src/tests/jsx.test.ts +583 -583
- package/src/tests/project-scanner.test.ts +63 -63
- package/src/tests/react-intercept.test.ts +280 -280
package/src/react-intercept.ts
CHANGED
|
@@ -12,37 +12,37 @@
|
|
|
12
12
|
* 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import ts from
|
|
15
|
+
import ts from 'typescript'
|
|
16
16
|
|
|
17
17
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
18
18
|
// Types
|
|
19
19
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
20
20
|
|
|
21
21
|
export type ReactDiagnosticCode =
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
22
|
+
| 'react-import'
|
|
23
|
+
| 'react-dom-import'
|
|
24
|
+
| 'react-router-import'
|
|
25
|
+
| 'use-state'
|
|
26
|
+
| 'use-effect-mount'
|
|
27
|
+
| 'use-effect-deps'
|
|
28
|
+
| 'use-effect-no-deps'
|
|
29
|
+
| 'use-memo'
|
|
30
|
+
| 'use-callback'
|
|
31
|
+
| 'use-ref-dom'
|
|
32
|
+
| 'use-ref-box'
|
|
33
|
+
| 'use-reducer'
|
|
34
|
+
| 'use-layout-effect'
|
|
35
|
+
| 'memo-wrapper'
|
|
36
|
+
| 'forward-ref'
|
|
37
|
+
| 'class-name-prop'
|
|
38
|
+
| 'html-for-prop'
|
|
39
|
+
| 'on-change-input'
|
|
40
|
+
| 'dangerously-set-inner-html'
|
|
41
|
+
| 'dot-value-signal'
|
|
42
|
+
| 'array-map-jsx'
|
|
43
|
+
| 'key-on-for-child'
|
|
44
|
+
| 'create-context-import'
|
|
45
|
+
| 'use-context-import'
|
|
46
46
|
|
|
47
47
|
export interface ReactDiagnostic {
|
|
48
48
|
/** Machine-readable code for filtering and programmatic handling */
|
|
@@ -62,7 +62,7 @@ export interface ReactDiagnostic {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export interface MigrationChange {
|
|
65
|
-
type:
|
|
65
|
+
type: 'replace' | 'remove' | 'add'
|
|
66
66
|
line: number
|
|
67
67
|
description: string
|
|
68
68
|
}
|
|
@@ -89,87 +89,87 @@ interface HookMapping {
|
|
|
89
89
|
|
|
90
90
|
const _REACT_HOOK_MAP: Record<string, HookMapping> = {
|
|
91
91
|
useState: {
|
|
92
|
-
pyreonFn:
|
|
93
|
-
pyreonImport:
|
|
94
|
-
description:
|
|
92
|
+
pyreonFn: 'signal',
|
|
93
|
+
pyreonImport: '@pyreon/reactivity',
|
|
94
|
+
description: 'Signals are callable functions — read: count(), write: count.set(5)',
|
|
95
95
|
example:
|
|
96
|
-
|
|
96
|
+
'const count = signal(0)\n// Read: count() Write: count.set(5) Update: count.update(n => n + 1)',
|
|
97
97
|
},
|
|
98
98
|
useEffect: {
|
|
99
|
-
pyreonFn:
|
|
100
|
-
pyreonImport:
|
|
101
|
-
description:
|
|
102
|
-
example:
|
|
99
|
+
pyreonFn: 'effect',
|
|
100
|
+
pyreonImport: '@pyreon/reactivity',
|
|
101
|
+
description: 'Effects auto-track signal dependencies — no dependency array needed',
|
|
102
|
+
example: 'effect(() => {\n console.log(count()) // auto-subscribes to count\n})',
|
|
103
103
|
},
|
|
104
104
|
useLayoutEffect: {
|
|
105
|
-
pyreonFn:
|
|
106
|
-
pyreonImport:
|
|
107
|
-
description:
|
|
108
|
-
example:
|
|
105
|
+
pyreonFn: 'effect',
|
|
106
|
+
pyreonImport: '@pyreon/reactivity',
|
|
107
|
+
description: 'Pyreon effects run synchronously after signal updates',
|
|
108
|
+
example: 'effect(() => {\n // runs sync after signal changes\n})',
|
|
109
109
|
},
|
|
110
110
|
useMemo: {
|
|
111
|
-
pyreonFn:
|
|
112
|
-
pyreonImport:
|
|
113
|
-
description:
|
|
114
|
-
example:
|
|
111
|
+
pyreonFn: 'computed',
|
|
112
|
+
pyreonImport: '@pyreon/reactivity',
|
|
113
|
+
description: 'Computed values auto-track dependencies and memoize',
|
|
114
|
+
example: 'const doubled = computed(() => count() * 2)',
|
|
115
115
|
},
|
|
116
116
|
useCallback: {
|
|
117
|
-
pyreonFn:
|
|
118
|
-
pyreonImport:
|
|
117
|
+
pyreonFn: '(plain function)',
|
|
118
|
+
pyreonImport: '',
|
|
119
119
|
description:
|
|
120
|
-
|
|
121
|
-
example:
|
|
120
|
+
'Not needed — Pyreon components run once, so closures never go stale. Use a plain function',
|
|
121
|
+
example: 'const handleClick = () => doSomething(count())',
|
|
122
122
|
},
|
|
123
123
|
useReducer: {
|
|
124
|
-
pyreonFn:
|
|
125
|
-
pyreonImport:
|
|
126
|
-
description:
|
|
124
|
+
pyreonFn: 'signal',
|
|
125
|
+
pyreonImport: '@pyreon/reactivity',
|
|
126
|
+
description: 'Use signal with update() for reducer-like patterns',
|
|
127
127
|
example:
|
|
128
|
-
|
|
128
|
+
'const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))',
|
|
129
129
|
},
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/** React import sources → Pyreon equivalents */
|
|
133
133
|
const IMPORT_REWRITES: Record<string, string | null> = {
|
|
134
|
-
react:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
react: '@pyreon/core',
|
|
135
|
+
'react-dom': '@pyreon/runtime-dom',
|
|
136
|
+
'react-dom/client': '@pyreon/runtime-dom',
|
|
137
|
+
'react-dom/server': '@pyreon/runtime-server',
|
|
138
|
+
'react-router': '@pyreon/router',
|
|
139
|
+
'react-router-dom': '@pyreon/router',
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/** React specifiers that map to specific Pyreon imports */
|
|
143
143
|
const SPECIFIER_REWRITES: Record<string, { name: string; from: string }> = {
|
|
144
|
-
useState: { name:
|
|
145
|
-
useEffect: { name:
|
|
146
|
-
useLayoutEffect: { name:
|
|
147
|
-
useMemo: { name:
|
|
148
|
-
useReducer: { name:
|
|
149
|
-
useRef: { name:
|
|
150
|
-
createContext: { name:
|
|
151
|
-
useContext: { name:
|
|
152
|
-
Fragment: { name:
|
|
153
|
-
Suspense: { name:
|
|
154
|
-
lazy: { name:
|
|
155
|
-
memo: { name:
|
|
156
|
-
forwardRef: { name:
|
|
157
|
-
createRoot: { name:
|
|
158
|
-
hydrateRoot: { name:
|
|
144
|
+
useState: { name: 'signal', from: '@pyreon/reactivity' },
|
|
145
|
+
useEffect: { name: 'effect', from: '@pyreon/reactivity' },
|
|
146
|
+
useLayoutEffect: { name: 'effect', from: '@pyreon/reactivity' },
|
|
147
|
+
useMemo: { name: 'computed', from: '@pyreon/reactivity' },
|
|
148
|
+
useReducer: { name: 'signal', from: '@pyreon/reactivity' },
|
|
149
|
+
useRef: { name: 'signal', from: '@pyreon/reactivity' },
|
|
150
|
+
createContext: { name: 'createContext', from: '@pyreon/core' },
|
|
151
|
+
useContext: { name: 'useContext', from: '@pyreon/core' },
|
|
152
|
+
Fragment: { name: 'Fragment', from: '@pyreon/core' },
|
|
153
|
+
Suspense: { name: 'Suspense', from: '@pyreon/core' },
|
|
154
|
+
lazy: { name: 'lazy', from: '@pyreon/core' },
|
|
155
|
+
memo: { name: '', from: '' }, // removed, not needed
|
|
156
|
+
forwardRef: { name: '', from: '' }, // removed, not needed
|
|
157
|
+
createRoot: { name: 'mount', from: '@pyreon/runtime-dom' },
|
|
158
|
+
hydrateRoot: { name: 'hydrateRoot', from: '@pyreon/runtime-dom' },
|
|
159
159
|
// React Router
|
|
160
|
-
useNavigate: { name:
|
|
161
|
-
useParams: { name:
|
|
162
|
-
useLocation: { name:
|
|
163
|
-
Link: { name:
|
|
164
|
-
NavLink: { name:
|
|
165
|
-
Outlet: { name:
|
|
166
|
-
useSearchParams: { name:
|
|
160
|
+
useNavigate: { name: 'useRouter', from: '@pyreon/router' },
|
|
161
|
+
useParams: { name: 'useRoute', from: '@pyreon/router' },
|
|
162
|
+
useLocation: { name: 'useRoute', from: '@pyreon/router' },
|
|
163
|
+
Link: { name: 'RouterLink', from: '@pyreon/router' },
|
|
164
|
+
NavLink: { name: 'RouterLink', from: '@pyreon/router' },
|
|
165
|
+
Outlet: { name: 'RouterView', from: '@pyreon/router' },
|
|
166
|
+
useSearchParams: { name: 'useSearchParams', from: '@pyreon/router' },
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
/** JSX attribute rewrites (React → standard HTML) */
|
|
170
170
|
const JSX_ATTR_REWRITES: Record<string, string> = {
|
|
171
|
-
className:
|
|
172
|
-
htmlFor:
|
|
171
|
+
className: 'class',
|
|
172
|
+
htmlFor: 'for',
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -220,11 +220,11 @@ function detectImportDeclaration(ctx: DetectContext, node: ts.ImportDeclaration)
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
const diagCode = source.startsWith(
|
|
224
|
-
?
|
|
225
|
-
: source.startsWith(
|
|
226
|
-
?
|
|
227
|
-
:
|
|
223
|
+
const diagCode = source.startsWith('react-router')
|
|
224
|
+
? 'react-router-import'
|
|
225
|
+
: source.startsWith('react-dom')
|
|
226
|
+
? 'react-dom-import'
|
|
227
|
+
: 'react-import'
|
|
228
228
|
|
|
229
229
|
detectDiag(
|
|
230
230
|
ctx,
|
|
@@ -234,7 +234,7 @@ function detectImportDeclaration(ctx: DetectContext, node: ts.ImportDeclaration)
|
|
|
234
234
|
detectGetNodeText(ctx, node),
|
|
235
235
|
pyreonSource
|
|
236
236
|
? `import { ... } from "${pyreonSource}"`
|
|
237
|
-
:
|
|
237
|
+
: 'Remove this import — not needed in Pyreon',
|
|
238
238
|
true,
|
|
239
239
|
)
|
|
240
240
|
}
|
|
@@ -250,13 +250,13 @@ function detectUseState(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
250
250
|
) {
|
|
251
251
|
const firstEl = parent.name.elements[0]
|
|
252
252
|
const valueName =
|
|
253
|
-
firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text :
|
|
254
|
-
const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) :
|
|
253
|
+
firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text : 'value'
|
|
254
|
+
const initArg = node.arguments[0] ? detectGetNodeText(ctx, node.arguments[0]) : 'undefined'
|
|
255
255
|
|
|
256
256
|
detectDiag(
|
|
257
257
|
ctx,
|
|
258
258
|
node,
|
|
259
|
-
|
|
259
|
+
'use-state',
|
|
260
260
|
`useState is a React API. In Pyreon, use signal(). Read: ${valueName}(), Write: ${valueName}.set(x)`,
|
|
261
261
|
detectGetNodeText(ctx, parent),
|
|
262
262
|
`${valueName} = signal(${initArg})`,
|
|
@@ -266,10 +266,10 @@ function detectUseState(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
266
266
|
detectDiag(
|
|
267
267
|
ctx,
|
|
268
268
|
node,
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
'use-state',
|
|
270
|
+
'useState is a React API. In Pyreon, use signal().',
|
|
271
271
|
detectGetNodeText(ctx, node),
|
|
272
|
-
|
|
272
|
+
'signal(initialValue)',
|
|
273
273
|
true,
|
|
274
274
|
)
|
|
275
275
|
}
|
|
@@ -296,32 +296,32 @@ function detectUseEffect(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
296
296
|
detectDiag(
|
|
297
297
|
ctx,
|
|
298
298
|
node,
|
|
299
|
-
|
|
299
|
+
'use-effect-mount',
|
|
300
300
|
`${hookName} with empty deps [] means "run once on mount". Use onMount() in Pyreon.`,
|
|
301
301
|
detectGetNodeText(ctx, node),
|
|
302
302
|
hasCleanup
|
|
303
|
-
?
|
|
304
|
-
:
|
|
303
|
+
? 'onMount(() => {\n // setup...\n return () => { /* cleanup */ }\n})'
|
|
304
|
+
: 'onMount(() => {\n // setup...\n})',
|
|
305
305
|
true,
|
|
306
306
|
)
|
|
307
307
|
} else if (depsArg && ts.isArrayLiteralExpression(depsArg)) {
|
|
308
308
|
detectDiag(
|
|
309
309
|
ctx,
|
|
310
310
|
node,
|
|
311
|
-
|
|
311
|
+
'use-effect-deps',
|
|
312
312
|
`${hookName} with dependency array. In Pyreon, effect() auto-tracks dependencies — no array needed.`,
|
|
313
313
|
detectGetNodeText(ctx, node),
|
|
314
|
-
|
|
314
|
+
'effect(() => {\n // reads are auto-tracked\n})',
|
|
315
315
|
true,
|
|
316
316
|
)
|
|
317
317
|
} else if (!depsArg) {
|
|
318
318
|
detectDiag(
|
|
319
319
|
ctx,
|
|
320
320
|
node,
|
|
321
|
-
|
|
321
|
+
'use-effect-no-deps',
|
|
322
322
|
`${hookName} with no dependency array. In Pyreon, use effect() — it auto-tracks signal reads.`,
|
|
323
323
|
detectGetNodeText(ctx, node),
|
|
324
|
-
|
|
324
|
+
'effect(() => {\n // runs when accessed signals change\n})',
|
|
325
325
|
true,
|
|
326
326
|
)
|
|
327
327
|
}
|
|
@@ -329,13 +329,13 @@ function detectUseEffect(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
329
329
|
|
|
330
330
|
function detectUseMemo(ctx: DetectContext, node: ts.CallExpression): void {
|
|
331
331
|
const computeFn = node.arguments[0]
|
|
332
|
-
const computeText = computeFn ? detectGetNodeText(ctx, computeFn) :
|
|
332
|
+
const computeText = computeFn ? detectGetNodeText(ctx, computeFn) : '() => value'
|
|
333
333
|
|
|
334
334
|
detectDiag(
|
|
335
335
|
ctx,
|
|
336
336
|
node,
|
|
337
|
-
|
|
338
|
-
|
|
337
|
+
'use-memo',
|
|
338
|
+
'useMemo is a React API. In Pyreon, use computed() — dependencies auto-tracked.',
|
|
339
339
|
detectGetNodeText(ctx, node),
|
|
340
340
|
`computed(${computeText})`,
|
|
341
341
|
true,
|
|
@@ -344,13 +344,13 @@ function detectUseMemo(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
344
344
|
|
|
345
345
|
function detectUseCallback(ctx: DetectContext, node: ts.CallExpression): void {
|
|
346
346
|
const callbackFn = node.arguments[0]
|
|
347
|
-
const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) :
|
|
347
|
+
const callbackText = callbackFn ? detectGetNodeText(ctx, callbackFn) : '() => {}'
|
|
348
348
|
|
|
349
349
|
detectDiag(
|
|
350
350
|
ctx,
|
|
351
351
|
node,
|
|
352
|
-
|
|
353
|
-
|
|
352
|
+
'use-callback',
|
|
353
|
+
'useCallback is not needed in Pyreon. Components run once, so closures never go stale. Use a plain function.',
|
|
354
354
|
detectGetNodeText(ctx, node),
|
|
355
355
|
callbackText,
|
|
356
356
|
true,
|
|
@@ -361,25 +361,25 @@ function detectUseRef(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
361
361
|
const arg = node.arguments[0]
|
|
362
362
|
const isNullInit =
|
|
363
363
|
arg &&
|
|
364
|
-
(arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text ===
|
|
364
|
+
(arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text === 'undefined'))
|
|
365
365
|
|
|
366
366
|
if (isNullInit) {
|
|
367
367
|
detectDiag(
|
|
368
368
|
ctx,
|
|
369
369
|
node,
|
|
370
|
-
|
|
371
|
-
|
|
370
|
+
'use-ref-dom',
|
|
371
|
+
'useRef(null) for DOM refs. In Pyreon, use createRef() from @pyreon/core.',
|
|
372
372
|
detectGetNodeText(ctx, node),
|
|
373
|
-
|
|
373
|
+
'createRef()',
|
|
374
374
|
true,
|
|
375
375
|
)
|
|
376
376
|
} else {
|
|
377
|
-
const initText = arg ? detectGetNodeText(ctx, arg) :
|
|
377
|
+
const initText = arg ? detectGetNodeText(ctx, arg) : 'undefined'
|
|
378
378
|
detectDiag(
|
|
379
379
|
ctx,
|
|
380
380
|
node,
|
|
381
|
-
|
|
382
|
-
|
|
381
|
+
'use-ref-box',
|
|
382
|
+
'useRef for mutable values. In Pyreon, use signal() — it works the same way but is reactive.',
|
|
383
383
|
detectGetNodeText(ctx, node),
|
|
384
384
|
`signal(${initText})`,
|
|
385
385
|
true,
|
|
@@ -391,10 +391,10 @@ function detectUseReducer(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
391
391
|
detectDiag(
|
|
392
392
|
ctx,
|
|
393
393
|
node,
|
|
394
|
-
|
|
395
|
-
|
|
394
|
+
'use-reducer',
|
|
395
|
+
'useReducer is a React API. In Pyreon, use signal() with update() for reducer patterns.',
|
|
396
396
|
detectGetNodeText(ctx, node),
|
|
397
|
-
|
|
397
|
+
'const state = signal(initialState)\nconst dispatch = (action) => state.update(s => reducer(s, action))',
|
|
398
398
|
false,
|
|
399
399
|
)
|
|
400
400
|
}
|
|
@@ -403,7 +403,7 @@ function isCallToReactDot(callee: ts.Expression, methodName: string): boolean {
|
|
|
403
403
|
return (
|
|
404
404
|
ts.isPropertyAccessExpression(callee) &&
|
|
405
405
|
ts.isIdentifier(callee.expression) &&
|
|
406
|
-
callee.expression.text ===
|
|
406
|
+
callee.expression.text === 'React' &&
|
|
407
407
|
callee.name.text === methodName
|
|
408
408
|
)
|
|
409
409
|
}
|
|
@@ -411,17 +411,17 @@ function isCallToReactDot(callee: ts.Expression, methodName: string): boolean {
|
|
|
411
411
|
function detectMemoWrapper(ctx: DetectContext, node: ts.CallExpression): void {
|
|
412
412
|
const callee = node.expression
|
|
413
413
|
const isMemo =
|
|
414
|
-
(ts.isIdentifier(callee) && callee.text ===
|
|
414
|
+
(ts.isIdentifier(callee) && callee.text === 'memo') || isCallToReactDot(callee, 'memo')
|
|
415
415
|
|
|
416
416
|
if (isMemo) {
|
|
417
417
|
const inner = node.arguments[0]
|
|
418
|
-
const innerText = inner ? detectGetNodeText(ctx, inner) :
|
|
418
|
+
const innerText = inner ? detectGetNodeText(ctx, inner) : 'Component'
|
|
419
419
|
|
|
420
420
|
detectDiag(
|
|
421
421
|
ctx,
|
|
422
422
|
node,
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
'memo-wrapper',
|
|
424
|
+
'memo() is not needed in Pyreon. Components run once — only signals trigger updates, not re-renders.',
|
|
425
425
|
detectGetNodeText(ctx, node),
|
|
426
426
|
innerText,
|
|
427
427
|
true,
|
|
@@ -432,17 +432,17 @@ function detectMemoWrapper(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
432
432
|
function detectForwardRef(ctx: DetectContext, node: ts.CallExpression): void {
|
|
433
433
|
const callee = node.expression
|
|
434
434
|
const isForwardRef =
|
|
435
|
-
(ts.isIdentifier(callee) && callee.text ===
|
|
436
|
-
isCallToReactDot(callee,
|
|
435
|
+
(ts.isIdentifier(callee) && callee.text === 'forwardRef') ||
|
|
436
|
+
isCallToReactDot(callee, 'forwardRef')
|
|
437
437
|
|
|
438
438
|
if (isForwardRef) {
|
|
439
439
|
detectDiag(
|
|
440
440
|
ctx,
|
|
441
441
|
node,
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
'forward-ref',
|
|
443
|
+
'forwardRef is not needed in Pyreon. Pass ref as a regular prop.',
|
|
444
444
|
detectGetNodeText(ctx, node),
|
|
445
|
-
|
|
445
|
+
'// Just pass ref as a prop:\nconst MyInput = (props) => <input ref={props.ref} />',
|
|
446
446
|
true,
|
|
447
447
|
)
|
|
448
448
|
}
|
|
@@ -456,7 +456,7 @@ function detectJsxAttributes(ctx: DetectContext, node: ts.JsxAttribute): void {
|
|
|
456
456
|
detectDiag(
|
|
457
457
|
ctx,
|
|
458
458
|
node,
|
|
459
|
-
attrName ===
|
|
459
|
+
attrName === 'className' ? 'class-name-prop' : 'html-for-prop',
|
|
460
460
|
`'${attrName}' is a React JSX attribute. Use '${htmlAttr}' in Pyreon (standard HTML).`,
|
|
461
461
|
detectGetNodeText(ctx, node),
|
|
462
462
|
detectGetNodeText(ctx, node).replace(attrName, htmlAttr),
|
|
@@ -464,32 +464,32 @@ function detectJsxAttributes(ctx: DetectContext, node: ts.JsxAttribute): void {
|
|
|
464
464
|
)
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
-
if (attrName ===
|
|
467
|
+
if (attrName === 'onChange') {
|
|
468
468
|
const jsxElement = findParentJsxElement(node)
|
|
469
469
|
if (jsxElement) {
|
|
470
470
|
const tagName = getJsxTagName(jsxElement)
|
|
471
|
-
if (tagName ===
|
|
471
|
+
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
|
472
472
|
detectDiag(
|
|
473
473
|
ctx,
|
|
474
474
|
node,
|
|
475
|
-
|
|
475
|
+
'on-change-input',
|
|
476
476
|
`onChange on <${tagName}> fires on blur in Pyreon (native DOM behavior). For keypress-by-keypress updates, use onInput.`,
|
|
477
477
|
detectGetNodeText(ctx, node),
|
|
478
|
-
detectGetNodeText(ctx, node).replace(
|
|
478
|
+
detectGetNodeText(ctx, node).replace('onChange', 'onInput'),
|
|
479
479
|
true,
|
|
480
480
|
)
|
|
481
481
|
}
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
if (attrName ===
|
|
485
|
+
if (attrName === 'dangerouslySetInnerHTML') {
|
|
486
486
|
detectDiag(
|
|
487
487
|
ctx,
|
|
488
488
|
node,
|
|
489
|
-
|
|
490
|
-
|
|
489
|
+
'dangerously-set-inner-html',
|
|
490
|
+
'dangerouslySetInnerHTML is React-specific. Use innerHTML prop in Pyreon.',
|
|
491
491
|
detectGetNodeText(ctx, node),
|
|
492
|
-
|
|
492
|
+
'innerHTML={htmlString}',
|
|
493
493
|
true,
|
|
494
494
|
)
|
|
495
495
|
}
|
|
@@ -502,7 +502,7 @@ function detectDotValueSignal(ctx: DetectContext, node: ts.PropertyAccessExpress
|
|
|
502
502
|
detectDiag(
|
|
503
503
|
ctx,
|
|
504
504
|
node,
|
|
505
|
-
|
|
505
|
+
'dot-value-signal',
|
|
506
506
|
`'${varName}.value' looks like a Vue ref pattern. Pyreon signals are callable functions. Use ${varName}.set(x) to write.`,
|
|
507
507
|
detectGetNodeText(ctx, parent),
|
|
508
508
|
`${varName}.set(${detectGetNodeText(ctx, parent.right)})`,
|
|
@@ -521,13 +521,13 @@ function detectArrayMapJsx(ctx: DetectContext, node: ts.CallExpression): void {
|
|
|
521
521
|
const mapCallback = node.arguments[0]
|
|
522
522
|
const mapCallbackText = mapCallback
|
|
523
523
|
? detectGetNodeText(ctx, mapCallback)
|
|
524
|
-
:
|
|
524
|
+
: 'item => <li>{item}</li>'
|
|
525
525
|
|
|
526
526
|
detectDiag(
|
|
527
527
|
ctx,
|
|
528
528
|
node,
|
|
529
|
-
|
|
530
|
-
|
|
529
|
+
'array-map-jsx',
|
|
530
|
+
'Array.map() in JSX is not reactive in Pyreon. Use <For> for efficient keyed list rendering.',
|
|
531
531
|
detectGetNodeText(ctx, node),
|
|
532
532
|
`<For each={${arrayExpr}} by={item => item.id}>\n {${mapCallbackText}}\n</For>`,
|
|
533
533
|
false,
|
|
@@ -547,7 +547,7 @@ function isCallToEffectHook(node: ts.Node): node is ts.CallExpression {
|
|
|
547
547
|
return (
|
|
548
548
|
ts.isCallExpression(node) &&
|
|
549
549
|
ts.isIdentifier(node.expression) &&
|
|
550
|
-
(node.expression.text ===
|
|
550
|
+
(node.expression.text === 'useEffect' || node.expression.text === 'useLayoutEffect')
|
|
551
551
|
)
|
|
552
552
|
}
|
|
553
553
|
|
|
@@ -556,7 +556,7 @@ function isMapCallExpression(node: ts.Node): node is ts.CallExpression {
|
|
|
556
556
|
ts.isCallExpression(node) &&
|
|
557
557
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
558
558
|
ts.isIdentifier(node.expression.name) &&
|
|
559
|
-
node.expression.name.text ===
|
|
559
|
+
node.expression.name.text === 'map'
|
|
560
560
|
)
|
|
561
561
|
}
|
|
562
562
|
|
|
@@ -564,19 +564,19 @@ function isDotValueAccess(node: ts.Node): node is ts.PropertyAccessExpression {
|
|
|
564
564
|
return (
|
|
565
565
|
ts.isPropertyAccessExpression(node) &&
|
|
566
566
|
ts.isIdentifier(node.name) &&
|
|
567
|
-
node.name.text ===
|
|
567
|
+
node.name.text === 'value' &&
|
|
568
568
|
ts.isIdentifier(node.expression)
|
|
569
569
|
)
|
|
570
570
|
}
|
|
571
571
|
|
|
572
572
|
function detectVisitNode(ctx: DetectContext, node: ts.Node): void {
|
|
573
573
|
if (ts.isImportDeclaration(node)) detectImportDeclaration(ctx, node)
|
|
574
|
-
if (isCallToHook(node,
|
|
574
|
+
if (isCallToHook(node, 'useState')) detectUseState(ctx, node)
|
|
575
575
|
if (isCallToEffectHook(node)) detectUseEffect(ctx, node)
|
|
576
|
-
if (isCallToHook(node,
|
|
577
|
-
if (isCallToHook(node,
|
|
578
|
-
if (isCallToHook(node,
|
|
579
|
-
if (isCallToHook(node,
|
|
576
|
+
if (isCallToHook(node, 'useMemo')) detectUseMemo(ctx, node)
|
|
577
|
+
if (isCallToHook(node, 'useCallback')) detectUseCallback(ctx, node)
|
|
578
|
+
if (isCallToHook(node, 'useRef')) detectUseRef(ctx, node)
|
|
579
|
+
if (isCallToHook(node, 'useReducer')) detectUseReducer(ctx, node)
|
|
580
580
|
if (ts.isCallExpression(node)) detectMemoWrapper(ctx, node)
|
|
581
581
|
if (ts.isCallExpression(node)) detectForwardRef(ctx, node)
|
|
582
582
|
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) detectJsxAttributes(ctx, node)
|
|
@@ -591,7 +591,7 @@ function detectVisit(ctx: DetectContext, node: ts.Node): void {
|
|
|
591
591
|
})
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
export function detectReactPatterns(code: string, filename =
|
|
594
|
+
export function detectReactPatterns(code: string, filename = 'input.tsx'): ReactDiagnostic[] {
|
|
595
595
|
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
|
|
596
596
|
const ctx: DetectContext = {
|
|
597
597
|
sf,
|
|
@@ -672,8 +672,8 @@ function migrateUseState(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
|
672
672
|
) {
|
|
673
673
|
const firstEl = parent.name.elements[0]
|
|
674
674
|
const valueName =
|
|
675
|
-
firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text :
|
|
676
|
-
const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) :
|
|
675
|
+
firstEl && ts.isBindingElement(firstEl) ? (firstEl.name as ts.Identifier).text : 'value'
|
|
676
|
+
const initArg = node.arguments[0] ? migrateGetNodeText(ctx, node.arguments[0]) : 'undefined'
|
|
677
677
|
|
|
678
678
|
const declStart = parent.getStart(ctx.sf)
|
|
679
679
|
const declEnd = parent.getEnd()
|
|
@@ -682,9 +682,9 @@ function migrateUseState(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
|
682
682
|
end: declEnd,
|
|
683
683
|
text: `${valueName} = signal(${initArg})`,
|
|
684
684
|
})
|
|
685
|
-
migrateAddImport(ctx,
|
|
685
|
+
migrateAddImport(ctx, '@pyreon/reactivity', 'signal')
|
|
686
686
|
ctx.changes.push({
|
|
687
|
-
type:
|
|
687
|
+
type: 'replace',
|
|
688
688
|
line: migrateGetLine(ctx, node),
|
|
689
689
|
description: `useState → signal: ${valueName}`,
|
|
690
690
|
})
|
|
@@ -704,18 +704,18 @@ function migrateUseEffect(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
|
704
704
|
) {
|
|
705
705
|
const callbackText = migrateGetNodeText(ctx, callbackArg)
|
|
706
706
|
migrateReplace(ctx, node, `onMount(${callbackText})`)
|
|
707
|
-
migrateAddImport(ctx,
|
|
707
|
+
migrateAddImport(ctx, '@pyreon/core', 'onMount')
|
|
708
708
|
ctx.changes.push({
|
|
709
|
-
type:
|
|
709
|
+
type: 'replace',
|
|
710
710
|
line: migrateGetLine(ctx, node),
|
|
711
711
|
description: `${hookName}(fn, []) → onMount(fn)`,
|
|
712
712
|
})
|
|
713
713
|
} else if (callbackArg) {
|
|
714
714
|
const callbackText = migrateGetNodeText(ctx, callbackArg)
|
|
715
715
|
migrateReplace(ctx, node, `effect(${callbackText})`)
|
|
716
|
-
migrateAddImport(ctx,
|
|
716
|
+
migrateAddImport(ctx, '@pyreon/reactivity', 'effect')
|
|
717
717
|
ctx.changes.push({
|
|
718
|
-
type:
|
|
718
|
+
type: 'replace',
|
|
719
719
|
line: migrateGetLine(ctx, node),
|
|
720
720
|
description: `${hookName} → effect (auto-tracks deps)`,
|
|
721
721
|
})
|
|
@@ -726,11 +726,11 @@ function migrateUseMemo(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
|
726
726
|
const computeFn = node.arguments[0]
|
|
727
727
|
if (computeFn) {
|
|
728
728
|
migrateReplace(ctx, node, `computed(${migrateGetNodeText(ctx, computeFn)})`)
|
|
729
|
-
migrateAddImport(ctx,
|
|
729
|
+
migrateAddImport(ctx, '@pyreon/reactivity', 'computed')
|
|
730
730
|
ctx.changes.push({
|
|
731
|
-
type:
|
|
731
|
+
type: 'replace',
|
|
732
732
|
line: migrateGetLine(ctx, node),
|
|
733
|
-
description:
|
|
733
|
+
description: 'useMemo → computed (auto-tracks deps)',
|
|
734
734
|
})
|
|
735
735
|
}
|
|
736
736
|
}
|
|
@@ -740,9 +740,9 @@ function migrateUseCallback(ctx: MigrateContext, node: ts.CallExpression): void
|
|
|
740
740
|
if (callbackFn) {
|
|
741
741
|
migrateReplace(ctx, node, migrateGetNodeText(ctx, callbackFn))
|
|
742
742
|
ctx.changes.push({
|
|
743
|
-
type:
|
|
743
|
+
type: 'replace',
|
|
744
744
|
line: migrateGetLine(ctx, node),
|
|
745
|
-
description:
|
|
745
|
+
description: 'useCallback → plain function (not needed in Pyreon)',
|
|
746
746
|
})
|
|
747
747
|
}
|
|
748
748
|
}
|
|
@@ -751,23 +751,23 @@ function migrateUseRef(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
|
751
751
|
const arg = node.arguments[0]
|
|
752
752
|
const isNullInit =
|
|
753
753
|
arg &&
|
|
754
|
-
(arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text ===
|
|
754
|
+
(arg.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(arg) && arg.text === 'undefined'))
|
|
755
755
|
|
|
756
756
|
if (isNullInit || !arg) {
|
|
757
|
-
migrateReplace(ctx, node,
|
|
758
|
-
migrateAddImport(ctx,
|
|
757
|
+
migrateReplace(ctx, node, 'createRef()')
|
|
758
|
+
migrateAddImport(ctx, '@pyreon/core', 'createRef')
|
|
759
759
|
ctx.changes.push({
|
|
760
|
-
type:
|
|
760
|
+
type: 'replace',
|
|
761
761
|
line: migrateGetLine(ctx, node),
|
|
762
|
-
description:
|
|
762
|
+
description: 'useRef(null) → createRef()',
|
|
763
763
|
})
|
|
764
764
|
} else {
|
|
765
765
|
migrateReplace(ctx, node, `signal(${migrateGetNodeText(ctx, arg)})`)
|
|
766
|
-
migrateAddImport(ctx,
|
|
766
|
+
migrateAddImport(ctx, '@pyreon/reactivity', 'signal')
|
|
767
767
|
ctx.changes.push({
|
|
768
|
-
type:
|
|
768
|
+
type: 'replace',
|
|
769
769
|
line: migrateGetLine(ctx, node),
|
|
770
|
-
description:
|
|
770
|
+
description: 'useRef(value) → signal(value)',
|
|
771
771
|
})
|
|
772
772
|
}
|
|
773
773
|
}
|
|
@@ -775,14 +775,14 @@ function migrateUseRef(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
|
775
775
|
function migrateMemoWrapper(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
776
776
|
const callee = node.expression
|
|
777
777
|
const isMemo =
|
|
778
|
-
(ts.isIdentifier(callee) && callee.text ===
|
|
778
|
+
(ts.isIdentifier(callee) && callee.text === 'memo') || isCallToReactDot(callee, 'memo')
|
|
779
779
|
|
|
780
780
|
if (isMemo && node.arguments[0]) {
|
|
781
781
|
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]))
|
|
782
782
|
ctx.changes.push({
|
|
783
|
-
type:
|
|
783
|
+
type: 'remove',
|
|
784
784
|
line: migrateGetLine(ctx, node),
|
|
785
|
-
description:
|
|
785
|
+
description: 'Removed memo() wrapper (not needed in Pyreon)',
|
|
786
786
|
})
|
|
787
787
|
}
|
|
788
788
|
}
|
|
@@ -790,15 +790,15 @@ function migrateMemoWrapper(ctx: MigrateContext, node: ts.CallExpression): void
|
|
|
790
790
|
function migrateForwardRef(ctx: MigrateContext, node: ts.CallExpression): void {
|
|
791
791
|
const callee = node.expression
|
|
792
792
|
const isForwardRef =
|
|
793
|
-
(ts.isIdentifier(callee) && callee.text ===
|
|
794
|
-
isCallToReactDot(callee,
|
|
793
|
+
(ts.isIdentifier(callee) && callee.text === 'forwardRef') ||
|
|
794
|
+
isCallToReactDot(callee, 'forwardRef')
|
|
795
795
|
|
|
796
796
|
if (isForwardRef && node.arguments[0]) {
|
|
797
797
|
migrateReplace(ctx, node, migrateGetNodeText(ctx, node.arguments[0]))
|
|
798
798
|
ctx.changes.push({
|
|
799
|
-
type:
|
|
799
|
+
type: 'remove',
|
|
800
800
|
line: migrateGetLine(ctx, node),
|
|
801
|
-
description:
|
|
801
|
+
description: 'Removed forwardRef wrapper (pass ref as normal prop in Pyreon)',
|
|
802
802
|
})
|
|
803
803
|
}
|
|
804
804
|
}
|
|
@@ -814,24 +814,24 @@ function migrateJsxAttributes(ctx: MigrateContext, node: ts.JsxAttribute): void
|
|
|
814
814
|
text: htmlAttr,
|
|
815
815
|
})
|
|
816
816
|
ctx.changes.push({
|
|
817
|
-
type:
|
|
817
|
+
type: 'replace',
|
|
818
818
|
line: migrateGetLine(ctx, node),
|
|
819
819
|
description: `${attrName} → ${htmlAttr}`,
|
|
820
820
|
})
|
|
821
821
|
}
|
|
822
822
|
|
|
823
|
-
if (attrName ===
|
|
823
|
+
if (attrName === 'onChange') {
|
|
824
824
|
const jsxElement = findParentJsxElement(node)
|
|
825
825
|
if (jsxElement) {
|
|
826
826
|
const tagName = getJsxTagName(jsxElement)
|
|
827
|
-
if (tagName ===
|
|
827
|
+
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
|
828
828
|
ctx.replacements.push({
|
|
829
829
|
start: node.name.getStart(ctx.sf),
|
|
830
830
|
end: node.name.getEnd(),
|
|
831
|
-
text:
|
|
831
|
+
text: 'onInput',
|
|
832
832
|
})
|
|
833
833
|
ctx.changes.push({
|
|
834
|
-
type:
|
|
834
|
+
type: 'replace',
|
|
835
835
|
line: migrateGetLine(ctx, node),
|
|
836
836
|
description: `onChange on <${tagName}> → onInput (native DOM events)`,
|
|
837
837
|
})
|
|
@@ -839,7 +839,7 @@ function migrateJsxAttributes(ctx: MigrateContext, node: ts.JsxAttribute): void
|
|
|
839
839
|
}
|
|
840
840
|
}
|
|
841
841
|
|
|
842
|
-
if (attrName ===
|
|
842
|
+
if (attrName === 'dangerouslySetInnerHTML') {
|
|
843
843
|
migrateDangerouslySetInnerHTML(ctx, node)
|
|
844
844
|
}
|
|
845
845
|
}
|
|
@@ -852,16 +852,16 @@ function migrateDangerouslySetInnerHTML(ctx: MigrateContext, node: ts.JsxAttribu
|
|
|
852
852
|
if (!ts.isObjectLiteralExpression(expr)) return
|
|
853
853
|
|
|
854
854
|
const htmlProp = expr.properties.find(
|
|
855
|
-
(p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text ===
|
|
855
|
+
(p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === '__html',
|
|
856
856
|
) as ts.PropertyAssignment | undefined
|
|
857
857
|
|
|
858
858
|
if (htmlProp) {
|
|
859
859
|
const valueText = migrateGetNodeText(ctx, htmlProp.initializer)
|
|
860
860
|
migrateReplace(ctx, node, `innerHTML={${valueText}}`)
|
|
861
861
|
ctx.changes.push({
|
|
862
|
-
type:
|
|
862
|
+
type: 'replace',
|
|
863
863
|
line: migrateGetLine(ctx, node),
|
|
864
|
-
description:
|
|
864
|
+
description: 'dangerouslySetInnerHTML → innerHTML',
|
|
865
865
|
})
|
|
866
866
|
}
|
|
867
867
|
}
|
|
@@ -869,11 +869,11 @@ function migrateDangerouslySetInnerHTML(ctx: MigrateContext, node: ts.JsxAttribu
|
|
|
869
869
|
function applyReplacements(code: string, ctx: MigrateContext): string {
|
|
870
870
|
// Remove React import declarations
|
|
871
871
|
for (const imp of ctx.importsToRemove) {
|
|
872
|
-
ctx.replacements.push({ start: imp.getStart(ctx.sf), end: imp.getEnd(), text:
|
|
872
|
+
ctx.replacements.push({ start: imp.getStart(ctx.sf), end: imp.getEnd(), text: '' })
|
|
873
873
|
ctx.changes.push({
|
|
874
|
-
type:
|
|
874
|
+
type: 'remove',
|
|
875
875
|
line: ctx.sf.getLineAndCharacterOfPosition(imp.getStart(ctx.sf)).line + 1,
|
|
876
|
-
description:
|
|
876
|
+
description: 'Removed React import',
|
|
877
877
|
})
|
|
878
878
|
}
|
|
879
879
|
|
|
@@ -908,7 +908,7 @@ function applyReplacements(code: string, ctx: MigrateContext): string {
|
|
|
908
908
|
lastPos = r.end
|
|
909
909
|
}
|
|
910
910
|
parts.push(code.slice(lastPos))
|
|
911
|
-
return parts.join(
|
|
911
|
+
return parts.join('')
|
|
912
912
|
}
|
|
913
913
|
|
|
914
914
|
function insertPyreonImports(code: string, pyreonImports: Map<string, Set<string>>): string {
|
|
@@ -917,10 +917,10 @@ function insertPyreonImports(code: string, pyreonImports: Map<string, Set<string
|
|
|
917
917
|
const importLines: string[] = []
|
|
918
918
|
const sorted = [...pyreonImports.entries()].sort(([a], [b]) => a.localeCompare(b))
|
|
919
919
|
for (const [source, specs] of sorted) {
|
|
920
|
-
const specList = [...specs].sort().join(
|
|
920
|
+
const specList = [...specs].sort().join(', ')
|
|
921
921
|
importLines.push(`import { ${specList} } from "${source}"`)
|
|
922
922
|
}
|
|
923
|
-
const importBlock = importLines.join(
|
|
923
|
+
const importBlock = importLines.join('\n')
|
|
924
924
|
|
|
925
925
|
const lastImportEnd = findLastImportEnd(code)
|
|
926
926
|
if (lastImportEnd > 0) {
|
|
@@ -931,11 +931,11 @@ function insertPyreonImports(code: string, pyreonImports: Map<string, Set<string
|
|
|
931
931
|
|
|
932
932
|
function migrateVisitNode(ctx: MigrateContext, node: ts.Node): void {
|
|
933
933
|
if (ts.isImportDeclaration(node)) migrateImportDeclaration(ctx, node)
|
|
934
|
-
if (isCallToHook(node,
|
|
934
|
+
if (isCallToHook(node, 'useState')) migrateUseState(ctx, node)
|
|
935
935
|
if (isCallToEffectHook(node)) migrateUseEffect(ctx, node)
|
|
936
|
-
if (isCallToHook(node,
|
|
937
|
-
if (isCallToHook(node,
|
|
938
|
-
if (isCallToHook(node,
|
|
936
|
+
if (isCallToHook(node, 'useMemo')) migrateUseMemo(ctx, node)
|
|
937
|
+
if (isCallToHook(node, 'useCallback')) migrateUseCallback(ctx, node)
|
|
938
|
+
if (isCallToHook(node, 'useRef')) migrateUseRef(ctx, node)
|
|
939
939
|
if (ts.isCallExpression(node)) migrateMemoWrapper(ctx, node)
|
|
940
940
|
if (ts.isCallExpression(node)) migrateForwardRef(ctx, node)
|
|
941
941
|
if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name)) migrateJsxAttributes(ctx, node)
|
|
@@ -948,7 +948,7 @@ function migrateVisit(ctx: MigrateContext, node: ts.Node): void {
|
|
|
948
948
|
})
|
|
949
949
|
}
|
|
950
950
|
|
|
951
|
-
export function migrateReactCode(code: string, filename =
|
|
951
|
+
export function migrateReactCode(code: string, filename = 'input.tsx'): MigrationResult {
|
|
952
952
|
const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
|
|
953
953
|
const diagnostics = detectReactPatterns(code, filename)
|
|
954
954
|
|
|
@@ -968,7 +968,7 @@ export function migrateReactCode(code: string, filename = "input.tsx"): Migratio
|
|
|
968
968
|
result = insertPyreonImports(result, ctx.pyreonImports)
|
|
969
969
|
|
|
970
970
|
// Clean up empty lines from removed imports
|
|
971
|
-
result = result.replace(/\n{3,}/g,
|
|
971
|
+
result = result.replace(/\n{3,}/g, '\n\n')
|
|
972
972
|
|
|
973
973
|
return { code: result, diagnostics, changes: ctx.changes }
|
|
974
974
|
}
|
|
@@ -1002,7 +1002,7 @@ function getJsxTagName(node: ts.JsxOpeningElement | ts.JsxSelfClosingElement): s
|
|
|
1002
1002
|
if (ts.isIdentifier(tagName)) {
|
|
1003
1003
|
return tagName.text
|
|
1004
1004
|
}
|
|
1005
|
-
return
|
|
1005
|
+
return ''
|
|
1006
1006
|
}
|
|
1007
1007
|
|
|
1008
1008
|
function findLastImportEnd(code: string): number {
|
|
@@ -1062,7 +1062,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
|
|
|
1062
1062
|
pattern: /Cannot read properties of undefined \(reading '(set|update|peek|subscribe)'\)/,
|
|
1063
1063
|
diagnose: (m) => ({
|
|
1064
1064
|
cause: `Calling .${m[1]}() on undefined. The signal variable is likely out of scope, misspelled, or not yet initialized.`,
|
|
1065
|
-
fix:
|
|
1065
|
+
fix: 'Check that the signal is defined and in scope. Signals must be created with signal() before use.',
|
|
1066
1066
|
fixCode: `const mySignal = signal(initialValue)\nmySignal.${m[1]}(newValue)`,
|
|
1067
1067
|
}),
|
|
1068
1068
|
},
|
|
@@ -1070,7 +1070,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
|
|
|
1070
1070
|
pattern: /(\w+) is not a function/,
|
|
1071
1071
|
diagnose: (m) => ({
|
|
1072
1072
|
cause: `'${m[1]}' is not callable. If this is a signal, you need to call it: ${m[1]}()`,
|
|
1073
|
-
fix:
|
|
1073
|
+
fix: 'Pyreon signals are callable functions. Read: signal(), Write: signal.set(value)',
|
|
1074
1074
|
fixCode: `// Read value:\nconst value = ${m[1]}()\n// Set value:\n${m[1]}.set(newValue)`,
|
|
1075
1075
|
}),
|
|
1076
1076
|
},
|
|
@@ -1086,7 +1086,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
|
|
|
1086
1086
|
pattern: /Cannot find module 'react'/,
|
|
1087
1087
|
diagnose: () => ({
|
|
1088
1088
|
cause: "Importing from 'react' in a Pyreon project.",
|
|
1089
|
-
fix:
|
|
1089
|
+
fix: 'Replace React imports with Pyreon equivalents.',
|
|
1090
1090
|
fixCode:
|
|
1091
1091
|
'// Instead of:\nimport { useState } from "react"\n// Use:\nimport { signal } from "@pyreon/reactivity"',
|
|
1092
1092
|
}),
|
|
@@ -1096,51 +1096,51 @@ const ERROR_PATTERNS: ErrorPattern[] = [
|
|
|
1096
1096
|
diagnose: (m) => ({
|
|
1097
1097
|
cause: `Accessing .${m[1]} on a signal. Pyreon signals don't have a .${m[1]} property.`,
|
|
1098
1098
|
fix:
|
|
1099
|
-
m[1] ===
|
|
1100
|
-
?
|
|
1099
|
+
m[1] === 'value'
|
|
1100
|
+
? 'Pyreon signals are callable functions, not .value getters. Call signal() to read, signal.set() to write.'
|
|
1101
1101
|
: `Signals have these methods: .set(), .update(), .peek(), .subscribe(). '${m[1]}' is not one of them.`,
|
|
1102
1102
|
fixCode:
|
|
1103
|
-
m[1] ===
|
|
1103
|
+
m[1] === 'value' ? '// Read: mySignal()\n// Write: mySignal.set(newValue)' : undefined,
|
|
1104
1104
|
}),
|
|
1105
1105
|
},
|
|
1106
1106
|
{
|
|
1107
1107
|
pattern: /Type '(\w+)' is not assignable to type 'VNode'/,
|
|
1108
1108
|
diagnose: (m) => ({
|
|
1109
1109
|
cause: `Component returned ${m[1]} instead of VNode. Components must return JSX, null, or a string.`,
|
|
1110
|
-
fix:
|
|
1111
|
-
fixCode:
|
|
1110
|
+
fix: 'Make sure your component returns a JSX element, null, or a string.',
|
|
1111
|
+
fixCode: 'const MyComponent = (props) => {\n return <div>{props.children}</div>\n}',
|
|
1112
1112
|
}),
|
|
1113
1113
|
},
|
|
1114
1114
|
{
|
|
1115
1115
|
pattern: /onMount callback must return/,
|
|
1116
1116
|
diagnose: () => ({
|
|
1117
|
-
cause:
|
|
1118
|
-
fix:
|
|
1119
|
-
fixCode:
|
|
1117
|
+
cause: 'onMount expects a callback that optionally returns a CleanupFn.',
|
|
1118
|
+
fix: 'Return a cleanup function, or return nothing.',
|
|
1119
|
+
fixCode: 'onMount(() => {\n // setup code\n})',
|
|
1120
1120
|
}),
|
|
1121
1121
|
},
|
|
1122
1122
|
{
|
|
1123
1123
|
pattern: /Expected 'by' prop on <For>/,
|
|
1124
1124
|
diagnose: () => ({
|
|
1125
1125
|
cause: "<For> requires a 'by' prop for efficient keyed reconciliation.",
|
|
1126
|
-
fix:
|
|
1126
|
+
fix: 'Add a by prop that returns a unique key for each item.',
|
|
1127
1127
|
fixCode:
|
|
1128
|
-
|
|
1128
|
+
'<For each={items()} by={item => item.id}>\n {item => <li>{item.name}</li>}\n</For>',
|
|
1129
1129
|
}),
|
|
1130
1130
|
},
|
|
1131
1131
|
{
|
|
1132
1132
|
pattern: /useHook.*outside.*component/i,
|
|
1133
1133
|
diagnose: () => ({
|
|
1134
1134
|
cause:
|
|
1135
|
-
|
|
1136
|
-
fix:
|
|
1135
|
+
'Hook called outside a component function. Pyreon hooks must be called during component setup.',
|
|
1136
|
+
fix: 'Move the hook call inside a component function body.',
|
|
1137
1137
|
}),
|
|
1138
1138
|
},
|
|
1139
1139
|
{
|
|
1140
1140
|
pattern: /Hydration mismatch/,
|
|
1141
1141
|
diagnose: () => ({
|
|
1142
1142
|
cause: "Server-rendered HTML doesn't match client-rendered output.",
|
|
1143
|
-
fix:
|
|
1143
|
+
fix: 'Ensure SSR and client render the same initial content. Check for browser-only APIs (window, document) in SSR code.',
|
|
1144
1144
|
related: "Use typeof window !== 'undefined' checks or onMount() for client-only code.",
|
|
1145
1145
|
}),
|
|
1146
1146
|
},
|