@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/lib/types/index.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ interface CompilerWarning {
|
|
|
41
41
|
/** Source file column number (0-based) */
|
|
42
42
|
column: number;
|
|
43
43
|
/** Warning code for filtering */
|
|
44
|
-
code:
|
|
44
|
+
code: 'signal-call-in-jsx' | 'missing-key-on-for' | 'signal-in-static-prop';
|
|
45
45
|
}
|
|
46
46
|
interface TransformResult {
|
|
47
47
|
/** Transformed source code (JSX preserved, only expression containers modified) */
|
|
@@ -78,7 +78,7 @@ interface IslandInfo {
|
|
|
78
78
|
hydrate: string;
|
|
79
79
|
}
|
|
80
80
|
interface ProjectContext {
|
|
81
|
-
framework:
|
|
81
|
+
framework: 'pyreon';
|
|
82
82
|
version: string;
|
|
83
83
|
generatedAt: string;
|
|
84
84
|
routes: RouteInfo[];
|
|
@@ -101,7 +101,7 @@ declare function generateContext(cwd: string): ProjectContext;
|
|
|
101
101
|
* 2. CLI `pyreon doctor` (project-wide scanning)
|
|
102
102
|
* 3. MCP server `migrate_react` / `validate` tools (AI agent integration)
|
|
103
103
|
*/
|
|
104
|
-
type ReactDiagnosticCode =
|
|
104
|
+
type ReactDiagnosticCode = 'react-import' | 'react-dom-import' | 'react-router-import' | 'use-state' | 'use-effect-mount' | 'use-effect-deps' | 'use-effect-no-deps' | 'use-memo' | 'use-callback' | 'use-ref-dom' | 'use-ref-box' | 'use-reducer' | 'use-layout-effect' | 'memo-wrapper' | 'forward-ref' | 'class-name-prop' | 'html-for-prop' | 'on-change-input' | 'dangerously-set-inner-html' | 'dot-value-signal' | 'array-map-jsx' | 'key-on-for-child' | 'create-context-import' | 'use-context-import';
|
|
105
105
|
interface ReactDiagnostic {
|
|
106
106
|
/** Machine-readable code for filtering and programmatic handling */
|
|
107
107
|
code: ReactDiagnosticCode;
|
|
@@ -119,7 +119,7 @@ interface ReactDiagnostic {
|
|
|
119
119
|
fixable: boolean;
|
|
120
120
|
}
|
|
121
121
|
interface MigrationChange {
|
|
122
|
-
type:
|
|
122
|
+
type: 'replace' | 'remove' | 'add';
|
|
123
123
|
line: number;
|
|
124
124
|
description: string;
|
|
125
125
|
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/compiler",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.7",
|
|
4
4
|
"description": "Template and JSX compiler for Pyreon",
|
|
5
|
+
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/compiler#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/pyreon/pyreon/issues"
|
|
8
|
+
},
|
|
5
9
|
"license": "MIT",
|
|
6
10
|
"repository": {
|
|
7
11
|
"type": "git",
|
|
8
12
|
"url": "https://github.com/pyreon/pyreon.git",
|
|
9
13
|
"directory": "packages/core/compiler"
|
|
10
14
|
},
|
|
11
|
-
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/compiler#readme",
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/pyreon/pyreon/issues"
|
|
14
|
-
},
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
17
|
"src",
|
|
18
18
|
"README.md",
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
|
-
"sideEffects": false,
|
|
22
21
|
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
23
|
"main": "./lib/index.js",
|
|
24
24
|
"module": "./lib/index.js",
|
|
25
25
|
"types": "./lib/types/index.d.ts",
|
|
@@ -30,18 +30,18 @@
|
|
|
30
30
|
"types": "./lib/types/index.d.ts"
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"build": "vl_rolldown_build",
|
|
35
38
|
"dev": "vl_rolldown_build-watch",
|
|
36
39
|
"test": "vitest run",
|
|
37
40
|
"typecheck": "tsc --noEmit",
|
|
38
|
-
"lint": "
|
|
41
|
+
"lint": "oxlint .",
|
|
39
42
|
"prepublishOnly": "bun run build"
|
|
40
43
|
},
|
|
41
44
|
"peerDependencies": {
|
|
42
45
|
"typescript": ">=5.0.0"
|
|
43
|
-
},
|
|
44
|
-
"publishConfig": {
|
|
45
|
-
"access": "public"
|
|
46
46
|
}
|
|
47
47
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
// @pyreon/compiler — JSX reactive transform for Pyreon
|
|
2
2
|
|
|
3
|
-
export type { CompilerWarning, TransformResult } from
|
|
4
|
-
export { transformJSX } from
|
|
5
|
-
export type {
|
|
6
|
-
|
|
7
|
-
IslandInfo,
|
|
8
|
-
ProjectContext,
|
|
9
|
-
RouteInfo,
|
|
10
|
-
} from "./project-scanner"
|
|
11
|
-
export { generateContext } from "./project-scanner"
|
|
3
|
+
export type { CompilerWarning, TransformResult } from './jsx'
|
|
4
|
+
export { transformJSX } from './jsx'
|
|
5
|
+
export type { ComponentInfo, IslandInfo, ProjectContext, RouteInfo } from './project-scanner'
|
|
6
|
+
export { generateContext } from './project-scanner'
|
|
12
7
|
export type {
|
|
13
8
|
ErrorDiagnosis,
|
|
14
9
|
MigrationChange,
|
|
15
10
|
MigrationResult,
|
|
16
11
|
ReactDiagnostic,
|
|
17
12
|
ReactDiagnosticCode,
|
|
18
|
-
} from
|
|
13
|
+
} from './react-intercept'
|
|
19
14
|
export {
|
|
20
15
|
detectReactPatterns,
|
|
21
16
|
diagnoseError,
|
|
22
17
|
hasReactPatterns,
|
|
23
18
|
migrateReactCode,
|
|
24
|
-
} from
|
|
19
|
+
} from './react-intercept'
|
package/src/jsx.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* granularity. Fine-grained nested wrapping is planned for a future pass.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import ts from
|
|
36
|
+
import ts from 'typescript'
|
|
37
37
|
|
|
38
38
|
export interface CompilerWarning {
|
|
39
39
|
/** Warning message */
|
|
@@ -43,7 +43,7 @@ export interface CompilerWarning {
|
|
|
43
43
|
/** Source file column number (0-based) */
|
|
44
44
|
column: number
|
|
45
45
|
/** Warning code for filtering */
|
|
46
|
-
code:
|
|
46
|
+
code: 'signal-call-in-jsx' | 'missing-key-on-for' | 'signal-in-static-prop'
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface TransformResult {
|
|
@@ -56,39 +56,39 @@ export interface TransformResult {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Props that should never be wrapped in a reactive getter
|
|
59
|
-
const SKIP_PROPS = new Set([
|
|
59
|
+
const SKIP_PROPS = new Set(['key', 'ref'])
|
|
60
60
|
// Event handler pattern: onClick, onInput, onMouseEnter, …
|
|
61
61
|
const EVENT_RE = /^on[A-Z]/
|
|
62
62
|
// Events delegated to the container — must match runtime DELEGATED_EVENTS set
|
|
63
63
|
const DELEGATED_EVENTS = new Set([
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
64
|
+
'click',
|
|
65
|
+
'dblclick',
|
|
66
|
+
'contextmenu',
|
|
67
|
+
'focusin',
|
|
68
|
+
'focusout',
|
|
69
|
+
'input',
|
|
70
|
+
'change',
|
|
71
|
+
'keydown',
|
|
72
|
+
'keyup',
|
|
73
|
+
'mousedown',
|
|
74
|
+
'mouseup',
|
|
75
|
+
'mousemove',
|
|
76
|
+
'mouseover',
|
|
77
|
+
'mouseout',
|
|
78
|
+
'pointerdown',
|
|
79
|
+
'pointerup',
|
|
80
|
+
'pointermove',
|
|
81
|
+
'pointerover',
|
|
82
|
+
'pointerout',
|
|
83
|
+
'touchstart',
|
|
84
|
+
'touchend',
|
|
85
|
+
'touchmove',
|
|
86
|
+
'submit',
|
|
87
87
|
])
|
|
88
88
|
|
|
89
|
-
export function transformJSX(code: string, filename =
|
|
89
|
+
export function transformJSX(code: string, filename = 'input.tsx'): TransformResult {
|
|
90
90
|
const scriptKind =
|
|
91
|
-
filename.endsWith(
|
|
91
|
+
filename.endsWith('.tsx') || filename.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TSX // default to TSX so JSX is always parsed
|
|
92
92
|
|
|
93
93
|
const sf = ts.createSourceFile(
|
|
94
94
|
filename,
|
|
@@ -102,7 +102,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
102
102
|
const replacements: Replacement[] = []
|
|
103
103
|
const warnings: CompilerWarning[] = []
|
|
104
104
|
|
|
105
|
-
function warn(node: ts.Node, message: string, warnCode: CompilerWarning[
|
|
105
|
+
function warn(node: ts.Node, message: string, warnCode: CompilerWarning['code']): void {
|
|
106
106
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
|
|
107
107
|
warnings.push({ message, line: line + 1, column: character, code: warnCode })
|
|
108
108
|
}
|
|
@@ -169,25 +169,25 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
169
169
|
/** Emit warnings for common JSX mistakes (e.g. <For> without by). */
|
|
170
170
|
function checkForWarnings(node: ts.JsxElement | ts.JsxSelfClosingElement): void {
|
|
171
171
|
const opening = ts.isJsxElement(node) ? node.openingElement : node
|
|
172
|
-
const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text :
|
|
173
|
-
if (tagName !==
|
|
172
|
+
const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : ''
|
|
173
|
+
if (tagName !== 'For') return
|
|
174
174
|
const hasBy = opening.attributes.properties.some(
|
|
175
|
-
(p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text ===
|
|
175
|
+
(p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'by',
|
|
176
176
|
)
|
|
177
177
|
if (!hasBy) {
|
|
178
178
|
warn(
|
|
179
179
|
opening.tagName,
|
|
180
180
|
`<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`,
|
|
181
|
-
|
|
181
|
+
'missing-key-on-for',
|
|
182
182
|
)
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
/** Handle a JSX attribute node — wrap or hoist its value if needed. */
|
|
187
187
|
function handleJsxAttribute(node: ts.JsxAttribute): void {
|
|
188
|
-
const name = ts.isIdentifier(node.name) ? node.name.text :
|
|
188
|
+
const name = ts.isIdentifier(node.name) ? node.name.text : ''
|
|
189
189
|
const openingEl = node.parent.parent as ts.JsxOpeningElement | ts.JsxSelfClosingElement
|
|
190
|
-
const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text :
|
|
190
|
+
const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : ''
|
|
191
191
|
const isComponentElement =
|
|
192
192
|
tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
|
|
193
193
|
if (isComponentElement) return
|
|
@@ -245,24 +245,24 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
245
245
|
lastPos = r.end
|
|
246
246
|
}
|
|
247
247
|
parts.push(code.slice(lastPos))
|
|
248
|
-
let result = parts.join(
|
|
248
|
+
let result = parts.join('')
|
|
249
249
|
|
|
250
250
|
// Prepend module-scope hoisted static VNode declarations
|
|
251
251
|
if (hoists.length > 0) {
|
|
252
|
-
const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join(
|
|
252
|
+
const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
|
|
253
253
|
result = preamble + result
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
// Prepend template imports if _tpl() was emitted
|
|
257
257
|
if (needsTplImport) {
|
|
258
|
-
const runtimeDomImports = [
|
|
259
|
-
if (needsBindDirectImportGlobal) runtimeDomImports.push(
|
|
260
|
-
if (needsBindTextImportGlobal) runtimeDomImports.push(
|
|
258
|
+
const runtimeDomImports = ['_tpl']
|
|
259
|
+
if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
|
|
260
|
+
if (needsBindTextImportGlobal) runtimeDomImports.push('_bindText')
|
|
261
261
|
const reactivityImports = needsBindImportGlobal
|
|
262
262
|
? `\nimport { _bind } from "@pyreon/reactivity";`
|
|
263
|
-
:
|
|
263
|
+
: ''
|
|
264
264
|
result =
|
|
265
|
-
`import { ${runtimeDomImports.join(
|
|
265
|
+
`import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
|
|
266
266
|
result
|
|
267
267
|
}
|
|
268
268
|
|
|
@@ -274,7 +274,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
274
274
|
function hasBailAttr(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
|
|
275
275
|
for (const attr of jsxAttrs(node)) {
|
|
276
276
|
if (ts.isJsxSpreadAttribute(attr)) return true
|
|
277
|
-
if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text ===
|
|
277
|
+
if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === 'key')
|
|
278
278
|
return true
|
|
279
279
|
}
|
|
280
280
|
return false
|
|
@@ -354,7 +354,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
354
354
|
|
|
355
355
|
/** Resolve the variable name for an element given its accessor path. */
|
|
356
356
|
function resolveElementVar(accessor: string, hasDynamic: boolean): string {
|
|
357
|
-
if (accessor ===
|
|
357
|
+
if (accessor === '__root') return '__root'
|
|
358
358
|
if (hasDynamic) {
|
|
359
359
|
const v = nextVar()
|
|
360
360
|
bindLines.push(`const ${v} = ${accessor}`)
|
|
@@ -372,7 +372,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
372
372
|
|
|
373
373
|
/** Emit event handler bind line — delegated (expando) or addEventListener. */
|
|
374
374
|
function emitEventListener(attr: ts.JsxAttribute, attrName: string, varName: string): void {
|
|
375
|
-
const eventName = (attrName[2] ??
|
|
375
|
+
const eventName = (attrName[2] ?? '').toLowerCase() + attrName.slice(3)
|
|
376
376
|
if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return
|
|
377
377
|
if (!attr.initializer.expression) return
|
|
378
378
|
const handler = sliceExpr(attr.initializer.expression)
|
|
@@ -390,7 +390,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
390
390
|
if (ts.isStringLiteral(exprNode)) return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.text)}"`
|
|
391
391
|
if (ts.isNumericLiteral(exprNode)) return ` ${htmlAttrName}="${exprNode.text}"`
|
|
392
392
|
if (exprNode.kind === ts.SyntaxKind.TrueKeyword) return ` ${htmlAttrName}`
|
|
393
|
-
return
|
|
393
|
+
return '' // false/null/undefined → omit
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
/**
|
|
@@ -431,8 +431,8 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
431
431
|
|
|
432
432
|
/** Build a setter expression for an attribute. */
|
|
433
433
|
function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
|
|
434
|
-
if (htmlAttrName ===
|
|
435
|
-
if (htmlAttrName ===
|
|
434
|
+
if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
|
|
435
|
+
if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
|
|
436
436
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
|
|
437
437
|
}
|
|
438
438
|
|
|
@@ -456,9 +456,9 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
456
456
|
needsBindDirectImport = true
|
|
457
457
|
const d = nextDisp()
|
|
458
458
|
const updater =
|
|
459
|
-
htmlAttrName ===
|
|
459
|
+
htmlAttrName === 'class'
|
|
460
460
|
? `(v) => { ${varName}.className = v == null ? "" : String(v) }`
|
|
461
|
-
: htmlAttrName ===
|
|
461
|
+
: htmlAttrName === 'style'
|
|
462
462
|
? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }`
|
|
463
463
|
: `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
|
|
464
464
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
|
|
@@ -478,18 +478,18 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
478
478
|
if (staticHtml !== null) return staticHtml
|
|
479
479
|
|
|
480
480
|
// style={{...}} → Object.assign(el.style, {...}) for object expressions
|
|
481
|
-
if (htmlAttrName ===
|
|
481
|
+
if (htmlAttrName === 'style' && ts.isObjectLiteralExpression(exprNode)) {
|
|
482
482
|
bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`)
|
|
483
|
-
return
|
|
483
|
+
return ''
|
|
484
484
|
}
|
|
485
485
|
|
|
486
486
|
emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
|
|
487
|
-
return
|
|
487
|
+
return ''
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
/** Emit side-effects for special attrs (ref, event). Returns true if handled. */
|
|
491
491
|
function tryEmitSpecialAttr(attr: ts.JsxAttribute, attrName: string, varName: string): boolean {
|
|
492
|
-
if (attrName ===
|
|
492
|
+
if (attrName === 'ref') {
|
|
493
493
|
emitRef(attr, varName)
|
|
494
494
|
return true
|
|
495
495
|
}
|
|
@@ -511,21 +511,21 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
511
511
|
return ` ${htmlAttrName}="${escapeHtmlAttr(attr.initializer.text)}"`
|
|
512
512
|
if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression)
|
|
513
513
|
return emitAttrExpression(attr.initializer.expression, htmlAttrName, varName)
|
|
514
|
-
return
|
|
514
|
+
return ''
|
|
515
515
|
}
|
|
516
516
|
|
|
517
517
|
/** Process a single attribute, returning HTML to append. */
|
|
518
518
|
function processOneAttr(attr: ts.JsxAttributeLike, varName: string): string {
|
|
519
|
-
if (!ts.isJsxAttribute(attr)) return
|
|
520
|
-
const attrName = ts.isIdentifier(attr.name) ? attr.name.text :
|
|
521
|
-
if (attrName ===
|
|
522
|
-
if (tryEmitSpecialAttr(attr, attrName, varName)) return
|
|
519
|
+
if (!ts.isJsxAttribute(attr)) return ''
|
|
520
|
+
const attrName = ts.isIdentifier(attr.name) ? attr.name.text : ''
|
|
521
|
+
if (attrName === 'key') return ''
|
|
522
|
+
if (tryEmitSpecialAttr(attr, attrName, varName)) return ''
|
|
523
523
|
return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
|
|
524
524
|
}
|
|
525
525
|
|
|
526
526
|
/** Process all attributes on an element, returning the HTML attribute string. */
|
|
527
527
|
function processAttrs(el: ts.JsxElement | ts.JsxSelfClosingElement, varName: string): string {
|
|
528
|
-
let htmlAttrs =
|
|
528
|
+
let htmlAttrs = ''
|
|
529
529
|
for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
|
|
530
530
|
return htmlAttrs
|
|
531
531
|
}
|
|
@@ -558,7 +558,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
558
558
|
// Collected into the combined _bind at the end
|
|
559
559
|
reactiveBindExprs.push(`${tVar}.data = ${expr}`)
|
|
560
560
|
}
|
|
561
|
-
return needsPlaceholder ?
|
|
561
|
+
return needsPlaceholder ? '<!>' : ''
|
|
562
562
|
}
|
|
563
563
|
|
|
564
564
|
/** Emit bind lines for a static text expression child. */
|
|
@@ -575,10 +575,10 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
575
575
|
bindLines.push(
|
|
576
576
|
`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
|
|
577
577
|
)
|
|
578
|
-
return
|
|
578
|
+
return '<!>'
|
|
579
579
|
}
|
|
580
580
|
bindLines.push(`${varName}.textContent = ${expr}`)
|
|
581
|
-
return
|
|
581
|
+
return ''
|
|
582
582
|
}
|
|
583
583
|
|
|
584
584
|
/** Process a single flat child, returning the HTML contribution or null on failure. */
|
|
@@ -590,8 +590,8 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
590
590
|
useMultiExpr: boolean,
|
|
591
591
|
childNodeIdx: number,
|
|
592
592
|
): string | null {
|
|
593
|
-
if (child.kind ===
|
|
594
|
-
if (child.kind ===
|
|
593
|
+
if (child.kind === 'text') return escapeHtmlText(child.text)
|
|
594
|
+
if (child.kind === 'element') {
|
|
595
595
|
const childAccessor = useMixed
|
|
596
596
|
? `${parentRef}.childNodes[${childNodeIdx}]`
|
|
597
597
|
: `${parentRef}.children[${child.elemIdx}]`
|
|
@@ -617,9 +617,9 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
617
617
|
function processChildren(el: ts.JsxElement, varName: string, accessor: string): string | null {
|
|
618
618
|
const flatChildren = flattenChildren(el.children)
|
|
619
619
|
const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
|
|
620
|
-
const parentRef = accessor ===
|
|
620
|
+
const parentRef = accessor === '__root' ? '__root' : varName
|
|
621
621
|
|
|
622
|
-
let html =
|
|
622
|
+
let html = ''
|
|
623
623
|
let childNodeIdx = 0
|
|
624
624
|
|
|
625
625
|
for (const child of flatChildren) {
|
|
@@ -661,14 +661,14 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
661
661
|
return html
|
|
662
662
|
}
|
|
663
663
|
|
|
664
|
-
const html = processElement(node,
|
|
664
|
+
const html = processElement(node, '__root')
|
|
665
665
|
if (html === null) return null
|
|
666
666
|
|
|
667
667
|
if (needsBindTextImport) needsBindTextImportGlobal = true
|
|
668
668
|
if (needsBindDirectImport) needsBindDirectImportGlobal = true
|
|
669
669
|
|
|
670
670
|
// Build bind function body
|
|
671
|
-
const escaped = html.replace(/\\/g,
|
|
671
|
+
const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
672
672
|
|
|
673
673
|
// Emit combined _bind for reactive attribute/text expressions that
|
|
674
674
|
// weren't handled by _bindText. This merges N separate _bind calls into
|
|
@@ -679,7 +679,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
679
679
|
if (reactiveBindExprs.length > 0) {
|
|
680
680
|
needsBindImportGlobal = true
|
|
681
681
|
const combinedName = nextDisp()
|
|
682
|
-
const combinedBody = reactiveBindExprs.join(
|
|
682
|
+
const combinedBody = reactiveBindExprs.join('; ')
|
|
683
683
|
bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`)
|
|
684
684
|
}
|
|
685
685
|
|
|
@@ -687,11 +687,11 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
687
687
|
return `_tpl("${escaped}", () => null)`
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
-
let body = bindLines.map((l) => ` ${l}`).join(
|
|
690
|
+
let body = bindLines.map((l) => ` ${l}`).join('\n')
|
|
691
691
|
if (disposerNames.length > 0) {
|
|
692
|
-
body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join(
|
|
692
|
+
body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join('; ')} }`
|
|
693
693
|
} else {
|
|
694
|
-
body +=
|
|
694
|
+
body += '\n return null'
|
|
695
695
|
}
|
|
696
696
|
|
|
697
697
|
return `_tpl("${escaped}", (__root) => {\n${body}\n})`
|
|
@@ -699,9 +699,9 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
699
699
|
|
|
700
700
|
/** Flat child descriptor for template children processing */
|
|
701
701
|
type FlatChild =
|
|
702
|
-
| { kind:
|
|
703
|
-
| { kind:
|
|
704
|
-
| { kind:
|
|
702
|
+
| { kind: 'text'; text: string }
|
|
703
|
+
| { kind: 'element'; node: ts.JsxElement | ts.JsxSelfClosingElement; elemIdx: number }
|
|
704
|
+
| { kind: 'expression'; expression: ts.Expression }
|
|
705
705
|
|
|
706
706
|
/** Classify a single JSX child into a FlatChild descriptor. */
|
|
707
707
|
function classifyJsxChild(
|
|
@@ -711,16 +711,16 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
711
711
|
recurse: (kids: ts.NodeArray<ts.JsxChild>) => void,
|
|
712
712
|
): void {
|
|
713
713
|
if (ts.isJsxText(child)) {
|
|
714
|
-
const trimmed = child.text.replace(/\n\s*/g,
|
|
715
|
-
if (trimmed) out.push({ kind:
|
|
714
|
+
const trimmed = child.text.replace(/\n\s*/g, '').trim()
|
|
715
|
+
if (trimmed) out.push({ kind: 'text', text: trimmed })
|
|
716
716
|
return
|
|
717
717
|
}
|
|
718
718
|
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
|
719
|
-
out.push({ kind:
|
|
719
|
+
out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
|
|
720
720
|
return
|
|
721
721
|
}
|
|
722
722
|
if (ts.isJsxExpression(child)) {
|
|
723
|
-
if (child.expression) out.push({ kind:
|
|
723
|
+
if (child.expression) out.push({ kind: 'expression', expression: child.expression })
|
|
724
724
|
return
|
|
725
725
|
}
|
|
726
726
|
if (ts.isJsxFragment(child)) recurse(child.children)
|
|
@@ -747,17 +747,17 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
747
747
|
useMixed: boolean
|
|
748
748
|
useMultiExpr: boolean
|
|
749
749
|
} {
|
|
750
|
-
const hasElem = flatChildren.some((c) => c.kind ===
|
|
751
|
-
const hasNonElem = flatChildren.some((c) => c.kind !==
|
|
752
|
-
const exprCount = flatChildren.filter((c) => c.kind ===
|
|
750
|
+
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
751
|
+
const hasNonElem = flatChildren.some((c) => c.kind !== 'element')
|
|
752
|
+
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
753
753
|
return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
|
|
754
754
|
}
|
|
755
755
|
|
|
756
756
|
/** Check if a single attribute is dynamic (has ref, event, or non-static expression). */
|
|
757
757
|
function attrIsDynamic(attr: ts.JsxAttributeLike): boolean {
|
|
758
758
|
if (!ts.isJsxAttribute(attr)) return false
|
|
759
|
-
const name = ts.isIdentifier(attr.name) ? attr.name.text :
|
|
760
|
-
if (name ===
|
|
759
|
+
const name = ts.isIdentifier(attr.name) ? attr.name.text : ''
|
|
760
|
+
if (name === 'ref') return true
|
|
761
761
|
if (EVENT_RE.test(name)) return true
|
|
762
762
|
if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return false
|
|
763
763
|
const expr = attr.initializer.expression
|
|
@@ -781,7 +781,7 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
781
781
|
/** Get tag name string */
|
|
782
782
|
function jsxTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string {
|
|
783
783
|
const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
|
|
784
|
-
return ts.isIdentifier(tag) ? tag.text :
|
|
784
|
+
return ts.isIdentifier(tag) ? tag.text : ''
|
|
785
785
|
}
|
|
786
786
|
|
|
787
787
|
/** Get attribute list */
|
|
@@ -797,25 +797,25 @@ export function transformJSX(code: string, filename = "input.tsx"): TransformRes
|
|
|
797
797
|
// ─── Template constants ──────────────────────────────────────────────────────
|
|
798
798
|
|
|
799
799
|
const VOID_ELEMENTS = new Set([
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
800
|
+
'area',
|
|
801
|
+
'base',
|
|
802
|
+
'br',
|
|
803
|
+
'col',
|
|
804
|
+
'embed',
|
|
805
|
+
'hr',
|
|
806
|
+
'img',
|
|
807
|
+
'input',
|
|
808
|
+
'link',
|
|
809
|
+
'meta',
|
|
810
|
+
'param',
|
|
811
|
+
'source',
|
|
812
|
+
'track',
|
|
813
|
+
'wbr',
|
|
814
814
|
])
|
|
815
815
|
|
|
816
816
|
const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
817
|
-
className:
|
|
818
|
-
htmlFor:
|
|
817
|
+
className: 'class',
|
|
818
|
+
htmlFor: 'for',
|
|
819
819
|
}
|
|
820
820
|
|
|
821
821
|
function isLowerCase(s: string): boolean {
|
|
@@ -830,11 +830,11 @@ function containsJSXInExpr(node: ts.Node): boolean {
|
|
|
830
830
|
}
|
|
831
831
|
|
|
832
832
|
function escapeHtmlAttr(s: string): string {
|
|
833
|
-
return s.replace(/&/g,
|
|
833
|
+
return s.replace(/&/g, '&').replace(/"/g, '"')
|
|
834
834
|
}
|
|
835
835
|
|
|
836
836
|
function escapeHtmlText(s: string): string {
|
|
837
|
-
return s.replace(/&/g,
|
|
837
|
+
return s.replace(/&/g, '&').replace(/</g, '<')
|
|
838
838
|
}
|
|
839
839
|
|
|
840
840
|
// ─── Static JSX analysis ──────────────────────────────────────────────────────
|