@pyreon/compiler 0.13.0 → 0.14.0
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 +14 -4
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1113 -406
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +140 -14
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +10 -1
- package/src/jsx.ts +839 -782
- package/src/pyreon-intercept.ts +504 -0
- package/src/test-audit.ts +435 -0
- package/src/tests/depth-stress.test.ts +16 -0
- package/src/tests/detector-tag-consistency.test.ts +83 -0
- package/src/tests/jsx.test.ts +934 -0
- package/src/tests/native-equivalence.test.ts +654 -0
- package/src/tests/project-scanner.test.ts +30 -0
- package/src/tests/pyreon-intercept.test.ts +331 -0
- package/src/tests/react-intercept.test.ts +354 -0
- package/src/tests/test-audit.test.ts +549 -0
package/src/jsx.ts
CHANGED
|
@@ -17,23 +17,42 @@
|
|
|
17
17
|
* values, and all children are text nodes or other static JSX nodes.
|
|
18
18
|
*
|
|
19
19
|
* Template emission:
|
|
20
|
-
* - JSX element trees with ≥
|
|
21
|
-
* are compiled to `_tpl(html, bindFn)` calls instead of nested
|
|
20
|
+
* - JSX element trees with ≥ 1 DOM elements (no components, no spread attrs on
|
|
21
|
+
* inner elements) are compiled to `_tpl(html, bindFn)` calls instead of nested
|
|
22
|
+
* `h()` calls.
|
|
22
23
|
* - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
|
|
23
24
|
* for each instance (~5-10x faster than sequential createElement calls).
|
|
24
25
|
* - Static attributes are baked into the HTML string; dynamic attributes and
|
|
25
26
|
* text content use renderEffect in the bind function.
|
|
26
27
|
*
|
|
27
|
-
* Implementation:
|
|
28
|
-
* No extra runtime dependencies — `typescript` is already in devDependencies.
|
|
29
|
-
*
|
|
30
|
-
* Known limitation (v0): expressions inside *nested* JSX within a child
|
|
31
|
-
* expression container are not individually wrapped. They are still reactive
|
|
32
|
-
* because the outer wrapper re-evaluates the whole subtree, just at a coarser
|
|
33
|
-
* granularity. Fine-grained nested wrapping is planned for a future pass.
|
|
28
|
+
* Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
|
|
34
29
|
*/
|
|
35
30
|
|
|
36
|
-
import
|
|
31
|
+
import { parseSync } from 'oxc-parser'
|
|
32
|
+
import { createRequire } from 'node:module'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
import { dirname, join } from 'node:path'
|
|
35
|
+
|
|
36
|
+
// ─── Native binary auto-detection ────────────────────────────────────────────
|
|
37
|
+
// Try to load the Rust napi-rs binary for 3.7-8.2x faster transforms.
|
|
38
|
+
// Falls back to the JS implementation below if the binary isn't available
|
|
39
|
+
// (wrong platform, CI environment, WASM runtime like StackBlitz, etc.)
|
|
40
|
+
//
|
|
41
|
+
// Uses createRequire for ESM compatibility — __dirname and require() don't
|
|
42
|
+
// exist in ESM modules.
|
|
43
|
+
type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
|
|
44
|
+
let nativeTransformJsx: NativeTransformFn | null = null
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
48
|
+
const __dirname = dirname(__filename)
|
|
49
|
+
const nativeRequire = createRequire(import.meta.url)
|
|
50
|
+
const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
|
|
51
|
+
const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
|
|
52
|
+
nativeTransformJsx = native.transformJsx
|
|
53
|
+
} catch {
|
|
54
|
+
// Native binary not available — JS fallback will be used
|
|
55
|
+
}
|
|
37
56
|
|
|
38
57
|
export interface CompilerWarning {
|
|
39
58
|
/** Warning message */
|
|
@@ -65,66 +84,196 @@ const SKIP_PROPS = new Set(['key', 'ref'])
|
|
|
65
84
|
const EVENT_RE = /^on[A-Z]/
|
|
66
85
|
// Events delegated to the container — must match runtime DELEGATED_EVENTS set
|
|
67
86
|
const DELEGATED_EVENTS = new Set([
|
|
68
|
-
'click',
|
|
69
|
-
'
|
|
70
|
-
'
|
|
71
|
-
'
|
|
72
|
-
'focusout',
|
|
73
|
-
'input',
|
|
74
|
-
'change',
|
|
75
|
-
'keydown',
|
|
76
|
-
'keyup',
|
|
77
|
-
'mousedown',
|
|
78
|
-
'mouseup',
|
|
79
|
-
'mousemove',
|
|
80
|
-
'mouseover',
|
|
81
|
-
'mouseout',
|
|
82
|
-
'pointerdown',
|
|
83
|
-
'pointerup',
|
|
84
|
-
'pointermove',
|
|
85
|
-
'pointerover',
|
|
86
|
-
'pointerout',
|
|
87
|
-
'touchstart',
|
|
88
|
-
'touchend',
|
|
89
|
-
'touchmove',
|
|
87
|
+
'click', 'dblclick', 'contextmenu', 'focusin', 'focusout', 'input',
|
|
88
|
+
'change', 'keydown', 'keyup', 'mousedown', 'mouseup', 'mousemove',
|
|
89
|
+
'mouseover', 'mouseout', 'pointerdown', 'pointerup', 'pointermove',
|
|
90
|
+
'pointerover', 'pointerout', 'touchstart', 'touchend', 'touchmove',
|
|
90
91
|
'submit',
|
|
91
92
|
])
|
|
92
93
|
|
|
93
94
|
export interface TransformOptions {
|
|
94
95
|
/**
|
|
95
96
|
* Compile for server-side rendering. When true, the compiler skips the
|
|
96
|
-
* `_tpl()` template optimization
|
|
97
|
-
* `
|
|
98
|
-
* `@pyreon/runtime-server` can walk the VNode tree. Client builds keep
|
|
99
|
-
* the `_tpl()` fast path. Default: false.
|
|
97
|
+
* `_tpl()` template optimization and falls back to plain `h()` calls so
|
|
98
|
+
* `@pyreon/runtime-server` can walk the VNode tree. Default: false.
|
|
100
99
|
*/
|
|
101
100
|
ssr?: boolean
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Known signal variable names from resolved imports.
|
|
104
|
+
* The Vite plugin maintains a cross-module signal export registry and
|
|
105
|
+
* passes imported signal names here so the compiler can auto-call them
|
|
106
|
+
* in JSX even though the `signal()` declaration is in another file.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // store.ts: export const count = signal(0)
|
|
110
|
+
* // component.tsx: import { count } from './store'
|
|
111
|
+
* transformJSX(code, 'component.tsx', { knownSignals: ['count'] })
|
|
112
|
+
* // {count} in JSX → {() => count()}
|
|
113
|
+
*/
|
|
114
|
+
knownSignals?: string[]
|
|
102
115
|
}
|
|
103
116
|
|
|
117
|
+
// ─── oxc ESTree helpers ───────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
120
|
+
type N = any // ESTree node — untyped for speed, matches the lint package approach
|
|
121
|
+
|
|
122
|
+
function getLang(filename: string): 'tsx' | 'jsx' {
|
|
123
|
+
if (filename.endsWith('.jsx')) return 'jsx'
|
|
124
|
+
// Default to tsx so JSX is always parsed — matches the original TypeScript
|
|
125
|
+
// parser behavior which forced ScriptKind.TSX for all files.
|
|
126
|
+
return 'tsx'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Binary search for line/column from byte offset. */
|
|
130
|
+
function makeLineIndex(code: string): (offset: number) => { line: number; column: number } {
|
|
131
|
+
const lineStarts = [0]
|
|
132
|
+
for (let i = 0; i < code.length; i++) {
|
|
133
|
+
if (code[i] === '\n') lineStarts.push(i + 1)
|
|
134
|
+
}
|
|
135
|
+
return (offset: number) => {
|
|
136
|
+
let lo = 0
|
|
137
|
+
let hi = lineStarts.length - 1
|
|
138
|
+
while (lo <= hi) {
|
|
139
|
+
const mid = (lo + hi) >>> 1
|
|
140
|
+
if (lineStarts[mid]! <= offset) lo = mid + 1
|
|
141
|
+
else hi = mid - 1
|
|
142
|
+
}
|
|
143
|
+
return { line: lo, column: offset - lineStarts[lo - 1]! }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Iterate all direct children of an ESTree node via known property keys. */
|
|
148
|
+
function forEachChild(node: N, cb: (child: N) => void): void {
|
|
149
|
+
if (!node || typeof node !== 'object') return
|
|
150
|
+
const keys = Object.keys(node)
|
|
151
|
+
for (let i = 0; i < keys.length; i++) {
|
|
152
|
+
const key = keys[i]!
|
|
153
|
+
// Skip metadata fields for speed
|
|
154
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
|
|
155
|
+
const val = node[key]
|
|
156
|
+
if (Array.isArray(val)) {
|
|
157
|
+
for (let j = 0; j < val.length; j++) {
|
|
158
|
+
const item = val[j]
|
|
159
|
+
if (item && typeof item === 'object' && item.type) cb(item)
|
|
160
|
+
}
|
|
161
|
+
} else if (val && typeof val === 'object' && val.type) {
|
|
162
|
+
cb(val)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── JSX element helpers ────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function jsxTagName(node: N): string {
|
|
170
|
+
const opening = node.openingElement
|
|
171
|
+
if (!opening) return ''
|
|
172
|
+
const name = opening.name
|
|
173
|
+
return name?.type === 'JSXIdentifier' ? name.name : ''
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isSelfClosing(node: N): boolean {
|
|
177
|
+
return node.type === 'JSXElement' && node.openingElement?.selfClosing === true
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function jsxAttrs(node: N): N[] {
|
|
181
|
+
return node.openingElement?.attributes ?? []
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function jsxChildren(node: N): N[] {
|
|
185
|
+
return node.children ?? []
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Main transform ─────────────────────────────────────────────────────────
|
|
189
|
+
|
|
104
190
|
export function transformJSX(
|
|
105
191
|
code: string,
|
|
106
192
|
filename = 'input.tsx',
|
|
107
193
|
options: TransformOptions = {},
|
|
194
|
+
): TransformResult {
|
|
195
|
+
// Try Rust native binary first (3.7-8.2x faster).
|
|
196
|
+
// Per-call try/catch: if the native binary panics on an edge case
|
|
197
|
+
// (bad UTF-8, unexpected AST shape), fall back gracefully instead
|
|
198
|
+
// of crashing the Vite dev server.
|
|
199
|
+
if (nativeTransformJsx) {
|
|
200
|
+
try {
|
|
201
|
+
return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null)
|
|
202
|
+
} catch {
|
|
203
|
+
// Native transform failed — fall through to JS implementation
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return transformJSX_JS(code, filename, options)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** JS fallback implementation — used when the native binary isn't available. */
|
|
210
|
+
export function transformJSX_JS(
|
|
211
|
+
code: string,
|
|
212
|
+
filename = 'input.tsx',
|
|
213
|
+
options: TransformOptions = {},
|
|
108
214
|
): TransformResult {
|
|
109
215
|
const ssr = options.ssr === true
|
|
110
|
-
const scriptKind =
|
|
111
|
-
filename.endsWith('.tsx') || filename.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TSX // default to TSX so JSX is always parsed
|
|
112
216
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
code,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
217
|
+
let program: N
|
|
218
|
+
try {
|
|
219
|
+
const result = parseSync(filename, code, {
|
|
220
|
+
sourceType: 'module',
|
|
221
|
+
lang: getLang(filename),
|
|
222
|
+
})
|
|
223
|
+
program = result.program
|
|
224
|
+
} catch {
|
|
225
|
+
return { code, warnings: [] }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const locate = makeLineIndex(code)
|
|
120
229
|
|
|
121
230
|
type Replacement = { start: number; end: number; text: string }
|
|
122
231
|
const replacements: Replacement[] = []
|
|
123
232
|
const warnings: CompilerWarning[] = []
|
|
124
233
|
|
|
125
|
-
function warn(node:
|
|
126
|
-
const { line,
|
|
127
|
-
warnings.push({ message, line
|
|
234
|
+
function warn(node: N, message: string, warnCode: CompilerWarning['code']): void {
|
|
235
|
+
const { line, column } = locate(node.start as number)
|
|
236
|
+
warnings.push({ message, line, column, code: warnCode })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Parent + children maps (built once, eliminates repeated Object.keys) ──
|
|
240
|
+
const parentMap = new WeakMap<object, N>()
|
|
241
|
+
const childrenMap = new WeakMap<object, N[]>()
|
|
242
|
+
|
|
243
|
+
/** Build parent pointers + cached children arrays for the entire AST. */
|
|
244
|
+
function buildMaps(node: N): void {
|
|
245
|
+
const kids: N[] = []
|
|
246
|
+
const keys = Object.keys(node)
|
|
247
|
+
for (let i = 0; i < keys.length; i++) {
|
|
248
|
+
const key = keys[i]!
|
|
249
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
|
|
250
|
+
const val = node[key]
|
|
251
|
+
if (Array.isArray(val)) {
|
|
252
|
+
for (let j = 0; j < val.length; j++) {
|
|
253
|
+
const item = val[j]
|
|
254
|
+
if (item && typeof item === 'object' && item.type) kids.push(item)
|
|
255
|
+
}
|
|
256
|
+
} else if (val && typeof val === 'object' && val.type) {
|
|
257
|
+
kids.push(val)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
childrenMap.set(node, kids)
|
|
261
|
+
for (let i = 0; i < kids.length; i++) {
|
|
262
|
+
parentMap.set(kids[i]!, node)
|
|
263
|
+
buildMaps(kids[i]!)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
buildMaps(program)
|
|
267
|
+
|
|
268
|
+
function findParent(node: N): N | undefined {
|
|
269
|
+
return parentMap.get(node)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Fast child iteration using pre-computed children array. */
|
|
273
|
+
function forEachChildFast(node: N, cb: (child: N) => void): void {
|
|
274
|
+
const kids = childrenMap.get(node)
|
|
275
|
+
if (!kids) return
|
|
276
|
+
for (let i = 0; i < kids.length; i++) cb(kids[i]!)
|
|
128
277
|
}
|
|
129
278
|
|
|
130
279
|
// ── Static hoisting state ─────────────────────────────────────────────────
|
|
@@ -139,444 +288,491 @@ export function transformJSX(
|
|
|
139
288
|
let needsApplyPropsImportGlobal = false
|
|
140
289
|
let needsMountSlotImportGlobal = false
|
|
141
290
|
|
|
142
|
-
|
|
143
|
-
* If `node` is a fully-static JSX element/fragment, register a module-scope
|
|
144
|
-
* hoist for it and return the generated variable name. Otherwise return null.
|
|
145
|
-
*/
|
|
146
|
-
function maybeHoist(node: ts.Node): string | null {
|
|
291
|
+
function maybeHoist(node: N): string | null {
|
|
147
292
|
if (
|
|
148
|
-
(
|
|
149
|
-
isStaticJSXNode(node
|
|
293
|
+
(node.type === 'JSXElement' || node.type === 'JSXFragment') &&
|
|
294
|
+
isStaticJSXNode(node)
|
|
150
295
|
) {
|
|
151
296
|
const name = `_$h${hoistIdx++}`
|
|
152
|
-
const text = code.slice(node.
|
|
297
|
+
const text = code.slice(node.start as number, node.end as number)
|
|
153
298
|
hoists.push({ name, text })
|
|
154
299
|
return name
|
|
155
300
|
}
|
|
156
301
|
return null
|
|
157
302
|
}
|
|
158
303
|
|
|
159
|
-
function wrap(expr:
|
|
160
|
-
const start = expr.
|
|
161
|
-
const end = expr.
|
|
162
|
-
// Object literals need parens: `() => { ... }` is a function body with
|
|
163
|
-
// labeled statements, not an object expression. Use `() => ({ ... })`.
|
|
304
|
+
function wrap(expr: N): void {
|
|
305
|
+
const start = expr.start as number
|
|
306
|
+
const end = expr.end as number
|
|
164
307
|
const sliced = sliceExpr(expr)
|
|
165
|
-
const text =
|
|
308
|
+
const text = expr.type === 'ObjectExpression'
|
|
166
309
|
? `() => (${sliced})`
|
|
167
310
|
: `() => ${sliced}`
|
|
168
311
|
replacements.push({ start, end, text })
|
|
169
312
|
}
|
|
170
313
|
|
|
171
|
-
|
|
172
|
-
function hoistOrWrap(expr: ts.Expression): void {
|
|
314
|
+
function hoistOrWrap(expr: N): void {
|
|
173
315
|
const hoistName = maybeHoist(expr)
|
|
174
316
|
if (hoistName) {
|
|
175
|
-
replacements.push({ start: expr.
|
|
317
|
+
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
176
318
|
} else if (shouldWrap(expr)) {
|
|
177
319
|
wrap(expr)
|
|
178
320
|
}
|
|
179
321
|
}
|
|
180
322
|
|
|
181
|
-
// ──
|
|
323
|
+
// ── Template emit ─────────────────────────────────────────────────────────
|
|
182
324
|
|
|
183
|
-
|
|
184
|
-
function tryTemplateEmit(node: ts.JsxElement): boolean {
|
|
185
|
-
// SSR builds skip the `_tpl()` fast path entirely. `_tpl` clones a real
|
|
186
|
-
// DOM element via `document.createElement('template')` and the emitted
|
|
187
|
-
// bind callback calls `appendChild`, `createTextNode`, etc. — none of
|
|
188
|
-
// that exists in Node. Falling back to standard JSX→`h()` lets
|
|
189
|
-
// `@pyreon/runtime-server` walk the VNode tree to a string. Client
|
|
190
|
-
// builds keep the template optimization.
|
|
325
|
+
function tryTemplateEmit(node: N): boolean {
|
|
191
326
|
if (ssr) return false
|
|
192
|
-
|
|
327
|
+
if (isSelfClosing(node)) return false
|
|
328
|
+
const elemCount = templateElementCount(node, true)
|
|
193
329
|
if (elemCount < 1) return false
|
|
194
330
|
const tplCall = buildTemplateCall(node)
|
|
195
331
|
if (!tplCall) return false
|
|
196
|
-
const start = node.
|
|
197
|
-
const end = node.
|
|
198
|
-
const parent = node
|
|
199
|
-
const needsBraces = parent && (
|
|
332
|
+
const start = node.start as number
|
|
333
|
+
const end = node.end as number
|
|
334
|
+
const parent = findParent(node)
|
|
335
|
+
const needsBraces = parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
200
336
|
replacements.push({ start, end, text: needsBraces ? `{${tplCall}}` : tplCall })
|
|
201
337
|
needsTplImport = true
|
|
202
338
|
return true
|
|
203
339
|
}
|
|
204
340
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const opening = ts.isJsxElement(node) ? node.openingElement : node
|
|
208
|
-
const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : ''
|
|
341
|
+
function checkForWarnings(node: N): void {
|
|
342
|
+
const tagName = jsxTagName(node)
|
|
209
343
|
if (tagName !== 'For') return
|
|
210
|
-
const hasBy =
|
|
211
|
-
(p) =>
|
|
344
|
+
const hasBy = jsxAttrs(node).some(
|
|
345
|
+
(p: N) => p.type === 'JSXAttribute' && p.name?.type === 'JSXIdentifier' && p.name.name === 'by',
|
|
212
346
|
)
|
|
213
347
|
if (!hasBy) {
|
|
214
348
|
warn(
|
|
215
|
-
|
|
349
|
+
node.openingElement?.name ?? node,
|
|
216
350
|
`<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.`,
|
|
217
351
|
'missing-key-on-for',
|
|
218
352
|
)
|
|
219
353
|
}
|
|
220
354
|
}
|
|
221
355
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
* Both DOM and component props are processed:
|
|
225
|
-
* - DOM props: () => expr — applyProp creates renderEffect
|
|
226
|
-
* - Component props: _rp(() => expr) — makeReactiveProps converts to getters
|
|
227
|
-
*
|
|
228
|
-
* The _rp() brand distinguishes compiler wrappers from user-written accessor
|
|
229
|
-
* props (like Show's when, For's each) so makeReactiveProps only converts
|
|
230
|
-
* compiler-emitted wrappers.
|
|
231
|
-
*/
|
|
232
|
-
function handleJsxAttribute(node: ts.JsxAttribute): void {
|
|
233
|
-
const name = ts.isIdentifier(node.name) ? node.name.text : ''
|
|
356
|
+
function handleJsxAttribute(node: N, parentElement: N): void {
|
|
357
|
+
const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
|
|
234
358
|
if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
|
|
235
|
-
if (!node.
|
|
236
|
-
const expr = node.
|
|
237
|
-
if (!expr) return
|
|
359
|
+
if (!node.value || node.value.type !== 'JSXExpressionContainer') return
|
|
360
|
+
const expr = node.value.expression
|
|
361
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
238
362
|
|
|
239
|
-
const
|
|
240
|
-
const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : ''
|
|
363
|
+
const tagName = jsxTagName(parentElement)
|
|
241
364
|
const isComponent = tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
|
|
242
365
|
|
|
243
366
|
if (isComponent) {
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
// EXCEPTION: If the expression is a single JSX element (not a conditional),
|
|
247
|
-
// do NOT wrap the outer expression. The JSX element is created once (stable VNode).
|
|
248
|
-
// Its own inner props will be wrapped individually via recursive walk().
|
|
249
|
-
// This prevents remounting: <Icon name={x()} /> stays one Icon instance,
|
|
250
|
-
// only its name prop updates reactively.
|
|
251
|
-
const isSingleJsx = ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr)
|
|
367
|
+
const isSingleJsx = expr.type === 'JSXElement' || expr.type === 'JSXFragment'
|
|
252
368
|
if (isSingleJsx) {
|
|
253
|
-
|
|
254
|
-
ts.forEachChild(expr, walk)
|
|
369
|
+
walkNode(expr)
|
|
255
370
|
return
|
|
256
371
|
}
|
|
257
|
-
|
|
258
372
|
const hoistName = maybeHoist(expr)
|
|
259
373
|
if (hoistName) {
|
|
260
|
-
replacements.push({ start: expr.
|
|
374
|
+
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
261
375
|
} else if (shouldWrap(expr)) {
|
|
262
|
-
const start = expr.
|
|
263
|
-
const end = expr.
|
|
264
|
-
// Object literals need parens to disambiguate from arrow function body
|
|
376
|
+
const start = expr.start as number
|
|
377
|
+
const end = expr.end as number
|
|
265
378
|
const sliced = sliceExpr(expr)
|
|
266
|
-
const inner =
|
|
379
|
+
const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
|
|
267
380
|
replacements.push({ start, end, text: `_rp(() => ${inner})` })
|
|
268
381
|
needsRpImport = true
|
|
269
382
|
}
|
|
270
383
|
} else {
|
|
271
|
-
// DOM prop: standard () => expr wrapping
|
|
272
384
|
hoistOrWrap(expr)
|
|
273
385
|
}
|
|
274
386
|
}
|
|
275
387
|
|
|
276
|
-
|
|
277
|
-
function handleJsxExpression(node: ts.JsxExpression): void {
|
|
388
|
+
function handleJsxExpression(node: N): void {
|
|
278
389
|
const expr = node.expression
|
|
279
|
-
if (!expr) return
|
|
390
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
280
391
|
const hoistName = maybeHoist(expr)
|
|
281
392
|
if (hoistName) {
|
|
282
|
-
replacements.push({ start: expr.
|
|
393
|
+
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
283
394
|
return
|
|
284
395
|
}
|
|
285
396
|
if (shouldWrap(expr)) {
|
|
286
397
|
wrap(expr)
|
|
287
398
|
return
|
|
288
399
|
}
|
|
289
|
-
|
|
290
|
-
// Recurse into the expression body to find nested JSX elements
|
|
291
|
-
// that should be compiled to _tpl() calls.
|
|
292
|
-
ts.forEachChild(expr, walk)
|
|
400
|
+
walkNode(expr)
|
|
293
401
|
}
|
|
294
402
|
|
|
295
|
-
// ── Prop-derived variable tracking
|
|
296
|
-
// Pre-pass: find variables derived from props/splitProps results inside
|
|
297
|
-
// component functions. These are inlined at JSX use sites so the compiler's
|
|
298
|
-
// existing wrapping makes them reactive.
|
|
299
|
-
//
|
|
300
|
-
// Example:
|
|
301
|
-
// const align = props.alignX ?? 'left'
|
|
302
|
-
// return <div class={align}> ← inlined to: class={props.alignX ?? 'left'}
|
|
303
|
-
// ← compiler wraps: class={() => props.alignX ?? 'left'}
|
|
304
|
-
|
|
305
|
-
/** Names that refer to the props object or splitProps results. */
|
|
403
|
+
// ── Prop-derived variable tracking (collected during the single walk) ─────
|
|
306
404
|
const propsNames = new Set<string>()
|
|
405
|
+
const propDerivedVars = new Map<string, { start: number; end: number }>()
|
|
406
|
+
|
|
407
|
+
// ── Signal variable tracking (for auto-call in JSX) ──────────────────────
|
|
408
|
+
// Tracks `const x = signal(...)` declarations. In JSX expressions, bare
|
|
409
|
+
// references to these identifiers are auto-called: `{x}` → `{x()}`.
|
|
410
|
+
// This makes signals look like plain JS variables in templates while
|
|
411
|
+
// maintaining fine-grained reactivity.
|
|
412
|
+
const signalVars = new Set<string>(options.knownSignals)
|
|
413
|
+
|
|
414
|
+
// ── Scope-aware signal shadowing ──────────────────────────────────────────
|
|
415
|
+
// When a function/block declares a variable with the same name as a signal
|
|
416
|
+
// (e.g. `const show = 'text'` shadowing module-scope `const show = signal(false)`),
|
|
417
|
+
// that name is NOT a signal within that scope. The shadowedSignals set tracks
|
|
418
|
+
// names that are currently shadowed by a closer non-signal declaration.
|
|
419
|
+
const shadowedSignals = new Set<string>()
|
|
420
|
+
|
|
421
|
+
/** Check if an identifier name is an active (non-shadowed) signal variable. */
|
|
422
|
+
function isActiveSignal(name: string): boolean {
|
|
423
|
+
return signalVars.has(name) && !shadowedSignals.has(name)
|
|
424
|
+
}
|
|
307
425
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
426
|
+
/** Find variable declarations and parameters in a function that shadow signal names. */
|
|
427
|
+
function findShadowingNames(node: N): string[] {
|
|
428
|
+
const shadows: string[] = []
|
|
429
|
+
// Check function parameters
|
|
430
|
+
for (const param of node.params ?? []) {
|
|
431
|
+
if (param.type === 'Identifier' && signalVars.has(param.name)) {
|
|
432
|
+
shadows.push(param.name)
|
|
433
|
+
}
|
|
434
|
+
// Handle destructured parameters: ({ name }) => ...
|
|
435
|
+
if (param.type === 'ObjectPattern') {
|
|
436
|
+
for (const prop of param.properties ?? []) {
|
|
437
|
+
const val = prop.value ?? prop.key
|
|
438
|
+
if (val?.type === 'Identifier' && signalVars.has(val.name)) {
|
|
439
|
+
shadows.push(val.name)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Handle array destructured parameters: ([a, b]) => ...
|
|
444
|
+
if (param.type === 'ArrayPattern') {
|
|
445
|
+
for (const el of param.elements ?? []) {
|
|
446
|
+
if (el?.type === 'Identifier' && signalVars.has(el.name)) {
|
|
447
|
+
shadows.push(el.name)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
316
451
|
}
|
|
317
|
-
|
|
318
|
-
|
|
452
|
+
// Check top-level variable declarations in the function body
|
|
453
|
+
const body = node.body
|
|
454
|
+
const stmts = body?.body ?? body?.statements
|
|
455
|
+
if (!Array.isArray(stmts)) return shadows
|
|
456
|
+
for (const stmt of stmts) {
|
|
457
|
+
if (stmt.type === 'VariableDeclaration') {
|
|
458
|
+
for (const decl of stmt.declarations ?? []) {
|
|
459
|
+
if (decl.id?.type === 'Identifier' && signalVars.has(decl.id.name)) {
|
|
460
|
+
// Only shadow if it's NOT a signal() call
|
|
461
|
+
if (!decl.init || !isSignalCall(decl.init)) {
|
|
462
|
+
shadows.push(decl.id.name)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return shadows
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function readsFromProps(node: N): boolean {
|
|
472
|
+
if (node.type === 'MemberExpression' && node.object?.type === 'Identifier') {
|
|
473
|
+
if (propsNames.has(node.object.name)) return true
|
|
319
474
|
}
|
|
320
|
-
// Check children recursively — e.g. props.x ?? 'default'
|
|
321
475
|
let found = false
|
|
322
|
-
|
|
476
|
+
forEachChildFast(node, (child) => {
|
|
323
477
|
if (found) return
|
|
324
478
|
if (readsFromProps(child)) found = true
|
|
325
479
|
})
|
|
326
480
|
return found
|
|
327
481
|
}
|
|
328
482
|
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node))) {
|
|
336
|
-
const parent = node.parent
|
|
337
|
-
if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
|
|
338
|
-
_callbackDepth++
|
|
339
|
-
ts.forEachChild(node, scanForPropDerivedVars)
|
|
340
|
-
_callbackDepth--
|
|
341
|
-
return
|
|
342
|
-
}
|
|
483
|
+
/** Check if an expression references any prop-derived variable. */
|
|
484
|
+
function referencesPropDerived(node: N): boolean {
|
|
485
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
486
|
+
const p = findParent(node)
|
|
487
|
+
if (p && p.type === 'MemberExpression' && p.property === node && !p.computed) return false
|
|
488
|
+
return true
|
|
343
489
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// Skip functions that are arguments to a call (map/filter callbacks)
|
|
352
|
-
const parent = node.parent
|
|
353
|
-
if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
|
|
354
|
-
ts.forEachChild(node, scanForPropDerivedVars)
|
|
355
|
-
return
|
|
356
|
-
}
|
|
490
|
+
let found = false
|
|
491
|
+
forEachChildFast(node, (child) => {
|
|
492
|
+
if (found) return
|
|
493
|
+
if (referencesPropDerived(child)) found = true
|
|
494
|
+
})
|
|
495
|
+
return found
|
|
496
|
+
}
|
|
357
497
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
498
|
+
/** Collect prop-derived variable info from a VariableDeclaration node.
|
|
499
|
+
* Called inline during the single-pass walk when we encounter a declaration. */
|
|
500
|
+
function collectPropDerivedFromDecl(node: N, callbackDepth: number): void {
|
|
501
|
+
if (node.type !== 'VariableDeclaration') return
|
|
502
|
+
for (const decl of node.declarations ?? []) {
|
|
503
|
+
// splitProps: const [own, rest] = splitProps(props, [...])
|
|
504
|
+
if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'CallExpression') {
|
|
505
|
+
const callee = decl.init.callee
|
|
506
|
+
if (callee?.type === 'Identifier' && callee.name === 'splitProps') {
|
|
507
|
+
for (const el of decl.id.elements ?? []) {
|
|
508
|
+
if (el?.type === 'Identifier') propsNames.add(el.name)
|
|
367
509
|
}
|
|
368
|
-
|
|
369
|
-
})
|
|
370
|
-
if (hasJSX) propsNames.add(firstParam.name.text)
|
|
510
|
+
}
|
|
371
511
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const callee = decl.initializer.expression
|
|
380
|
-
if (ts.isIdentifier(callee) && callee.text === 'splitProps') {
|
|
381
|
-
for (const el of decl.name.elements) {
|
|
382
|
-
if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) {
|
|
383
|
-
propsNames.add(el.name.text)
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
512
|
+
if (node.kind !== 'const') continue
|
|
513
|
+
if (callbackDepth > 0) continue
|
|
514
|
+
if (decl.id?.type === 'Identifier' && decl.init) {
|
|
515
|
+
if (isStatefulCall(decl.init)) {
|
|
516
|
+
// Track signal() declarations for auto-call in JSX
|
|
517
|
+
if (isSignalCall(decl.init)) signalVars.add(decl.id.name)
|
|
518
|
+
continue
|
|
387
519
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
// Skip declarations inside callbacks (map, filter, etc.)
|
|
392
|
-
// Skip stateful calls (signal, computed, effect) — inlining creates new instances
|
|
393
|
-
if (!(node.declarationList.flags & ts.NodeFlags.Const)) continue
|
|
394
|
-
if (_callbackDepth > 0) continue
|
|
395
|
-
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
396
|
-
if (isStatefulCall(decl.initializer)) continue
|
|
397
|
-
if (readsFromProps(decl.initializer)) {
|
|
398
|
-
propDerivedVars.set(decl.name.text, decl.initializer)
|
|
399
|
-
}
|
|
520
|
+
// Direct prop read OR transitive (references another prop-derived var)
|
|
521
|
+
if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) {
|
|
522
|
+
propDerivedVars.set(decl.id.name, { start: decl.init.start as number, end: decl.init.end as number })
|
|
400
523
|
}
|
|
401
524
|
}
|
|
402
525
|
}
|
|
403
|
-
|
|
404
|
-
ts.forEachChild(node, scanForPropDerivedVars)
|
|
405
526
|
}
|
|
406
527
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
ts.forEachChild(decl.initializer, function check(n) {
|
|
425
|
-
if (usesPropVar) return
|
|
426
|
-
if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
|
|
427
|
-
const parent = n.parent
|
|
428
|
-
if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return
|
|
429
|
-
usesPropVar = true
|
|
430
|
-
}
|
|
431
|
-
ts.forEachChild(n, check)
|
|
432
|
-
})
|
|
433
|
-
if (usesPropVar) {
|
|
434
|
-
propDerivedVars.set(varName, decl.initializer)
|
|
435
|
-
changed = true
|
|
528
|
+
/** Detect component functions and register their first param as a props name.
|
|
529
|
+
* Called inline during the walk when entering a function. */
|
|
530
|
+
function maybeRegisterComponentProps(node: N): void {
|
|
531
|
+
if (
|
|
532
|
+
(node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') &&
|
|
533
|
+
(node.params?.length ?? 0) > 0
|
|
534
|
+
) {
|
|
535
|
+
const parent = findParent(node)
|
|
536
|
+
// Skip callback functions (arguments to calls like .map, .filter)
|
|
537
|
+
if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) return
|
|
538
|
+
const firstParam = node.params[0]
|
|
539
|
+
if (firstParam?.type === 'Identifier') {
|
|
540
|
+
let hasJSX = false
|
|
541
|
+
function checkJSX(n: N): void {
|
|
542
|
+
if (hasJSX) return
|
|
543
|
+
if (n.type === 'JSXElement' || n.type === 'JSXFragment') { hasJSX = true; return }
|
|
544
|
+
forEachChildFast(n, checkJSX)
|
|
436
545
|
}
|
|
546
|
+
forEachChildFast(node, checkJSX)
|
|
547
|
+
if (hasJSX) propsNames.add(firstParam.name)
|
|
437
548
|
}
|
|
438
|
-
}
|
|
549
|
+
}
|
|
439
550
|
}
|
|
440
551
|
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
// The `visited` set prevents infinite recursion on circular references:
|
|
446
|
-
// const a = b + props.x; const b = a + 1;
|
|
447
|
-
// Without it, resolving `a` reaches `b`, which reaches `a` again, and
|
|
448
|
-
// the compiler stack-overflows. The fix: when a variable is already in
|
|
449
|
-
// the visited set, leave the identifier as-is (it falls back to the
|
|
450
|
-
// captured const value, which is the correct runtime behavior for a
|
|
451
|
-
// circular dependency — the variable reads its value at definition time).
|
|
452
|
-
// Track which cycles have been warned about so we don't emit
|
|
453
|
-
// duplicate warnings for the same cycle seen from different vars.
|
|
552
|
+
// ── String-based transitive resolution ─────────────────────────────────────
|
|
553
|
+
const resolvedCache = new Map<string, string>()
|
|
554
|
+
const resolving = new Set<string>()
|
|
454
555
|
const warnedCycles = new Set<string>()
|
|
455
556
|
|
|
456
|
-
function
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (ts.isShorthandPropertyAssignment(parent)) return n
|
|
483
|
-
}
|
|
557
|
+
function resolveVarToString(varName: string, sourceNode?: N): string {
|
|
558
|
+
if (resolvedCache.has(varName)) return resolvedCache.get(varName)!
|
|
559
|
+
if (resolving.has(varName)) {
|
|
560
|
+
const cycleKey = [...resolving, varName].sort().join(',')
|
|
561
|
+
if (!warnedCycles.has(cycleKey)) {
|
|
562
|
+
warnedCycles.add(cycleKey)
|
|
563
|
+
const chain = [...resolving, varName].join(' → ')
|
|
564
|
+
warn(
|
|
565
|
+
sourceNode ?? program,
|
|
566
|
+
`[Pyreon] Circular prop-derived const reference: ${chain}. ` +
|
|
567
|
+
`The cyclic identifier \`${varName}\` will use its captured value ` +
|
|
568
|
+
`instead of being reactively inlined. Break the cycle by reading ` +
|
|
569
|
+
`from \`props.*\` directly or restructuring the derivation chain.`,
|
|
570
|
+
'circular-prop-derived',
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
return varName
|
|
574
|
+
}
|
|
575
|
+
resolving.add(varName)
|
|
576
|
+
const span = propDerivedVars.get(varName)!
|
|
577
|
+
const rawText = code.slice(span.start, span.end)
|
|
578
|
+
const resolved = resolveIdentifiersInText(rawText, span.start, sourceNode)
|
|
579
|
+
resolving.delete(varName)
|
|
580
|
+
resolvedCache.set(varName, resolved)
|
|
581
|
+
return resolved
|
|
582
|
+
}
|
|
484
583
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
)
|
|
584
|
+
function resolveIdentifiersInText(text: string, baseOffset: number, sourceNode?: N): string {
|
|
585
|
+
const endOffset = baseOffset + text.length
|
|
586
|
+
const idents: { start: number; end: number; name: string }[] = []
|
|
587
|
+
|
|
588
|
+
// Walk the AST to find identifiers in the span, passing parent context
|
|
589
|
+
// to skip non-reference positions (property names, declarations, etc.)
|
|
590
|
+
function findIdents(node: N, parent: N | null): void {
|
|
591
|
+
const nodeStart = node.start as number
|
|
592
|
+
const nodeEnd = node.end as number
|
|
593
|
+
if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
|
|
594
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
595
|
+
if (parent) {
|
|
596
|
+
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
|
|
597
|
+
else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
|
|
598
|
+
else if (parent.type === 'Property' && parent.key === node && !parent.computed) { /* skip */ }
|
|
599
|
+
else if (parent.type === 'Property' && parent.shorthand) { /* skip */ }
|
|
600
|
+
else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
|
|
601
|
+
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
503
602
|
}
|
|
504
|
-
|
|
603
|
+
} else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
|
|
604
|
+
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
505
605
|
}
|
|
506
|
-
|
|
507
|
-
const resolved = propDerivedVars.get(n.text)!
|
|
508
|
-
// Mark this variable as visited BEFORE recursing so cycles are
|
|
509
|
-
// detected on the next encounter rather than re-entering.
|
|
510
|
-
const nextVisited = new Set(visited)
|
|
511
|
-
nextVisited.add(n.text)
|
|
512
|
-
return ts.factory.createParenthesizedExpression(
|
|
513
|
-
resolveExprTransitive(resolved, nextVisited, sourceNode),
|
|
514
|
-
)
|
|
515
606
|
}
|
|
516
|
-
|
|
517
|
-
}
|
|
607
|
+
forEachChildFast(node, (child) => findIdents(child, node))
|
|
608
|
+
}
|
|
609
|
+
findIdents(program, null)
|
|
610
|
+
|
|
611
|
+
if (idents.length === 0) return text
|
|
612
|
+
|
|
613
|
+
idents.sort((a, b) => a.start - b.start)
|
|
614
|
+
const parts: string[] = []
|
|
615
|
+
let lastPos = baseOffset
|
|
616
|
+
for (const id of idents) {
|
|
617
|
+
parts.push(code.slice(lastPos, id.start))
|
|
618
|
+
parts.push(`(${resolveVarToString(id.name, sourceNode)})`)
|
|
619
|
+
lastPos = id.end
|
|
620
|
+
}
|
|
621
|
+
parts.push(code.slice(lastPos, endOffset))
|
|
622
|
+
return parts.join('')
|
|
518
623
|
}
|
|
519
624
|
|
|
520
|
-
|
|
521
|
-
|
|
625
|
+
// ── Analysis helpers with memoization (Phase 3) ────────────────────────────
|
|
626
|
+
// Cache results keyed by node.start (unique per node in a file).
|
|
627
|
+
// Eliminates redundant subtree traversals for containsCall + accessesProps.
|
|
628
|
+
const _isDynamicCache = new Map<number, boolean>()
|
|
629
|
+
|
|
630
|
+
/** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
|
|
631
|
+
function isDynamic(node: N): boolean {
|
|
632
|
+
const key = node.start as number
|
|
633
|
+
const cached = _isDynamicCache.get(key)
|
|
634
|
+
if (cached !== undefined) return cached
|
|
635
|
+
const result = _isDynamicImpl(node)
|
|
636
|
+
_isDynamicCache.set(key, result)
|
|
637
|
+
return result
|
|
638
|
+
}
|
|
522
639
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
640
|
+
function _isDynamicImpl(node: N): boolean {
|
|
641
|
+
// Call expression (non-pure)
|
|
642
|
+
if (node.type === 'CallExpression') {
|
|
643
|
+
if (!isPureStaticCall(node)) return true
|
|
644
|
+
}
|
|
645
|
+
if (node.type === 'TaggedTemplateExpression') return true
|
|
646
|
+
// Props access
|
|
647
|
+
if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
|
|
648
|
+
if (propsNames.has(node.object.name)) return true
|
|
649
|
+
}
|
|
650
|
+
// Prop-derived variable reference
|
|
651
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
652
|
+
const parent = findParent(node)
|
|
653
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
|
|
654
|
+
// This is a property name position, not a reference — fall through
|
|
655
|
+
} else {
|
|
656
|
+
return true
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Signal variable reference — treated as dynamic (will be auto-called)
|
|
660
|
+
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
661
|
+
const parent = findParent(node)
|
|
662
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
|
|
663
|
+
// Property name position — not a reference
|
|
664
|
+
} else if (parent && parent.type === 'CallExpression' && parent.callee === node) {
|
|
665
|
+
// Already being called: signal() — don't double-flag
|
|
666
|
+
} else {
|
|
667
|
+
return true
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
// Don't recurse into nested functions
|
|
671
|
+
if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
|
|
672
|
+
// Recurse into children
|
|
673
|
+
let found = false
|
|
674
|
+
forEachChildFast(node, (child) => {
|
|
675
|
+
if (found) return
|
|
676
|
+
if (isDynamic(child)) found = true
|
|
677
|
+
})
|
|
678
|
+
return found
|
|
531
679
|
}
|
|
532
680
|
|
|
533
|
-
/**
|
|
534
|
-
function accessesProps(node:
|
|
535
|
-
if (
|
|
536
|
-
if (propsNames.has(node.
|
|
681
|
+
/** accessesProps — kept for sliceExpr's quick check (does this need resolution?) */
|
|
682
|
+
function accessesProps(node: N): boolean {
|
|
683
|
+
if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
|
|
684
|
+
if (propsNames.has(node.object.name)) return true
|
|
537
685
|
}
|
|
538
|
-
if (
|
|
539
|
-
const parent = node
|
|
540
|
-
if (parent &&
|
|
686
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
687
|
+
const parent = findParent(node)
|
|
688
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
541
689
|
return true
|
|
542
690
|
}
|
|
543
691
|
let found = false
|
|
544
|
-
|
|
692
|
+
forEachChildFast(node, (child) => {
|
|
545
693
|
if (found) return
|
|
546
|
-
if (
|
|
694
|
+
if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
|
|
547
695
|
if (accessesProps(child)) found = true
|
|
548
696
|
})
|
|
549
697
|
return found
|
|
550
698
|
}
|
|
551
699
|
|
|
552
|
-
function shouldWrap(node:
|
|
553
|
-
if (
|
|
700
|
+
function shouldWrap(node: N): boolean {
|
|
701
|
+
if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
|
|
554
702
|
if (isStatic(node)) return false
|
|
555
|
-
if (
|
|
703
|
+
if (node.type === 'CallExpression' && isPureStaticCall(node)) return false
|
|
556
704
|
return isDynamic(node)
|
|
557
705
|
}
|
|
558
706
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
707
|
+
// ── Single unified walk (Phase 2) ─────────────────────────────────────────
|
|
708
|
+
// Merges the old 3-pass architecture (scanForPropDerivedVars + transitive
|
|
709
|
+
// resolution + JSX walk) into one top-down traversal. Works because `const`
|
|
710
|
+
// declarations have a temporal dead zone — they're always before their use.
|
|
711
|
+
let _callbackDepth = 0
|
|
712
|
+
|
|
713
|
+
function walkNode(node: N): void {
|
|
714
|
+
// ── Component function detection (was pass 1) ──
|
|
715
|
+
const isFunction = node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression'
|
|
716
|
+
let scopeShadows: string[] | null = null
|
|
717
|
+
if (isFunction) {
|
|
718
|
+
// Track callback nesting for prop-derived var exclusion
|
|
719
|
+
const parent = findParent(node)
|
|
720
|
+
const isCallbackArg = parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)
|
|
721
|
+
if (isCallbackArg) _callbackDepth++
|
|
722
|
+
// Register component props (only for non-callback functions with JSX)
|
|
723
|
+
maybeRegisterComponentProps(node)
|
|
724
|
+
// Track signal name shadowing for scope awareness
|
|
725
|
+
if (signalVars.size > 0) {
|
|
726
|
+
scopeShadows = findShadowingNames(node)
|
|
727
|
+
for (const name of scopeShadows) shadowedSignals.add(name)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ── Variable declaration collection (was pass 1 + 2) ──
|
|
732
|
+
if (node.type === 'VariableDeclaration') {
|
|
733
|
+
collectPropDerivedFromDecl(node, _callbackDepth)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── JSX processing (was pass 3) ──
|
|
737
|
+
if (node.type === 'JSXElement') {
|
|
738
|
+
if (!isSelfClosing(node) && tryTemplateEmit(node)) {
|
|
739
|
+
// Template emitted — don't recurse into this subtree (JSXElement is never a function)
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
checkForWarnings(node)
|
|
743
|
+
for (const attr of jsxAttrs(node)) {
|
|
744
|
+
if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
|
|
745
|
+
}
|
|
746
|
+
for (const child of jsxChildren(node)) {
|
|
747
|
+
if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
|
|
748
|
+
else walkNode(child)
|
|
749
|
+
}
|
|
750
|
+
// Note: JSXElement is never a function, so no callback depth or scope cleanup needed here
|
|
564
751
|
return
|
|
565
752
|
}
|
|
566
|
-
if (
|
|
753
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
567
754
|
handleJsxExpression(node)
|
|
755
|
+
// Note: JSXExpressionContainer is never a function, no scope cleanup needed
|
|
568
756
|
return
|
|
569
757
|
}
|
|
570
|
-
|
|
758
|
+
|
|
759
|
+
// Generic descent
|
|
760
|
+
forEachChildFast(node, walkNode)
|
|
761
|
+
|
|
762
|
+
// Restore callback depth after leaving function
|
|
763
|
+
if (isFunction) {
|
|
764
|
+
const parent = findParent(node)
|
|
765
|
+
if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) _callbackDepth--
|
|
766
|
+
}
|
|
767
|
+
// Restore signal shadowing
|
|
768
|
+
if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name)
|
|
571
769
|
}
|
|
572
770
|
|
|
573
|
-
|
|
771
|
+
walkNode(program)
|
|
574
772
|
|
|
575
773
|
if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
|
|
576
774
|
|
|
577
|
-
// Apply replacements left-to-right via string builder — O(n) single join
|
|
578
775
|
replacements.sort((a, b) => a.start - b.start)
|
|
579
|
-
|
|
580
776
|
const parts: string[] = []
|
|
581
777
|
let lastPos = 0
|
|
582
778
|
for (const r of replacements) {
|
|
@@ -587,13 +783,11 @@ export function transformJSX(
|
|
|
587
783
|
parts.push(code.slice(lastPos))
|
|
588
784
|
let result = parts.join('')
|
|
589
785
|
|
|
590
|
-
// Prepend module-scope hoisted static VNode declarations
|
|
591
786
|
if (hoists.length > 0) {
|
|
592
787
|
const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
|
|
593
788
|
result = preamble + result
|
|
594
789
|
}
|
|
595
790
|
|
|
596
|
-
// Prepend template imports if _tpl() was emitted
|
|
597
791
|
if (needsTplImport) {
|
|
598
792
|
const runtimeDomImports = ['_tpl']
|
|
599
793
|
if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
|
|
@@ -608,65 +802,45 @@ export function transformJSX(
|
|
|
608
802
|
result
|
|
609
803
|
}
|
|
610
804
|
|
|
611
|
-
// Prepend _rp import if reactive component props were emitted
|
|
612
805
|
if (needsRpImport) {
|
|
613
806
|
result = `import { _rp } from "@pyreon/core";\n` + result
|
|
614
807
|
}
|
|
615
808
|
|
|
616
809
|
return { code: result, usesTemplates: needsTplImport, warnings }
|
|
617
810
|
|
|
618
|
-
// ── Template emission helpers
|
|
811
|
+
// ── Template emission helpers ─────────────────────────────────────────────
|
|
619
812
|
|
|
620
|
-
|
|
621
|
-
* Check if attributes prevent template emission.
|
|
622
|
-
* - `key` always bails (VNode reconciliation prop)
|
|
623
|
-
* - Spread on inner elements bails (too complex to merge in _bind)
|
|
624
|
-
* - Spread on root element is allowed — applied via applyProps in _bind
|
|
625
|
-
*/
|
|
626
|
-
function hasBailAttr(node: ts.JsxElement | ts.JsxSelfClosingElement, isRoot = false): boolean {
|
|
813
|
+
function hasBailAttr(node: N, isRoot = false): boolean {
|
|
627
814
|
for (const attr of jsxAttrs(node)) {
|
|
628
|
-
if (
|
|
629
|
-
// Allow spread on root element — handled in buildTemplateCall
|
|
815
|
+
if (attr.type === 'JSXSpreadAttribute') {
|
|
630
816
|
if (isRoot) continue
|
|
631
817
|
return true
|
|
632
818
|
}
|
|
633
|
-
if (
|
|
819
|
+
if (attr.type === 'JSXAttribute' && attr.name?.type === 'JSXIdentifier' && attr.name.name === 'key')
|
|
634
820
|
return true
|
|
635
821
|
}
|
|
636
822
|
return false
|
|
637
823
|
}
|
|
638
824
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if (
|
|
648
|
-
if (!child.expression) return 0
|
|
649
|
-
return containsJSXInExpr(child.expression) ? -1 : 0
|
|
650
|
-
}
|
|
651
|
-
if (ts.isJsxFragment(child)) return templateFragmentCount(child)
|
|
825
|
+
function countChildForTemplate(child: N): number {
|
|
826
|
+
if (child.type === 'JSXText') return 0
|
|
827
|
+
if (child.type === 'JSXElement') return templateElementCount(child)
|
|
828
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
829
|
+
const expr = child.expression
|
|
830
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return 0
|
|
831
|
+
return containsJSXInExpr(expr) ? -1 : 0
|
|
832
|
+
}
|
|
833
|
+
if (child.type === 'JSXFragment') return templateFragmentCount(child)
|
|
652
834
|
return -1
|
|
653
835
|
}
|
|
654
836
|
|
|
655
|
-
|
|
656
|
-
* Count DOM elements in a JSX subtree. Returns -1 if the tree is not
|
|
657
|
-
* eligible for template emission.
|
|
658
|
-
*/
|
|
659
|
-
function templateElementCount(
|
|
660
|
-
node: ts.JsxElement | ts.JsxSelfClosingElement,
|
|
661
|
-
isRoot = false,
|
|
662
|
-
): number {
|
|
837
|
+
function templateElementCount(node: N, isRoot = false): number {
|
|
663
838
|
const tag = jsxTagName(node)
|
|
664
839
|
if (!tag || !isLowerCase(tag)) return -1
|
|
665
840
|
if (hasBailAttr(node, isRoot)) return -1
|
|
666
|
-
if (
|
|
667
|
-
|
|
841
|
+
if (isSelfClosing(node)) return 1
|
|
668
842
|
let count = 1
|
|
669
|
-
for (const child of node
|
|
843
|
+
for (const child of jsxChildren(node)) {
|
|
670
844
|
const c = countChildForTemplate(child)
|
|
671
845
|
if (c === -1) return -1
|
|
672
846
|
count += c
|
|
@@ -674,10 +848,9 @@ export function transformJSX(
|
|
|
674
848
|
return count
|
|
675
849
|
}
|
|
676
850
|
|
|
677
|
-
|
|
678
|
-
function templateFragmentCount(frag: ts.JsxFragment): number {
|
|
851
|
+
function templateFragmentCount(frag: N): number {
|
|
679
852
|
let count = 0
|
|
680
|
-
for (const child of frag
|
|
853
|
+
for (const child of jsxChildren(frag)) {
|
|
681
854
|
const c = countChildForTemplate(child)
|
|
682
855
|
if (c === -1) return -1
|
|
683
856
|
count += c
|
|
@@ -685,35 +858,25 @@ export function transformJSX(
|
|
|
685
858
|
return count
|
|
686
859
|
}
|
|
687
860
|
|
|
688
|
-
|
|
689
|
-
* Build the complete `_tpl("html", (__root) => { ... })` call string
|
|
690
|
-
* for a template-eligible JSX element tree. Returns null if codegen fails.
|
|
691
|
-
*/
|
|
692
|
-
function buildTemplateCall(node: ts.JsxElement | ts.JsxSelfClosingElement): string | null {
|
|
861
|
+
function buildTemplateCall(node: N): string | null {
|
|
693
862
|
const bindLines: string[] = []
|
|
694
863
|
const disposerNames: string[] = []
|
|
695
864
|
let varIdx = 0
|
|
696
865
|
let dispIdx = 0
|
|
697
|
-
// Reactive expressions that will be combined into a single _bind call
|
|
698
866
|
const reactiveBindExprs: string[] = []
|
|
699
867
|
let needsBindTextImport = false
|
|
700
868
|
let needsBindDirectImport = false
|
|
701
869
|
let needsApplyPropsImport = false
|
|
702
870
|
let needsMountSlotImport = false
|
|
703
871
|
|
|
704
|
-
function nextVar(): string {
|
|
705
|
-
return `__e${varIdx++}`
|
|
706
|
-
}
|
|
872
|
+
function nextVar(): string { return `__e${varIdx++}` }
|
|
707
873
|
function nextDisp(): string {
|
|
708
874
|
const name = `__d${dispIdx++}`
|
|
709
875
|
disposerNames.push(name)
|
|
710
876
|
return name
|
|
711
877
|
}
|
|
712
|
-
function nextTextVar(): string {
|
|
713
|
-
return `__t${varIdx++}`
|
|
714
|
-
}
|
|
878
|
+
function nextTextVar(): string { return `__t${varIdx++}` }
|
|
715
879
|
|
|
716
|
-
/** Resolve the variable name for an element given its accessor path. */
|
|
717
880
|
function resolveElementVar(accessor: string, hasDynamic: boolean): string {
|
|
718
881
|
if (accessor === '__root') return '__root'
|
|
719
882
|
if (hasDynamic) {
|
|
@@ -724,14 +887,11 @@ export function transformJSX(
|
|
|
724
887
|
return accessor
|
|
725
888
|
}
|
|
726
889
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (
|
|
732
|
-
// Function ref: ref={(el) => { ... }} or ref={fn} → call with element
|
|
733
|
-
// Object ref: ref={myRef} → assign element to .current
|
|
734
|
-
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
|
890
|
+
function emitRef(attr: N, varName: string): void {
|
|
891
|
+
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
892
|
+
const expr = attr.value.expression
|
|
893
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
894
|
+
if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') {
|
|
735
895
|
bindLines.push(`(${sliceExpr(expr)})(${varName})`)
|
|
736
896
|
} else {
|
|
737
897
|
bindLines.push(
|
|
@@ -740,87 +900,67 @@ export function transformJSX(
|
|
|
740
900
|
}
|
|
741
901
|
}
|
|
742
902
|
|
|
743
|
-
|
|
744
|
-
function emitEventListener(attr: ts.JsxAttribute, attrName: string, varName: string): void {
|
|
903
|
+
function emitEventListener(attr: N, attrName: string, varName: string): void {
|
|
745
904
|
const eventName = (attrName[2] ?? '').toLowerCase() + attrName.slice(3)
|
|
746
|
-
if (!attr.
|
|
747
|
-
|
|
748
|
-
|
|
905
|
+
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
906
|
+
const expr = attr.value.expression
|
|
907
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
908
|
+
const handler = sliceExpr(expr)
|
|
749
909
|
if (DELEGATED_EVENTS.has(eventName)) {
|
|
750
|
-
// Delegated: store handler as expando property — container listener picks it up
|
|
751
910
|
bindLines.push(`${varName}.__ev_${eventName} = ${handler}`)
|
|
752
911
|
} else {
|
|
753
912
|
bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`)
|
|
754
913
|
}
|
|
755
914
|
}
|
|
756
915
|
|
|
757
|
-
|
|
758
|
-
function staticAttrToHtml(exprNode: ts.Expression, htmlAttrName: string): string | null {
|
|
916
|
+
function staticAttrToHtml(exprNode: N, htmlAttrName: string): string | null {
|
|
759
917
|
if (!isStatic(exprNode)) return null
|
|
760
|
-
|
|
761
|
-
if (
|
|
762
|
-
|
|
918
|
+
// String literal
|
|
919
|
+
if ((exprNode.type === 'Literal' || exprNode.type === 'StringLiteral') && typeof exprNode.value === 'string')
|
|
920
|
+
return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`
|
|
921
|
+
// Numeric literal
|
|
922
|
+
if ((exprNode.type === 'Literal' || exprNode.type === 'NumericLiteral') && typeof exprNode.value === 'number')
|
|
923
|
+
return ` ${htmlAttrName}="${exprNode.value}"`
|
|
924
|
+
// Boolean true
|
|
925
|
+
if ((exprNode.type === 'Literal' || exprNode.type === 'BooleanLiteral') && exprNode.value === true)
|
|
926
|
+
return ` ${htmlAttrName}`
|
|
763
927
|
return '' // false/null/undefined → omit
|
|
764
928
|
}
|
|
765
929
|
|
|
766
|
-
|
|
767
|
-
* Try to extract a direct signal reference from an expression.
|
|
768
|
-
* Returns the callee text (e.g. "count" or "row.label") if the expression
|
|
769
|
-
* is a single call with no arguments, otherwise null.
|
|
770
|
-
*/
|
|
771
|
-
function tryDirectSignalRef(exprNode: ts.Expression): string | null {
|
|
930
|
+
function tryDirectSignalRef(exprNode: N): string | null {
|
|
772
931
|
let inner = exprNode
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
inner = inner.body as ts.Expression
|
|
776
|
-
}
|
|
777
|
-
if (!ts.isCallExpression(inner)) return null
|
|
778
|
-
if (inner.arguments.length > 0) return null
|
|
779
|
-
const callee = inner.expression
|
|
780
|
-
// Only match simple identifiers: count() → _bindText(count, node)
|
|
781
|
-
// Property access like obj.method() is NOT safe — detaching the method
|
|
782
|
-
// loses `this` context (e.g. value.toLocaleString becomes unbound).
|
|
783
|
-
if (ts.isIdentifier(callee)) {
|
|
784
|
-
return sliceExpr(callee)
|
|
932
|
+
if (inner.type === 'ArrowFunctionExpression' && inner.body?.type !== 'BlockStatement') {
|
|
933
|
+
inner = inner.body
|
|
785
934
|
}
|
|
935
|
+
if (inner.type !== 'CallExpression') return null
|
|
936
|
+
if ((inner.arguments?.length ?? 0) > 0) return null
|
|
937
|
+
const callee = inner.callee
|
|
938
|
+
if (callee?.type === 'Identifier') return sliceExpr(callee)
|
|
786
939
|
return null
|
|
787
940
|
}
|
|
788
941
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (ts.isArrowFunction(exprNode) && !ts.isBlock(exprNode.body)) {
|
|
793
|
-
return { expr: sliceExpr(exprNode.body as ts.Expression), isReactive: true }
|
|
942
|
+
function unwrapAccessor(exprNode: N): { expr: string; isReactive: boolean } {
|
|
943
|
+
if (exprNode.type === 'ArrowFunctionExpression' && exprNode.body?.type !== 'BlockStatement') {
|
|
944
|
+
return { expr: sliceExpr(exprNode.body), isReactive: true }
|
|
794
945
|
}
|
|
795
|
-
|
|
796
|
-
if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) {
|
|
946
|
+
if (exprNode.type === 'ArrowFunctionExpression' || exprNode.type === 'FunctionExpression') {
|
|
797
947
|
return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
|
|
798
948
|
}
|
|
799
949
|
return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
|
|
800
950
|
}
|
|
801
951
|
|
|
802
|
-
/** Build a setter expression for an attribute. */
|
|
803
952
|
function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
|
|
804
953
|
if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
|
|
805
954
|
if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
|
|
806
955
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
|
|
807
956
|
}
|
|
808
957
|
|
|
809
|
-
|
|
810
|
-
function emitDynamicAttr(
|
|
811
|
-
_expr: string,
|
|
812
|
-
exprNode: ts.Expression,
|
|
813
|
-
htmlAttrName: string,
|
|
814
|
-
varName: string,
|
|
815
|
-
): void {
|
|
958
|
+
function emitDynamicAttr(_expr: string, exprNode: N, htmlAttrName: string, varName: string): void {
|
|
816
959
|
const { expr, isReactive } = unwrapAccessor(exprNode)
|
|
817
|
-
|
|
818
960
|
if (!isReactive) {
|
|
819
961
|
bindLines.push(attrSetter(htmlAttrName, varName, expr))
|
|
820
962
|
return
|
|
821
963
|
}
|
|
822
|
-
|
|
823
|
-
// Direct signal binding for bare signal calls (e.g. class={() => active()})
|
|
824
964
|
const directRef = tryDirectSignalRef(exprNode)
|
|
825
965
|
if (directRef) {
|
|
826
966
|
needsBindDirectImport = true
|
|
@@ -834,111 +974,79 @@ export function transformJSX(
|
|
|
834
974
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
|
|
835
975
|
return
|
|
836
976
|
}
|
|
837
|
-
|
|
838
977
|
reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr))
|
|
839
978
|
}
|
|
840
979
|
|
|
841
|
-
|
|
842
|
-
function emitAttrExpression(
|
|
843
|
-
exprNode: ts.Expression,
|
|
844
|
-
htmlAttrName: string,
|
|
845
|
-
varName: string,
|
|
846
|
-
): string {
|
|
980
|
+
function emitAttrExpression(exprNode: N, htmlAttrName: string, varName: string): string {
|
|
847
981
|
const staticHtml = staticAttrToHtml(exprNode, htmlAttrName)
|
|
848
982
|
if (staticHtml !== null) return staticHtml
|
|
849
|
-
|
|
850
|
-
// style={{...}} → Object.assign(el.style, {...}) for object expressions
|
|
851
|
-
if (htmlAttrName === 'style' && ts.isObjectLiteralExpression(exprNode)) {
|
|
983
|
+
if (htmlAttrName === 'style' && exprNode.type === 'ObjectExpression') {
|
|
852
984
|
bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`)
|
|
853
985
|
return ''
|
|
854
986
|
}
|
|
855
|
-
|
|
856
987
|
emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
|
|
857
988
|
return ''
|
|
858
989
|
}
|
|
859
990
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
if (attrName
|
|
863
|
-
emitRef(attr, varName)
|
|
864
|
-
return true
|
|
865
|
-
}
|
|
866
|
-
if (EVENT_RE.test(attrName)) {
|
|
867
|
-
emitEventListener(attr, attrName, varName)
|
|
868
|
-
return true
|
|
869
|
-
}
|
|
991
|
+
function tryEmitSpecialAttr(attr: N, attrName: string, varName: string): boolean {
|
|
992
|
+
if (attrName === 'ref') { emitRef(attr, varName); return true }
|
|
993
|
+
if (EVENT_RE.test(attrName)) { emitEventListener(attr, attrName, varName); return true }
|
|
870
994
|
return false
|
|
871
995
|
}
|
|
872
996
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression)
|
|
883
|
-
return emitAttrExpression(attr.initializer.expression, htmlAttrName, varName)
|
|
997
|
+
function attrInitializerToHtml(attr: N, htmlAttrName: string, varName: string): string {
|
|
998
|
+
if (!attr.value) return ` ${htmlAttrName}`
|
|
999
|
+
// JSX string attribute: class="foo"
|
|
1000
|
+
if (attr.value.type === 'StringLiteral' || (attr.value.type === 'Literal' && typeof attr.value.value === 'string'))
|
|
1001
|
+
return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`
|
|
1002
|
+
if (attr.value.type === 'JSXExpressionContainer') {
|
|
1003
|
+
const expr = attr.value.expression
|
|
1004
|
+
if (expr && expr.type !== 'JSXEmptyExpression') return emitAttrExpression(expr, htmlAttrName, varName)
|
|
1005
|
+
}
|
|
884
1006
|
return ''
|
|
885
1007
|
}
|
|
886
1008
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
if (ts.isJsxSpreadAttribute(attr)) {
|
|
891
|
-
const expr = sliceExpr(attr.expression)
|
|
892
|
-
// Use runtime-dom's applyProps which handles class, style, events, etc.
|
|
1009
|
+
function processOneAttr(attr: N, varName: string): string {
|
|
1010
|
+
if (attr.type === 'JSXSpreadAttribute') {
|
|
1011
|
+
const expr = sliceExpr(attr.argument)
|
|
893
1012
|
needsApplyPropsImport = true
|
|
894
|
-
if (isDynamic(attr.
|
|
1013
|
+
if (isDynamic(attr.argument)) {
|
|
895
1014
|
reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
|
|
896
1015
|
} else {
|
|
897
1016
|
bindLines.push(`_applyProps(${varName}, ${expr})`)
|
|
898
1017
|
}
|
|
899
1018
|
return ''
|
|
900
1019
|
}
|
|
901
|
-
if (
|
|
902
|
-
const attrName =
|
|
1020
|
+
if (attr.type !== 'JSXAttribute') return ''
|
|
1021
|
+
const attrName = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
|
|
903
1022
|
if (attrName === 'key') return ''
|
|
904
1023
|
if (tryEmitSpecialAttr(attr, attrName, varName)) return ''
|
|
905
1024
|
return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
|
|
906
1025
|
}
|
|
907
1026
|
|
|
908
|
-
|
|
909
|
-
function processAttrs(el: ts.JsxElement | ts.JsxSelfClosingElement, varName: string): string {
|
|
1027
|
+
function processAttrs(el: N, varName: string): string {
|
|
910
1028
|
let htmlAttrs = ''
|
|
911
1029
|
for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
|
|
912
1030
|
return htmlAttrs
|
|
913
1031
|
}
|
|
914
1032
|
|
|
915
|
-
/** Emit bind lines for a reactive text expression child. */
|
|
916
1033
|
function emitReactiveTextChild(
|
|
917
|
-
expr: string,
|
|
918
|
-
|
|
919
|
-
varName: string,
|
|
920
|
-
parentRef: string,
|
|
921
|
-
childNodeIdx: number,
|
|
922
|
-
needsPlaceholder: boolean,
|
|
1034
|
+
expr: string, exprNode: N, varName: string,
|
|
1035
|
+
parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
|
|
923
1036
|
): string {
|
|
924
1037
|
const tVar = nextTextVar()
|
|
925
1038
|
bindLines.push(`const ${tVar} = document.createTextNode("")`)
|
|
926
1039
|
if (needsPlaceholder) {
|
|
927
|
-
bindLines.push(
|
|
928
|
-
`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
|
|
929
|
-
)
|
|
1040
|
+
bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
|
|
930
1041
|
} else {
|
|
931
1042
|
bindLines.push(`${varName}.appendChild(${tVar})`)
|
|
932
1043
|
}
|
|
933
|
-
// Direct signal binding: bypass effect system entirely
|
|
934
1044
|
const directRef = tryDirectSignalRef(exprNode)
|
|
935
1045
|
if (directRef) {
|
|
936
1046
|
needsBindTextImport = true
|
|
937
1047
|
const d = nextDisp()
|
|
938
1048
|
bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`)
|
|
939
1049
|
} else {
|
|
940
|
-
// Each reactive text child gets its own _bind — independent tracking.
|
|
941
|
-
// When r.name() changes, r.email()'s _bind doesn't re-run.
|
|
942
1050
|
needsBindImportGlobal = true
|
|
943
1051
|
const d = nextDisp()
|
|
944
1052
|
bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
|
|
@@ -946,34 +1054,88 @@ export function transformJSX(
|
|
|
946
1054
|
return needsPlaceholder ? '<!>' : ''
|
|
947
1055
|
}
|
|
948
1056
|
|
|
949
|
-
/** Emit bind lines for a static text expression child. */
|
|
950
1057
|
function emitStaticTextChild(
|
|
951
|
-
expr: string,
|
|
952
|
-
|
|
953
|
-
parentRef: string,
|
|
954
|
-
childNodeIdx: number,
|
|
955
|
-
needsPlaceholder: boolean,
|
|
1058
|
+
expr: string, varName: string,
|
|
1059
|
+
parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
|
|
956
1060
|
): string {
|
|
957
1061
|
if (needsPlaceholder) {
|
|
958
1062
|
const tVar = nextTextVar()
|
|
959
1063
|
bindLines.push(`const ${tVar} = document.createTextNode(${expr})`)
|
|
960
|
-
bindLines.push(
|
|
961
|
-
`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
|
|
962
|
-
)
|
|
1064
|
+
bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
|
|
963
1065
|
return '<!>'
|
|
964
1066
|
}
|
|
965
1067
|
bindLines.push(`${varName}.textContent = ${expr}`)
|
|
966
1068
|
return ''
|
|
967
1069
|
}
|
|
968
1070
|
|
|
969
|
-
|
|
1071
|
+
type FlatChild =
|
|
1072
|
+
| { kind: 'text'; text: string }
|
|
1073
|
+
| { kind: 'element'; node: N; elemIdx: number }
|
|
1074
|
+
| { kind: 'expression'; expression: N }
|
|
1075
|
+
|
|
1076
|
+
function classifyJsxChild(
|
|
1077
|
+
child: N, out: FlatChild[],
|
|
1078
|
+
elemIdxRef: { value: number },
|
|
1079
|
+
recurse: (kids: N[]) => void,
|
|
1080
|
+
): void {
|
|
1081
|
+
if (child.type === 'JSXText') {
|
|
1082
|
+
const raw = child.value ?? child.raw ?? ''
|
|
1083
|
+
const trimmed = raw.replace(/\n\s*/g, '').trim()
|
|
1084
|
+
if (trimmed) out.push({ kind: 'text', text: trimmed })
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
if (child.type === 'JSXElement') {
|
|
1088
|
+
out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
|
|
1089
|
+
return
|
|
1090
|
+
}
|
|
1091
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
1092
|
+
const expr = child.expression
|
|
1093
|
+
if (expr && expr.type !== 'JSXEmptyExpression') out.push({ kind: 'expression', expression: expr })
|
|
1094
|
+
return
|
|
1095
|
+
}
|
|
1096
|
+
if (child.type === 'JSXFragment') recurse(jsxChildren(child))
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function flattenChildren(children: N[]): FlatChild[] {
|
|
1100
|
+
const flatList: FlatChild[] = []
|
|
1101
|
+
const elemIdxRef = { value: 0 }
|
|
1102
|
+
function addChildren(kids: N[]): void {
|
|
1103
|
+
for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
|
|
1104
|
+
}
|
|
1105
|
+
addChildren(children)
|
|
1106
|
+
return flatList
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
|
|
1110
|
+
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
1111
|
+
const hasNonElem = flatChildren.some((c) => c.kind !== 'element')
|
|
1112
|
+
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
1113
|
+
return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function attrIsDynamic(attr: N): boolean {
|
|
1117
|
+
if (attr.type !== 'JSXAttribute') return false
|
|
1118
|
+
const name = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
|
|
1119
|
+
if (name === 'ref') return true
|
|
1120
|
+
if (EVENT_RE.test(name)) return true
|
|
1121
|
+
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return false
|
|
1122
|
+
const expr = attr.value.expression
|
|
1123
|
+
return expr && expr.type !== 'JSXEmptyExpression' ? !isStatic(expr) : false
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function elementHasDynamic(node: N): boolean {
|
|
1127
|
+
if (jsxAttrs(node).some(attrIsDynamic)) return true
|
|
1128
|
+
if (!isSelfClosing(node)) {
|
|
1129
|
+
return jsxChildren(node).some((c: N) =>
|
|
1130
|
+
c.type === 'JSXExpressionContainer' && c.expression && c.expression.type !== 'JSXEmptyExpression',
|
|
1131
|
+
)
|
|
1132
|
+
}
|
|
1133
|
+
return false
|
|
1134
|
+
}
|
|
1135
|
+
|
|
970
1136
|
function processOneChild(
|
|
971
|
-
child: FlatChild,
|
|
972
|
-
|
|
973
|
-
parentRef: string,
|
|
974
|
-
useMixed: boolean,
|
|
975
|
-
useMultiExpr: boolean,
|
|
976
|
-
childNodeIdx: number,
|
|
1137
|
+
child: FlatChild, varName: string, parentRef: string,
|
|
1138
|
+
useMixed: boolean, useMultiExpr: boolean, childNodeIdx: number,
|
|
977
1139
|
): string | null {
|
|
978
1140
|
if (child.kind === 'text') return escapeHtmlText(child.text)
|
|
979
1141
|
if (child.kind === 'element') {
|
|
@@ -982,12 +1144,8 @@ export function transformJSX(
|
|
|
982
1144
|
: `${parentRef}.children[${child.elemIdx}]`
|
|
983
1145
|
return processElement(child.node, childAccessor)
|
|
984
1146
|
}
|
|
985
|
-
// expression
|
|
986
1147
|
const needsPlaceholder = useMixed || useMultiExpr
|
|
987
1148
|
const { expr, isReactive } = unwrapAccessor(child.expression)
|
|
988
|
-
|
|
989
|
-
// Children slot: expression accesses .children (e.g. props.children, own.children)
|
|
990
|
-
// These can contain VNodes — use _mountSlot instead of text node binding.
|
|
991
1149
|
if (isChildrenExpression(child.expression, expr)) {
|
|
992
1150
|
needsMountSlotImport = true
|
|
993
1151
|
const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
|
|
@@ -995,64 +1153,38 @@ export function transformJSX(
|
|
|
995
1153
|
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
|
|
996
1154
|
return '<!>'
|
|
997
1155
|
}
|
|
998
|
-
|
|
999
1156
|
if (isReactive) {
|
|
1000
|
-
return emitReactiveTextChild(
|
|
1001
|
-
expr,
|
|
1002
|
-
child.expression,
|
|
1003
|
-
varName,
|
|
1004
|
-
parentRef,
|
|
1005
|
-
childNodeIdx,
|
|
1006
|
-
needsPlaceholder,
|
|
1007
|
-
)
|
|
1157
|
+
return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1008
1158
|
}
|
|
1009
1159
|
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1010
1160
|
}
|
|
1011
1161
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const flatChildren = flattenChildren(el.children)
|
|
1162
|
+
function processChildren(el: N, varName: string, accessor: string): string | null {
|
|
1163
|
+
const flatChildren = flattenChildren(jsxChildren(el))
|
|
1015
1164
|
const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
|
|
1016
1165
|
const parentRef = accessor === '__root' ? '__root' : varName
|
|
1017
|
-
|
|
1018
1166
|
let html = ''
|
|
1019
1167
|
let childNodeIdx = 0
|
|
1020
|
-
|
|
1021
1168
|
for (const child of flatChildren) {
|
|
1022
|
-
const childHtml = processOneChild(
|
|
1023
|
-
child,
|
|
1024
|
-
varName,
|
|
1025
|
-
parentRef,
|
|
1026
|
-
useMixed,
|
|
1027
|
-
useMultiExpr,
|
|
1028
|
-
childNodeIdx,
|
|
1029
|
-
)
|
|
1169
|
+
const childHtml = processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx)
|
|
1030
1170
|
if (childHtml === null) return null
|
|
1031
1171
|
html += childHtml
|
|
1032
1172
|
childNodeIdx++
|
|
1033
1173
|
}
|
|
1034
|
-
|
|
1035
1174
|
return html
|
|
1036
1175
|
}
|
|
1037
1176
|
|
|
1038
|
-
|
|
1039
|
-
function processElement(
|
|
1040
|
-
el: ts.JsxElement | ts.JsxSelfClosingElement,
|
|
1041
|
-
accessor: string,
|
|
1042
|
-
): string | null {
|
|
1177
|
+
function processElement(el: N, accessor: string): string | null {
|
|
1043
1178
|
const tag = jsxTagName(el)
|
|
1044
1179
|
if (!tag) return null
|
|
1045
|
-
|
|
1046
1180
|
const varName = resolveElementVar(accessor, elementHasDynamic(el))
|
|
1047
1181
|
const htmlAttrs = processAttrs(el, varName)
|
|
1048
1182
|
let html = `<${tag}${htmlAttrs}>`
|
|
1049
|
-
|
|
1050
|
-
if (ts.isJsxElement(el)) {
|
|
1183
|
+
if (!isSelfClosing(el)) {
|
|
1051
1184
|
const childHtml = processChildren(el, varName, accessor)
|
|
1052
1185
|
if (childHtml === null) return null
|
|
1053
1186
|
html += childHtml
|
|
1054
1187
|
}
|
|
1055
|
-
|
|
1056
1188
|
if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`
|
|
1057
1189
|
return html
|
|
1058
1190
|
}
|
|
@@ -1065,15 +1197,8 @@ export function transformJSX(
|
|
|
1065
1197
|
if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
|
|
1066
1198
|
if (needsMountSlotImport) needsMountSlotImportGlobal = true
|
|
1067
1199
|
|
|
1068
|
-
// Build bind function body
|
|
1069
1200
|
const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
1070
1201
|
|
|
1071
|
-
// Emit combined _bind for reactive attribute/text expressions that
|
|
1072
|
-
// weren't handled by _bindText. This merges N separate _bind calls into
|
|
1073
|
-
// one — saving N-1 closures + deps arrays per template instance.
|
|
1074
|
-
// Emit a single combined _bind for all reactive attribute/text expressions
|
|
1075
|
-
// that weren't handled by _bindText. Merges N separate _bind calls into one —
|
|
1076
|
-
// saving N-1 closures + deps arrays per template instance.
|
|
1077
1202
|
if (reactiveBindExprs.length > 0) {
|
|
1078
1203
|
needsBindImportGlobal = true
|
|
1079
1204
|
const combinedName = nextDisp()
|
|
@@ -1095,127 +1220,92 @@ export function transformJSX(
|
|
|
1095
1220
|
return `_tpl("${escaped}", (__root) => {\n${body}\n})`
|
|
1096
1221
|
}
|
|
1097
1222
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
child: ts.JsxChild,
|
|
1107
|
-
out: FlatChild[],
|
|
1108
|
-
elemIdxRef: { value: number },
|
|
1109
|
-
recurse: (kids: ts.NodeArray<ts.JsxChild>) => void,
|
|
1110
|
-
): void {
|
|
1111
|
-
if (ts.isJsxText(child)) {
|
|
1112
|
-
const trimmed = child.text.replace(/\n\s*/g, '').trim()
|
|
1113
|
-
if (trimmed) out.push({ kind: 'text', text: trimmed })
|
|
1114
|
-
return
|
|
1115
|
-
}
|
|
1116
|
-
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
|
1117
|
-
out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
|
|
1118
|
-
return
|
|
1119
|
-
}
|
|
1120
|
-
if (ts.isJsxExpression(child)) {
|
|
1121
|
-
if (child.expression) out.push({ kind: 'expression', expression: child.expression })
|
|
1122
|
-
return
|
|
1223
|
+
function sliceExpr(expr: N): string {
|
|
1224
|
+
let result: string
|
|
1225
|
+
if (propDerivedVars.size > 0 && accessesProps(expr)) {
|
|
1226
|
+
const start = expr.start as number
|
|
1227
|
+
const end = expr.end as number
|
|
1228
|
+
result = resolveIdentifiersInText(code.slice(start, end), start, expr)
|
|
1229
|
+
} else {
|
|
1230
|
+
result = code.slice(expr.start as number, expr.end as number)
|
|
1123
1231
|
}
|
|
1124
|
-
if (ts.isJsxFragment(child)) recurse(child.children)
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
/**
|
|
1128
|
-
* Flatten JSX children, inlining fragment children and stripping whitespace-only text.
|
|
1129
|
-
* Returns a flat array of child descriptors with element indices pre-computed.
|
|
1130
|
-
*/
|
|
1131
|
-
function flattenChildren(children: ts.NodeArray<ts.JsxChild>): FlatChild[] {
|
|
1132
|
-
const flatList: FlatChild[] = []
|
|
1133
|
-
const elemIdxRef = { value: 0 }
|
|
1134
1232
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1233
|
+
// Auto-call signal variables: replace bare `x` with `x()` in the expression.
|
|
1234
|
+
// Only applies to identifiers that are NOT already being called (not `x()`).
|
|
1235
|
+
if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) {
|
|
1236
|
+
result = autoCallSignals(result, expr)
|
|
1137
1237
|
}
|
|
1138
1238
|
|
|
1139
|
-
|
|
1140
|
-
return flatList
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/** Analyze flat children to determine indexing strategy. */
|
|
1144
|
-
function analyzeChildren(flatChildren: FlatChild[]): {
|
|
1145
|
-
useMixed: boolean
|
|
1146
|
-
useMultiExpr: boolean
|
|
1147
|
-
} {
|
|
1148
|
-
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
1149
|
-
const hasNonElem = flatChildren.some((c) => c.kind !== 'element')
|
|
1150
|
-
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
1151
|
-
return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
|
|
1239
|
+
return result
|
|
1152
1240
|
}
|
|
1153
1241
|
|
|
1154
|
-
/** Check if
|
|
1155
|
-
function
|
|
1156
|
-
if (
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
const expr = attr.initializer.expression
|
|
1162
|
-
return expr ? !isStatic(expr) : false
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/** Check if an element has any dynamic attributes, events, ref, or expression children */
|
|
1166
|
-
function elementHasDynamic(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
|
|
1167
|
-
if (jsxAttrs(node).some(attrIsDynamic)) return true
|
|
1168
|
-
if (ts.isJsxElement(node)) {
|
|
1169
|
-
return node.children.some((c) => ts.isJsxExpression(c) && c.expression !== undefined)
|
|
1242
|
+
/** Check if an expression references any tracked signal variable. */
|
|
1243
|
+
function referencesSignalVar(node: N): boolean {
|
|
1244
|
+
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
1245
|
+
const parent = findParent(node)
|
|
1246
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
1247
|
+
if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
|
|
1248
|
+
return true
|
|
1170
1249
|
}
|
|
1171
|
-
|
|
1250
|
+
let found = false
|
|
1251
|
+
forEachChildFast(node, (child) => {
|
|
1252
|
+
if (found) return
|
|
1253
|
+
if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
|
|
1254
|
+
if (referencesSignalVar(child)) found = true
|
|
1255
|
+
})
|
|
1256
|
+
return found
|
|
1172
1257
|
}
|
|
1173
1258
|
|
|
1174
|
-
/**
|
|
1175
|
-
*
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
//
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1259
|
+
/** Auto-insert () after signal variable references in the expression source.
|
|
1260
|
+
* Uses the AST to find exact Identifier positions — never scans raw text. */
|
|
1261
|
+
function autoCallSignals(text: string, expr: N): string {
|
|
1262
|
+
const start = expr.start as number
|
|
1263
|
+
// Collect signal identifier positions that need auto-calling
|
|
1264
|
+
const idents: { start: number; end: number }[] = []
|
|
1265
|
+
|
|
1266
|
+
function findSignalIdents(node: N): void {
|
|
1267
|
+
if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
|
|
1268
|
+
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
1269
|
+
const parent = findParent(node)
|
|
1270
|
+
// Skip property name positions (obj.name)
|
|
1271
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
|
|
1272
|
+
// Skip if already being called: signal()
|
|
1273
|
+
if (parent && parent.type === 'CallExpression' && parent.callee === node) return
|
|
1274
|
+
// Skip declaration positions
|
|
1275
|
+
if (parent && parent.type === 'VariableDeclarator' && parent.id === node) return
|
|
1276
|
+
// Skip object property keys and shorthand properties ({ name } or { name: val })
|
|
1277
|
+
// Inserting () after a shorthand key produces name() which is a method shorthand — invalid
|
|
1278
|
+
if (parent && (parent.type === 'Property' || parent.type === 'ObjectProperty')) {
|
|
1279
|
+
if (parent.shorthand) return // { name } — can't auto-call without breaking syntax
|
|
1280
|
+
if (parent.key === node && !parent.computed) return // { name: val } — key position
|
|
1281
|
+
}
|
|
1282
|
+
idents.push({ start: node.start as number, end: node.end as number })
|
|
1283
|
+
}
|
|
1284
|
+
forEachChildFast(node, findSignalIdents)
|
|
1182
1285
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
: node.attributes.properties
|
|
1286
|
+
findSignalIdents(expr)
|
|
1287
|
+
|
|
1288
|
+
if (idents.length === 0) return text
|
|
1289
|
+
|
|
1290
|
+
// Sort by position and insert () after each identifier
|
|
1291
|
+
idents.sort((a, b) => a.start - b.start)
|
|
1292
|
+
const parts: string[] = []
|
|
1293
|
+
let lastPos = start
|
|
1294
|
+
for (const id of idents) {
|
|
1295
|
+
parts.push(code.slice(lastPos, id.end))
|
|
1296
|
+
parts.push('()') // auto-call
|
|
1297
|
+
lastPos = id.end
|
|
1298
|
+
}
|
|
1299
|
+
parts.push(code.slice(lastPos, start + text.length))
|
|
1300
|
+
return parts.join('')
|
|
1199
1301
|
}
|
|
1200
1302
|
}
|
|
1201
1303
|
|
|
1202
|
-
// ───
|
|
1304
|
+
// ─── Module-scope constants and helpers ─────────────────────────────────────
|
|
1203
1305
|
|
|
1204
1306
|
const VOID_ELEMENTS = new Set([
|
|
1205
|
-
'area',
|
|
1206
|
-
'
|
|
1207
|
-
'br',
|
|
1208
|
-
'col',
|
|
1209
|
-
'embed',
|
|
1210
|
-
'hr',
|
|
1211
|
-
'img',
|
|
1212
|
-
'input',
|
|
1213
|
-
'link',
|
|
1214
|
-
'meta',
|
|
1215
|
-
'param',
|
|
1216
|
-
'source',
|
|
1217
|
-
'track',
|
|
1218
|
-
'wbr',
|
|
1307
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
1308
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
1219
1309
|
])
|
|
1220
1310
|
|
|
1221
1311
|
const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
@@ -1223,11 +1313,6 @@ const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
|
1223
1313
|
htmlFor: 'for',
|
|
1224
1314
|
}
|
|
1225
1315
|
|
|
1226
|
-
/**
|
|
1227
|
-
* Detect if an expression is a stateful call that must NOT be inlined.
|
|
1228
|
-
* signal(), computed(), effect() etc. create state — inlining them would
|
|
1229
|
-
* create new instances at each use site instead of referencing the original.
|
|
1230
|
-
*/
|
|
1231
1316
|
const STATEFUL_CALLS = new Set([
|
|
1232
1317
|
'signal', 'computed', 'effect', 'batch',
|
|
1233
1318
|
'createContext', 'createReactiveContext',
|
|
@@ -1236,24 +1321,23 @@ const STATEFUL_CALLS = new Set([
|
|
|
1236
1321
|
'defineStore', 'useStore',
|
|
1237
1322
|
])
|
|
1238
1323
|
|
|
1239
|
-
function isStatefulCall(node:
|
|
1240
|
-
if (
|
|
1241
|
-
const callee = node.
|
|
1242
|
-
if (
|
|
1324
|
+
function isStatefulCall(node: N): boolean {
|
|
1325
|
+
if (node.type !== 'CallExpression') return false
|
|
1326
|
+
const callee = node.callee
|
|
1327
|
+
if (callee?.type === 'Identifier') return STATEFUL_CALLS.has(callee.name)
|
|
1243
1328
|
return false
|
|
1244
1329
|
}
|
|
1245
1330
|
|
|
1246
|
-
/**
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
if (
|
|
1256
|
-
// String fallback for inlined expressions
|
|
1331
|
+
/** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
|
|
1332
|
+
function isSignalCall(node: N): boolean {
|
|
1333
|
+
if (node.type !== 'CallExpression') return false
|
|
1334
|
+
const callee = node.callee
|
|
1335
|
+
return callee?.type === 'Identifier' && (callee.name === 'signal' || callee.name === 'computed')
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function isChildrenExpression(node: N, expr: string): boolean {
|
|
1339
|
+
if (node.type === 'MemberExpression' && !node.computed && node.property?.type === 'Identifier' && node.property.name === 'children') return true
|
|
1340
|
+
if (node.type === 'Identifier' && node.name === 'children') return true
|
|
1257
1341
|
if (expr.endsWith('.children') || expr === 'children') return true
|
|
1258
1342
|
return false
|
|
1259
1343
|
}
|
|
@@ -1262,11 +1346,14 @@ function isLowerCase(s: string): boolean {
|
|
|
1262
1346
|
return s.length > 0 && s[0] === s[0]?.toLowerCase()
|
|
1263
1347
|
}
|
|
1264
1348
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1349
|
+
function containsJSXInExpr(node: N): boolean {
|
|
1350
|
+
if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true
|
|
1351
|
+
let found = false
|
|
1352
|
+
forEachChild(node, (child) => {
|
|
1353
|
+
if (found) return
|
|
1354
|
+
if (containsJSXInExpr(child)) found = true
|
|
1355
|
+
})
|
|
1356
|
+
return found
|
|
1270
1357
|
}
|
|
1271
1358
|
|
|
1272
1359
|
function escapeHtmlAttr(s: string): string {
|
|
@@ -1274,71 +1361,57 @@ function escapeHtmlAttr(s: string): string {
|
|
|
1274
1361
|
}
|
|
1275
1362
|
|
|
1276
1363
|
function escapeHtmlText(s: string): string {
|
|
1277
|
-
// TypeScript's JsxText preserves HTML entities as-is (e.g. "<" stays "<",
|
|
1278
|
-
// not decoded to "<"). Since the template is parsed via innerHTML, entities are
|
|
1279
|
-
// already valid HTML — pass them through. Only escape raw `<` and raw `&` that
|
|
1280
|
-
// are NOT part of existing entities.
|
|
1281
1364
|
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&').replace(/</g, '<')
|
|
1282
1365
|
}
|
|
1283
1366
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
function isStaticJSXNode(node: StaticJSXNode): boolean {
|
|
1289
|
-
if (ts.isJsxSelfClosingElement(node)) {
|
|
1290
|
-
return isStaticAttrs(node.attributes)
|
|
1367
|
+
function isStaticJSXNode(node: N): boolean {
|
|
1368
|
+
if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
|
|
1369
|
+
return isStaticAttrs(node.openingElement.attributes ?? [])
|
|
1291
1370
|
}
|
|
1292
|
-
if (
|
|
1293
|
-
return node.children.every(isStaticChild)
|
|
1371
|
+
if (node.type === 'JSXFragment') {
|
|
1372
|
+
return (node.children ?? []).every(isStaticChild)
|
|
1294
1373
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1374
|
+
if (node.type === 'JSXElement') {
|
|
1375
|
+
return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild)
|
|
1376
|
+
}
|
|
1377
|
+
return false
|
|
1297
1378
|
}
|
|
1298
1379
|
|
|
1299
|
-
function isStaticAttrs(attrs:
|
|
1300
|
-
return attrs.
|
|
1301
|
-
|
|
1302
|
-
if (!
|
|
1303
|
-
|
|
1304
|
-
if (
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
return
|
|
1380
|
+
function isStaticAttrs(attrs: N[]): boolean {
|
|
1381
|
+
return attrs.every((prop: N) => {
|
|
1382
|
+
if (prop.type !== 'JSXAttribute') return false
|
|
1383
|
+
if (!prop.value) return true
|
|
1384
|
+
if (prop.value.type === 'StringLiteral' || (prop.value.type === 'Literal' && typeof prop.value.value === 'string')) return true
|
|
1385
|
+
if (prop.value.type === 'JSXExpressionContainer') {
|
|
1386
|
+
const expr = prop.value.expression
|
|
1387
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return true
|
|
1388
|
+
return isStatic(expr)
|
|
1389
|
+
}
|
|
1390
|
+
return false
|
|
1310
1391
|
})
|
|
1311
1392
|
}
|
|
1312
1393
|
|
|
1313
|
-
function isStaticChild(child:
|
|
1314
|
-
|
|
1315
|
-
if (
|
|
1316
|
-
|
|
1317
|
-
if (
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
return
|
|
1394
|
+
function isStaticChild(child: N): boolean {
|
|
1395
|
+
if (child.type === 'JSXText') return true
|
|
1396
|
+
if (child.type === 'JSXElement') return isStaticJSXNode(child)
|
|
1397
|
+
if (child.type === 'JSXFragment') return isStaticJSXNode(child)
|
|
1398
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
1399
|
+
const expr = child.expression
|
|
1400
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return true
|
|
1401
|
+
return isStatic(expr)
|
|
1402
|
+
}
|
|
1403
|
+
return false
|
|
1323
1404
|
}
|
|
1324
1405
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
return
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
1333
|
-
node.kind === ts.SyntaxKind.FalseKeyword ||
|
|
1334
|
-
node.kind === ts.SyntaxKind.NullKeyword ||
|
|
1335
|
-
node.kind === ts.SyntaxKind.UndefinedKeyword
|
|
1336
|
-
)
|
|
1337
|
-
// Note: object/array literals are NOT static — they need runtime application
|
|
1338
|
-
// (e.g., style={{ color: "red" }} requires Object.assign at runtime).
|
|
1406
|
+
function isStatic(node: N): boolean {
|
|
1407
|
+
if (node.type === 'Literal') return true
|
|
1408
|
+
if (node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral' || node.type === 'NullLiteral') return true
|
|
1409
|
+
if (node.type === 'TemplateLiteral' && (node.expressions?.length ?? 0) === 0) return true
|
|
1410
|
+
// Note: `undefined` is an Identifier in ESTree, not a keyword literal.
|
|
1411
|
+
// It is NOT treated as static — it goes through the dynamic attr path.
|
|
1412
|
+
return false
|
|
1339
1413
|
}
|
|
1340
1414
|
|
|
1341
|
-
/** Known pure global functions that don't read signals. */
|
|
1342
1415
|
const PURE_CALLS = new Set([
|
|
1343
1416
|
'Math.max', 'Math.min', 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round',
|
|
1344
1417
|
'Math.pow', 'Math.sqrt', 'Math.random', 'Math.trunc', 'Math.sign',
|
|
@@ -1353,30 +1426,14 @@ const PURE_CALLS = new Set([
|
|
|
1353
1426
|
'Date.now',
|
|
1354
1427
|
])
|
|
1355
1428
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
const callee = node.expression
|
|
1429
|
+
function isPureStaticCall(node: N): boolean {
|
|
1430
|
+
const callee = node.callee
|
|
1359
1431
|
let name = ''
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
name = `${callee.expression.text}.${callee.name.text}`
|
|
1432
|
+
if (callee?.type === 'Identifier') {
|
|
1433
|
+
name = callee.name
|
|
1434
|
+
} else if (callee?.type === 'MemberExpression' && !callee.computed && callee.object?.type === 'Identifier' && callee.property?.type === 'Identifier') {
|
|
1435
|
+
name = `${callee.object.name}.${callee.property.name}`
|
|
1365
1436
|
}
|
|
1366
|
-
|
|
1367
1437
|
if (!PURE_CALLS.has(name)) return false
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
function containsCall(node: ts.Node): boolean {
|
|
1373
|
-
if (ts.isCallExpression(node)) {
|
|
1374
|
-
// Skip pure calls with static args
|
|
1375
|
-
if (isPureStaticCall(node as ts.CallExpression)) return false
|
|
1376
|
-
return true
|
|
1377
|
-
}
|
|
1378
|
-
if (ts.isTaggedTemplateExpression(node)) return true
|
|
1379
|
-
// Don't recurse into nested functions — they're self-contained
|
|
1380
|
-
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
|
|
1381
|
-
return ts.forEachChild(node, containsCall) ?? false
|
|
1382
|
-
}
|
|
1438
|
+
return (node.arguments ?? []).every((arg: N) => arg.type !== 'SpreadElement' && isStatic(arg))
|
|
1439
|
+
}
|