@ozsarman/clarityjs 0.6.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/codegen.js ADDED
@@ -0,0 +1,1934 @@
1
+ /**
2
+ * Clarity.js Code Generator
3
+ *
4
+ * Transforms a Clarity AST into vanilla JavaScript that uses the Clarity runtime.
5
+ * Output is clean, readable, and debuggable — not minified soup.
6
+ * Every generated line is traceable to the original .clarity source.
7
+ *
8
+ * Author: Claude (Anthropic)
9
+ */
10
+
11
+ import { processSourceMarkers } from './sourcemap.js';
12
+
13
+ // ─── Signal method names ──────────────────────────────────────────────────────
14
+ // These are the built-in methods of a Clarity signal object.
15
+ // When we see signal.METHOD(...) in statement context we must NOT wrap the signal
16
+ // receiver with .get() — the caller wants the signal itself, not its value.
17
+ const _SIGNAL_METHODS = new Set(['get', 'set', 'update', 'peek', 'subscribe']);
18
+
19
+ // ─── CodegenError ─────────────────────────────────────────────────────────────
20
+ export class CodegenError extends Error {
21
+ constructor(message, node) {
22
+ super(
23
+ `[Clarity Codegen] ${message}\n` +
24
+ ` → Node: ${node?.type ?? 'unknown'} at ${node?.loc?.line ?? '?'}:${node?.loc?.col ?? '?'}\n` +
25
+ ` LLM-hint: This is likely a parser output mismatch. Check the AST node type.`
26
+ );
27
+ this.name = 'CodegenError';
28
+ this.node = node;
29
+ }
30
+ }
31
+
32
+ // ─── Code Generator ───────────────────────────────────────────────────────────
33
+ export class CodeGenerator {
34
+ constructor(options = {}) {
35
+ this.runtimePath = options.runtimePath ?? './clarity-runtime.js';
36
+ this.routerPath = options.routerPath ?? './clarity-router.js';
37
+ this.sourceMap = options.sourceMap ?? false;
38
+ this.sourceFile = options.sourceFile ?? '<anonymous>';
39
+ this.outputFile = options.outputFile ?? '<output>';
40
+ this.sourceContent = options.sourceContent ?? '';
41
+ this._indent = 0;
42
+ this._output = [];
43
+ this._usedRuntime = new Set();
44
+ this._usedRouter = new Set(); // router symbols used in this file
45
+ }
46
+
47
+ generate(ast) {
48
+ this._output = [];
49
+ this._usedRuntime = new Set(['signal', 'effect', 'computed', 'h', 'appendChild', 'mount', 'batch', '_callCleanup']);
50
+ this._usedRouter = new Set();
51
+
52
+ // Collect store names so they can be injected into every component's signal list
53
+ const storeDecls = ast.stores ?? [];
54
+ this._storeNames = storeDecls.map(s => s.name);
55
+
56
+ // Process all components — only the last one gets `export default`
57
+ const lastIdx = ast.components.length - 1;
58
+ const componentCode = ast.components.map((c, i) => this._genComponent(c, i === lastIdx)).join('\n\n');
59
+
60
+ // Build header with runtime imports
61
+ const runtimeImports = `import { ${[...this._usedRuntime].join(', ')} } from '${this.runtimePath}';`;
62
+
63
+ // Router imports — only emitted when the file uses <Route> / <Link> / navigate
64
+ // Internal names are aliased to avoid conflicts with user-defined variables.
65
+ const routerAlias = {
66
+ navigate: '_clarityNavigate',
67
+ navigateReplace: '_clarityNavReplace',
68
+ currentPath: '_clarityCurrentPath',
69
+ routeParams: '_clarityRouteParams',
70
+ matchRoute: '_clarityMatchRoute',
71
+ Switch: '_claritySwitch',
72
+ Outlet: '_clarityOutlet',
73
+ beforeEnter: '_clarityBeforeEnter',
74
+ };
75
+ let routerImports = '';
76
+ if (this._usedRouter.size > 0) {
77
+ const needed = [...this._usedRouter].map(alias => {
78
+ const original = Object.entries(routerAlias).find(([, v]) => v === alias)?.[0] ?? alias;
79
+ return `${original} as ${alias}`;
80
+ });
81
+ routerImports = `import { ${needed.join(', ')} } from '${this.routerPath}';`;
82
+ }
83
+
84
+ // User imports
85
+ const userImports = ast.imports.map(i => this._genImport(i)).join('\n');
86
+
87
+ // Store declarations — emitted as exported signals before components
88
+ const storeCode = storeDecls.length > 0
89
+ ? storeDecls.map(s => `export const ${s.name} = signal(${this._genExpr(s.init)});`).join('\n')
90
+ : null;
91
+
92
+ const parts = [
93
+ `// Generated by Clarity.js compiler v0.1.0`,
94
+ `// DO NOT EDIT — edit the .clarity source file instead`,
95
+ ``,
96
+ runtimeImports,
97
+ routerImports || null,
98
+ userImports,
99
+ ``,
100
+ storeCode || null,
101
+ storeCode ? `` : null,
102
+ componentCode,
103
+ ].filter(p => p !== null);
104
+
105
+ const annotatedCode = parts.join('\n');
106
+
107
+ // Strip //@ SM:N markers and build V3 source map if requested
108
+ if (this.sourceMap) {
109
+ return processSourceMarkers(annotatedCode, {
110
+ filename: this.outputFile,
111
+ sourceFile: this.sourceFile,
112
+ sourceContent: this.sourceContent,
113
+ inline: true,
114
+ });
115
+ }
116
+
117
+ // Without source maps: strip markers silently (they're just comments anyway,
118
+ // but cleaner output without them). Allow leading whitespace (indented markers).
119
+ const cleanCode = annotatedCode.replace(/^\s*\/\/@ SM:\d+\s*\n?/gm, '');
120
+ return { code: cleanCode, map: null };
121
+ }
122
+
123
+ // ── Import ──
124
+ _genImport(node) {
125
+ const names = node.names.join(', ');
126
+ return `import { ${names} } from '${node.source}';`;
127
+ }
128
+
129
+ // ── Component ──
130
+ _genComponent(comp, isLast = true) {
131
+ // Param names (without children) — used for duck-typing reactive props
132
+ const compParamNames = comp.params.map(p => p.name);
133
+
134
+ // Collect scoped style blocks
135
+ const styleBlocks = comp.body.filter(n => n.type === 'StyleBlock');
136
+ const hasScopedCSS = styleBlocks.length > 0;
137
+ const scopeClass = `_c-${comp.name}`; // e.g. _c-Button
138
+
139
+ // Separate body nodes by type
140
+ const stateDecls = comp.body.filter(n => n.type === 'StateDecl');
141
+ const asyncStateDecls = comp.body.filter(n => n.type === 'AsyncStateDecl');
142
+ const refDecls = comp.body.filter(n => n.type === 'RefDecl');
143
+ const computedDecls = comp.body.filter(n => n.type === 'ComputedDecl');
144
+ const effectDecls = comp.body.filter(n => n.type === 'EffectDecl');
145
+ const renderBlock = comp.body.find(n => n.type === 'RenderBlock');
146
+ const serverBlock = comp.body.find(n => n.type === 'ServerBlock');
147
+ // Names declared via `data name = expr` inside server { } — these will be
148
+ // pre-populated by the server runner and passed as __ssr prop.
149
+ const serverVarNames = new Set(serverBlock?.data?.map(d => d.name) ?? []);
150
+
151
+ // Destructured prop list — always include 'children' so <slot /> works.
152
+ // When the component has a server block we also destructure __ssr so that
153
+ // signal initialisers can reference it: signal(__ssr?.posts ?? [])
154
+ const destructuredParams = [...compParamNames, 'children'];
155
+ if (serverVarNames.size > 0) destructuredParams.push('__ssr');
156
+ const paramStr = `{ ${destructuredParams.join(', ')} } = {}`;
157
+
158
+ const aiDecls = comp.body.filter(n => n.type === 'AIDecl');
159
+ const beforeMountBlocks = comp.body.filter(n => n.type === 'BeforeMountBlock');
160
+ const onMountBlocks = comp.body.filter(n => n.type === 'OnMountBlock');
161
+ const onCleanupBlocks = comp.body.filter(n => n.type === 'OnCleanupBlock');
162
+ const actionDecls = comp.body.filter(n => n.type === 'ActionDecl');
163
+ const hasLifecycle = beforeMountBlocks.length > 0 || onMountBlocks.length > 0 || onCleanupBlocks.length > 0;
164
+
165
+ // All reactive names (state signals + computed signals + refs + async state signals + stores)
166
+ // — used for .get() codegen
167
+ const asyncStateSignalNames = asyncStateDecls.flatMap(a => [
168
+ a.name,
169
+ `${a.name}Loading`,
170
+ `${a.name}Error`,
171
+ ]);
172
+ const signalNames = [
173
+ ...stateDecls.map(s => s.name),
174
+ ...asyncStateSignalNames,
175
+ ...refDecls.map(r => r.name),
176
+ ...computedDecls.map(c => c.name),
177
+ ...(this._storeNames ?? []),
178
+ ];
179
+ // Store on instance so _genAssignTarget and _genStmt can reach them
180
+ this._currentSignals = signalNames;
181
+ this._currentParams = compParamNames;
182
+
183
+ const lines = [];
184
+ const sm = (loc) => loc?.line ? `//@ SM:${loc.line}` : null; // source map marker helper
185
+
186
+ sm(comp.loc) && lines.push(sm(comp.loc));
187
+ lines.push(`export function ${comp.name}(${paramStr}) {`);
188
+ this._indent++;
189
+
190
+ // Effect disposer tracking — collect all effect() return values so we can
191
+ // dispose them on unmount and avoid memory leaks.
192
+ lines.push(this._i(`const _disposes = [];`));
193
+ lines.push(this._i(`const _e = (d) => { _disposes.push(d); return d; };`));
194
+ lines.push('');
195
+
196
+ // CSS Scoping — inject component-scoped <style> once into document.head.
197
+ // Each CSS selector is prefixed with ._c-ComponentName so rules are isolated.
198
+ // The style tag is identified by a unique id so it is only injected once
199
+ // even when multiple instances of the component are mounted.
200
+ if (hasScopedCSS) {
201
+ const rawCSS = styleBlocks.map(s => s.css).join('\n');
202
+ const scopedCSS = this._scopeCSS(rawCSS, `.${scopeClass}`);
203
+ const cssLiteral = JSON.stringify(scopedCSS);
204
+ lines.push(this._i(`// Scoped CSS — injected once per component type`));
205
+ lines.push(this._i(`if (!document.getElementById('clarity-scope-${comp.name}')) {`));
206
+ this._indent++;
207
+ lines.push(this._i(`const _style = document.createElement('style');`));
208
+ lines.push(this._i(`_style.id = 'clarity-scope-${comp.name}';`));
209
+ lines.push(this._i(`_style.textContent = ${cssLiteral};`));
210
+ lines.push(this._i(`document.head.appendChild(_style);`));
211
+ this._indent--;
212
+ lines.push(this._i(`}`));
213
+ lines.push('');
214
+ }
215
+
216
+ // AI contract (as JSDoc comment for tooling)
217
+ if (aiDecls.length > 0) {
218
+ lines.push(this._i(`// AI Contract`));
219
+ for (const ai of aiDecls) {
220
+ lines.push(this._i(`// ai:${ai.capability} = [${ai.targets.join(', ')}]`));
221
+ }
222
+ lines.push('');
223
+ }
224
+
225
+ // State declarations → signals
226
+ // Forbidden signals are wrapped with _protectedSignal() so AI actions can't write them.
227
+ // Server-seeded signals use __ssr?.name (passed from the server runner) as the
228
+ // initial value, falling back to the declared default on the client.
229
+ const forbDecl0 = aiDecls.find(a => a.capability === 'forbidden');
230
+ const forbNames = new Set(forbDecl0 ? forbDecl0.targets : []);
231
+ if (stateDecls.length > 0) {
232
+ lines.push(this._i(`// State`));
233
+ for (const s of stateDecls) {
234
+ sm(s.loc) && lines.push(this._i(sm(s.loc)));
235
+ // Compute the initial-value expression — server-seeded signals prefer __ssr
236
+ const defaultExpr = this._genExpr(s.init);
237
+ const initExpr = serverVarNames.has(s.name)
238
+ ? `(__ssr !== undefined && __ssr.${s.name} !== undefined) ? __ssr.${s.name} : ${defaultExpr}`
239
+ : defaultExpr;
240
+ if (forbNames.has(s.name)) {
241
+ // Wrap with _protectedSignal for runtime AI permission enforcement
242
+ lines.push(this._i(`const ${s.name} = _protectedSignal(signal(${initExpr}), '${comp.name}', '${s.name}');`));
243
+ this._usedRuntime.add('_protectedSignal');
244
+ } else {
245
+ lines.push(this._i(`const ${s.name} = signal(${initExpr});`));
246
+ }
247
+ }
248
+ lines.push('');
249
+ }
250
+
251
+ // Async state declarations → data signal + loading + error signals
252
+ if (asyncStateDecls.length > 0) {
253
+ lines.push(this._i(`// Async State`));
254
+ for (const a of asyncStateDecls) {
255
+ sm(a.loc) && lines.push(this._i(sm(a.loc)));
256
+ lines.push(this._i(`const ${a.name} = signal(${this._genExpr(a.init)});`));
257
+ lines.push(this._i(`const ${a.name}Loading = signal(true);`));
258
+ lines.push(this._i(`const ${a.name}Error = signal("");`));
259
+ }
260
+ lines.push('');
261
+ }
262
+
263
+ // Ref declarations → signal(null) that gets auto-populated when element is created
264
+ if (refDecls.length > 0) {
265
+ lines.push(this._i(`// Refs`));
266
+ for (const r of refDecls) {
267
+ sm(r.loc) && lines.push(this._i(sm(r.loc)));
268
+ lines.push(this._i(`const ${r.name} = signal(null);`));
269
+ }
270
+ lines.push('');
271
+ }
272
+
273
+ // Computed declarations
274
+ if (computedDecls.length > 0) {
275
+ lines.push(this._i(`// Computed`));
276
+ for (const c of computedDecls) {
277
+ sm(c.loc) && lines.push(this._i(sm(c.loc)));
278
+ // FIX: use _genExprRead so signal refs inside computed bodies call .get()
279
+ lines.push(this._i(`const ${c.name} = computed(() => ${this._genExprRead(c.expr, signalNames)});`));
280
+ }
281
+ lines.push('');
282
+ }
283
+
284
+ // Effect declarations
285
+ if (effectDecls.length > 0) {
286
+ lines.push(this._i(`// Effects`));
287
+ for (const eff of effectDecls) {
288
+ sm(eff.loc) && lines.push(this._i(sm(eff.loc)));
289
+ lines.push(...this._genEffect(eff));
290
+ }
291
+ lines.push('');
292
+ }
293
+
294
+ // Render block → DOM construction
295
+ if (renderBlock) {
296
+ lines.push(this._i(`// Render`));
297
+ // Pass state+computed names AND param names; params use duck-typing (.get() if signal)
298
+ const [renderLines, rootVar] = this._genJSX(renderBlock.root, signalNames, { n: 0 }, compParamNames);
299
+ lines.push(...renderLines.map(l => this._i(l)));
300
+
301
+ // Apply the scope class to the root element so scoped CSS rules match
302
+ if (hasScopedCSS) {
303
+ lines.push(this._i(`if (${rootVar} instanceof Element) ${rootVar}.classList.add('${scopeClass}');`));
304
+ }
305
+ lines.push('');
306
+
307
+ // Lifecycle hooks — attached to root element as special properties.
308
+ // mount() in the runtime calls __clarity_mount__ after DOM insertion,
309
+ // and returns an unmount() that calls __clarity_cleanup__.
310
+ if (onMountBlocks.length > 0) {
311
+ lines.push(this._i(`// onMount — runs after component is inserted into the DOM`));
312
+ lines.push(this._i(`${rootVar}.__clarity_mount__ = () => {`));
313
+ this._indent++;
314
+ for (const block of onMountBlocks) {
315
+ for (const stmt of this._genBlockStmts(block.body.body)) {
316
+ lines.push(this._i(stmt));
317
+ }
318
+ }
319
+ this._indent--;
320
+ lines.push(this._i(`};`));
321
+ lines.push('');
322
+ }
323
+
324
+ if (onCleanupBlocks.length > 0 || true /* always generate cleanup for effect disposal */) {
325
+ lines.push(this._i(`// onCleanup — runs on unmount, disposes all reactive effects`));
326
+ lines.push(this._i(`${rootVar}.__clarity_cleanup__ = () => {`));
327
+ this._indent++;
328
+ // User-defined cleanup code
329
+ for (const block of onCleanupBlocks) {
330
+ for (const stmt of this._genBlockStmts(block.body.body)) {
331
+ lines.push(this._i(stmt));
332
+ }
333
+ }
334
+ // Always dispose all tracked effects
335
+ lines.push(this._i(`_disposes.forEach(d => d());`));
336
+ this._indent--;
337
+ lines.push(this._i(`};`));
338
+ lines.push('');
339
+ }
340
+
341
+ // Action declarations — AI-callable functions
342
+ // Each `action name(params) { body }` becomes a plain JS function that
343
+ // closes over the component's state signals. The AI bridge calls them
344
+ // through act() with full audit instrumentation.
345
+ if (actionDecls.length > 0) {
346
+ lines.push(this._i(`// AI Actions`));
347
+ for (const a of actionDecls) {
348
+ const paramList = a.params.join(', ');
349
+ lines.push(this._i(`function ${a.name}(${paramList}) {`));
350
+ this._indent++;
351
+ for (const stmt of this._genBlockStmts(a.body.body)) {
352
+ lines.push(this._i(stmt));
353
+ }
354
+ this._indent--;
355
+ lines.push(this._i(`}`));
356
+ }
357
+ lines.push('');
358
+ }
359
+
360
+ // AI Runtime Bridge ─────────────────────────────────────────────────────
361
+ // Attaches __clarity_ai__ to the root element so AI agents can:
362
+ // contract.readable['field']() → audited read of declared-readable state
363
+ // contract.snapshot() → audited snapshot of all readable state
364
+ // contract.act('name', ...args) → audited, guarded action call
365
+ // contract.forbidden → list of off-limits field names
366
+ // contract.batch(fn) → multiple actions as one atomic audit entry
367
+ if (aiDecls.length > 0 || actionDecls.length > 0) {
368
+ lines.push(this._i(`// AI Runtime Bridge`));
369
+ lines.push(this._i(`${rootVar}.__clarity_ai__ = {`));
370
+ this._indent++;
371
+ lines.push(this._i(`component: '${comp.name}',`));
372
+
373
+ // readable — audited getters for each declared-readable signal
374
+ const readDecl = aiDecls.find(a => a.capability === 'readable');
375
+ if (readDecl) {
376
+ const entries = readDecl.targets.map(t => {
377
+ const base = t.split('.')[0];
378
+ return `'${t}': () => { _aiAuditRecord({ type: 'read', component: '${comp.name}', field: '${t}', value: ${base}.get() }); return ${base}.get(); }`;
379
+ }).join(', ');
380
+ lines.push(this._i(`readable: { ${entries} },`));
381
+ // snapshot — reads all readable fields at once, logged as a single entry
382
+ const snapFields = readDecl.targets.map(t => {
383
+ const base = t.split('.')[0];
384
+ return `'${t}': ${base}.get()`;
385
+ }).join(', ');
386
+ lines.push(this._i(`snapshot: () => { const _s = { ${snapFields} }; _aiAuditRecord({ type: 'snapshot', component: '${comp.name}', value: _s }); return _s; },`));
387
+ this._usedRuntime.add('_aiAuditRecord');
388
+ } else {
389
+ lines.push(this._i(`readable: {},`));
390
+ lines.push(this._i(`snapshot: () => ({}),`));
391
+ }
392
+
393
+ // read(field) — the single sanctioned read entrypoint for agents. Returns
394
+ // a declared-readable value, or throws ClarityAIForbiddenError (audited)
395
+ // for forbidden / undeclared fields.
396
+ lines.push(this._i(`read(name) { if (Object.prototype.hasOwnProperty.call(this.readable, name)) return this.readable[name](); _aiThrowForbidden('${comp.name}', name, 'read'); },`));
397
+ this._usedRuntime.add('_aiThrowForbidden');
398
+
399
+ // actionable — callable action functions, each wrapped with audit + context guard
400
+ // Prefers explicit `action` declarations; falls back to ai:actionable targets.
401
+ const actDecl = aiDecls.find(a => a.capability === 'actionable');
402
+ const explicitActionNames = actionDecls.map(a => a.name);
403
+ const declaredActionNames = actDecl ? actDecl.targets : [];
404
+ // Merge: explicit action declarations + ai:actionable references
405
+ const allActionNames = [...new Set([...explicitActionNames, ...declaredActionNames])];
406
+
407
+ if (allActionNames.length > 0) {
408
+ const entries = allActionNames.map(t =>
409
+ `'${t}': (...args) => { ` +
410
+ `_aiAuditRecord({ type: 'act', component: '${comp.name}', action: '${t}', args }); ` +
411
+ `_setAIActionContext(true); ` +
412
+ `try { return ${t}(...args); } finally { _setAIActionContext(false); } }`
413
+ ).join(', ');
414
+ lines.push(this._i(`actionable: { ${entries} },`));
415
+ lines.push(this._i(`act(name, ...args) {`));
416
+ this._indent++;
417
+ lines.push(this._i(`if (name in this.actionable) return this.actionable[name](...args);`));
418
+ lines.push(this._i(`_aiAuditRecord({ type: 'unknown_action', component: '${comp.name}', action: name });`));
419
+ lines.push(this._i(`throw new Error('[Clarity AI] Unknown action \\'' + name + '\\' on ${comp.name}. Declared: ${allActionNames.join(', ')}');`));
420
+ this._indent--;
421
+ lines.push(this._i(`},`));
422
+ this._usedRuntime.add('_aiAuditRecord');
423
+ this._usedRuntime.add('_setAIActionContext');
424
+ } else {
425
+ lines.push(this._i(`actionable: {},`));
426
+ lines.push(this._i(`act(name) { _aiAuditRecord({ type: 'unknown_action', component: '${comp.name}', action: name }); throw new Error('[Clarity AI] No actions declared on ${comp.name}'); },`));
427
+ this._usedRuntime.add('_aiAuditRecord');
428
+ }
429
+
430
+ // batch — execute multiple actions as one logical unit
431
+ lines.push(this._i(`batch(fn) { return fn(this); },`));
432
+
433
+ // forbidden — field names AI must never read or write
434
+ const forbList2 = forbNames.size > 0
435
+ ? [...forbNames].map(t => `'${t}'`).join(', ')
436
+ : '';
437
+ lines.push(this._i(`forbidden: [${forbList2}],`));
438
+
439
+ this._indent--;
440
+ lines.push(this._i(`};`));
441
+ lines.push('');
442
+ }
443
+
444
+ // beforeMount — runs after the root node is built but BEFORE it is
445
+ // returned/inserted into the DOM (Vue onBeforeMount semantics).
446
+ if (beforeMountBlocks.length > 0) {
447
+ lines.push(this._i(`// beforeMount — runs after render, before DOM insertion`));
448
+ for (const block of beforeMountBlocks) {
449
+ for (const stmt of this._genBlockStmts(block.body.body)) {
450
+ lines.push(this._i(stmt));
451
+ }
452
+ }
453
+ lines.push('');
454
+ }
455
+
456
+ lines.push(this._i(`return ${rootVar};`));
457
+ } else {
458
+ lines.push(this._i(`return document.createComment('${comp.name}: no render block');`));
459
+ }
460
+
461
+ this._indent--;
462
+ lines.push(`}`);
463
+ lines.push('');
464
+
465
+ // __clarity_server__ — async data loader called by renderToStringAsync (SSR).
466
+ // Receives the same props as the component, runs all `data name = expr` bindings
467
+ // (which may use `await`), and returns a plain object { name: value, ... }.
468
+ // The result is passed back as the __ssr prop so signal initialisers can pick it up.
469
+ if (serverBlock && serverBlock.data && serverBlock.data.length > 0) {
470
+ const paramDestructure = compParamNames.length > 0
471
+ ? `const { ${compParamNames.join(', ')} } = _props;`
472
+ : '';
473
+ lines.push(`${comp.name}.__clarity_server__ = async function(_props = {}, _ctx = {}) {`);
474
+ this._indent++;
475
+ if (paramDestructure) lines.push(this._i(paramDestructure));
476
+ for (const d of serverBlock.data) {
477
+ lines.push(this._i(`const ${d.name} = ${this._genExpr(d.init)};`));
478
+ }
479
+ const returnFields = serverBlock.data.map(d => d.name).join(', ');
480
+ lines.push(this._i(`return { ${returnFields} };`));
481
+ this._indent--;
482
+ lines.push(`};`);
483
+ lines.push('');
484
+ }
485
+
486
+ if (isLast) lines.push(`export default ${comp.name};`);
487
+
488
+ return lines.join('\n');
489
+ }
490
+
491
+ // ── Effect ──
492
+ _genEffect(eff) {
493
+ const lines = [];
494
+ const bodyLines = this._genBlockStmts(eff.body.body);
495
+
496
+ // Wrap with _e() so the dispose function is tracked for cleanup on unmount
497
+ lines.push(this._i(`_e(effect(() => {`));
498
+ this._indent++;
499
+
500
+ // Read each dependency to subscribe
501
+ for (const dep of eff.deps) {
502
+ lines.push(this._i(`const _${dep} = ${dep}.get(); // tracked`));
503
+ }
504
+
505
+ for (const stmt of bodyLines) {
506
+ lines.push(this._i(stmt));
507
+ }
508
+
509
+ this._indent--;
510
+ lines.push(this._i(`}));`));
511
+ return lines;
512
+ }
513
+
514
+ // ── JSX → h() calls ──
515
+ // Returns [lines, varName]
516
+ // params: component param names that may receive signals — use duck-typing
517
+ _genJSX(jsxNode, signals = [], counter = { n: 0 }, params = []) {
518
+ if (!jsxNode) return [[], 'null'];
519
+
520
+ const varName = `_el${counter.n++}`;
521
+
522
+ if (jsxNode.type === 'JSXText') {
523
+ // The parser already collapses internal whitespace and adds at most one
524
+ // boundary space; preserve it so inline text spacing is correct
525
+ // (e.g. "The " + <span> + " framework"). Do NOT trim here.
526
+ const escaped = JSON.stringify(jsxNode.value);
527
+ return [[`const ${varName} = document.createTextNode(${escaped});`], varName];
528
+ }
529
+
530
+ if (jsxNode.type === 'JSXExpr') {
531
+ // Reactive expression → text node with effect
532
+ // Generate the expression in "read" mode (signals use .get())
533
+ const expr = this._genExprRead(jsxNode.expr, signals, params);
534
+ const isSignalRef = this._exprReadsSignals(jsxNode.expr, signals, params);
535
+
536
+ if (isSignalRef) {
537
+ return [
538
+ [
539
+ `const ${varName} = document.createTextNode('');`,
540
+ `_e(effect(() => { ${varName}.textContent = String(${expr}); }));`,
541
+ ],
542
+ varName,
543
+ ];
544
+ } else {
545
+ return [[`const ${varName} = document.createTextNode(String(${expr}));`], varName];
546
+ }
547
+ }
548
+
549
+ if (jsxNode.type !== 'JSXElement') {
550
+ return [[`const ${varName} = document.createComment('unknown node');`], varName];
551
+ }
552
+
553
+ const tag = jsxNode.tag;
554
+
555
+ // ── Built-in directives ──────────────────────────────────────────────────
556
+
557
+ // <slot /> or <slot name="header" /> or <slot name="row" :data="item" />
558
+ // Renders children passed from the parent component into a DocumentFragment.
559
+ //
560
+ // Children are now passed as:
561
+ // { default: [...], header: [...], footer: [...] }
562
+ // for named slots. A legacy flat-array format is also accepted for backward
563
+ // compat with old-style children: [...] usage.
564
+ //
565
+ // SCOPED SLOTS — if the slot has a :data attribute (or any :propName attr),
566
+ // the slot content is treated as a render function that receives the data:
567
+ //
568
+ // Component body: <slot name="row" :data="item" />
569
+ // Generated code: children.row(item) (returns Node[])
570
+ //
571
+ // Parent template: <template slot="row" let:data> <td>{data.name}</td> </template>
572
+ // Generated code: row: (data) => [<rendered nodes>]
573
+ //
574
+ // Usage inside a component:
575
+ // <slot /> → renders children.default (array)
576
+ // <slot name="header" /> → renders children.header (array)
577
+ // <slot name="row" :data="item"> → calls children.row(item) (function)
578
+ if (tag === 'slot') {
579
+ const nameAttr = jsxNode.attrs.find(a => a.name === 'name');
580
+ const slotName = nameAttr?.value?.type === 'Literal' ? nameAttr.value.value : null;
581
+
582
+ // Detect scoped slot (:data="expr" or any :propName="expr")
583
+ const dataAttr = jsxNode.attrs.find(
584
+ a => a.type === 'Attr' && a.name.startsWith(':') && a.name !== ':key'
585
+ );
586
+
587
+ if (dataAttr) {
588
+ // Scoped slot — children.slotName is a render function
589
+ const dataExpr = this._genExprRead(dataAttr.value, signals, params);
590
+ const slotFnExpr = slotName ? `children?.${slotName}` : `children?.default`;
591
+ const lines = [
592
+ `const ${varName} = document.createDocumentFragment();`,
593
+ `if (typeof ${slotFnExpr} === 'function') {`,
594
+ ` const _scopedNodes = [${slotFnExpr}(${dataExpr})].flat(Infinity);`,
595
+ ` _scopedNodes.forEach(_n => _n && ${varName}.appendChild(_n));`,
596
+ `} else if (Array.isArray(${slotFnExpr})) {`,
597
+ ` ${slotFnExpr}.forEach(_c => _c && ${varName}.appendChild(_c));`,
598
+ `}`,
599
+ ];
600
+ return [lines, varName];
601
+ }
602
+
603
+ // Regular slot — children is an array
604
+ const contentExpr = slotName
605
+ ? `(children?.${slotName} ?? [])`
606
+ : `(children?.default ?? (Array.isArray(children) ? children : []))`;
607
+ return [
608
+ [
609
+ `const ${varName} = document.createDocumentFragment();`,
610
+ `(Array.isArray(${contentExpr}) ? ${contentExpr} : [${contentExpr}]).forEach(_c => _c && ${varName}.appendChild(_c));`,
611
+ ],
612
+ varName,
613
+ ];
614
+ }
615
+
616
+ // <when cond={expr}> thenChild </when>
617
+ // <when cond={expr}> thenChild <else/> elseChild </when>
618
+ if (tag === 'when') {
619
+ // Support both `test` (documented API) and legacy `cond`
620
+ const condAttr = jsxNode.attrs.find(a => a.name === 'test' || a.name === 'cond');
621
+ const condExpr = condAttr ? this._genExprRead(condAttr.value, signals, params) : 'true';
622
+ const allLines2 = [];
623
+
624
+ // Split children at <else> marker.
625
+ // Two supported forms:
626
+ // Form A (wrapping): <when><span>yes</span><else><span>no</span></else></when>
627
+ // → then = children before <else>, else = children of <else> node
628
+ // Form B (self-close): <when><span>yes</span></when><else/><span>no</span>
629
+ // (not currently supported — Form A is canonical)
630
+ const elseIdx = jsxNode.children.findIndex(c => c.type === 'JSXElement' && c.tag === 'else');
631
+ const thenChildren = elseIdx >= 0 ? jsxNode.children.slice(0, elseIdx) : jsxNode.children;
632
+ // Else children are the children INSIDE the <else> element, not siblings after it
633
+ const elseEl = elseIdx >= 0 ? jsxNode.children[elseIdx] : null;
634
+ const elseChildren = elseEl ? (elseEl.children ?? []) : [];
635
+
636
+ const whenN = counter.n;
637
+ allLines2.push(`const ${varName} = document.createComment('clarity:when');`);
638
+ allLines2.push(`let _when${whenN}_nodes = [];`);
639
+ // Defer so effect runs after the comment node is inserted into the DOM tree
640
+ allLines2.push(`queueMicrotask(() => _e(effect(() => {`);
641
+ allLines2.push(` _when${whenN}_nodes.forEach(n => { n.parentNode?.removeChild(n); _callCleanup(n); });`);
642
+ allLines2.push(` _when${whenN}_nodes = [];`);
643
+ allLines2.push(` if (${condExpr}) {`);
644
+
645
+ const thenCounter = { n: counter.n + 100 };
646
+ for (const child of thenChildren) {
647
+ const [cl, cv] = this._genJSX(child, signals, thenCounter, params);
648
+ allLines2.push(...cl.map(l => ' ' + l));
649
+ allLines2.push(` ${varName}.parentNode?.insertBefore(${cv}, ${varName});`);
650
+ allLines2.push(` _when${whenN}_nodes.push(${cv});`);
651
+ }
652
+ counter.n = thenCounter.n;
653
+
654
+ if (elseChildren.length > 0) {
655
+ allLines2.push(` } else {`);
656
+ const elseCounter = { n: counter.n + 200 };
657
+ for (const child of elseChildren) {
658
+ const [cl, cv] = this._genJSX(child, signals, elseCounter, params);
659
+ allLines2.push(...cl.map(l => ' ' + l));
660
+ allLines2.push(` ${varName}.parentNode?.insertBefore(${cv}, ${varName});`);
661
+ allLines2.push(` _when${whenN}_nodes.push(${cv});`);
662
+ }
663
+ counter.n = elseCounter.n;
664
+ }
665
+
666
+ allLines2.push(` }`);
667
+ allLines2.push(`})));`);
668
+ this._usedRuntime.add('effect');
669
+ return [allLines2, varName];
670
+ }
671
+
672
+ // ── Error boundary ───────────────────────────────────────────────────────
673
+
674
+ // <catch fallback={expr}> risky children </catch>
675
+ // Wraps child rendering in try/catch. If any child throws during initial
676
+ // render, the fallback expression is rendered instead.
677
+ //
678
+ // Usage:
679
+ // <catch fallback={ <p>Oops! Something broke.</p> }>
680
+ // <UserProfile />
681
+ // </catch>
682
+ if (tag === 'catch') {
683
+ const fallbackAttr = jsxNode.attrs.find(a => a.name === 'fallback');
684
+ const allLines2 = [];
685
+ const catchN = counter.n++;
686
+
687
+ allLines2.push(`const ${varName} = document.createComment('clarity:catch');`);
688
+ allLines2.push(`let _catch${catchN}_nodes = [];`);
689
+ allLines2.push(`const _catch${catchN}_remove = () => {`);
690
+ allLines2.push(` _catch${catchN}_nodes.forEach(n => n.parentNode?.removeChild(n));`);
691
+ allLines2.push(` _catch${catchN}_nodes = [];`);
692
+ allLines2.push(`};`);
693
+ allLines2.push(`try {`);
694
+
695
+ const tryCounter = { n: counter.n + 700 };
696
+ for (const child of jsxNode.children) {
697
+ if (child.type === 'JSXText' && !child.value.trim()) continue;
698
+ const [cl, cv] = this._genJSX(child, signals, tryCounter, params);
699
+ allLines2.push(...cl.map(l => ' ' + l));
700
+ allLines2.push(` ${varName}.parentNode?.insertBefore(${cv}, ${varName});`);
701
+ allLines2.push(` _catch${catchN}_nodes.push(${cv});`);
702
+ }
703
+ counter.n = tryCounter.n;
704
+
705
+ allLines2.push(`} catch (_err${catchN}) {`);
706
+ allLines2.push(` _catch${catchN}_remove();`);
707
+
708
+ // Fallback: if fallback attr is an expression, evaluate it
709
+ if (fallbackAttr) {
710
+ const fbExpr = this._genExpr(fallbackAttr.value);
711
+ allLines2.push(` const _fb${catchN} = (() => {`);
712
+ allLines2.push(` try { const _v = ${fbExpr}; return typeof _v === 'string' ? document.createTextNode(_v) : _v; }`);
713
+ allLines2.push(` catch (_) { return document.createTextNode('[render error]'); }`);
714
+ allLines2.push(` })();`);
715
+ allLines2.push(` ${varName}.parentNode?.insertBefore(_fb${catchN}, ${varName});`);
716
+ allLines2.push(` _catch${catchN}_nodes.push(_fb${catchN});`);
717
+ } else {
718
+ // No fallback attr — render a default error text node
719
+ allLines2.push(` const _fb${catchN} = document.createTextNode('[render error: ' + (_err${catchN}?.message ?? _err${catchN}) + ']');`);
720
+ allLines2.push(` ${varName}.parentNode?.insertBefore(_fb${catchN}, ${varName});`);
721
+ allLines2.push(` _catch${catchN}_nodes.push(_fb${catchN});`);
722
+ }
723
+
724
+ allLines2.push(` if (typeof console !== 'undefined') console.error('[Clarity] <catch> boundary caught:', _err${catchN});`);
725
+ allLines2.push(`}`);
726
+ return [allLines2, varName];
727
+ }
728
+
729
+ // ── Router directives ────────────────────────────────────────────────────
730
+
731
+ // <Provider ctx={MyCtx} value={someValue}> children </Provider>
732
+ // Pushes a context value for all children during the synchronous render,
733
+ // then pops it after — equivalent to React <Context.Provider value={}>.
734
+ // If value is a signal, useContext() inside effects will track it reactively.
735
+ if (tag === 'Provider') {
736
+ const ctxAttr = jsxNode.attrs.find(a => a.name === 'ctx');
737
+ const valAttr = jsxNode.attrs.find(a => a.name === 'value');
738
+ const ctxExpr = ctxAttr ? this._genExpr(ctxAttr.value) : 'undefined';
739
+ const valExpr = valAttr ? this._genExpr(valAttr.value) : 'undefined';
740
+ const provN = counter.n++;
741
+ const allLines2 = [];
742
+
743
+ allLines2.push(`_pushContext(${ctxExpr}, ${valExpr});`);
744
+ allLines2.push(`const ${varName} = document.createDocumentFragment();`);
745
+
746
+ const provCounter = { n: counter.n + 400 };
747
+ for (const child of jsxNode.children) {
748
+ if (child.type === 'JSXText' && !child.value.trim()) continue;
749
+ const [cl, cv] = this._genJSX(child, signals, provCounter, params);
750
+ allLines2.push(...cl);
751
+ allLines2.push(`if (${cv} instanceof Node) ${varName}.appendChild(${cv});`);
752
+ }
753
+ counter.n = provCounter.n;
754
+
755
+ allLines2.push(`_popContext(${ctxExpr});`);
756
+ this._usedRuntime.add('_pushContext');
757
+ this._usedRuntime.add('_popContext');
758
+ return [allLines2, varName];
759
+ }
760
+
761
+ // <Outlet /> — renders a placeholder where nested child routes appear.
762
+ // Equivalent to <Outlet> from React Router v6.
763
+ // In simple mode (no children) it just returns a comment anchor and the
764
+ // nested <Route> nodes rendered as siblings handle conditional display.
765
+ if (tag === 'Outlet') {
766
+ const outletN = counter.n++;
767
+ const allLines2 = [];
768
+
769
+ const routesAttr = jsxNode.attrs.find(a => a.name === 'routes');
770
+ const fallbackAttr = jsxNode.attrs.find(a => a.name === 'fallback');
771
+
772
+ if (routesAttr || fallbackAttr) {
773
+ // Advanced mode: explicit routes prop
774
+ const routesExpr = routesAttr ? this._genExpr(routesAttr.value) : '[]';
775
+ const fallbackExpr = fallbackAttr ? this._genExpr(fallbackAttr.value) : 'null';
776
+ allLines2.push(`const ${varName} = _clarityOutlet({ routes: ${routesExpr}, fallback: ${fallbackExpr} });`);
777
+ this._usedRouter.add('_clarityOutlet');
778
+ } else {
779
+ // Simple mode: transparent comment anchor — nested routes render after it
780
+ allLines2.push(`const ${varName} = document.createComment('clarity:outlet');`);
781
+ }
782
+ return [allLines2, varName];
783
+ }
784
+
785
+ // <Route path="/about"> children </Route>
786
+ // Renders children only when the current hash path matches the pattern.
787
+ // Named params (:id) are injected as 'params' into child signals.
788
+ // v0.3 additions: guard={fn}, exact={true}
789
+ if (tag === 'Route') {
790
+ const pathAttr = jsxNode.attrs.find(a => a.name === 'path');
791
+ const guardAttr = jsxNode.attrs.find(a => a.name === 'guard');
792
+ const exactAttr = jsxNode.attrs.find(a => a.name === 'exact');
793
+ const pattern = pathAttr ? this._genExpr(pathAttr.value) : '"/"';
794
+ const guardExpr = guardAttr ? this._genExpr(guardAttr.value) : 'null';
795
+ const exactExpr = exactAttr ? this._genExpr(exactAttr.value) : 'false';
796
+ const routeN = counter.n++;
797
+ const allLines2 = [];
798
+
799
+ allLines2.push(`const ${varName} = document.createComment('clarity:route(' + ${pattern} + ')');`);
800
+ allLines2.push(`let _route${routeN}_nodes = [];`);
801
+ // Defer route effect so it runs after mount() inserts the component into the DOM.
802
+ allLines2.push(`queueMicrotask(() => _e(effect(() => {`);
803
+ allLines2.push(` const _rpath = _clarityCurrentPath.get();`);
804
+ // Support exact matching
805
+ allLines2.push(` const _rparams = (${exactExpr})`);
806
+ allLines2.push(` ? (() => { const _p = _clarityMatchRoute(${pattern}, _rpath); if (!_p) return null; const _pp = ${pattern}.split('/').filter(Boolean); const _ap = _rpath.split('/').filter(Boolean); return _pp.length === _ap.length ? _p : null; })()`);
807
+ allLines2.push(` : _clarityMatchRoute(${pattern}, _rpath);`);
808
+ allLines2.push(` _route${routeN}_nodes.forEach(n => n.parentNode?.removeChild(n));`);
809
+ allLines2.push(` _route${routeN}_nodes = [];`);
810
+ allLines2.push(` if (_rparams !== null) {`);
811
+ // Inline guard check
812
+ allLines2.push(` const _guard${routeN} = ${guardExpr};`);
813
+ allLines2.push(` if (typeof _guard${routeN} === 'function') {`);
814
+ allLines2.push(` const _gr = _guard${routeN}({ path: _rpath, params: _rparams });`);
815
+ allLines2.push(` if (_gr === false) return;`);
816
+ allLines2.push(` if (typeof _gr === 'string') { _clarityNavigate(_gr); return; }`);
817
+ allLines2.push(` }`);
818
+
819
+ const routeCounter = { n: counter.n + 600 };
820
+ const childSignals = [...signals, '_rparams'];
821
+ for (const child of jsxNode.children) {
822
+ if (child.type === 'JSXText' && !child.value.trim()) continue;
823
+ const [cl, cv] = this._genJSX(child, childSignals, routeCounter, params);
824
+ allLines2.push(...cl.map(l => ' ' + l));
825
+ allLines2.push(` ${varName}.parentNode?.insertBefore(${cv}, ${varName});`);
826
+ allLines2.push(` _route${routeN}_nodes.push(${cv});`);
827
+ }
828
+ counter.n = routeCounter.n;
829
+
830
+ allLines2.push(` }`);
831
+ allLines2.push(`})));`);
832
+ this._usedRuntime.add('effect');
833
+ this._usedRouter.add('_clarityCurrentPath');
834
+ this._usedRouter.add('_clarityMatchRoute');
835
+ this._usedRouter.add('_clarityNavigate');
836
+ return [allLines2, varName];
837
+ }
838
+
839
+ // <Link to="/about"> label or children </Link>
840
+ // Renders an <a> element that calls navigate() on click.
841
+ // Adds 'clarity-link-active' class when the current path matches.
842
+ if (tag === 'Link') {
843
+ const toAttr = jsxNode.attrs.find(a => a.name === 'to');
844
+ const toExpr = toAttr ? this._genExpr(toAttr.value) : '"/"';
845
+ const linkN = counter.n++;
846
+ const allLines2 = [];
847
+ const linkChildren = [];
848
+
849
+ for (const child of jsxNode.children) {
850
+ const [cl, cv] = this._genJSX(child, signals, counter, params);
851
+ allLines2.push(...cl);
852
+ linkChildren.push(cv);
853
+ }
854
+
855
+ allLines2.push(`const ${varName} = document.createElement('a');`);
856
+ allLines2.push(`${varName}.href = '#' + ${toExpr};`);
857
+ if (linkChildren.length > 0) {
858
+ allLines2.push(`[${linkChildren.join(', ')}].forEach(_c => ${varName}.appendChild(_c));`);
859
+ }
860
+ allLines2.push(`${varName}.addEventListener('click', (e) => { e.preventDefault(); _clarityNavigate(${toExpr}); });`);
861
+ allLines2.push(`_e(effect(() => {`);
862
+ allLines2.push(` const _active = _clarityMatchRoute(${toExpr}, _clarityCurrentPath.get()) !== null;`);
863
+ allLines2.push(` ${varName}.classList.toggle('clarity-link-active', _active);`);
864
+ allLines2.push(` ${varName}.setAttribute('aria-current', _active ? 'page' : 'false');`);
865
+ allLines2.push(`}));`);
866
+ this._usedRuntime.add('effect');
867
+ this._usedRouter.add('_clarityNavigate');
868
+ this._usedRouter.add('_clarityCurrentPath');
869
+ this._usedRouter.add('_clarityMatchRoute');
870
+ return [allLines2, varName];
871
+ }
872
+
873
+ // <list items={signal} key="fieldName"> child template </list>
874
+ if (tag === 'list') {
875
+ const itemsAttr = jsxNode.attrs.find(a => a.name === 'items');
876
+ const itemsExpr = itemsAttr ? this._genExpr(itemsAttr.value) : '[]';
877
+ const keyAttr = jsxNode.attrs.find(a => a.name === 'key');
878
+ const keyField = keyAttr
879
+ ? (typeof keyAttr.value === 'object' && keyAttr.value.type === 'Literal' ? keyAttr.value.value : null)
880
+ : null;
881
+ const keyExpr = keyField ? `String(item.${keyField})` : 'String(_idx)';
882
+ const keysExpr = keyField
883
+ ? `_items.map((item) => String(item.${keyField}))`
884
+ : `_items.map((_, i) => String(i))`;
885
+ const allLines2 = [];
886
+ const listN = counter.n++;
887
+
888
+ allLines2.push(`const ${varName} = document.createComment('clarity:list');`);
889
+ // Map stores an ARRAY of nodes per key so fragment templates don't leak.
890
+ // Old design stored one node per key → if a template rendered siblings,
891
+ // only the last node was tracked and the rest were never removed (node leak).
892
+ allLines2.push(`const _list${listN}_map = new Map(); // key → Node[]`);
893
+ // Defer so effect runs after the comment node is inserted into the DOM tree
894
+ allLines2.push(`queueMicrotask(() => _e(effect(() => {`);
895
+ allLines2.push(` const _items = ${itemsExpr}.get ? ${itemsExpr}.get() : ${itemsExpr};`);
896
+ allLines2.push(` const _keys = ${keysExpr};`);
897
+ // Snapshot map entries to avoid mutation-during-iteration bugs
898
+ allLines2.push(` for (const [k, nodes] of [..._list${listN}_map]) {`);
899
+ allLines2.push(` if (!_keys.includes(k)) {`);
900
+ allLines2.push(` nodes.forEach(_n => { _n.parentNode?.removeChild(_n); _callCleanup(_n); });`);
901
+ allLines2.push(` _list${listN}_map.delete(k);`);
902
+ allLines2.push(` }`);
903
+ allLines2.push(` }`);
904
+ allLines2.push(` _items.forEach((item, _idx) => {`);
905
+ allLines2.push(` const _key = ${keyExpr};`);
906
+ allLines2.push(` if (!_list${listN}_map.has(_key)) {`);
907
+ allLines2.push(` const _list${listN}_batch = []; // all nodes for this key`);
908
+
909
+ // Generate template for each item — 'item' is a plain loop variable (not a signal).
910
+ // We pass it via params so duck-typing handles the case where array elements ARE signals.
911
+ const tmplCounter = { n: counter.n + 500 };
912
+ const itemParams = [...params, 'item'];
913
+ for (const child of jsxNode.children) {
914
+ if (child.type === 'JSXText') continue;
915
+ const [cl, cv] = this._genJSX(child, signals, tmplCounter, itemParams);
916
+ allLines2.push(...cl.map(l => ' ' + l));
917
+ allLines2.push(` ${varName}.parentNode?.insertBefore(${cv}, ${varName});`);
918
+ allLines2.push(` _list${listN}_batch.push(${cv});`);
919
+ }
920
+ counter.n = tmplCounter.n;
921
+
922
+ allLines2.push(` _list${listN}_map.set(_key, _list${listN}_batch);`);
923
+ allLines2.push(` }`);
924
+ allLines2.push(` });`);
925
+ allLines2.push(`})));`);
926
+ this._usedRuntime.add('effect');
927
+ return [allLines2, varName];
928
+ }
929
+
930
+ // ── Portal ──────────────────────────────────────────────────────────────────
931
+ //
932
+ // <portal target="body"> children </portal>
933
+ // <portal target="#modal-root"> children </portal>
934
+ //
935
+ // Renders children directly into document.querySelector(target) instead of
936
+ // the current DOM tree. This lets modal overlays, drawers, and tooltips escape
937
+ // ancestor overflow:hidden / z-index stacking contexts — the same pattern as
938
+ // React's ReactDOM.createPortal().
939
+ //
940
+ // The portal leaves a comment node ( clarity:portal ) as a placeholder in the
941
+ // normal document flow. All portal children are tracked in _portal_N_nodes[] and
942
+ // removed when the host component unmounts (registered via _disposes).
943
+ //
944
+ // Reactive content inside the portal (signals, <when>, <list>) works exactly as
945
+ // normal — effect closures still close over the host component's signals.
946
+ if (tag === 'portal') {
947
+ const targetAttr = jsxNode.attrs.find(a => a.name === 'target');
948
+ const targetExpr = targetAttr ? this._genExpr(targetAttr.value) : '"body"';
949
+ const portalN = counter.n++;
950
+ const allLines2 = [];
951
+
952
+ allLines2.push(`const ${varName} = document.createComment('clarity:portal');`);
953
+ allLines2.push(`const _portal${portalN}_el = document.querySelector(${targetExpr}) ?? document.body;`);
954
+ allLines2.push(`const _portal${portalN}_nodes = [];`);
955
+
956
+ const portalCounter = { n: counter.n + 400 };
957
+ for (const child of jsxNode.children) {
958
+ if (child.type === 'JSXText' && !child.value.trim()) continue;
959
+ const [cl, cv] = this._genJSX(child, signals, portalCounter, params);
960
+ allLines2.push(...cl);
961
+ allLines2.push(`_portal${portalN}_el.appendChild(${cv});`);
962
+ allLines2.push(`_portal${portalN}_nodes.push(${cv});`);
963
+ }
964
+ counter.n = portalCounter.n;
965
+
966
+ // Register cleanup so portal nodes are removed when the host component unmounts
967
+ allLines2.push(`_disposes.push(() => _portal${portalN}_nodes.forEach(_n => _n.parentNode?.removeChild(_n)));`);
968
+
969
+ return [allLines2, varName];
970
+ }
971
+
972
+ // ── Suspense boundary: <Suspense fallback="…"> lazy children </Suspense> ────
973
+ //
974
+ // Shows a fallback while any direct lazy() child is loading, then replaces it
975
+ // with the real content once all lazy components have resolved.
976
+ //
977
+ // Fallback can be:
978
+ // fallback="Loading…" → text node
979
+ // fallback={<Spinner />} → JSX element (if parser supports JSX in attrs)
980
+ // <p slot="fallback">…</p> → named slot child (preferred for complex fallbacks)
981
+ //
982
+ // Implementation strategy:
983
+ // 1. Children are rendered into a hidden <div> wrapper (display:none) so they
984
+ // ARE in the live DOM — lazy() self-swap needs placeholder.parentNode to work.
985
+ // 2. If any child registers a lazy promise (via _registerLazy), show the fallback.
986
+ // 3. When all promises resolve, remove the fallback and show the wrapper
987
+ // (display:contents — transparent to CSS layout).
988
+ //
989
+ // The extra <div> uses display:contents when visible, so it's invisible to
990
+ // flex/grid layout algorithms and won't break any parent CSS.
991
+ if (tag === 'Suspense') {
992
+ const fallbackAttr = jsxNode.attrs.find(a => a.name === 'fallback');
993
+ const suspN = counter.n++;
994
+ const allLines2 = [];
995
+
996
+ // Wrapper div that holds children — hidden during load
997
+ allLines2.push(`const ${varName} = document.createComment('clarity:suspense');`);
998
+ allLines2.push(`const _susp${suspN}_wrap = document.createElement('div');`);
999
+ allLines2.push(`_susp${suspN}_wrap.style.display = 'none';`);
1000
+ allLines2.push(`const _susp${suspN}_ctx = { _pending: [] };`);
1001
+
1002
+ // Push suspense context — lazy() children will register their promises here
1003
+ allLines2.push(`_pushSuspense(_susp${suspN}_ctx);`);
1004
+
1005
+ // ── Generate real (non-fallback) children ──────────────────────────────
1006
+ const susChildVars = [];
1007
+ const susChildNodes = [];
1008
+ // Split children: fallback slot vs. default content
1009
+ for (const child of jsxNode.children) {
1010
+ if (child.type === 'JSXElement') {
1011
+ const sf = child.attrs?.find(a => a.type === 'Attr' && a.name === 'slot' && a.value?.value === 'fallback');
1012
+ if (sf) continue; // handled separately below
1013
+ }
1014
+ if (child.type === 'JSXText' && !child.value.trim()) continue;
1015
+ const [cl, cv] = this._genJSX(child, signals, counter, params);
1016
+ allLines2.push(...cl);
1017
+ susChildVars.push(cv);
1018
+ susChildNodes.push(child);
1019
+ }
1020
+
1021
+ // Append content children into wrapper
1022
+ for (const cv of susChildVars) {
1023
+ allLines2.push(`_susp${suspN}_wrap.appendChild(${cv});`);
1024
+ }
1025
+
1026
+ // Pop suspense context
1027
+ allLines2.push(`_popSuspense();`);
1028
+
1029
+ // ── Generate fallback ──────────────────────────────────────────────────
1030
+ // Prefer slot="fallback" child, then fallback attr, then default text
1031
+ const fallbackSlotChild = jsxNode.children.find(
1032
+ c => c.type === 'JSXElement' &&
1033
+ c.attrs?.find(a => a.type === 'Attr' && a.name === 'slot' && a.value?.value === 'fallback')
1034
+ );
1035
+
1036
+ const fbLines = [];
1037
+ let fbVar;
1038
+ if (fallbackSlotChild) {
1039
+ const fbChild = { ...fallbackSlotChild, attrs: fallbackSlotChild.attrs.filter(a => a.name !== 'slot') };
1040
+ const [fl, fv] = this._genJSX(fbChild, signals, counter, params);
1041
+ fbLines.push(...fl);
1042
+ fbVar = fv;
1043
+ } else if (fallbackAttr) {
1044
+ if (fallbackAttr.value?.type === 'JSXElement') {
1045
+ const [fl, fv] = this._genJSX(fallbackAttr.value, signals, counter, params);
1046
+ fbLines.push(...fl);
1047
+ fbVar = fv;
1048
+ } else {
1049
+ const fbExpr = this._genExprRead(fallbackAttr.value, signals, params);
1050
+ const fbN = counter.n++;
1051
+ fbLines.push(`const _susp${suspN}_fbv${fbN} = ${fbExpr};`);
1052
+ fbLines.push(`const _susp${suspN}_fbn${fbN} = typeof _susp${suspN}_fbv${fbN} === 'string' ? document.createTextNode(_susp${suspN}_fbv${fbN}) : (_susp${suspN}_fbv${fbN} instanceof Node ? _susp${suspN}_fbv${fbN} : document.createTextNode(String(_susp${suspN}_fbv${fbN} ?? 'Loading…')));`);
1053
+ fbVar = `_susp${suspN}_fbn${fbN}`;
1054
+ }
1055
+ } else {
1056
+ const fbN = counter.n++;
1057
+ fbLines.push(`const ${`_susp${suspN}_fb_default${fbN}`} = document.createTextNode('Loading\u2026');`);
1058
+ fbVar = `_susp${suspN}_fb_default${fbN}`;
1059
+ }
1060
+
1061
+ // ── Mount + show/hide logic ────────────────────────────────────────────
1062
+ allLines2.push(`let _susp${suspN}_fb = null;`);
1063
+ allLines2.push(`queueMicrotask(() => {`);
1064
+ allLines2.push(` ${varName}.parentNode?.insertBefore(_susp${suspN}_wrap, ${varName});`);
1065
+ allLines2.push(` if (_susp${suspN}_ctx._pending.length === 0) {`);
1066
+ allLines2.push(` _susp${suspN}_wrap.style.display = 'contents';`);
1067
+ allLines2.push(` } else {`);
1068
+ // generate fallback inside the microtask
1069
+ for (const l of fbLines) allLines2.push(` ${l}`);
1070
+ allLines2.push(` _susp${suspN}_fb = ${fbVar};`);
1071
+ allLines2.push(` ${varName}.parentNode?.insertBefore(_susp${suspN}_fb, ${varName});`);
1072
+ allLines2.push(` Promise.all(_susp${suspN}_ctx._pending).then(() => {`);
1073
+ allLines2.push(` setTimeout(() => {`);
1074
+ allLines2.push(` _susp${suspN}_wrap.style.display = 'contents';`);
1075
+ allLines2.push(` if (_susp${suspN}_fb?.parentNode) _susp${suspN}_fb.parentNode.removeChild(_susp${suspN}_fb);`);
1076
+ allLines2.push(` _susp${suspN}_fb = null;`);
1077
+ allLines2.push(` }, 0);`);
1078
+ allLines2.push(` });`);
1079
+ allLines2.push(` }`);
1080
+ allLines2.push(`});`);
1081
+
1082
+ // Register cleanup for wrapper and fallback
1083
+ allLines2.push(`_disposes.push(() => {`);
1084
+ allLines2.push(` _susp${suspN}_wrap.parentNode?.removeChild(_susp${suspN}_wrap);`);
1085
+ allLines2.push(` if (_susp${suspN}_fb?.parentNode) _susp${suspN}_fb.parentNode.removeChild(_susp${suspN}_fb);`);
1086
+ allLines2.push(`});`);
1087
+
1088
+ this._usedRuntime.add('_pushSuspense');
1089
+ this._usedRuntime.add('_popSuspense');
1090
+
1091
+ return [allLines2, varName];
1092
+ }
1093
+
1094
+ // ── Dynamic component: <Component is={expr} /> ─────────────────────────────
1095
+ //
1096
+ // Allows the rendered component to be determined at runtime:
1097
+ //
1098
+ // <Component is={CounterDemo} /> — static: always CounterDemo
1099
+ // <Component is={activeTab} /> — reactive: switches when signal changes
1100
+ // <Component is={tabs[idx]} title="Hello" /> — reactive with extra props
1101
+ //
1102
+ // Props (excluding `is`) are forwarded as-is. Named slots and spread props work
1103
+ // the same as regular component calls.
1104
+ //
1105
+ // When `is` is NOT reactive (e.g. a plain identifier or object literal):
1106
+ // → generates a direct call: const _el0 = (CounterDemo)({ ...props });
1107
+ //
1108
+ // When `is` IS reactive (reads a signal):
1109
+ // → generates a comment placeholder + effect that unmounts the old component
1110
+ // and mounts the new one whenever the signal changes.
1111
+ if (tag === 'Component') {
1112
+ const isAttr = jsxNode.attrs.find(a => a.type === 'Attr' && a.name === 'is');
1113
+ const dynN = counter.n++;
1114
+ const allLines2 = [];
1115
+
1116
+ // Missing `is` — emit a warning placeholder and bail
1117
+ if (!isAttr) {
1118
+ allLines2.push(`const ${varName} = document.createComment('clarity:dynamic:missing-is');`);
1119
+ if (typeof console !== 'undefined') {
1120
+ allLines2.push(`console.warn('[Clarity] <Component> requires an \`is\` prop');`);
1121
+ }
1122
+ return [allLines2, varName];
1123
+ }
1124
+
1125
+ // ── Generate children (same as regular component call) ──────────────────
1126
+ const dynChildVars = [];
1127
+ const dynChildNodes = [];
1128
+ for (const child of jsxNode.children) {
1129
+ const [cl, cv] = this._genJSX(child, signals, counter, params);
1130
+ allLines2.push(...cl);
1131
+ dynChildVars.push(cv);
1132
+ dynChildNodes.push(child);
1133
+ }
1134
+
1135
+ // ── Build attrProps — all attrs except `is` ─────────────────────────────
1136
+ const passAttrs = jsxNode.attrs.filter(a => a.name !== 'is');
1137
+ const attrProps2 = passAttrs.flatMap(a => {
1138
+ if (a.type === 'SpreadAttr') {
1139
+ return [`...(${this._genExprRead(a.expr, signals, params)} ?? {})`];
1140
+ }
1141
+ if (a.type === 'EventAttr') {
1142
+ const modifiers = a.modifiers ?? [];
1143
+ const handler = this._genExprWithSignalAwareness(a.handler);
1144
+ const wrapped = this._wrapWithModifiers(handler, modifiers);
1145
+ return [`on${this._capitalize(a.event)}: (e) => { ${wrapped} }`];
1146
+ }
1147
+ if (a.type === 'BindAttr') return [`${a.prop}: ${this._genExpr(a.signal)}`];
1148
+
1149
+ const isDirectSignal = a.value?.type === 'Ident' && signals.includes(a.value.name);
1150
+ const isReactProp = !isDirectSignal && this._exprReadsSignals(a.value, signals, params);
1151
+ let valCode;
1152
+ if (isDirectSignal) {
1153
+ valCode = this._genExpr(a.value);
1154
+ } else if (isReactProp) {
1155
+ valCode = `computed(() => ${this._genExprRead(a.value, signals, params)})`;
1156
+ this._usedRuntime.add('computed');
1157
+ } else {
1158
+ valCode = this._genExpr(a.value);
1159
+ }
1160
+ return [`${a.name}: ${valCode}`];
1161
+ });
1162
+
1163
+ // ── Slot grouping ────────────────────────────────────────────────────────
1164
+ const dynSlotGroups = {};
1165
+ for (let _ci = 0; _ci < dynChildVars.length; _ci++) {
1166
+ const childVar = dynChildVars[_ci];
1167
+ if (!childVar || childVar === 'null') continue;
1168
+ const childNode = dynChildNodes[_ci];
1169
+ let slotName = 'default';
1170
+ if (childNode?.type === 'JSXElement') {
1171
+ const slotAttr = childNode.attrs?.find(
1172
+ a => a.type === 'Attr' && a.name === 'slot' && a.value?.type === 'Literal'
1173
+ );
1174
+ if (slotAttr) slotName = String(slotAttr.value.value);
1175
+ }
1176
+ (dynSlotGroups[slotName] ??= []).push(childVar);
1177
+ }
1178
+ const dynSlotEntries = Object.entries(dynSlotGroups);
1179
+ if (dynSlotEntries.length > 0) {
1180
+ const childrenObj = dynSlotEntries.map(([name, vars]) => `${name}: [${vars.join(', ')}]`).join(', ');
1181
+ attrProps2.push(`children: { ${childrenObj} }`);
1182
+ }
1183
+ const propsStr2 = attrProps2.join(', ');
1184
+
1185
+ // ── Emit code ───────────────────────────────────────────────────────────
1186
+ const isExprStatic = this._genExpr(isAttr.value);
1187
+ const isExprRead = this._genExprRead(isAttr.value, signals, params);
1188
+ const isReactive = this._exprReadsSignals(isAttr.value, signals, params);
1189
+
1190
+ if (!isReactive) {
1191
+ // Static: direct call — zero overhead, same as writing <CounterDemo /> directly
1192
+ allLines2.push(`const ${varName} = (${isExprStatic})({ ${propsStr2} });`);
1193
+ } else {
1194
+ // Reactive: effect-driven — swap component when the signal changes
1195
+ allLines2.push(`const ${varName} = document.createComment('clarity:dynamic');`);
1196
+ allLines2.push(`let _dyn${dynN}_node = null;`);
1197
+ allLines2.push(`queueMicrotask(() => _e(effect(() => {`);
1198
+ allLines2.push(` if (_dyn${dynN}_node) { _dyn${dynN}_node.parentNode?.removeChild(_dyn${dynN}_node); _dyn${dynN}_node = null; }`);
1199
+ allLines2.push(` const _DynComp${dynN} = ${isExprRead};`);
1200
+ allLines2.push(` if (typeof _DynComp${dynN} === 'function') {`);
1201
+ allLines2.push(` _dyn${dynN}_node = _DynComp${dynN}({ ${propsStr2} });`);
1202
+ allLines2.push(` ${varName}.parentNode?.insertBefore(_dyn${dynN}_node, ${varName});`);
1203
+ allLines2.push(` }`);
1204
+ allLines2.push(`})));`);
1205
+ this._usedRuntime.add('effect');
1206
+ }
1207
+
1208
+ return [allLines2, varName];
1209
+ }
1210
+
1211
+ // ── Fragment <> ... </> ─────────────────────────────────────────────────────
1212
+ // Empty tag name signals a fragment: render all children into a DocumentFragment
1213
+ // with no wrapper DOM element. This lets a component return sibling nodes freely.
1214
+ if (tag === '') {
1215
+ const fragLines = [];
1216
+ if (jsxNode.loc?.line) fragLines.push(`//@ SM:${jsxNode.loc.line}`);
1217
+ fragLines.push(`const ${varName} = document.createDocumentFragment();`);
1218
+ for (const child of jsxNode.children) {
1219
+ const [childLines, childVar] = this._genJSX(child, signals, counter, params);
1220
+ fragLines.push(...childLines);
1221
+ fragLines.push(`${varName}.appendChild(${childVar});`);
1222
+ }
1223
+ return [fragLines, varName];
1224
+ }
1225
+
1226
+ const isComponent = tag[0] === tag[0].toUpperCase();
1227
+ const allLines = [];
1228
+
1229
+ // Source map marker for this element
1230
+ if (jsxNode.loc?.line) allLines.push(`//@ SM:${jsxNode.loc.line}`);
1231
+
1232
+ // Generate children first (bottom-up).
1233
+ // We keep childNodes in parallel so that, for component calls, we can inspect
1234
+ // the slot="name" attribute on each child and route it to the correct slot bucket.
1235
+ //
1236
+ // Special case: <template slot="name">...</template>
1237
+ // The <template> element is transparent — its children are inlined with the
1238
+ // slot attribute copied onto a synthetic wrapper so the slot router picks it up.
1239
+ const childVars = [];
1240
+ const childNodes = []; // original JSX nodes, parallel to childVars
1241
+ for (const child of jsxNode.children) {
1242
+ // Expand <template slot="name"> and <template slot="name" let:data> transparently
1243
+ //
1244
+ // SCOPED SLOTS: <template slot="row" let:data> wraps children in (data) => [...]
1245
+ // The `let:data` attribute binds the slot data variable name.
1246
+ // Multiple let: bindings are supported: let:data let:index → (data, index) => [...]
1247
+ if (
1248
+ child.type === 'JSXElement' &&
1249
+ child.tag === 'template' &&
1250
+ child.attrs?.some(a => a.type === 'Attr' && a.name === 'slot')
1251
+ ) {
1252
+ const slotAttr = child.attrs.find(a => a.type === 'Attr' && a.name === 'slot');
1253
+ // Collect let: bindings for scoped slots
1254
+ const letAttrs = child.attrs.filter(a => a.type === 'Attr' && a.name.startsWith('let:'));
1255
+ const letParams = letAttrs.map(a => a.name.slice(4)); // strip 'let:'
1256
+
1257
+ if (letParams.length > 0) {
1258
+ // SCOPED SLOT — generate a render function instead of a fragment
1259
+ // Template children are compiled with letParams added to the params scope
1260
+ const scopedParams = [...params, ...letParams];
1261
+ const innerVars = [];
1262
+ const innerLines = [];
1263
+ for (const templateChild of child.children) {
1264
+ const [childLines, childVar] = this._genJSX(templateChild, signals, counter, scopedParams);
1265
+ innerLines.push(...childLines);
1266
+ innerVars.push(childVar);
1267
+ }
1268
+ // Build a render function: (data, index) => { ... return [nodes] }
1269
+ const fnVar = `_el${counter.n++}`;
1270
+ const paramList = letParams.join(', ');
1271
+ allLines.push(`const ${fnVar} = (${paramList}) => {`);
1272
+ allLines.push(...innerLines.map(l => ' ' + l));
1273
+ allLines.push(` return [${innerVars.filter(Boolean).join(', ')}];`);
1274
+ allLines.push(`};`);
1275
+ childVars.push(fnVar);
1276
+ childNodes.push({ type: 'JSXElement', tag: '__scoped_slot__', attrs: [slotAttr], children: [], __scoped: true });
1277
+ continue;
1278
+ }
1279
+
1280
+ // Regular named slot (no let: bindings) — generate a DocumentFragment
1281
+ const fragVar = `_el${counter.n++}`;
1282
+ allLines.push(`const ${fragVar} = document.createDocumentFragment();`);
1283
+ for (const templateChild of child.children) {
1284
+ const [childLines, childVar] = this._genJSX(templateChild, signals, counter, params);
1285
+ allLines.push(...childLines);
1286
+ allLines.push(`if (${childVar} != null) ${fragVar}.appendChild(${childVar});`);
1287
+ }
1288
+ childVars.push(fragVar);
1289
+ // Attach a synthetic JSX node with slot="name" so the slot router sees it
1290
+ childNodes.push({ type: 'JSXElement', tag: '__template_slot__', attrs: [slotAttr], children: [] });
1291
+ continue;
1292
+ }
1293
+ const [childLines, childVar] = this._genJSX(child, signals, counter, params);
1294
+ allLines.push(...childLines);
1295
+ childVars.push(childVar);
1296
+ childNodes.push(child);
1297
+ }
1298
+
1299
+ if (isComponent) {
1300
+ // Component call: const _el0 = MyComponent({ prop: value, children: { default: [...], header: [...] } })
1301
+ // PROPS REACTIVITY: pass signal references as-is (not .get()) so children
1302
+ // receive live signals and stay reactive. The child uses duck-typing to read them.
1303
+ const attrProps = jsxNode.attrs.flatMap(a => {
1304
+ if (a.type === 'SpreadAttr') {
1305
+ // Spread: {...obj} → ...(obj ?? {}) — flattens into the props object at call site
1306
+ return [`...(${this._genExprRead(a.expr, signals, params)} ?? {})`];
1307
+ }
1308
+ if (a.type === 'EventAttr') {
1309
+ const modifiers = a.modifiers ?? [];
1310
+ const handler = this._genExprWithSignalAwareness(a.handler);
1311
+ const wrapped = this._wrapWithModifiers(handler, modifiers);
1312
+ return [`on${this._capitalize(a.event)}: (e) => { ${wrapped} }`];
1313
+ }
1314
+ if (a.type === 'BindAttr') return [`${a.prop}: ${this._genExpr(a.signal)}`];
1315
+
1316
+ // Prop reactivity strategy:
1317
+ // • Direct signal ref (count, color) → pass signal object by reference; child duck-types it
1318
+ // • Reactive expression (count * 2) → wrap in computed() so child sees a live signal
1319
+ // • Static value ("hello", 42) → pass as-is
1320
+ const isDirectSignal = a.value?.type === 'Ident' && signals.includes(a.value.name);
1321
+ const isReactive = !isDirectSignal && this._exprReadsSignals(a.value, signals, params);
1322
+ let valCode;
1323
+ if (isDirectSignal) {
1324
+ valCode = this._genExpr(a.value); // raw signal reference
1325
+ } else if (isReactive) {
1326
+ // Computed derived prop — stays reactive in child without extra effort
1327
+ valCode = `computed(() => ${this._genExprRead(a.value, signals, params)})`;
1328
+ this._usedRuntime.add('computed');
1329
+ } else {
1330
+ valCode = this._genExpr(a.value); // static
1331
+ }
1332
+ return [`${a.name}: ${valCode}`];
1333
+ });
1334
+
1335
+ // Pass children categorised by slot name.
1336
+ //
1337
+ // A child JSXElement with slot="name" attribute goes to children.name.
1338
+ // Everything else (text, expressions, unslotted elements) goes to children.default.
1339
+ //
1340
+ // The component receives: { default: [...], header: [...], footer: [...] }
1341
+ // <slot /> renders children.default
1342
+ // <slot name="x" /> renders children.x
1343
+ //
1344
+ // This is backward-compatible: components that don't use named slots only see
1345
+ // children.default, and the <slot /> handler still accepts legacy flat arrays.
1346
+ const slotGroups = {};
1347
+ for (let _ci = 0; _ci < childVars.length; _ci++) {
1348
+ const childVar = childVars[_ci];
1349
+ if (!childVar || childVar === 'null') continue;
1350
+ const childNode = childNodes[_ci];
1351
+ // Determine slot target: JSXElement with slot="name" → that name; else default
1352
+ let slotName = 'default';
1353
+ let isScoped = false;
1354
+ if (childNode?.type === 'JSXElement') {
1355
+ const slotAttr = childNode.attrs?.find(
1356
+ a => a.type === 'Attr' && a.name === 'slot' && a.value?.type === 'Literal'
1357
+ );
1358
+ if (slotAttr) slotName = String(slotAttr.value.value);
1359
+ isScoped = childNode.__scoped === true;
1360
+ }
1361
+ // Scoped slots are render functions; regular slots are nodes in an array
1362
+ if (isScoped) {
1363
+ // Scoped slot: store the render function directly (not in an array)
1364
+ slotGroups[slotName] = { __scopedFn: childVar };
1365
+ } else {
1366
+ if (!slotGroups[slotName] || slotGroups[slotName].__scopedFn) {
1367
+ slotGroups[slotName] = [];
1368
+ }
1369
+ slotGroups[slotName].push(childVar);
1370
+ }
1371
+ }
1372
+ const slotEntries = Object.entries(slotGroups);
1373
+ if (slotEntries.length > 0) {
1374
+ const childrenObj = slotEntries.map(([name, val]) => {
1375
+ if (val?.__scopedFn) return `${name}: ${val.__scopedFn}`; // scoped → function
1376
+ return `${name}: [${val.join(', ')}]`; // regular → array
1377
+ }).join(', ');
1378
+ attrProps.push(`children: { ${childrenObj} }`);
1379
+ }
1380
+
1381
+ const propsStr = attrProps.join(', ');
1382
+ allLines.push(`const ${varName} = ${tag}({ ${propsStr} });`);
1383
+ } else {
1384
+ // HTML element
1385
+ allLines.push(`const ${varName} = document.createElement('${tag}');`);
1386
+
1387
+ // Attributes
1388
+ for (const attr of jsxNode.attrs) {
1389
+ if (attr.type === 'SpreadAttr') {
1390
+ // Spread props onto an HTML element: {...obj} → Object.entries(obj).forEach(...)
1391
+ const spreadExpr = this._genExprRead(attr.expr, signals, params);
1392
+ allLines.push(`Object.entries(${spreadExpr} ?? {}).forEach(([_k, _v]) => {`);
1393
+ allLines.push(` if (_k === 'class') ${varName}.className = _v;`);
1394
+ allLines.push(` else if (_k === 'style') ${varName}.style.cssText = _v;`);
1395
+ allLines.push(` else ${varName}.setAttribute(_k, String(_v ?? ''));`);
1396
+ allLines.push(`});`);
1397
+ } else if (attr.type === 'EventAttr') {
1398
+ // Event handlers with optional modifiers
1399
+ // Modifiers: .prevent → e.preventDefault() .stop → e.stopPropagation()
1400
+ // .enter .escape .space .tab → key guards
1401
+ const handler = this._genExprWithSignalAwareness(attr.handler);
1402
+ const modifiers = attr.modifiers ?? [];
1403
+ const innerCode = this._wrapWithModifiers(`${handler};`, modifiers);
1404
+ allLines.push(`${varName}.addEventListener('${attr.event}', (e) => { ${innerCode} });`);
1405
+ } else if (attr.type === 'BindAttr') {
1406
+ const sigExpr = this._genExpr(attr.signal);
1407
+
1408
+ // Peek at sibling attributes to infer input type / element context
1409
+ const typeAttr = jsxNode.attrs.find(a => a.name === 'type' && a.value?.type === 'Literal');
1410
+ const inputType = typeAttr?.value?.value?.toLowerCase() ?? '';
1411
+
1412
+ if (attr.prop === 'group') {
1413
+ // bind:group — checkbox/radio group backed by an array signal
1414
+ // Effect: keep element checked based on array membership
1415
+ allLines.push(`_e(effect(() => {`);
1416
+ allLines.push(` const _gv = ${sigExpr}.get();`);
1417
+ allLines.push(` ${varName}.checked = Array.isArray(_gv) ? _gv.includes(${varName}.value) : false;`);
1418
+ allLines.push(`}));`);
1419
+ // On change: add/remove from array
1420
+ allLines.push(`${varName}.addEventListener('change', () => {`);
1421
+ allLines.push(` const _prev = ${sigExpr}.get() ?? [];`);
1422
+ allLines.push(` if (${varName}.checked) ${sigExpr}.set([..._prev, ${varName}.value]);`);
1423
+ allLines.push(` else ${sigExpr}.set(_prev.filter(_v => _v !== ${varName}.value));`);
1424
+ allLines.push(`});`);
1425
+ } else if (attr.prop === 'checked') {
1426
+ // bind:checked — boolean binding for checkboxes
1427
+ allLines.push(`_e(effect(() => { ${varName}.checked = !!${sigExpr}.get(); }));`);
1428
+ allLines.push(`${varName}.addEventListener('change', () => { ${sigExpr}.set(${varName}.checked); });`);
1429
+ } else if (tag === 'select') {
1430
+ // bind:value on <select> — use 'change' event, support multiple attribute
1431
+ allLines.push(`_e(effect(() => {`);
1432
+ allLines.push(` const _sv = ${sigExpr}.get();`);
1433
+ allLines.push(` if (Array.isArray(_sv)) {`);
1434
+ allLines.push(` [...${varName}.options].forEach(o => { o.selected = _sv.includes(o.value); });`);
1435
+ allLines.push(` } else { ${varName}.value = _sv ?? ''; }`);
1436
+ allLines.push(`}));`);
1437
+ allLines.push(`${varName}.addEventListener('change', () => {`);
1438
+ allLines.push(` if (${varName}.multiple) {`);
1439
+ allLines.push(` ${sigExpr}.set([...${varName}.selectedOptions].map(o => o.value));`);
1440
+ allLines.push(` } else { ${sigExpr}.set(${varName}.value); }`);
1441
+ allLines.push(`});`);
1442
+ } else if (inputType === 'number' || inputType === 'range') {
1443
+ // bind:value on number/range inputs — coerce to number
1444
+ allLines.push(`_e(effect(() => { ${varName}.value = String(${sigExpr}.get() ?? ''); }));`);
1445
+ allLines.push(`${varName}.addEventListener('input', () => {`);
1446
+ allLines.push(` const _nv = ${varName}.valueAsNumber;`);
1447
+ allLines.push(` ${sigExpr}.set(isNaN(_nv) ? '' : _nv);`);
1448
+ allLines.push(`});`);
1449
+ } else if (tag === 'textarea') {
1450
+ // bind:value on <textarea>
1451
+ allLines.push(`_e(effect(() => { ${varName}.value = ${sigExpr}.get() ?? ''; }));`);
1452
+ allLines.push(`${varName}.addEventListener('input', () => { ${sigExpr}.set(${varName}.value); });`);
1453
+ } else {
1454
+ // Default: bind:value on <input type="text"> and similar
1455
+ allLines.push(`_e(effect(() => { ${varName}.value = String(${sigExpr}.get() ?? ''); }));`);
1456
+ allLines.push(`${varName}.addEventListener('input', () => { ${sigExpr}.set(${varName}.value); });`);
1457
+ }
1458
+ } else if (attr.name === 'ref') {
1459
+ // ref — store DOM node reference into a signal or callback
1460
+ // <input ref={myRef} /> → myRef.set(el) (if signal)
1461
+ // → myRef(el) (if callback)
1462
+ const refExpr = this._genExpr(attr.value);
1463
+ allLines.push(`if (typeof ${refExpr} === 'function') ${refExpr}(${varName});`);
1464
+ allLines.push(`else if (${refExpr}?._type === 'signal') ${refExpr}.set(${varName});`);
1465
+ } else if (tag === 'form' && attr.name === 'action' && attr.value && attr.value.type !== 'Literal') {
1466
+ // <form action={serverAction}> — Server Actions form integration.
1467
+ // Intercept submit, collect FormData as a plain object, and invoke the
1468
+ // action. Accepts a useServerAction() client ({ execute }) or a plain
1469
+ // function. A string literal action (action="/url") is left as a native
1470
+ // form post (handled by the generic attribute branch below).
1471
+ const actionExpr = this._genExpr(attr.value);
1472
+ allLines.push(`${varName}.addEventListener('submit', (e) => {`);
1473
+ allLines.push(` e.preventDefault();`);
1474
+ allLines.push(` const _data = Object.fromEntries(new FormData(${varName}).entries());`);
1475
+ allLines.push(` const _act = ${actionExpr};`);
1476
+ allLines.push(` const _fn = (_act && typeof _act.execute === 'function') ? _act.execute.bind(_act) : _act;`);
1477
+ allLines.push(` Promise.resolve(typeof _fn === 'function' ? _fn(_data) : undefined)`);
1478
+ allLines.push(` .catch((_err) => console.error('[Clarity] form action failed:', _err));`);
1479
+ allLines.push(`});`);
1480
+ } else {
1481
+ const isReactive = this._exprReadsSignals(attr.value, signals, params);
1482
+ const val = isReactive
1483
+ ? this._genExprRead(attr.value, signals, params)
1484
+ : this._genExpr(attr.value);
1485
+ if (attr.name === 'class') {
1486
+ if (isReactive) {
1487
+ allLines.push(`_e(effect(() => { ${varName}.className = ${val}; }));`);
1488
+ } else {
1489
+ allLines.push(`${varName}.className = ${val};`);
1490
+ }
1491
+ } else {
1492
+ if (isReactive) {
1493
+ allLines.push(`_e(effect(() => { ${varName}.setAttribute('${attr.name}', ${val}); }));`);
1494
+ } else {
1495
+ allLines.push(`${varName}.setAttribute('${attr.name}', ${val});`);
1496
+ }
1497
+ }
1498
+ }
1499
+ }
1500
+
1501
+ // Append children
1502
+ for (const childVar of childVars) {
1503
+ allLines.push(`${varName}.appendChild(${childVar});`);
1504
+ }
1505
+ }
1506
+
1507
+ return [allLines, varName];
1508
+ }
1509
+
1510
+ // ── Expression Generator ──
1511
+ _genExpr(node) {
1512
+ if (!node) return 'undefined';
1513
+
1514
+ switch (node.type) {
1515
+ case 'Literal':
1516
+ return JSON.stringify(node.value);
1517
+
1518
+ case 'Ident':
1519
+ return node.name;
1520
+
1521
+ case 'BinaryExpr':
1522
+ return `(${this._genExpr(node.left)} ${node.op} ${this._genExpr(node.right)})`;
1523
+
1524
+ case 'UnaryExpr':
1525
+ return `${node.op}(${this._genExpr(node.expr)})`;
1526
+
1527
+ case 'UpdateExpr':
1528
+ return this._genUpdateExpr(node);
1529
+
1530
+ case 'TernaryExpr':
1531
+ return `(${this._genExpr(node.cond)} ? ${this._genExpr(node.then)} : ${this._genExpr(node.else)})`;
1532
+
1533
+ case 'AssignExpr': {
1534
+ const target = this._genAssignTarget(node.target, node.op, node.value);
1535
+ return target;
1536
+ }
1537
+
1538
+ case 'MemberExpr':
1539
+ if (node.computed) {
1540
+ return `${this._genExpr(node.obj)}[${this._genExpr(node.prop)}]`;
1541
+ }
1542
+ return `${this._genExpr(node.obj)}.${this._genExpr(node.prop)}`;
1543
+
1544
+ case 'CallExpr': {
1545
+ const calleeCode = this._genExpr(node.callee);
1546
+ // Arrow functions as callees need parens: (() => {})() not () => {}()
1547
+ const callee = node.callee.type === 'ArrowFn' ? `(${calleeCode})` : calleeCode;
1548
+ const args = node.args.map(a => this._genExpr(a)).join(', ');
1549
+ return `${callee}(${args})`;
1550
+ }
1551
+
1552
+ case 'ArrowFn': {
1553
+ const params = node.params.map(p => p.name).join(', ');
1554
+ const body = node.body.type === 'Block'
1555
+ ? `{\n${this._genBlockStmts(node.body.body).join('\n')}\n}`
1556
+ : this._genExpr(node.body);
1557
+ return `(${params}) => ${body}`;
1558
+ }
1559
+
1560
+ case 'CommaExpr':
1561
+ return node.parts.map(p => this._genExprWithSignalAwareness(p)).join(', ');
1562
+
1563
+ case 'NewExpr': {
1564
+ const callee = this._genExpr(node.callee);
1565
+ const args = node.args.map(a => a.type === 'SpreadExpr' ? `...${this._genExpr(a.expr)}` : this._genExpr(a)).join(', ');
1566
+ return `new ${callee}(${args})`;
1567
+ }
1568
+
1569
+ case 'SpreadExpr':
1570
+ return `...${this._genExpr(node.expr)}`;
1571
+
1572
+ case 'ArrayExpr':
1573
+ return `[${node.elements.map(e => this._genExpr(e)).join(', ')}]`;
1574
+
1575
+ case 'ObjectExpr': {
1576
+ const props = node.properties.map(p => `${p.key}: ${this._genExpr(p.value)}`);
1577
+ return `{ ${props.join(', ')} }`;
1578
+ }
1579
+
1580
+ case 'TemplateLiteral': {
1581
+ // Reconstruct as a JS template literal. In _genExpr we don't call .get()
1582
+ // on signals — use _genExprRead only when in reactive read context.
1583
+ const inner = node.parts.map(p =>
1584
+ p.type === 'str' ? p.value : '${' + this._genExpr(p.expr) + '}'
1585
+ ).join('');
1586
+ return '`' + inner + '`';
1587
+ }
1588
+
1589
+ case 'AwaitExpr':
1590
+ return `await ${this._genExpr(node.expr)}`;
1591
+
1592
+ default:
1593
+ return `/* unknown expr: ${node?.type} */`;
1594
+ }
1595
+ }
1596
+
1597
+ // State signal assignment: count = 5 → count.set(5)
1598
+ _genAssignTarget(target, op, value) {
1599
+ // FIX: use _genExprRead for the value so signal refs inside RHS call .get()
1600
+ // e.g. todos = [...todos, {text: inputValue}] → todos.set([...todos.get(), {text: inputValue.get()}])
1601
+ const sigs = this._currentSignals ?? [];
1602
+ const params = this._currentParams ?? [];
1603
+ if (target.type === 'Ident') {
1604
+ const valExpr = this._genExprRead(value, sigs, params);
1605
+ if (op === '=') return `${target.name}.set(${valExpr})`;
1606
+ if (op === '+=') return `${target.name}.set(${target.name}.get() + (${valExpr}))`;
1607
+ if (op === '-=') return `${target.name}.set(${target.name}.get() - (${valExpr}))`;
1608
+ }
1609
+ // Non-signal assignment (member access, etc.)
1610
+ return `${this._genExpr(target)} ${op} ${this._genExprRead(value, sigs, params)}`;
1611
+ }
1612
+
1613
+ // Unused — kept for clarity
1614
+ _genUpdateTarget(expr) { return this._genExpr(expr); }
1615
+
1616
+ // Signal-aware update: count++ → count.set(count.get() + 1)
1617
+ // In event handler context we don't need the return value, so keep it simple.
1618
+ _genUpdateExpr(node) {
1619
+ if (node.expr.type === 'Ident') {
1620
+ const name = node.expr.name;
1621
+ if (node.op === '++') return `${name}.set(${name}.get() + 1)`;
1622
+ if (node.op === '--') return `${name}.set(${name}.get() - 1)`;
1623
+ }
1624
+ // Fallback for non-signal identifiers
1625
+ return node.prefix
1626
+ ? `${node.op}${this._genExpr(node.expr)}`
1627
+ : `${this._genExpr(node.expr)}${node.op}`;
1628
+ }
1629
+
1630
+ // ── Statement Generator ──
1631
+ _genBlockStmts(stmts) {
1632
+ return stmts.map(stmt => this._genStmt(stmt)).filter(Boolean);
1633
+ }
1634
+
1635
+ _genBlock(blockNode, inline = false) {
1636
+ return this._genBlockStmts(blockNode.body ?? []);
1637
+ }
1638
+
1639
+ _genStmt(stmt) {
1640
+ if (!stmt) return null;
1641
+ switch (stmt.type) {
1642
+ case 'ExprStmt':
1643
+ return `${this._genExprWithSignalAwareness(stmt.expr)};`;
1644
+ case 'ReturnStmt':
1645
+ // Read-aware: a returned signal must yield its VALUE (signal.get()), not
1646
+ // the signal object. Critical for ai:forbidden — `return secret` compiles
1647
+ // to `secret.get()`, which the protected-signal guard blocks in AI context.
1648
+ return stmt.value ? `return ${this._genReadValue(stmt.value)};` : 'return;';
1649
+ case 'VarDecl':
1650
+ return `${stmt.kind} ${stmt.name} = ${this._genReadValue(stmt.init)};`;
1651
+ case 'MultiVarDecl': {
1652
+ const parts = stmt.declarators.map(d => `${d.name} = ${this._genReadValue(d.init)}`).join(', ');
1653
+ return `${stmt.kind} ${parts};`;
1654
+ }
1655
+ case 'IfStmt': {
1656
+ let s = `if (${this._genReadValue(stmt.cond)}) {\n${this._genBlockStmts(stmt.then.body).join('\n')}\n}`;
1657
+ if (stmt.else) s += ` else {\n${this._genBlockStmts(stmt.else.body).join('\n')}\n}`;
1658
+ return s;
1659
+ }
1660
+ default:
1661
+ return `/* unknown stmt: ${stmt?.type} */`;
1662
+ }
1663
+ }
1664
+
1665
+ // Read-aware value generator for statement positions (return / var init / if
1666
+ // condition). Signal identifiers become `signal.get()`, object/array/template
1667
+ // values are read-expanded, but signal-method calls (items.set(...)) keep their
1668
+ // raw receiver. This makes signal reads consistent and closes ai:forbidden leaks
1669
+ // (you cannot return/alias a forbidden signal object to escape the guard).
1670
+ _genReadValue(expr) {
1671
+ if (!expr) return 'undefined';
1672
+ switch (expr.type) {
1673
+ case 'UpdateExpr':
1674
+ case 'AssignExpr':
1675
+ case 'CommaExpr':
1676
+ case 'CallExpr':
1677
+ return this._genExprWithSignalAwareness(expr);
1678
+ default:
1679
+ return this._genExprRead(expr, this._currentSignals ?? [], this._currentParams ?? []);
1680
+ }
1681
+ }
1682
+
1683
+ // In statement context, handle signal updates
1684
+ _genExprWithSignalAwareness(expr) {
1685
+ if (!expr) return '';
1686
+ if (expr.type === 'UpdateExpr') return this._genUpdateExpr(expr);
1687
+ if (expr.type === 'AssignExpr') return this._genAssignTarget(expr.target, expr.op, expr.value);
1688
+ // FIX: CommaExpr in event handlers must be ; separated statements, not , expressions
1689
+ // e.g. on:click={ a = 1, b = 2 } → a.set(1); b.set(2) (not a.set(1), b.set(2))
1690
+ if (expr.type === 'CommaExpr') return expr.parts.map(p => this._genExprWithSignalAwareness(p)).join('; ');
1691
+ // For CallExpr in event handlers and action bodies, use signal-aware read mode.
1692
+ // Special case: signal.set/update/peek/subscribe(...) — the signal itself is
1693
+ // the receiver, so we must NOT wrap it with .get(). Only its arguments need read expansion.
1694
+ // e.g. items.set([...items, x]) → items.set([...items.get(), x])
1695
+ // myRef.get().focus() → myRef.get().focus() (no change — not a signal method)
1696
+ if (expr.type === 'CallExpr') {
1697
+ const sigs = this._currentSignals ?? [];
1698
+ const params = this._currentParams ?? [];
1699
+ const { callee } = expr;
1700
+ if (
1701
+ callee.type === 'MemberExpr' && !callee.computed &&
1702
+ callee.obj.type === 'Ident' && sigs.includes(callee.obj.name) &&
1703
+ _SIGNAL_METHODS.has(callee.prop.name ?? callee.prop)
1704
+ ) {
1705
+ // signal.method(args…) — keep the raw signal reference, expand args reactively
1706
+ const method = this._genExpr(callee.prop);
1707
+ const args = expr.args.map(a => this._genExprRead(a, sigs, params)).join(', ');
1708
+ return `${callee.obj.name}.${method}(${args})`;
1709
+ }
1710
+ return this._genExprRead(expr, sigs, params);
1711
+ }
1712
+ return this._genExpr(expr);
1713
+ }
1714
+
1715
+ // Generate expression in "read mode" — signal references become signal.get()
1716
+ // params: component params that may be signals from parent (duck-typing)
1717
+ _genExprRead(node, signals, params = []) {
1718
+ if (!node) return 'undefined';
1719
+ switch (node.type) {
1720
+ case 'Ident':
1721
+ if (signals.includes(node.name)) return `${node.name}.get()`;
1722
+ // Props duck-typing: parent may pass a signal OR a plain value.
1723
+ // At runtime, check the _type tag and call .get() only when it's a signal.
1724
+ if (params.includes(node.name))
1725
+ return `(${node.name}?._type === 'signal' ? ${node.name}.get() : ${node.name})`;
1726
+ return node.name;
1727
+ case 'Literal':
1728
+ return JSON.stringify(node.value);
1729
+ case 'BinaryExpr':
1730
+ return `(${this._genExprRead(node.left, signals, params)} ${node.op} ${this._genExprRead(node.right, signals, params)})`;
1731
+ case 'UnaryExpr':
1732
+ return `${node.op}(${this._genExprRead(node.expr, signals, params)})`;
1733
+ case 'TernaryExpr':
1734
+ return `(${this._genExprRead(node.cond, signals, params)} ? ${this._genExprRead(node.then, signals, params)} : ${this._genExprRead(node.else, signals, params)})`;
1735
+ case 'MemberExpr':
1736
+ if (node.computed) return `${this._genExprRead(node.obj, signals, params)}[${this._genExprRead(node.prop, signals, params)}]`;
1737
+ return `${this._genExprRead(node.obj, signals, params)}.${this._genExpr(node.prop)}`;
1738
+ case 'CallExpr': {
1739
+ const calleeCode = this._genExprRead(node.callee, signals, params);
1740
+ const callee = node.callee.type === 'ArrowFn' ? `(${calleeCode})` : calleeCode;
1741
+ const args = node.args.map(a => this._genExprRead(a, signals, params)).join(', ');
1742
+ return `${callee}(${args})`;
1743
+ }
1744
+ case 'NewExpr': {
1745
+ const callee = this._genExprRead(node.callee, signals, params);
1746
+ const args = node.args.map(a => a.type === 'SpreadExpr' ? `...${this._genExprRead(a.expr, signals, params)}` : this._genExprRead(a, signals, params)).join(', ');
1747
+ return `new ${callee}(${args})`;
1748
+ }
1749
+ case 'SpreadExpr':
1750
+ return `...${this._genExprRead(node.expr, signals, params)}`;
1751
+ case 'ArrayExpr':
1752
+ return `[${node.elements.map(e => this._genExprRead(e, signals, params)).join(', ')}]`;
1753
+ case 'ObjectExpr': {
1754
+ const props = node.properties.map(p => `${p.key}: ${this._genExprRead(p.value, signals, params)}`);
1755
+ return `{ ${props.join(', ')} }`;
1756
+ }
1757
+ case 'TemplateLiteral': {
1758
+ // Reactive template: signal references inside ${} become .get() calls
1759
+ const inner = node.parts.map(p =>
1760
+ p.type === 'str' ? p.value : '${' + this._genExprRead(p.expr, signals, params) + '}'
1761
+ ).join('');
1762
+ return '`' + inner + '`';
1763
+ }
1764
+ case 'ArrowFn': {
1765
+ // Arrow fn params shadow signals — exclude them from the reactive signal list inside
1766
+ const ownParams = node.params.map(p => p.name);
1767
+ const innerSignals = signals.filter(s => !ownParams.includes(s));
1768
+ const innerParams = params.filter(p => !ownParams.includes(p));
1769
+ const paramStr = ownParams.join(', ');
1770
+ if (node.body.type === 'Block') {
1771
+ const bodyLines = this._genBlockStmtsReactive(node.body.body, innerSignals, innerParams);
1772
+ return `(${paramStr}) => {\n${bodyLines.join('\n')}\n}`;
1773
+ }
1774
+ return `(${paramStr}) => ${this._genExprRead(node.body, innerSignals, innerParams)}`;
1775
+ }
1776
+ default:
1777
+ return this._genExpr(node);
1778
+ }
1779
+ }
1780
+
1781
+ // ── Reactive statement generation (for computed block bodies & arrow fns) ──
1782
+ // Like _genStmt but threads signal reactivity through all expressions.
1783
+ _genStmtReactive(stmt, signals, params) {
1784
+ if (!stmt) return null;
1785
+ switch (stmt.type) {
1786
+ case 'ExprStmt':
1787
+ return `${this._genExprRead(stmt.expr, signals, params)};`;
1788
+ case 'ReturnStmt':
1789
+ return stmt.value ? `return ${this._genExprRead(stmt.value, signals, params)};` : 'return;';
1790
+ case 'VarDecl':
1791
+ return `${stmt.kind} ${stmt.name} = ${this._genExprRead(stmt.init, signals, params)};`;
1792
+ case 'MultiVarDecl': {
1793
+ const parts = stmt.declarators.map(d => `${d.name} = ${this._genExprRead(d.init, signals, params)}`).join(', ');
1794
+ return `${stmt.kind} ${parts};`;
1795
+ }
1796
+ case 'IfStmt': {
1797
+ let s = `if (${this._genExprRead(stmt.cond, signals, params)}) {\n${this._genBlockStmtsReactive(stmt.then.body, signals, params).join('\n')}\n}`;
1798
+ if (stmt.else) s += ` else {\n${this._genBlockStmtsReactive(stmt.else.body, signals, params).join('\n')}\n}`;
1799
+ return s;
1800
+ }
1801
+ default:
1802
+ return this._genStmt(stmt);
1803
+ }
1804
+ }
1805
+
1806
+ _genBlockStmtsReactive(stmts, signals, params) {
1807
+ return (stmts ?? []).map(s => this._genStmtReactive(s, signals, params)).filter(Boolean);
1808
+ }
1809
+
1810
+ // ── Helpers ──
1811
+ // params: component params that may carry signal values — treated as potentially reactive
1812
+ _exprReadsSignals(expr, signals, params = []) {
1813
+ if (!expr) return false;
1814
+ if (expr.type === 'Ident' && signals.includes(expr.name)) return true;
1815
+ // A param could be a signal from the parent — treat as reactive to be safe
1816
+ if (expr.type === 'Ident' && params.includes(expr.name)) return true;
1817
+ if (expr.type === 'MemberExpr') return this._exprReadsSignals(expr.obj, signals, params);
1818
+ if (expr.type === 'CallExpr') {
1819
+ return this._exprReadsSignals(expr.callee, signals, params) ||
1820
+ expr.args.some(a => this._exprReadsSignals(a, signals, params));
1821
+ }
1822
+ if (expr.type === 'BinaryExpr') {
1823
+ return this._exprReadsSignals(expr.left, signals, params) ||
1824
+ this._exprReadsSignals(expr.right, signals, params);
1825
+ }
1826
+ if (expr.type === 'TernaryExpr') {
1827
+ return this._exprReadsSignals(expr.cond, signals, params) ||
1828
+ this._exprReadsSignals(expr.then, signals, params) ||
1829
+ this._exprReadsSignals(expr.else, signals, params);
1830
+ }
1831
+ if (expr.type === 'UnaryExpr') return this._exprReadsSignals(expr.expr, signals, params);
1832
+ if (expr.type === 'TemplateLiteral')
1833
+ return expr.parts.some(p => p.type === 'expr' && this._exprReadsSignals(p.expr, signals, params));
1834
+ return false;
1835
+ }
1836
+
1837
+ // ── Event modifier wrapper ──
1838
+ // Wraps a handler code string with modifier guards.
1839
+ // Supported modifiers:
1840
+ // .prevent → e.preventDefault()
1841
+ // .stop → e.stopPropagation()
1842
+ // .enter → if (e.key !== 'Enter') return;
1843
+ // .escape → if (e.key !== 'Escape') return;
1844
+ // .space → if (e.key !== ' ') return;
1845
+ // .tab → if (e.key !== 'Tab') return;
1846
+ // Multiple modifiers are combined: .enter.escape → if (e.key!=='Enter' && e.key!=='Escape') return;
1847
+ _wrapWithModifiers(handlerCode, modifiers = []) {
1848
+ const KEY_MAP = { enter: 'Enter', escape: 'Escape', space: ' ', tab: 'Tab' };
1849
+ let code = handlerCode;
1850
+
1851
+ // Key guards — if multiple key modifiers, allow any of them through
1852
+ const keyMods = modifiers.filter(m => KEY_MAP[m]);
1853
+ if (keyMods.length > 0) {
1854
+ const checks = keyMods.map(m => `e.key !== ${JSON.stringify(KEY_MAP[m])}`).join(' && ');
1855
+ code = `if (${checks}) return; ${code}`;
1856
+ }
1857
+
1858
+ // Propagation modifiers — prepended so they run before key guards
1859
+ if (modifiers.includes('stop')) code = `e.stopPropagation(); ${code}`;
1860
+ if (modifiers.includes('prevent')) code = `e.preventDefault(); ${code}`;
1861
+
1862
+ return code;
1863
+ }
1864
+
1865
+ // ── CSS scoper ──
1866
+ // Prefixes every CSS selector with scopeClass (e.g. "._c-Button") so that
1867
+ // component styles don't leak out. Uses a brace-paired scanner instead of
1868
+ // a naive regex to avoid matching across closing braces.
1869
+ //
1870
+ // .card { color: red; } → ._c-Button .card { color: red; }
1871
+ // @media (…) { .x {} } → @media (…) { ._c-Button .x {} } (inner scoped)
1872
+ _scopeCSS(css, scopeClass) {
1873
+ let result = '';
1874
+ let remaining = css;
1875
+
1876
+ while (remaining.length > 0) {
1877
+ const braceIdx = remaining.indexOf('{');
1878
+ if (braceIdx < 0) { result += remaining; break; }
1879
+
1880
+ const selectorText = remaining.slice(0, braceIdx);
1881
+ remaining = remaining.slice(braceIdx + 1); // skip '{'
1882
+
1883
+ // Find matching '}'
1884
+ let depth = 1, bodyEnd = 0;
1885
+ while (bodyEnd < remaining.length && depth > 0) {
1886
+ if (remaining[bodyEnd] === '{') depth++;
1887
+ else if (remaining[bodyEnd] === '}') depth--;
1888
+ if (depth > 0) bodyEnd++;
1889
+ }
1890
+ const body = remaining.slice(0, bodyEnd);
1891
+ remaining = remaining.slice(bodyEnd + 1); // skip '}'
1892
+
1893
+ const selector = selectorText.trim();
1894
+
1895
+ if (!selector) {
1896
+ // Empty selector — just re-emit the block unchanged
1897
+ result += `${selectorText}{${body}}`;
1898
+ continue;
1899
+ }
1900
+
1901
+ if (selector.startsWith('@')) {
1902
+ // @-rule (e.g. @media, @keyframes) — scope inner rules recursively
1903
+ result += `${selectorText}{${this._scopeCSS(body, scopeClass)}}`;
1904
+ continue;
1905
+ }
1906
+
1907
+ // Regular rule — scope each comma-separated selector
1908
+ const scoped = selector.split(',').map(s => {
1909
+ const t = s.trim();
1910
+ if (!t || t.startsWith(scopeClass)) return t;
1911
+ // Global selectors — don't isolate
1912
+ if (t === ':root' || t === 'html' || t === 'body' || t === '*') return t;
1913
+ return `${scopeClass} ${t}`;
1914
+ }).join(',\n');
1915
+
1916
+ result += `${scoped} {${body}}`;
1917
+ }
1918
+
1919
+ return result;
1920
+ }
1921
+
1922
+ _i(str = '') {
1923
+ return ' '.repeat(this._indent) + str;
1924
+ }
1925
+
1926
+ _capitalize(str) {
1927
+ return str[0].toUpperCase() + str.slice(1);
1928
+ }
1929
+ }
1930
+
1931
+ // ─── Convenience export ───────────────────────────────────────────────────────
1932
+ export function generate(ast, options) {
1933
+ return new CodeGenerator(options).generate(ast);
1934
+ }