@sigil-dev/compiler 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,876 +1,863 @@
1
- import { types as t } from "@babel/core";
2
- //@ts-expect-error Not our problem babel forgot tp export types.
3
- import { generate } from "@babel/generator";
4
- import {
5
- handleGlobalComponent,
6
- isGlobalComponent,
7
- } from "../handlers/dom/globals";
8
- import { buildBind, buildProxyBind } from "../util/bind";
9
- import { buildAnchorMount } from "./anchor-mount";
10
- import { collectChildren } from "./children";
11
- import { processFragment } from "./fragment";
12
- import { buildKeyedList, findKeyedMapExpr } from "./keyed-list";
13
- import { buildTextNode } from "./text-node";
14
- import {
15
- ATTR_MAP,
16
- buildHydrationScope,
17
- containsSignal,
18
- getCreateElement,
19
- isPrimitive,
20
- } from "./utils";
21
-
22
- /**
23
- * Build a property-access expression for a JSX attribute name.
24
- * Dashed names (data-*, aria-*) require computed access (e.g. el["data-id"])
25
- * because t.identifier() rejects names with dashes.
26
- */
27
- function attrAccess(varName: string, attrName: string): t.MemberExpression {
28
- if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(attrName)) {
29
- return t.memberExpression(t.identifier(varName), t.identifier(attrName));
30
- }
31
- return t.memberExpression(
32
- t.identifier(varName),
33
- t.stringLiteral(attrName),
34
- true,
35
- );
36
- }
37
-
38
- /**
39
- * Dashed names (data-*, aria-*, custom attributes) have no IDL property —
40
- * `el["data-x"] = v` sets a bare JS property the DOM never reflects, so the
41
- * attribute is invisible to selectors, getAttribute, and CSS. They must go
42
- * through setAttribute.
43
- */
44
- function isDashedAttr(attrName: string): boolean {
45
- return attrName.includes("-");
46
- }
47
-
48
- function setAttrCall(
49
- varName: string,
50
- attrName: string,
51
- value: t.Expression,
52
- ): t.CallExpression {
53
- return t.callExpression(
54
- t.memberExpression(t.identifier(varName), t.identifier("setAttribute")),
55
- [t.stringLiteral(attrName), value],
56
- );
57
- }
58
-
59
- /**
60
- * Build an object property key (identifier for valid names, string literal for dashed).
61
- */
62
- function attrKey(attrName: string): t.Identifier | t.StringLiteral {
63
- if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(attrName)) {
64
- return t.identifier(attrName);
65
- }
66
- return t.stringLiteral(attrName);
67
- }
68
-
69
- /**
70
- * Add classList.add(hash) statement for scoped CSS.
71
- */
72
- function addScopedClass(
73
- varName: string,
74
- hash: string,
75
- statements: t.Statement[],
76
- ): void {
77
- statements.push(
78
- t.expressionStatement(
79
- t.callExpression(
80
- t.memberExpression(
81
- t.memberExpression(t.identifier(varName), t.identifier("classList")),
82
- t.identifier("add"),
83
- ),
84
- [t.stringLiteral(hash)],
85
- ),
86
- ),
87
- );
88
- }
89
-
90
- export function processElement(
91
- node: t.JSXElement,
92
- statements: t.Statement[],
93
- genId: () => string,
94
- signals: Set<string>,
95
- hash?: string,
96
- hydrate?: boolean,
97
- nodesVar?: string,
98
- parentVar?: string,
99
- ): string {
100
- const varName = genId();
101
- const tag = (node.openingElement.name as t.JSXIdentifier).name;
102
- const isComponent = /^[A-Z]/.test(tag);
103
-
104
- if (isComponent) {
105
- // Global components: Window, Document, Body
106
- if (isGlobalComponent(tag)) {
107
- return handleGlobalComponent(tag, node, statements, genId);
108
- }
109
-
110
- // Component: call the function with a props object
111
- const propsObj = t.objectExpression([]);
112
-
113
- // Add attributes to props (including spread)
114
- for (const attr of node.openingElement.attributes) {
115
- if (t.isJSXSpreadAttribute(attr)) {
116
- // {…obj} — spread into props
117
- propsObj.properties.push(t.spreadElement(attr.argument));
118
- continue;
119
- }
120
- if (!t.isJSXAttribute(attr)) continue;
121
- const attrName = (attr.name as t.JSXIdentifier).name;
122
-
123
- if (t.isStringLiteral(attr.value)) {
124
- propsObj.properties.push(
125
- t.objectProperty(attrKey(attrName), attr.value),
126
- );
127
- } else if (t.isJSXExpressionContainer(attr.value)) {
128
- const expr = attr.value.expression as t.Expression;
129
- propsObj.properties.push(t.objectProperty(attrKey(attrName), expr));
130
- }
131
- }
132
-
133
- // Add children to props
134
- const childrenExpr = collectChildren(
135
- node,
136
- statements,
137
- genId,
138
- signals,
139
- hash,
140
- hydrate,
141
- nodesVar,
142
- );
143
- if (childrenExpr) {
144
- propsObj.properties.push(
145
- t.objectProperty(t.identifier("children"), childrenExpr),
146
- );
147
- }
148
-
149
- const componentCall = t.callExpression(t.identifier(tag), [propsObj]);
150
-
151
- if (hydrate && nodesVar) {
152
- // Push the parent's child pool before calling the component so that
153
- // getHydrationNodes() inside the component returns the right scope.
154
- // In SSR hydration the component claims its root node from that pool
155
- // (it's already in the DOM). In empty-pool / SPA mode the component
156
- // creates a fresh node, so we explicitly insert it when the pool is empty.
157
- statements.push(
158
- t.expressionStatement(
159
- t.callExpression(t.identifier("pushHydrationNodes"), [
160
- t.identifier(nodesVar),
161
- ]),
162
- ),
163
- );
164
- statements.push(
165
- t.variableDeclaration("const", [
166
- t.variableDeclarator(t.identifier(varName), componentCall),
167
- ]),
168
- );
169
- statements.push(
170
- t.expressionStatement(
171
- t.callExpression(t.identifier("popHydrationNodes"), []),
172
- ),
173
- );
174
- if (parentVar) {
175
- // Insert only when the pool was empty (fresh DOM); if the pool had
176
- // nodes the component already claimed its SSR element (already in DOM).
177
- statements.push(
178
- t.ifStatement(
179
- t.binaryExpression(
180
- "===",
181
- t.memberExpression(
182
- t.identifier(nodesVar),
183
- t.identifier("length"),
184
- ),
185
- t.numericLiteral(0),
186
- ),
187
- t.expressionStatement(
188
- t.callExpression(t.identifier("insert"), [
189
- t.identifier(parentVar),
190
- t.identifier(varName),
191
- ]),
192
- ),
193
- ),
194
- );
195
- }
196
- } else {
197
- statements.push(
198
- t.variableDeclaration("const", [
199
- t.variableDeclarator(t.identifier(varName), componentCall),
200
- ]),
201
- );
202
- }
203
-
204
- return varName;
205
- }
206
-
207
- // Native element: claim (hydrate) or create (dom)
208
- const currentNodesVar = nodesVar ?? "__nodes";
209
- statements.push(
210
- t.variableDeclaration("const", [
211
- t.variableDeclarator(
212
- t.identifier(varName),
213
- getCreateElement(
214
- tag,
215
- !!hydrate,
216
- currentNodesVar,
217
- hydrate ? parentVar : undefined,
218
- ),
219
- ),
220
- ]),
221
- );
222
-
223
- // In hydrate mode, scope to this element's children for descendants
224
- let childNodesVar: string | undefined;
225
- if (hydrate) {
226
- childNodesVar = genId();
227
- buildHydrationScope(varName, statements, childNodesVar);
228
- }
229
-
230
- // attributes
231
- const seenAttrs = new Set<string>();
232
- for (const attr of node.openingElement.attributes) {
233
- if (t.isJSXSpreadAttribute(attr)) {
234
- // {…obj} — spread onto native element
235
- statements.push(
236
- t.expressionStatement(
237
- t.callExpression(t.identifier("Object.assign"), [
238
- t.identifier(varName),
239
- attr.argument as t.Expression,
240
- ]),
241
- ),
242
- );
243
- continue;
244
- }
245
- if (!t.isJSXAttribute(attr)) continue;
246
- let attrName: string;
247
- let attrNamespace: string | undefined;
248
- if (t.isJSXNamespacedName(attr.name)) {
249
- attrNamespace = attr.name.namespace.name;
250
- attrName = attr.name.name.name;
251
- } else {
252
- attrName = (attr.name as t.JSXIdentifier).name;
253
- // bindValue -> bind:value, bindChecked -> bind:checked
254
- if (/^bind[A-Z]/.test(attrName)) {
255
- attrNamespace = "bind";
256
- attrName = attrName.slice(4).toLowerCase();
257
- }
258
- }
259
- const realAttr = ATTR_MAP[attrName] ?? attrName;
260
-
261
- if (seenAttrs.has(realAttr)) {
262
- throw new TypeError(
263
- `Duplicate attribute "${realAttr}" (via "${attrName}"). Use class or className, not both.`,
264
- );
265
- }
266
- seenAttrs.add(realAttr);
267
- if (
268
- (attrNamespace === "transition" ||
269
- attrNamespace === "in" ||
270
- attrNamespace === "out") &&
271
- t.isJSXExpressionContainer(attr.value)
272
- ) {
273
- // transition:fade={fade} / in:appear={x} / out:leave={[y, params]}
274
- const expr = attr.value.expression as t.Expression;
275
- statements.push(
276
- t.expressionStatement(
277
- t.callExpression(t.identifier("mountTransition"), [
278
- t.identifier(varName),
279
- expr,
280
- t.stringLiteral(attrNamespace as "transition" | "in" | "out"),
281
- ]),
282
- ),
283
- );
284
- } else if (attrName === "use") {
285
- // use={directive} or use={[directive, params]}
286
- const expr = (attr.value as t.JSXExpressionContainer)
287
- .expression as t.Expression;
288
- statements.push(
289
- t.expressionStatement(
290
- t.callExpression(t.identifier("applyDirective"), [
291
- t.identifier(varName),
292
- expr,
293
- ]),
294
- ),
295
- );
296
- } else if (/^on[A-Z]/.test(attrName)) {
297
- // Standard JSX: onClick, onSubmit, onMouseenter, etc.
298
- // Convert camelCase to lowercase: onClick -> click, onMouseenter -> mouseenter
299
- const event = attrName.slice(2).toLowerCase();
300
- const handler = (attr.value as t.JSXExpressionContainer)
301
- .expression as t.Expression;
302
- statements.push(
303
- t.expressionStatement(
304
- t.callExpression(
305
- t.memberExpression(
306
- t.identifier(varName),
307
- t.identifier("addEventListener"),
308
- ),
309
- [t.stringLiteral(event), handler],
310
- ),
311
- ),
312
- );
313
- } else if (attrNamespace === "bind") {
314
- const bindProp = attrName;
315
- const signal = (attr.value as t.JSXExpressionContainer)
316
- .expression as t.Expression;
317
-
318
- if (bindProp === "this") {
319
- statements.push(
320
- t.expressionStatement(
321
- t.callExpression(
322
- t.memberExpression(
323
- (signal as t.CallExpression).callee as t.Expression,
324
- t.identifier("set"),
325
- ),
326
- [t.identifier(varName)],
327
- ),
328
- ),
329
- );
330
- continue;
331
- }
332
-
333
- if (bindProp === "group") {
334
- // Scan element attributes for type and value
335
- let inputType = "";
336
- let _inputValue: t.StringLiteral | undefined;
337
- for (const a of node.openingElement.attributes) {
338
- if (t.isJSXAttribute(a) && t.isJSXIdentifier(a.name)) {
339
- if (a.name.name === "type" && t.isStringLiteral(a.value)) {
340
- inputType = a.value.value;
341
- }
342
- if (a.name.name === "value" && t.isStringLiteral(a.value)) {
343
- _inputValue = a.value;
344
- }
345
- }
346
- }
347
-
348
- if (inputType === "radio" || inputType === "") {
349
- const isTopLevel =
350
- t.isCallExpression(signal) &&
351
- t.isIdentifier((signal as t.CallExpression).callee) &&
352
- signals.has(
353
- ((signal as t.CallExpression).callee as t.Identifier).name,
354
- );
355
-
356
- if (isTopLevel) {
357
- const signalId = (signal as t.CallExpression)
358
- .callee as t.Identifier;
359
- statements.push(
360
- t.expressionStatement(
361
- t.callExpression(
362
- t.memberExpression(
363
- t.identifier(varName),
364
- t.identifier("addEventListener"),
365
- ),
366
- [
367
- t.stringLiteral("change"),
368
- t.arrowFunctionExpression(
369
- [],
370
- t.callExpression(
371
- t.memberExpression(signalId, t.identifier("set")),
372
- [
373
- t.memberExpression(
374
- t.identifier(varName),
375
- t.identifier("value"),
376
- ),
377
- ],
378
- ),
379
- ),
380
- ],
381
- ),
382
- ),
383
- );
384
- }
385
- } else if (inputType === "checkbox") {
386
- const isTopLevel =
387
- t.isCallExpression(signal) &&
388
- t.isIdentifier((signal as t.CallExpression).callee) &&
389
- signals.has(
390
- ((signal as t.CallExpression).callee as t.Identifier).name,
391
- );
392
-
393
- if (isTopLevel) {
394
- const callExpr = signal as t.CallExpression;
395
- statements.push(
396
- t.expressionStatement(
397
- t.callExpression(
398
- t.memberExpression(
399
- t.identifier(varName),
400
- t.identifier("addEventListener"),
401
- ),
402
- [
403
- t.stringLiteral("change"),
404
- t.arrowFunctionExpression(
405
- [],
406
- t.blockStatement([
407
- t.ifStatement(
408
- t.memberExpression(
409
- t.identifier(varName),
410
- t.identifier("checked"),
411
- ),
412
- // checked: push value into array
413
- t.blockStatement([
414
- t.expressionStatement(
415
- t.callExpression(
416
- t.memberExpression(
417
- t.callExpression(callExpr, []),
418
- t.identifier("push"),
419
- ),
420
- [
421
- t.memberExpression(
422
- t.identifier(varName),
423
- t.identifier("value"),
424
- ),
425
- ],
426
- ),
427
- ),
428
- ]),
429
- // unchecked: splice value out of array
430
- t.blockStatement([
431
- t.expressionStatement(
432
- t.callExpression(
433
- t.memberExpression(
434
- t.callExpression(callExpr, []),
435
- t.identifier("splice"),
436
- ),
437
- [
438
- t.callExpression(
439
- t.memberExpression(
440
- t.callExpression(callExpr, []),
441
- t.identifier("indexOf"),
442
- ),
443
- [
444
- t.memberExpression(
445
- t.identifier(varName),
446
- t.identifier("value"),
447
- ),
448
- ],
449
- ),
450
- t.numericLiteral(1),
451
- ],
452
- ),
453
- ),
454
- ]),
455
- ),
456
- ]),
457
- ),
458
- ],
459
- ),
460
- ),
461
- );
462
- }
463
- } else {
464
- console.warn(
465
- `[sigil] bind:group requires type="radio" or type="checkbox" on the element. ` +
466
- `Got: type="${inputType}".`,
467
- );
468
- }
469
- continue;
470
- }
471
-
472
- // Generic bind:innerHTML, bind:textContent, bind:value, bind:checked, etc.
473
- const isTopLevelSignal =
474
- t.isCallExpression(signal) &&
475
- t.isIdentifier((signal as t.CallExpression).callee) &&
476
- signals.has(((signal as t.CallExpression).callee as t.Identifier).name);
477
-
478
- const isMemberOfSignal =
479
- t.isMemberExpression(signal) &&
480
- t.isCallExpression((signal as t.MemberExpression).object) &&
481
- t.isIdentifier(
482
- ((signal as t.MemberExpression).object as t.CallExpression).callee,
483
- ) &&
484
- signals.has(
485
- (
486
- ((signal as t.MemberExpression).object as t.CallExpression)
487
- .callee as t.Identifier
488
- ).name,
489
- );
490
-
491
- if (isTopLevelSignal) {
492
- buildBind(varName, bindProp, attr, statements);
493
- } else if (isMemberOfSignal) {
494
- buildProxyBind(varName, bindProp, attr, statements, signal);
495
- } else {
496
- console.warn(
497
- `[sigil] bind:${bindProp}={...} requires a $state signal or $state object property. ` +
498
- `Got: ${generate(signal).code}. ` +
499
- `Hint: change to a $state() call.`,
500
- );
501
- }
502
- } else if (attr.value === null) {
503
- // bare attribute (<input disabled>, <div data-beacon>)
504
- if (isDashedAttr(realAttr)) {
505
- statements.push(
506
- t.expressionStatement(
507
- setAttrCall(varName, realAttr, t.stringLiteral("")),
508
- ),
509
- );
510
- } else {
511
- statements.push(
512
- t.expressionStatement(
513
- t.assignmentExpression(
514
- "=",
515
- attrAccess(varName, realAttr),
516
- t.booleanLiteral(true),
517
- ),
518
- ),
519
- );
520
- }
521
- } else if (t.isStringLiteral(attr.value)) {
522
- statements.push(
523
- t.expressionStatement(
524
- isDashedAttr(realAttr)
525
- ? setAttrCall(varName, realAttr, t.stringLiteral(attr.value.value))
526
- : t.assignmentExpression(
527
- "=",
528
- attrAccess(varName, realAttr),
529
- attr.value,
530
- ),
531
- ),
532
- );
533
- } else if (t.isJSXExpressionContainer(attr.value)) {
534
- const expr = attr.value.expression as t.Expression;
535
- // style={{ color: "red", fontSize: val }} — per-property assignment
536
- if (attrName === "style" && t.isObjectExpression(expr)) {
537
- for (const prop of expr.properties) {
538
- if (!t.isObjectProperty(prop)) continue;
539
- const key = t.isIdentifier(prop.key)
540
- ? prop.key.name
541
- : t.isStringLiteral(prop.key)
542
- ? prop.key.value
543
- : null;
544
- if (!key) continue;
545
- const val = prop.value as t.Expression;
546
- const styleProp = t.memberExpression(
547
- t.memberExpression(t.identifier(varName), t.identifier("style")),
548
- t.identifier(key),
549
- );
550
- if (containsSignal(val, signals)) {
551
- statements.push(
552
- t.expressionStatement(
553
- t.callExpression(t.identifier("createEffect"), [
554
- t.arrowFunctionExpression(
555
- [],
556
- t.blockStatement([
557
- t.expressionStatement(
558
- t.assignmentExpression("=", styleProp, val),
559
- ),
560
- ]),
561
- ),
562
- ]),
563
- ),
564
- );
565
- } else {
566
- statements.push(
567
- t.expressionStatement(
568
- t.assignmentExpression("=", styleProp, val),
569
- ),
570
- );
571
- }
572
- }
573
- // done with this attribute
574
- } else if (containsSignal(expr, signals)) {
575
- // Dynamic class with scoped CSS: use classList.value to preserve hash
576
- if (attrName === "class" && hash) {
577
- statements.push(
578
- t.expressionStatement(
579
- t.callExpression(t.identifier("createEffect"), [
580
- t.arrowFunctionExpression(
581
- [],
582
- t.blockStatement([
583
- t.expressionStatement(
584
- t.assignmentExpression(
585
- "=",
586
- t.memberExpression(
587
- t.memberExpression(
588
- t.identifier(varName),
589
- t.identifier("classList"),
590
- ),
591
- t.identifier("value"),
592
- ),
593
- expr,
594
- ),
595
- ),
596
- t.expressionStatement(
597
- t.callExpression(
598
- t.memberExpression(
599
- t.memberExpression(
600
- t.identifier(varName),
601
- t.identifier("classList"),
602
- ),
603
- t.identifier("add"),
604
- ),
605
- [t.stringLiteral(hash)],
606
- ),
607
- ),
608
- ]),
609
- ),
610
- ]),
611
- ),
612
- );
613
- } else {
614
- // createEffect(() => setAttribute/prop = expr)
615
- statements.push(
616
- t.expressionStatement(
617
- t.callExpression(t.identifier("createEffect"), [
618
- t.arrowFunctionExpression(
619
- [],
620
- t.blockStatement([
621
- t.expressionStatement(
622
- isDashedAttr(realAttr)
623
- ? setAttrCall(varName, realAttr, expr)
624
- : t.assignmentExpression(
625
- "=",
626
- attrAccess(varName, realAttr),
627
- expr,
628
- ),
629
- ),
630
- ]),
631
- ),
632
- ]),
633
- ),
634
- );
635
- }
636
- } else {
637
- // setAttribute/varName.realAttr = expr
638
- statements.push(
639
- t.expressionStatement(
640
- isDashedAttr(realAttr)
641
- ? setAttrCall(varName, realAttr, expr)
642
- : t.assignmentExpression(
643
- "=",
644
- attrAccess(varName, realAttr),
645
- expr,
646
- ),
647
- ),
648
- );
649
- }
650
- }
651
- }
652
-
653
- // Add scoped CSS hash to classList (if hash is present)
654
- if (hash) {
655
- addScopedClass(varName, hash, statements);
656
- }
657
-
658
- // children
659
- for (const child of node.children) {
660
- if (t.isJSXElement(child)) {
661
- const childVar = processElement(
662
- child,
663
- statements,
664
- genId,
665
- signals,
666
- hash,
667
- hydrate,
668
- childNodesVar,
669
- hydrate ? varName : undefined,
670
- );
671
- if (!hydrate) {
672
- statements.push(
673
- t.expressionStatement(
674
- t.callExpression(
675
- t.memberExpression(t.identifier(varName), t.identifier("append")),
676
- [t.identifier(childVar)],
677
- ),
678
- ),
679
- );
680
- }
681
- } else if (t.isJSXFragment(child)) {
682
- const childVar = processFragment(
683
- child,
684
- statements,
685
- genId,
686
- signals,
687
- hash,
688
- hydrate,
689
- childNodesVar,
690
- hydrate ? varName : undefined,
691
- );
692
- if (!hydrate) {
693
- statements.push(
694
- t.expressionStatement(
695
- t.callExpression(
696
- t.memberExpression(t.identifier(varName), t.identifier("append")),
697
- [t.identifier(childVar)],
698
- ),
699
- ),
700
- );
701
- }
702
- } else if (t.isJSXText(child)) {
703
- const text = child.value;
704
- if (!text.trim()) continue;
705
- if (hydrate) {
706
- // SSR hydration: text already in DOM from SSR, do nothing.
707
- // SPA navigation: childNodesVar pool is empty, create text node.
708
- const poolLen = t.memberExpression(
709
- t.identifier(childNodesVar!),
710
- t.identifier("length"),
711
- );
712
- statements.push(
713
- t.ifStatement(
714
- t.binaryExpression("===", poolLen, t.numericLiteral(0)),
715
- t.expressionStatement(
716
- t.callExpression(
717
- t.memberExpression(
718
- t.identifier(varName),
719
- t.identifier("append"),
720
- ),
721
- [
722
- t.callExpression(
723
- t.memberExpression(
724
- t.identifier("document"),
725
- t.identifier("createTextNode"),
726
- ),
727
- [t.stringLiteral(text.replace(/\s*\n\s*/g, " "))],
728
- ),
729
- ],
730
- ),
731
- ),
732
- ),
733
- );
734
- } else {
735
- statements.push(
736
- t.expressionStatement(
737
- t.callExpression(
738
- t.memberExpression(t.identifier(varName), t.identifier("append")),
739
- [
740
- t.callExpression(
741
- t.memberExpression(
742
- t.identifier("document"),
743
- t.identifier("createTextNode"),
744
- ),
745
- [t.stringLiteral(text)],
746
- ),
747
- ],
748
- ),
749
- ),
750
- );
751
- }
752
- } else if (t.isJSXExpressionContainer(child)) {
753
- if (t.isJSXEmptyExpression(child.expression)) continue;
754
- const expr = child.expression as t.Expression;
755
- if (t.isJSXElement(expr)) {
756
- const childVar = processElement(
757
- expr,
758
- statements,
759
- genId,
760
- signals,
761
- hash,
762
- hydrate,
763
- childNodesVar,
764
- hydrate ? varName : undefined,
765
- );
766
- if (!hydrate) {
767
- statements.push(
768
- t.expressionStatement(
769
- t.callExpression(
770
- t.memberExpression(
771
- t.identifier(varName),
772
- t.identifier("append"),
773
- ),
774
- [t.identifier(childVar)],
775
- ),
776
- ),
777
- );
778
- }
779
- } else if (t.isJSXFragment(expr)) {
780
- const childVar = processFragment(
781
- expr,
782
- statements,
783
- genId,
784
- signals,
785
- hash,
786
- hydrate,
787
- childNodesVar,
788
- hydrate ? varName : undefined,
789
- );
790
- if (!hydrate) {
791
- statements.push(
792
- t.expressionStatement(
793
- t.callExpression(
794
- t.memberExpression(
795
- t.identifier(varName),
796
- t.identifier("append"),
797
- ),
798
- [t.identifier(childVar)],
799
- ),
800
- ),
801
- );
802
- }
803
- } else if (containsSignal(expr, signals)) {
804
- if (isPrimitive(expr)) {
805
- // fast path: text node + textContent
806
- buildTextNode(
807
- varName,
808
- expr,
809
- statements,
810
- genId,
811
- hydrate,
812
- childNodesVar,
813
- hydrate ? varName : undefined,
814
- );
815
- } else {
816
- // Check for keyed list pattern
817
- const keyed = findKeyedMapExpr(expr, signals);
818
- if (keyed) {
819
- buildKeyedList(
820
- varName,
821
- keyed,
822
- statements,
823
- genId,
824
- signals,
825
- processElement,
826
- hash,
827
- hydrate,
828
- childNodesVar,
829
- hydrate ? varName : undefined,
830
- );
831
- } else {
832
- buildAnchorMount(
833
- varName,
834
- expr,
835
- statements,
836
- genId,
837
- hydrate,
838
- childNodesVar,
839
- hydrate ? varName : undefined,
840
- );
841
- }
842
- }
843
- } else {
844
- // non-reactive: set once
845
- if (hydrate) {
846
- // hydrateSlot consumes the <!--g-->…<!--/g--> run and runs the
847
- // value against the content SSR put inside it, so both deferred
848
- // component children and inline JSX (`{list.map(…)}`) claim that
849
- // subtree. The value must stay lazy: evaluating it as an argument
850
- // would claim from the enclosing pool instead. Empty pool
851
- // (SPA nav) falls back to a fresh insert.
852
- statements.push(
853
- t.expressionStatement(
854
- t.callExpression(t.identifier("hydrateSlot"), [
855
- t.identifier(childNodesVar!),
856
- t.identifier(varName),
857
- t.arrowFunctionExpression([], expr),
858
- ]),
859
- ),
860
- );
861
- } else {
862
- statements.push(
863
- t.expressionStatement(
864
- t.callExpression(t.identifier("insert"), [
865
- t.identifier(varName),
866
- expr,
867
- ]),
868
- ),
869
- );
870
- }
871
- }
872
- }
873
- }
874
-
875
- return varName;
876
- }
1
+ import { types as t } from "@babel/core";
2
+ //@ts-expect-error Not our problem babel forgot tp export types.
3
+ import { generate } from "@babel/generator";
4
+ import {
5
+ handleGlobalComponent,
6
+ isGlobalComponent,
7
+ } from "../handlers/dom/globals";
8
+ import { buildBind, buildProxyBind } from "../util/bind";
9
+ import { buildAnchorMount } from "./anchor-mount";
10
+ import { collectChildren } from "./children";
11
+ import { processFragment } from "./fragment";
12
+ import { buildKeyedList, findKeyedMapExpr } from "./keyed-list";
13
+ import { buildTextNode } from "./text-node";
14
+ import {
15
+ ATTR_MAP,
16
+ buildHydrationScope,
17
+ containsSignal,
18
+ getCreateElement,
19
+ isPrimitive,
20
+ } from "./utils";
21
+
22
+ /**
23
+ * Dashed names (data-*, aria-*, custom attributes) have no IDL property —
24
+ * `el["data-x"] = v` sets a bare JS property the DOM never reflects, so the
25
+ * attribute is invisible to selectors, getAttribute, and CSS. They must go
26
+ * through setAttribute.
27
+ */
28
+ function isDashedAttr(attrName: string): boolean {
29
+ return attrName.includes("-");
30
+ }
31
+
32
+ function setAttrCall(
33
+ varName: string,
34
+ attrName: string,
35
+ value: t.Expression,
36
+ ): t.CallExpression {
37
+ return t.callExpression(
38
+ t.memberExpression(t.identifier(varName), t.identifier("setAttribute")),
39
+ [t.stringLiteral(attrName), value],
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Route a prop write through the runtime setProp() helper. On HTML elements
45
+ * this is today's blind `el[name] = value`; on SVG-namespace elements it
46
+ * goes through setAttribute instead — IDL property assignment throws on real
47
+ * SVG elements (`svg.width = 15` hits a getter-only property), which crashed
48
+ * CSR renders the moment claim() started creating true-namespaced SVG.
49
+ */
50
+ function setPropCall(
51
+ varName: string,
52
+ attrName: string,
53
+ value: t.Expression,
54
+ ): t.CallExpression {
55
+ return t.callExpression(t.identifier("setProp"), [
56
+ t.identifier(varName),
57
+ t.stringLiteral(attrName),
58
+ value,
59
+ ]);
60
+ }
61
+
62
+ /**
63
+ * Build an object property key (identifier for valid names, string literal for dashed).
64
+ */
65
+ function attrKey(attrName: string): t.Identifier | t.StringLiteral {
66
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(attrName)) {
67
+ return t.identifier(attrName);
68
+ }
69
+ return t.stringLiteral(attrName);
70
+ }
71
+
72
+ /**
73
+ * Add classList.add(hash) statement for scoped CSS.
74
+ */
75
+ function addScopedClass(
76
+ varName: string,
77
+ hash: string,
78
+ statements: t.Statement[],
79
+ ): void {
80
+ statements.push(
81
+ t.expressionStatement(
82
+ t.callExpression(
83
+ t.memberExpression(
84
+ t.memberExpression(t.identifier(varName), t.identifier("classList")),
85
+ t.identifier("add"),
86
+ ),
87
+ [t.stringLiteral(hash)],
88
+ ),
89
+ ),
90
+ );
91
+ }
92
+
93
+ export function processElement(
94
+ node: t.JSXElement,
95
+ statements: t.Statement[],
96
+ genId: () => string,
97
+ signals: Set<string>,
98
+ hash?: string,
99
+ hydrate?: boolean,
100
+ nodesVar?: string,
101
+ parentVar?: string,
102
+ ): string {
103
+ const varName = genId();
104
+ const tag = (node.openingElement.name as t.JSXIdentifier).name;
105
+ const isComponent = /^[A-Z]/.test(tag);
106
+
107
+ if (isComponent) {
108
+ // Global components: Window, Document, Body
109
+ if (isGlobalComponent(tag)) {
110
+ return handleGlobalComponent(tag, node, statements, genId);
111
+ }
112
+
113
+ // Component: call the function with a props object
114
+ const propsObj = t.objectExpression([]);
115
+
116
+ // Add attributes to props (including spread)
117
+ for (const attr of node.openingElement.attributes) {
118
+ if (t.isJSXSpreadAttribute(attr)) {
119
+ // {…obj} — spread into props
120
+ propsObj.properties.push(t.spreadElement(attr.argument));
121
+ continue;
122
+ }
123
+ if (!t.isJSXAttribute(attr)) continue;
124
+ const attrName = (attr.name as t.JSXIdentifier).name;
125
+
126
+ if (t.isStringLiteral(attr.value)) {
127
+ propsObj.properties.push(
128
+ t.objectProperty(attrKey(attrName), attr.value),
129
+ );
130
+ } else if (t.isJSXExpressionContainer(attr.value)) {
131
+ const expr = attr.value.expression as t.Expression;
132
+ propsObj.properties.push(t.objectProperty(attrKey(attrName), expr));
133
+ }
134
+ }
135
+
136
+ // Add children to props
137
+ const childrenExpr = collectChildren(
138
+ node,
139
+ statements,
140
+ genId,
141
+ signals,
142
+ hash,
143
+ hydrate,
144
+ nodesVar,
145
+ );
146
+ if (childrenExpr) {
147
+ propsObj.properties.push(
148
+ t.objectProperty(t.identifier("children"), childrenExpr),
149
+ );
150
+ }
151
+
152
+ const componentCall = t.callExpression(t.identifier(tag), [propsObj]);
153
+
154
+ if (hydrate && nodesVar) {
155
+ // Push the parent's child pool before calling the component so that
156
+ // getHydrationNodes() inside the component returns the right scope.
157
+ // In SSR hydration the component claims its root node from that pool
158
+ // (it's already in the DOM). In empty-pool / SPA mode the component
159
+ // creates a fresh node, so we explicitly insert it when the pool is empty.
160
+ statements.push(
161
+ t.expressionStatement(
162
+ t.callExpression(t.identifier("pushHydrationNodes"), [
163
+ t.identifier(nodesVar),
164
+ ]),
165
+ ),
166
+ );
167
+ statements.push(
168
+ t.variableDeclaration("const", [
169
+ t.variableDeclarator(t.identifier(varName), componentCall),
170
+ ]),
171
+ );
172
+ statements.push(
173
+ t.expressionStatement(
174
+ t.callExpression(t.identifier("popHydrationNodes"), []),
175
+ ),
176
+ );
177
+ if (parentVar) {
178
+ // Insert only when the pool was empty (fresh DOM); if the pool had
179
+ // nodes the component already claimed its SSR element (already in DOM).
180
+ statements.push(
181
+ t.ifStatement(
182
+ t.binaryExpression(
183
+ "===",
184
+ t.memberExpression(
185
+ t.identifier(nodesVar),
186
+ t.identifier("length"),
187
+ ),
188
+ t.numericLiteral(0),
189
+ ),
190
+ t.expressionStatement(
191
+ t.callExpression(t.identifier("insert"), [
192
+ t.identifier(parentVar),
193
+ t.identifier(varName),
194
+ ]),
195
+ ),
196
+ ),
197
+ );
198
+ }
199
+ } else {
200
+ statements.push(
201
+ t.variableDeclaration("const", [
202
+ t.variableDeclarator(t.identifier(varName), componentCall),
203
+ ]),
204
+ );
205
+ }
206
+
207
+ return varName;
208
+ }
209
+
210
+ // Native element: claim (hydrate) or create (dom)
211
+ const currentNodesVar = nodesVar ?? "__nodes";
212
+ statements.push(
213
+ t.variableDeclaration("const", [
214
+ t.variableDeclarator(
215
+ t.identifier(varName),
216
+ getCreateElement(
217
+ tag,
218
+ !!hydrate,
219
+ currentNodesVar,
220
+ hydrate ? parentVar : undefined,
221
+ ),
222
+ ),
223
+ ]),
224
+ );
225
+
226
+ // In hydrate mode, scope to this element's children for descendants
227
+ let childNodesVar: string | undefined;
228
+ if (hydrate) {
229
+ childNodesVar = genId();
230
+ buildHydrationScope(varName, statements, childNodesVar);
231
+ }
232
+
233
+ // attributes
234
+ const seenAttrs = new Set<string>();
235
+ for (const attr of node.openingElement.attributes) {
236
+ if (t.isJSXSpreadAttribute(attr)) {
237
+ // {…obj} — spread onto native element
238
+ statements.push(
239
+ t.expressionStatement(
240
+ t.callExpression(t.identifier("Object.assign"), [
241
+ t.identifier(varName),
242
+ attr.argument as t.Expression,
243
+ ]),
244
+ ),
245
+ );
246
+ continue;
247
+ }
248
+ if (!t.isJSXAttribute(attr)) continue;
249
+ let attrName: string;
250
+ let attrNamespace: string | undefined;
251
+ if (t.isJSXNamespacedName(attr.name)) {
252
+ attrNamespace = attr.name.namespace.name;
253
+ attrName = attr.name.name.name;
254
+ } else {
255
+ attrName = (attr.name as t.JSXIdentifier).name;
256
+ // bindValue -> bind:value, bindChecked -> bind:checked
257
+ if (/^bind[A-Z]/.test(attrName)) {
258
+ attrNamespace = "bind";
259
+ attrName = attrName.slice(4).toLowerCase();
260
+ }
261
+ }
262
+ const realAttr = ATTR_MAP[attrName] ?? attrName;
263
+
264
+ if (seenAttrs.has(realAttr)) {
265
+ throw new TypeError(
266
+ `Duplicate attribute "${realAttr}" (via "${attrName}"). Use class or className, not both.`,
267
+ );
268
+ }
269
+ seenAttrs.add(realAttr);
270
+ if (
271
+ (attrNamespace === "transition" ||
272
+ attrNamespace === "in" ||
273
+ attrNamespace === "out") &&
274
+ t.isJSXExpressionContainer(attr.value)
275
+ ) {
276
+ // transition:fade={fade} / in:appear={x} / out:leave={[y, params]}
277
+ const expr = attr.value.expression as t.Expression;
278
+ statements.push(
279
+ t.expressionStatement(
280
+ t.callExpression(t.identifier("mountTransition"), [
281
+ t.identifier(varName),
282
+ expr,
283
+ t.stringLiteral(attrNamespace as "transition" | "in" | "out"),
284
+ ]),
285
+ ),
286
+ );
287
+ } else if (attrName === "use") {
288
+ // use={directive} or use={[directive, params]}
289
+ const expr = (attr.value as t.JSXExpressionContainer)
290
+ .expression as t.Expression;
291
+ statements.push(
292
+ t.expressionStatement(
293
+ t.callExpression(t.identifier("applyDirective"), [
294
+ t.identifier(varName),
295
+ expr,
296
+ ]),
297
+ ),
298
+ );
299
+ } else if (/^on[A-Z]/.test(attrName)) {
300
+ // Standard JSX: onClick, onSubmit, onMouseenter, etc.
301
+ // Convert camelCase to lowercase: onClick -> click, onMouseenter -> mouseenter
302
+ const event = attrName.slice(2).toLowerCase();
303
+ const handler = (attr.value as t.JSXExpressionContainer)
304
+ .expression as t.Expression;
305
+ statements.push(
306
+ t.expressionStatement(
307
+ t.callExpression(
308
+ t.memberExpression(
309
+ t.identifier(varName),
310
+ t.identifier("addEventListener"),
311
+ ),
312
+ [t.stringLiteral(event), handler],
313
+ ),
314
+ ),
315
+ );
316
+ } else if (attrNamespace === "bind") {
317
+ const bindProp = attrName;
318
+ const signal = (attr.value as t.JSXExpressionContainer)
319
+ .expression as t.Expression;
320
+
321
+ if (bindProp === "this") {
322
+ statements.push(
323
+ t.expressionStatement(
324
+ t.callExpression(
325
+ t.memberExpression(
326
+ (signal as t.CallExpression).callee as t.Expression,
327
+ t.identifier("set"),
328
+ ),
329
+ [t.identifier(varName)],
330
+ ),
331
+ ),
332
+ );
333
+ continue;
334
+ }
335
+
336
+ if (bindProp === "group") {
337
+ // Scan element attributes for type and value
338
+ let inputType = "";
339
+ let _inputValue: t.StringLiteral | undefined;
340
+ for (const a of node.openingElement.attributes) {
341
+ if (t.isJSXAttribute(a) && t.isJSXIdentifier(a.name)) {
342
+ if (a.name.name === "type" && t.isStringLiteral(a.value)) {
343
+ inputType = a.value.value;
344
+ }
345
+ if (a.name.name === "value" && t.isStringLiteral(a.value)) {
346
+ _inputValue = a.value;
347
+ }
348
+ }
349
+ }
350
+
351
+ if (inputType === "radio" || inputType === "") {
352
+ const isTopLevel =
353
+ t.isCallExpression(signal) &&
354
+ t.isIdentifier((signal as t.CallExpression).callee) &&
355
+ signals.has(
356
+ ((signal as t.CallExpression).callee as t.Identifier).name,
357
+ );
358
+
359
+ if (isTopLevel) {
360
+ const signalId = (signal as t.CallExpression)
361
+ .callee as t.Identifier;
362
+ statements.push(
363
+ t.expressionStatement(
364
+ t.callExpression(
365
+ t.memberExpression(
366
+ t.identifier(varName),
367
+ t.identifier("addEventListener"),
368
+ ),
369
+ [
370
+ t.stringLiteral("change"),
371
+ t.arrowFunctionExpression(
372
+ [],
373
+ t.callExpression(
374
+ t.memberExpression(signalId, t.identifier("set")),
375
+ [
376
+ t.memberExpression(
377
+ t.identifier(varName),
378
+ t.identifier("value"),
379
+ ),
380
+ ],
381
+ ),
382
+ ),
383
+ ],
384
+ ),
385
+ ),
386
+ );
387
+ }
388
+ } else if (inputType === "checkbox") {
389
+ const isTopLevel =
390
+ t.isCallExpression(signal) &&
391
+ t.isIdentifier((signal as t.CallExpression).callee) &&
392
+ signals.has(
393
+ ((signal as t.CallExpression).callee as t.Identifier).name,
394
+ );
395
+
396
+ if (isTopLevel) {
397
+ const callExpr = signal as t.CallExpression;
398
+ statements.push(
399
+ t.expressionStatement(
400
+ t.callExpression(
401
+ t.memberExpression(
402
+ t.identifier(varName),
403
+ t.identifier("addEventListener"),
404
+ ),
405
+ [
406
+ t.stringLiteral("change"),
407
+ t.arrowFunctionExpression(
408
+ [],
409
+ t.blockStatement([
410
+ t.ifStatement(
411
+ t.memberExpression(
412
+ t.identifier(varName),
413
+ t.identifier("checked"),
414
+ ),
415
+ // checked: push value into array
416
+ t.blockStatement([
417
+ t.expressionStatement(
418
+ t.callExpression(
419
+ t.memberExpression(
420
+ t.callExpression(callExpr, []),
421
+ t.identifier("push"),
422
+ ),
423
+ [
424
+ t.memberExpression(
425
+ t.identifier(varName),
426
+ t.identifier("value"),
427
+ ),
428
+ ],
429
+ ),
430
+ ),
431
+ ]),
432
+ // unchecked: splice value out of array
433
+ t.blockStatement([
434
+ t.expressionStatement(
435
+ t.callExpression(
436
+ t.memberExpression(
437
+ t.callExpression(callExpr, []),
438
+ t.identifier("splice"),
439
+ ),
440
+ [
441
+ t.callExpression(
442
+ t.memberExpression(
443
+ t.callExpression(callExpr, []),
444
+ t.identifier("indexOf"),
445
+ ),
446
+ [
447
+ t.memberExpression(
448
+ t.identifier(varName),
449
+ t.identifier("value"),
450
+ ),
451
+ ],
452
+ ),
453
+ t.numericLiteral(1),
454
+ ],
455
+ ),
456
+ ),
457
+ ]),
458
+ ),
459
+ ]),
460
+ ),
461
+ ],
462
+ ),
463
+ ),
464
+ );
465
+ }
466
+ } else {
467
+ console.warn(
468
+ `[sigil] bind:group requires type="radio" or type="checkbox" on the element. ` +
469
+ `Got: type="${inputType}".`,
470
+ );
471
+ }
472
+ continue;
473
+ }
474
+
475
+ // Generic bind:innerHTML, bind:textContent, bind:value, bind:checked, etc.
476
+ const isTopLevelSignal =
477
+ t.isCallExpression(signal) &&
478
+ t.isIdentifier((signal as t.CallExpression).callee) &&
479
+ signals.has(((signal as t.CallExpression).callee as t.Identifier).name);
480
+
481
+ const isMemberOfSignal =
482
+ t.isMemberExpression(signal) &&
483
+ t.isCallExpression((signal as t.MemberExpression).object) &&
484
+ t.isIdentifier(
485
+ ((signal as t.MemberExpression).object as t.CallExpression).callee,
486
+ ) &&
487
+ signals.has(
488
+ (
489
+ ((signal as t.MemberExpression).object as t.CallExpression)
490
+ .callee as t.Identifier
491
+ ).name,
492
+ );
493
+
494
+ if (isTopLevelSignal) {
495
+ buildBind(varName, bindProp, attr, statements);
496
+ } else if (isMemberOfSignal) {
497
+ buildProxyBind(varName, bindProp, attr, statements, signal);
498
+ } else {
499
+ console.warn(
500
+ `[sigil] bind:${bindProp}={...} requires a $state signal or $state object property. ` +
501
+ `Got: ${generate(signal).code}. ` +
502
+ `Hint: change to a $state() call.`,
503
+ );
504
+ }
505
+ } else if (attr.value === null) {
506
+ // bare attribute (<input disabled>, <div data-beacon>)
507
+ if (isDashedAttr(realAttr)) {
508
+ statements.push(
509
+ t.expressionStatement(
510
+ setAttrCall(varName, realAttr, t.stringLiteral("")),
511
+ ),
512
+ );
513
+ } else {
514
+ statements.push(
515
+ t.expressionStatement(
516
+ setPropCall(varName, realAttr, t.booleanLiteral(true)),
517
+ ),
518
+ );
519
+ }
520
+ } else if (t.isStringLiteral(attr.value)) {
521
+ statements.push(
522
+ t.expressionStatement(
523
+ isDashedAttr(realAttr)
524
+ ? setAttrCall(varName, realAttr, t.stringLiteral(attr.value.value))
525
+ : setPropCall(varName, realAttr, attr.value),
526
+ ),
527
+ );
528
+ } else if (t.isJSXExpressionContainer(attr.value)) {
529
+ const expr = attr.value.expression as t.Expression;
530
+ // style={{ color: "red", fontSize: val }} — per-property assignment
531
+ if (attrName === "style" && t.isObjectExpression(expr)) {
532
+ for (const prop of expr.properties) {
533
+ if (!t.isObjectProperty(prop)) continue;
534
+ const key = t.isIdentifier(prop.key)
535
+ ? prop.key.name
536
+ : t.isStringLiteral(prop.key)
537
+ ? prop.key.value
538
+ : null;
539
+ if (!key) continue;
540
+ const val = prop.value as t.Expression;
541
+ const styleProp = t.memberExpression(
542
+ t.memberExpression(t.identifier(varName), t.identifier("style")),
543
+ t.identifier(key),
544
+ );
545
+ if (containsSignal(val, signals)) {
546
+ statements.push(
547
+ t.expressionStatement(
548
+ t.callExpression(t.identifier("createEffect"), [
549
+ t.arrowFunctionExpression(
550
+ [],
551
+ t.blockStatement([
552
+ t.expressionStatement(
553
+ t.assignmentExpression("=", styleProp, val),
554
+ ),
555
+ ]),
556
+ ),
557
+ ]),
558
+ ),
559
+ );
560
+ } else {
561
+ statements.push(
562
+ t.expressionStatement(
563
+ t.assignmentExpression("=", styleProp, val),
564
+ ),
565
+ );
566
+ }
567
+ }
568
+ // done with this attribute
569
+ } else if (containsSignal(expr, signals)) {
570
+ // Dynamic class with scoped CSS: use classList.value to preserve hash
571
+ if (attrName === "class" && hash) {
572
+ statements.push(
573
+ t.expressionStatement(
574
+ t.callExpression(t.identifier("createEffect"), [
575
+ t.arrowFunctionExpression(
576
+ [],
577
+ t.blockStatement([
578
+ t.expressionStatement(
579
+ t.assignmentExpression(
580
+ "=",
581
+ t.memberExpression(
582
+ t.memberExpression(
583
+ t.identifier(varName),
584
+ t.identifier("classList"),
585
+ ),
586
+ t.identifier("value"),
587
+ ),
588
+ expr,
589
+ ),
590
+ ),
591
+ t.expressionStatement(
592
+ t.callExpression(
593
+ t.memberExpression(
594
+ t.memberExpression(
595
+ t.identifier(varName),
596
+ t.identifier("classList"),
597
+ ),
598
+ t.identifier("add"),
599
+ ),
600
+ [t.stringLiteral(hash)],
601
+ ),
602
+ ),
603
+ ]),
604
+ ),
605
+ ]),
606
+ ),
607
+ );
608
+ } else {
609
+ // createEffect(() => setAttribute/prop = expr)
610
+ statements.push(
611
+ t.expressionStatement(
612
+ t.callExpression(t.identifier("createEffect"), [
613
+ t.arrowFunctionExpression(
614
+ [],
615
+ t.blockStatement([
616
+ t.expressionStatement(
617
+ isDashedAttr(realAttr)
618
+ ? setAttrCall(varName, realAttr, expr)
619
+ : setPropCall(varName, realAttr, expr),
620
+ ),
621
+ ]),
622
+ ),
623
+ ]),
624
+ ),
625
+ );
626
+ }
627
+ } else {
628
+ // setAttribute for dashed, runtime setProp (attribute-safe on SVG) otherwise
629
+ statements.push(
630
+ t.expressionStatement(
631
+ isDashedAttr(realAttr)
632
+ ? setAttrCall(varName, realAttr, expr)
633
+ : setPropCall(varName, realAttr, expr),
634
+ ),
635
+ );
636
+ }
637
+ }
638
+ }
639
+
640
+ // Add scoped CSS hash to classList (if hash is present)
641
+ if (hash) {
642
+ addScopedClass(varName, hash, statements);
643
+ }
644
+
645
+ // children
646
+ for (const child of node.children) {
647
+ if (t.isJSXElement(child)) {
648
+ const childVar = processElement(
649
+ child,
650
+ statements,
651
+ genId,
652
+ signals,
653
+ hash,
654
+ hydrate,
655
+ childNodesVar,
656
+ hydrate ? varName : undefined,
657
+ );
658
+ if (!hydrate) {
659
+ statements.push(
660
+ t.expressionStatement(
661
+ t.callExpression(
662
+ t.memberExpression(t.identifier(varName), t.identifier("append")),
663
+ [t.identifier(childVar)],
664
+ ),
665
+ ),
666
+ );
667
+ }
668
+ } else if (t.isJSXFragment(child)) {
669
+ const childVar = processFragment(
670
+ child,
671
+ statements,
672
+ genId,
673
+ signals,
674
+ hash,
675
+ hydrate,
676
+ childNodesVar,
677
+ hydrate ? varName : undefined,
678
+ );
679
+ if (!hydrate) {
680
+ statements.push(
681
+ t.expressionStatement(
682
+ t.callExpression(
683
+ t.memberExpression(t.identifier(varName), t.identifier("append")),
684
+ [t.identifier(childVar)],
685
+ ),
686
+ ),
687
+ );
688
+ }
689
+ } else if (t.isJSXText(child)) {
690
+ const text = child.value;
691
+ if (!text.trim()) continue;
692
+ if (hydrate) {
693
+ // SSR hydration: text already in DOM from SSR, do nothing.
694
+ // SPA navigation: childNodesVar pool is empty, create text node.
695
+ const poolLen = t.memberExpression(
696
+ t.identifier(childNodesVar!),
697
+ t.identifier("length"),
698
+ );
699
+ statements.push(
700
+ t.ifStatement(
701
+ t.binaryExpression("===", poolLen, t.numericLiteral(0)),
702
+ t.expressionStatement(
703
+ t.callExpression(
704
+ t.memberExpression(
705
+ t.identifier(varName),
706
+ t.identifier("append"),
707
+ ),
708
+ [
709
+ t.callExpression(
710
+ t.memberExpression(
711
+ t.identifier("document"),
712
+ t.identifier("createTextNode"),
713
+ ),
714
+ [t.stringLiteral(text.replace(/\s*\n\s*/g, " "))],
715
+ ),
716
+ ],
717
+ ),
718
+ ),
719
+ ),
720
+ );
721
+ } else {
722
+ statements.push(
723
+ t.expressionStatement(
724
+ t.callExpression(
725
+ t.memberExpression(t.identifier(varName), t.identifier("append")),
726
+ [
727
+ t.callExpression(
728
+ t.memberExpression(
729
+ t.identifier("document"),
730
+ t.identifier("createTextNode"),
731
+ ),
732
+ [t.stringLiteral(text)],
733
+ ),
734
+ ],
735
+ ),
736
+ ),
737
+ );
738
+ }
739
+ } else if (t.isJSXExpressionContainer(child)) {
740
+ if (t.isJSXEmptyExpression(child.expression)) continue;
741
+ const expr = child.expression as t.Expression;
742
+ if (t.isJSXElement(expr)) {
743
+ const childVar = processElement(
744
+ expr,
745
+ statements,
746
+ genId,
747
+ signals,
748
+ hash,
749
+ hydrate,
750
+ childNodesVar,
751
+ hydrate ? varName : undefined,
752
+ );
753
+ if (!hydrate) {
754
+ statements.push(
755
+ t.expressionStatement(
756
+ t.callExpression(
757
+ t.memberExpression(
758
+ t.identifier(varName),
759
+ t.identifier("append"),
760
+ ),
761
+ [t.identifier(childVar)],
762
+ ),
763
+ ),
764
+ );
765
+ }
766
+ } else if (t.isJSXFragment(expr)) {
767
+ const childVar = processFragment(
768
+ expr,
769
+ statements,
770
+ genId,
771
+ signals,
772
+ hash,
773
+ hydrate,
774
+ childNodesVar,
775
+ hydrate ? varName : undefined,
776
+ );
777
+ if (!hydrate) {
778
+ statements.push(
779
+ t.expressionStatement(
780
+ t.callExpression(
781
+ t.memberExpression(
782
+ t.identifier(varName),
783
+ t.identifier("append"),
784
+ ),
785
+ [t.identifier(childVar)],
786
+ ),
787
+ ),
788
+ );
789
+ }
790
+ } else if (containsSignal(expr, signals)) {
791
+ if (isPrimitive(expr)) {
792
+ // fast path: text node + textContent
793
+ buildTextNode(
794
+ varName,
795
+ expr,
796
+ statements,
797
+ genId,
798
+ hydrate,
799
+ childNodesVar,
800
+ hydrate ? varName : undefined,
801
+ );
802
+ } else {
803
+ // Check for keyed list pattern
804
+ const keyed = findKeyedMapExpr(expr, signals);
805
+ if (keyed) {
806
+ buildKeyedList(
807
+ varName,
808
+ keyed,
809
+ statements,
810
+ genId,
811
+ signals,
812
+ processElement,
813
+ hash,
814
+ hydrate,
815
+ childNodesVar,
816
+ hydrate ? varName : undefined,
817
+ );
818
+ } else {
819
+ buildAnchorMount(
820
+ varName,
821
+ expr,
822
+ statements,
823
+ genId,
824
+ hydrate,
825
+ childNodesVar,
826
+ hydrate ? varName : undefined,
827
+ );
828
+ }
829
+ }
830
+ } else {
831
+ // non-reactive: set once
832
+ if (hydrate) {
833
+ // hydrateSlot consumes the <!--g-->…<!--/g--> run and runs the
834
+ // value against the content SSR put inside it, so both deferred
835
+ // component children and inline JSX (`{list.map(…)}`) claim that
836
+ // subtree. The value must stay lazy: evaluating it as an argument
837
+ // would claim from the enclosing pool instead. Empty pool
838
+ // (SPA nav) falls back to a fresh insert.
839
+ statements.push(
840
+ t.expressionStatement(
841
+ t.callExpression(t.identifier("hydrateSlot"), [
842
+ t.identifier(childNodesVar!),
843
+ t.identifier(varName),
844
+ t.arrowFunctionExpression([], expr),
845
+ ]),
846
+ ),
847
+ );
848
+ } else {
849
+ statements.push(
850
+ t.expressionStatement(
851
+ t.callExpression(t.identifier("insert"), [
852
+ t.identifier(varName),
853
+ expr,
854
+ ]),
855
+ ),
856
+ );
857
+ }
858
+ }
859
+ }
860
+ }
861
+
862
+ return varName;
863
+ }