@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/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
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
|
+
}
|