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