@pyreon/compiler 0.1.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/src/jsx.ts ADDED
@@ -0,0 +1,772 @@
1
+ /**
2
+ * JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
3
+ * receives reactive getters instead of eagerly-evaluated snapshot values.
4
+ *
5
+ * Rules:
6
+ * - `<div>{expr}</div>` → `<div>{() => expr}</div>` (child)
7
+ * - `<div class={expr}>` → `<div class={() => expr}>` (prop)
8
+ * - `<button onClick={fn}>` → unchanged (event handler)
9
+ * - `<div>{() => expr}</div>` → unchanged (already wrapped)
10
+ * - `<div>{"literal"}</div>` → unchanged (static)
11
+ *
12
+ * Static VNode hoisting:
13
+ * - Fully static JSX in expression containers is hoisted to module scope:
14
+ * `{<span>Hello</span>}` → `const _$h0 = <span>Hello</span>` + `{_$h0}`
15
+ * - Hoisted nodes are created ONCE at module initialisation, not per-instance.
16
+ * - A JSX node is static if: all props are string literals / booleans / static
17
+ * values, and all children are text nodes or other static JSX nodes.
18
+ *
19
+ * Template emission:
20
+ * - JSX element trees with ≥ 2 DOM elements (no components, no spread attrs)
21
+ * are compiled to `_tpl(html, bindFn)` calls instead of nested `h()` calls.
22
+ * - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
23
+ * for each instance (~5-10x faster than sequential createElement calls).
24
+ * - Static attributes are baked into the HTML string; dynamic attributes and
25
+ * text content use renderEffect in the bind function.
26
+ *
27
+ * Implementation: TypeScript parser for positions + magic-string replacements.
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.
34
+ */
35
+
36
+ import ts from "typescript"
37
+
38
+ export interface CompilerWarning {
39
+ /** Warning message */
40
+ message: string
41
+ /** Source file line number (1-based) */
42
+ line: number
43
+ /** Source file column number (0-based) */
44
+ column: number
45
+ /** Warning code for filtering */
46
+ code: "signal-call-in-jsx" | "missing-key-on-for" | "signal-in-static-prop"
47
+ }
48
+
49
+ export interface TransformResult {
50
+ /** Transformed source code (JSX preserved, only expression containers modified) */
51
+ code: string
52
+ /** Whether the output uses _tpl/_re template helpers (needs auto-import) */
53
+ usesTemplates?: boolean
54
+ /** Compiler warnings for common mistakes */
55
+ warnings: CompilerWarning[]
56
+ }
57
+
58
+ // Props that should never be wrapped in a reactive getter
59
+ const SKIP_PROPS = new Set(["key", "ref"])
60
+ // Event handler pattern: onClick, onInput, onMouseEnter, …
61
+ const EVENT_RE = /^on[A-Z]/
62
+
63
+ export function transformJSX(code: string, filename = "input.tsx"): TransformResult {
64
+ const scriptKind =
65
+ filename.endsWith(".tsx") || filename.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TSX // default to TSX so JSX is always parsed
66
+
67
+ const sf = ts.createSourceFile(
68
+ filename,
69
+ code,
70
+ ts.ScriptTarget.ESNext,
71
+ /* setParentNodes */ true,
72
+ scriptKind,
73
+ )
74
+
75
+ type Replacement = { start: number; end: number; text: string }
76
+ const replacements: Replacement[] = []
77
+ const warnings: CompilerWarning[] = []
78
+
79
+ function warn(node: ts.Node, message: string, warnCode: CompilerWarning["code"]): void {
80
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
81
+ warnings.push({ message, line: line + 1, column: character, code: warnCode })
82
+ }
83
+
84
+ // ── Static hoisting state ─────────────────────────────────────────────────
85
+ type Hoist = { name: string; text: string }
86
+ const hoists: Hoist[] = []
87
+ let hoistIdx = 0
88
+ let needsTplImport = false
89
+
90
+ /**
91
+ * If `node` is a fully-static JSX element/fragment, register a module-scope
92
+ * hoist for it and return the generated variable name. Otherwise return null.
93
+ */
94
+ function maybeHoist(node: ts.Node): string | null {
95
+ if (
96
+ (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) &&
97
+ isStaticJSXNode(node as ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment)
98
+ ) {
99
+ const name = `_$h${hoistIdx++}`
100
+ const text = code.slice(node.getStart(sf), node.getEnd())
101
+ hoists.push({ name, text })
102
+ return name
103
+ }
104
+ return null
105
+ }
106
+
107
+ function wrap(expr: ts.Expression): void {
108
+ const start = expr.getStart(sf)
109
+ const end = expr.getEnd()
110
+ replacements.push({ start, end, text: `() => ${code.slice(start, end)}` })
111
+ }
112
+
113
+ /** Try to hoist or wrap an expression, pushing a replacement if needed. */
114
+ function hoistOrWrap(expr: ts.Expression): void {
115
+ const hoistName = maybeHoist(expr)
116
+ if (hoistName) {
117
+ replacements.push({ start: expr.getStart(sf), end: expr.getEnd(), text: hoistName })
118
+ } else if (shouldWrap(expr)) {
119
+ wrap(expr)
120
+ }
121
+ }
122
+
123
+ // ── walk sub-handlers ───────────────────────────────────────────────────────
124
+
125
+ /** Try to emit a template for a JsxElement. Returns true if handled. */
126
+ function tryTemplateEmit(node: ts.JsxElement): boolean {
127
+ const elemCount = templateElementCount(node)
128
+ if (elemCount < 1) return false
129
+ const tplCall = buildTemplateCall(node)
130
+ if (!tplCall) return false
131
+ const start = node.getStart(sf)
132
+ const end = node.getEnd()
133
+ const parent = node.parent
134
+ const needsBraces = parent && (ts.isJsxElement(parent) || ts.isJsxFragment(parent))
135
+ replacements.push({ start, end, text: needsBraces ? `{${tplCall}}` : tplCall })
136
+ needsTplImport = true
137
+ return true
138
+ }
139
+
140
+ /** Emit warnings for common JSX mistakes (e.g. <For> without by). */
141
+ function checkForWarnings(node: ts.JsxElement | ts.JsxSelfClosingElement): void {
142
+ const opening = ts.isJsxElement(node) ? node.openingElement : node
143
+ const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : ""
144
+ if (tagName !== "For") return
145
+ const hasBy = opening.attributes.properties.some(
146
+ (p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === "by",
147
+ )
148
+ if (!hasBy) {
149
+ warn(
150
+ opening.tagName,
151
+ `<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.`,
152
+ "missing-key-on-for",
153
+ )
154
+ }
155
+ }
156
+
157
+ /** Handle a JSX attribute node — wrap or hoist its value if needed. */
158
+ function handleJsxAttribute(node: ts.JsxAttribute): void {
159
+ const name = ts.isIdentifier(node.name) ? node.name.text : ""
160
+ const openingEl = node.parent.parent as ts.JsxOpeningElement | ts.JsxSelfClosingElement
161
+ const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : ""
162
+ const isComponentElement =
163
+ tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
164
+ if (isComponentElement) return
165
+ if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
166
+ if (!node.initializer || !ts.isJsxExpression(node.initializer)) return
167
+ const expr = node.initializer.expression
168
+ if (expr) hoistOrWrap(expr)
169
+ }
170
+
171
+ /** Handle a JSX expression in child position — wrap or hoist. */
172
+ function handleJsxExpression(node: ts.JsxExpression): void {
173
+ const expr = node.expression
174
+ if (expr) hoistOrWrap(expr)
175
+ }
176
+
177
+ function walk(node: ts.Node): void {
178
+ if (ts.isJsxElement(node) && tryTemplateEmit(node)) return
179
+ if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) checkForWarnings(node)
180
+ if (ts.isJsxAttribute(node)) {
181
+ handleJsxAttribute(node)
182
+ return
183
+ }
184
+ if (ts.isJsxExpression(node)) {
185
+ handleJsxExpression(node)
186
+ return
187
+ }
188
+ ts.forEachChild(node, walk)
189
+ }
190
+
191
+ walk(sf)
192
+
193
+ if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
194
+
195
+ // Apply replacements from right to left so earlier positions stay valid
196
+ replacements.sort((a, b) => b.start - a.start)
197
+
198
+ let result = code
199
+ for (const r of replacements) {
200
+ result = result.slice(0, r.start) + r.text + result.slice(r.end)
201
+ }
202
+
203
+ // Prepend module-scope hoisted static VNode declarations
204
+ if (hoists.length > 0) {
205
+ const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join("")
206
+ result = preamble + result
207
+ }
208
+
209
+ // Prepend template imports if _tpl() was emitted
210
+ if (needsTplImport) {
211
+ result =
212
+ `import { _tpl } from "@pyreon/runtime-dom";\nimport { _bind } from "@pyreon/reactivity";\n` +
213
+ result
214
+ }
215
+
216
+ return { code: result, usesTemplates: needsTplImport, warnings }
217
+
218
+ // ── Template emission helpers (closures over sf, code) ──────────────────────
219
+
220
+ /** Check if a single attribute would prevent template emission. */
221
+ function hasBailAttr(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
222
+ for (const attr of jsxAttrs(node)) {
223
+ if (ts.isJsxSpreadAttribute(attr)) return true
224
+ if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === "key")
225
+ return true
226
+ }
227
+ return false
228
+ }
229
+
230
+ /**
231
+ * Count template-eligible elements for a single JSX child.
232
+ * Returns 0 for skippable children, -1 for bail, positive for element count.
233
+ */
234
+ function countChildForTemplate(child: ts.JsxChild): number {
235
+ if (ts.isJsxText(child)) return 0
236
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child))
237
+ return templateElementCount(child)
238
+ if (ts.isJsxExpression(child)) {
239
+ if (!child.expression) return 0
240
+ return containsJSXInExpr(child.expression) ? -1 : 0
241
+ }
242
+ if (ts.isJsxFragment(child)) return templateFragmentCount(child)
243
+ return -1
244
+ }
245
+
246
+ /**
247
+ * Count DOM elements in a JSX subtree. Returns -1 if the tree is not
248
+ * eligible for template emission.
249
+ */
250
+ function templateElementCount(node: ts.JsxElement | ts.JsxSelfClosingElement): number {
251
+ const tag = jsxTagName(node)
252
+ if (!tag || !isLowerCase(tag)) return -1
253
+ if (hasBailAttr(node)) return -1
254
+ if (!ts.isJsxElement(node)) return 1
255
+
256
+ let count = 1
257
+ for (const child of node.children) {
258
+ const c = countChildForTemplate(child)
259
+ if (c === -1) return -1
260
+ count += c
261
+ }
262
+ return count
263
+ }
264
+
265
+ /** Count template-eligible elements inside a fragment. */
266
+ function templateFragmentCount(frag: ts.JsxFragment): number {
267
+ let count = 0
268
+ for (const child of frag.children) {
269
+ const c = countChildForTemplate(child)
270
+ if (c === -1) return -1
271
+ count += c
272
+ }
273
+ return count
274
+ }
275
+
276
+ /**
277
+ * Build the complete `_tpl("html", (__root) => { ... })` call string
278
+ * for a template-eligible JSX element tree. Returns null if codegen fails.
279
+ */
280
+ function buildTemplateCall(node: ts.JsxElement | ts.JsxSelfClosingElement): string | null {
281
+ const bindLines: string[] = []
282
+ const disposerNames: string[] = []
283
+ let varIdx = 0
284
+ let dispIdx = 0
285
+
286
+ function nextVar(): string {
287
+ return `__e${varIdx++}`
288
+ }
289
+ function nextDisp(): string {
290
+ const name = `__d${dispIdx++}`
291
+ disposerNames.push(name)
292
+ return name
293
+ }
294
+ function nextTextVar(): string {
295
+ return `__t${varIdx++}`
296
+ }
297
+
298
+ /** Resolve the variable name for an element given its accessor path. */
299
+ function resolveElementVar(accessor: string, hasDynamic: boolean): string {
300
+ if (accessor === "__root") return "__root"
301
+ if (hasDynamic) {
302
+ const v = nextVar()
303
+ bindLines.push(`const ${v} = ${accessor}`)
304
+ return v
305
+ }
306
+ return accessor
307
+ }
308
+
309
+ /** Emit bind line for a ref attribute. */
310
+ function emitRef(attr: ts.JsxAttribute, varName: string): void {
311
+ if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return
312
+ if (!attr.initializer.expression) return
313
+ bindLines.push(`${sliceExpr(attr.initializer.expression)}.current = ${varName}`)
314
+ }
315
+
316
+ /** Emit addEventListener bind line for an event handler attribute. */
317
+ function emitEventListener(attr: ts.JsxAttribute, attrName: string, varName: string): void {
318
+ const eventName = (attrName[2] ?? "").toLowerCase() + attrName.slice(3)
319
+ if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return
320
+ if (!attr.initializer.expression) return
321
+ bindLines.push(
322
+ `${varName}.addEventListener("${eventName}", ${sliceExpr(attr.initializer.expression)})`,
323
+ )
324
+ }
325
+
326
+ /** Return HTML string for a static attribute expression, or null if not static. */
327
+ function staticAttrToHtml(exprNode: ts.Expression, htmlAttrName: string): string | null {
328
+ if (!isStatic(exprNode)) return null
329
+ if (ts.isStringLiteral(exprNode)) return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.text)}"`
330
+ if (ts.isNumericLiteral(exprNode)) return ` ${htmlAttrName}="${exprNode.text}"`
331
+ if (exprNode.kind === ts.SyntaxKind.TrueKeyword) return ` ${htmlAttrName}`
332
+ return "" // false/null/undefined → omit
333
+ }
334
+
335
+ /** Unwrap a reactive accessor expression for use inside _bind(). */
336
+ function unwrapAccessor(exprNode: ts.Expression): { expr: string; isReactive: boolean } {
337
+ // Concise arrow: () => value() → unwrap to "value()"
338
+ if (ts.isArrowFunction(exprNode) && !ts.isBlock(exprNode.body)) {
339
+ return { expr: sliceExpr(exprNode.body as ts.Expression), isReactive: true }
340
+ }
341
+ // Block-body arrow/function: invoke it
342
+ if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) {
343
+ return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
344
+ }
345
+ return { expr: sliceExpr(exprNode), isReactive: containsCall(exprNode) }
346
+ }
347
+
348
+ /** Emit bind line for a dynamic (non-static) attribute. */
349
+ function emitDynamicAttr(
350
+ _expr: string,
351
+ exprNode: ts.Expression,
352
+ htmlAttrName: string,
353
+ varName: string,
354
+ ): void {
355
+ const { expr, isReactive } = unwrapAccessor(exprNode)
356
+ const setter =
357
+ htmlAttrName === "class"
358
+ ? `${varName}.className = ${expr}`
359
+ : `${varName}.setAttribute("${htmlAttrName}", ${expr})`
360
+
361
+ if (isReactive) {
362
+ const d = nextDisp()
363
+ bindLines.push(`const ${d} = _bind(() => { ${setter} })`)
364
+ } else {
365
+ bindLines.push(setter)
366
+ }
367
+ }
368
+
369
+ /** Emit bind line or HTML for an expression attribute value. */
370
+ function emitAttrExpression(
371
+ exprNode: ts.Expression,
372
+ htmlAttrName: string,
373
+ varName: string,
374
+ ): string {
375
+ const staticHtml = staticAttrToHtml(exprNode, htmlAttrName)
376
+ if (staticHtml !== null) return staticHtml
377
+ emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
378
+ return ""
379
+ }
380
+
381
+ /** Emit side-effects for special attrs (ref, event). Returns true if handled. */
382
+ function tryEmitSpecialAttr(attr: ts.JsxAttribute, attrName: string, varName: string): boolean {
383
+ if (attrName === "ref") {
384
+ emitRef(attr, varName)
385
+ return true
386
+ }
387
+ if (EVENT_RE.test(attrName)) {
388
+ emitEventListener(attr, attrName, varName)
389
+ return true
390
+ }
391
+ return false
392
+ }
393
+
394
+ /** Convert an attribute initializer to HTML. Returns empty string for side-effect-only attrs. */
395
+ function attrInitializerToHtml(
396
+ attr: ts.JsxAttribute,
397
+ htmlAttrName: string,
398
+ varName: string,
399
+ ): string {
400
+ if (!attr.initializer) return ` ${htmlAttrName}`
401
+ if (ts.isStringLiteral(attr.initializer))
402
+ return ` ${htmlAttrName}="${escapeHtmlAttr(attr.initializer.text)}"`
403
+ if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression)
404
+ return emitAttrExpression(attr.initializer.expression, htmlAttrName, varName)
405
+ return ""
406
+ }
407
+
408
+ /** Process a single attribute, returning HTML to append. */
409
+ function processOneAttr(attr: ts.JsxAttributeLike, varName: string): string {
410
+ if (!ts.isJsxAttribute(attr)) return ""
411
+ const attrName = ts.isIdentifier(attr.name) ? attr.name.text : ""
412
+ if (attrName === "key") return ""
413
+ if (tryEmitSpecialAttr(attr, attrName, varName)) return ""
414
+ return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
415
+ }
416
+
417
+ /** Process all attributes on an element, returning the HTML attribute string. */
418
+ function processAttrs(el: ts.JsxElement | ts.JsxSelfClosingElement, varName: string): string {
419
+ let htmlAttrs = ""
420
+ for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
421
+ return htmlAttrs
422
+ }
423
+
424
+ /** Emit bind lines for a reactive text expression child. */
425
+ function emitReactiveTextChild(
426
+ expr: string,
427
+ varName: string,
428
+ parentRef: string,
429
+ childNodeIdx: number,
430
+ needsPlaceholder: boolean,
431
+ ): string {
432
+ const tVar = nextTextVar()
433
+ const d = nextDisp()
434
+ bindLines.push(`const ${tVar} = document.createTextNode("")`)
435
+ if (needsPlaceholder) {
436
+ bindLines.push(
437
+ `${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
438
+ )
439
+ } else {
440
+ bindLines.push(`${varName}.appendChild(${tVar})`)
441
+ }
442
+ bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
443
+ return needsPlaceholder ? "<!>" : ""
444
+ }
445
+
446
+ /** Emit bind lines for a static text expression child. */
447
+ function emitStaticTextChild(
448
+ expr: string,
449
+ varName: string,
450
+ parentRef: string,
451
+ childNodeIdx: number,
452
+ needsPlaceholder: boolean,
453
+ ): string {
454
+ if (needsPlaceholder) {
455
+ const tVar = nextTextVar()
456
+ bindLines.push(`const ${tVar} = document.createTextNode(${expr})`)
457
+ bindLines.push(
458
+ `${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
459
+ )
460
+ return "<!>"
461
+ }
462
+ bindLines.push(`${varName}.textContent = ${expr}`)
463
+ return ""
464
+ }
465
+
466
+ /** Process a single flat child, returning the HTML contribution or null on failure. */
467
+ function processOneChild(
468
+ child: FlatChild,
469
+ varName: string,
470
+ parentRef: string,
471
+ useMixed: boolean,
472
+ useMultiExpr: boolean,
473
+ childNodeIdx: number,
474
+ ): string | null {
475
+ if (child.kind === "text") return escapeHtmlText(child.text)
476
+ if (child.kind === "element") {
477
+ const childAccessor = useMixed
478
+ ? `${parentRef}.childNodes[${childNodeIdx}]`
479
+ : `${parentRef}.children[${child.elemIdx}]`
480
+ return processElement(child.node, childAccessor)
481
+ }
482
+ // expression
483
+ const needsPlaceholder = useMixed || useMultiExpr
484
+ const { expr, isReactive } = unwrapAccessor(child.expression)
485
+ if (isReactive) {
486
+ return emitReactiveTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
487
+ }
488
+ return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
489
+ }
490
+
491
+ /** Process children of a JsxElement, returning the children HTML. */
492
+ function processChildren(el: ts.JsxElement, varName: string, accessor: string): string | null {
493
+ const flatChildren = flattenChildren(el.children)
494
+ const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
495
+ const parentRef = accessor === "__root" ? "__root" : varName
496
+
497
+ let html = ""
498
+ let childNodeIdx = 0
499
+
500
+ for (const child of flatChildren) {
501
+ const childHtml = processOneChild(
502
+ child,
503
+ varName,
504
+ parentRef,
505
+ useMixed,
506
+ useMultiExpr,
507
+ childNodeIdx,
508
+ )
509
+ if (childHtml === null) return null
510
+ html += childHtml
511
+ childNodeIdx++
512
+ }
513
+
514
+ return html
515
+ }
516
+
517
+ /** Process a single DOM element for template emission. Returns the HTML string or null. */
518
+ function processElement(
519
+ el: ts.JsxElement | ts.JsxSelfClosingElement,
520
+ accessor: string,
521
+ ): string | null {
522
+ const tag = jsxTagName(el)
523
+ if (!tag) return null
524
+
525
+ const varName = resolveElementVar(accessor, elementHasDynamic(el))
526
+ const htmlAttrs = processAttrs(el, varName)
527
+ let html = `<${tag}${htmlAttrs}>`
528
+
529
+ if (ts.isJsxElement(el)) {
530
+ const childHtml = processChildren(el, varName, accessor)
531
+ if (childHtml === null) return null
532
+ html += childHtml
533
+ }
534
+
535
+ if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`
536
+ return html
537
+ }
538
+
539
+ const html = processElement(node, "__root")
540
+ if (html === null) return null
541
+
542
+ // Build bind function body
543
+ const escaped = html.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
544
+
545
+ if (bindLines.length === 0 && disposerNames.length === 0) {
546
+ return `_tpl("${escaped}", () => null)`
547
+ }
548
+
549
+ let body = bindLines.map((l) => ` ${l}`).join("\n")
550
+ if (disposerNames.length > 0) {
551
+ body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join("; ")} }`
552
+ } else {
553
+ body += "\n return null"
554
+ }
555
+
556
+ return `_tpl("${escaped}", (__root) => {\n${body}\n})`
557
+ }
558
+
559
+ /** Flat child descriptor for template children processing */
560
+ type FlatChild =
561
+ | { kind: "text"; text: string }
562
+ | { kind: "element"; node: ts.JsxElement | ts.JsxSelfClosingElement; elemIdx: number }
563
+ | { kind: "expression"; expression: ts.Expression }
564
+
565
+ /** Classify a single JSX child into a FlatChild descriptor. */
566
+ function classifyJsxChild(
567
+ child: ts.JsxChild,
568
+ out: FlatChild[],
569
+ elemIdxRef: { value: number },
570
+ recurse: (kids: ts.NodeArray<ts.JsxChild>) => void,
571
+ ): void {
572
+ if (ts.isJsxText(child)) {
573
+ const trimmed = child.text.replace(/\n\s*/g, "").trim()
574
+ if (trimmed) out.push({ kind: "text", text: trimmed })
575
+ return
576
+ }
577
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
578
+ out.push({ kind: "element", node: child, elemIdx: elemIdxRef.value++ })
579
+ return
580
+ }
581
+ if (ts.isJsxExpression(child)) {
582
+ if (child.expression) out.push({ kind: "expression", expression: child.expression })
583
+ return
584
+ }
585
+ if (ts.isJsxFragment(child)) recurse(child.children)
586
+ }
587
+
588
+ /**
589
+ * Flatten JSX children, inlining fragment children and stripping whitespace-only text.
590
+ * Returns a flat array of child descriptors with element indices pre-computed.
591
+ */
592
+ function flattenChildren(children: ts.NodeArray<ts.JsxChild>): FlatChild[] {
593
+ const flatList: FlatChild[] = []
594
+ const elemIdxRef = { value: 0 }
595
+
596
+ function addChildren(kids: ts.NodeArray<ts.JsxChild>): void {
597
+ for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
598
+ }
599
+
600
+ addChildren(children)
601
+ return flatList
602
+ }
603
+
604
+ /** Analyze flat children to determine indexing strategy. */
605
+ function analyzeChildren(flatChildren: FlatChild[]): {
606
+ useMixed: boolean
607
+ useMultiExpr: boolean
608
+ } {
609
+ const hasElem = flatChildren.some((c) => c.kind === "element")
610
+ const hasNonElem = flatChildren.some((c) => c.kind !== "element")
611
+ const exprCount = flatChildren.filter((c) => c.kind === "expression").length
612
+ return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
613
+ }
614
+
615
+ /** Check if a single attribute is dynamic (has ref, event, or non-static expression). */
616
+ function attrIsDynamic(attr: ts.JsxAttributeLike): boolean {
617
+ if (!ts.isJsxAttribute(attr)) return false
618
+ const name = ts.isIdentifier(attr.name) ? attr.name.text : ""
619
+ if (name === "ref") return true
620
+ if (EVENT_RE.test(name)) return true
621
+ if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return false
622
+ const expr = attr.initializer.expression
623
+ return expr ? !isStatic(expr) : false
624
+ }
625
+
626
+ /** Check if an element has any dynamic attributes, events, ref, or expression children */
627
+ function elementHasDynamic(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
628
+ if (jsxAttrs(node).some(attrIsDynamic)) return true
629
+ if (ts.isJsxElement(node)) {
630
+ return node.children.some((c) => ts.isJsxExpression(c) && c.expression !== undefined)
631
+ }
632
+ return false
633
+ }
634
+
635
+ /** Slice expression source from the original code */
636
+ function sliceExpr(expr: ts.Expression): string {
637
+ return code.slice(expr.getStart(sf), expr.getEnd())
638
+ }
639
+
640
+ /** Get tag name string */
641
+ function jsxTagName(node: ts.JsxElement | ts.JsxSelfClosingElement): string {
642
+ const tag = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName
643
+ return ts.isIdentifier(tag) ? tag.text : ""
644
+ }
645
+
646
+ /** Get attribute list */
647
+ function jsxAttrs(
648
+ node: ts.JsxElement | ts.JsxSelfClosingElement,
649
+ ): ts.NodeArray<ts.JsxAttributeLike> {
650
+ return ts.isJsxElement(node)
651
+ ? node.openingElement.attributes.properties
652
+ : node.attributes.properties
653
+ }
654
+ }
655
+
656
+ // ─── Template constants ──────────────────────────────────────────────────────
657
+
658
+ const VOID_ELEMENTS = new Set([
659
+ "area",
660
+ "base",
661
+ "br",
662
+ "col",
663
+ "embed",
664
+ "hr",
665
+ "img",
666
+ "input",
667
+ "link",
668
+ "meta",
669
+ "param",
670
+ "source",
671
+ "track",
672
+ "wbr",
673
+ ])
674
+
675
+ const JSX_TO_HTML_ATTR: Record<string, string> = {
676
+ className: "class",
677
+ htmlFor: "for",
678
+ }
679
+
680
+ function isLowerCase(s: string): boolean {
681
+ return s.length > 0 && s[0] === s[0]?.toLowerCase()
682
+ }
683
+
684
+ /** Check if an expression subtree contains JSX nodes */
685
+ function containsJSXInExpr(node: ts.Node): boolean {
686
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))
687
+ return true
688
+ return ts.forEachChild(node, containsJSXInExpr) ?? false
689
+ }
690
+
691
+ function escapeHtmlAttr(s: string): string {
692
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;")
693
+ }
694
+
695
+ function escapeHtmlText(s: string): string {
696
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
697
+ }
698
+
699
+ // ─── Static JSX analysis ──────────────────────────────────────────────────────
700
+
701
+ type StaticJSXNode = ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment
702
+
703
+ function isStaticJSXNode(node: StaticJSXNode): boolean {
704
+ if (ts.isJsxSelfClosingElement(node)) {
705
+ return isStaticAttrs(node.attributes)
706
+ }
707
+ if (ts.isJsxFragment(node)) {
708
+ return node.children.every(isStaticChild)
709
+ }
710
+ // JsxElement
711
+ return isStaticAttrs(node.openingElement.attributes) && node.children.every(isStaticChild)
712
+ }
713
+
714
+ function isStaticAttrs(attrs: ts.JsxAttributes): boolean {
715
+ return attrs.properties.every((prop) => {
716
+ // Spread attribute — always dynamic
717
+ if (!ts.isJsxAttribute(prop)) return false
718
+ // Boolean shorthand: <input disabled />
719
+ if (!prop.initializer) return true
720
+ // String literal: class="foo"
721
+ if (ts.isStringLiteral(prop.initializer)) return true
722
+ // Must be JsxExpression — the only remaining JsxAttributeValue type
723
+ const expr = (prop.initializer as ts.JsxExpression).expression
724
+ return expr ? isStatic(expr) : true
725
+ })
726
+ }
727
+
728
+ function isStaticChild(child: ts.JsxChild): boolean {
729
+ // Plain text content
730
+ if (ts.isJsxText(child)) return true
731
+ // Nested JSX elements
732
+ if (ts.isJsxSelfClosingElement(child)) return isStaticJSXNode(child)
733
+ if (ts.isJsxElement(child)) return isStaticJSXNode(child)
734
+ if (ts.isJsxFragment(child)) return isStaticJSXNode(child)
735
+ // Must be JsxExpression — the only remaining JsxChild type
736
+ const expr = (child as ts.JsxExpression).expression
737
+ return expr ? isStatic(expr) : true
738
+ }
739
+
740
+ // ─── General helpers ──────────────────────────────────────────────────────────
741
+
742
+ function isStatic(node: ts.Expression): boolean {
743
+ return (
744
+ ts.isStringLiteral(node) ||
745
+ ts.isNumericLiteral(node) ||
746
+ ts.isNoSubstitutionTemplateLiteral(node) ||
747
+ node.kind === ts.SyntaxKind.TrueKeyword ||
748
+ node.kind === ts.SyntaxKind.FalseKeyword ||
749
+ node.kind === ts.SyntaxKind.NullKeyword ||
750
+ node.kind === ts.SyntaxKind.UndefinedKeyword
751
+ )
752
+ }
753
+
754
+ function shouldWrap(node: ts.Expression): boolean {
755
+ // Already a function — user explicitly wrapped or it's a callback
756
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
757
+ // Static literal — no signals involved
758
+ if (isStatic(node)) return false
759
+ // Only wrap if the expression tree contains a call — signal reads are always
760
+ // function calls (e.g. `count()`, `name()`). Plain identifiers, object literals
761
+ // like `style={{ color: "red" }}`, array literals, and member accesses are
762
+ // left as-is to avoid unnecessary reactive wrappers.
763
+ return containsCall(node)
764
+ }
765
+
766
+ function containsCall(node: ts.Node): boolean {
767
+ if (ts.isCallExpression(node)) return true
768
+ if (ts.isTaggedTemplateExpression(node)) return true
769
+ // Don't recurse into nested functions — they're self-contained
770
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
771
+ return ts.forEachChild(node, containsCall) ?? false
772
+ }