@jxsuite/compiler 0.0.1

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.
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Compile-client.js — Pre-rendered HTML with reactive bindings
3
+ *
4
+ * Produces clean HTML with `data-bind` marker attributes and a small JS bootstrapper using
5
+ * vue/reactivity's `effect` + `computed`.
6
+ *
7
+ * Functions whose body contains `return` become computed() on state. Mapped arrays ($prototype:
8
+ * "Array") use lit-html for efficient rendering.
9
+ *
10
+ * Output pattern: HTML: pre-rendered with data-bind, :prop="key", @event="key" JS: state (reactive
11
+ * state + computed signals), bind (DOM getters), on (event handlers), hydrate()
12
+ */
13
+
14
+ import { camelToKebab, RESERVED_KEYS } from "@jxsuite/runtime";
15
+ import {
16
+ isSchemaOnly,
17
+ isTemplateString,
18
+ isRefObject,
19
+ resolveStaticValue,
20
+ createCompileContext,
21
+ buildAttrs,
22
+ compileStyles,
23
+ escapeHtml,
24
+ DEFAULT_REACTIVITY_SRC,
25
+ DEFAULT_LIT_HTML_SRC,
26
+ } from "../shared.js";
27
+
28
+ /**
29
+ * Compile a Jx document to pre-rendered HTML + reactive JS module.
30
+ *
31
+ * @param {any} raw
32
+ * @param {any} opts
33
+ * @returns {{ html: string; files: { path: string; content: string }[] }}
34
+ */
35
+ export function compileClient(raw, opts) {
36
+ const {
37
+ title,
38
+ reactivitySrc = DEFAULT_REACTIVITY_SRC,
39
+ litHtmlSrc = DEFAULT_LIT_HTML_SRC,
40
+ modulePath = "app.js",
41
+ } = opts;
42
+
43
+ const context = createCompileContext(raw, null, raw.state ?? {}, raw.$media ?? {});
44
+ const styleBlock = compileStyles(raw, raw.$media ?? {});
45
+
46
+ // Collectors for bindings and handlers
47
+ const counter = { t: 0, s: 0, h: 0, m: 0, sw: 0, l: 0, needsLit: false };
48
+ /** @type {Map<string, string>} */
49
+ const bindings = new Map(); // key → expression string
50
+ /** @type {Map<string, any>} */
51
+ const handlers = new Map(); // key → { body, args }
52
+
53
+ // Classify state entries into reactive state, computed, bind, on, and init blocks
54
+ /** @type {[string, any][]} */
55
+ const stateEntries = []; // [key, initValue] → reactive({...})
56
+ /** @type {[string, string][]} */
57
+ const computedEntries = []; // [key, bodyExpr] → state.key = computed(...)
58
+ /** @type {[string, string][]} */
59
+ const bindEntries = []; // [key, bodyExpr] → bind = {...}
60
+ /** @type {[string, any][]} */
61
+ const onEntries = []; // [key, { args, body }] → on = {...}
62
+ /** @type {string[]} */
63
+ const initBlocks = []; // lines emitted after state for prototype init
64
+
65
+ // Map $src path → Set of function names to import
66
+ /** @type {Map<string, Set<string>>} */
67
+ const srcImportMap = new Map();
68
+
69
+ const defs = raw.state ?? {};
70
+ for (const [key, def] of Object.entries(defs)) {
71
+ if (def === null || typeof def !== "object" || Array.isArray(def)) {
72
+ // Naked primitive or array → reactive state
73
+ if (typeof def === "string" && isTemplateString(def)) {
74
+ // Template string → computed on state so other computeds can ref it
75
+ computedEntries.push([key, "() => `" + def + "`"]);
76
+ } else {
77
+ stateEntries.push([key, def]);
78
+ }
79
+ continue;
80
+ }
81
+
82
+ const d = /** @type {any} */ (def);
83
+
84
+ // $prototype: "Function"
85
+ if (d.$prototype === "Function") {
86
+ const args = d.parameters ?? d.arguments;
87
+ if (d.$src) {
88
+ if (!srcImportMap.has(d.$src)) srcImportMap.set(d.$src, new Set());
89
+ /** @type {Set<string>} */ (srcImportMap.get(d.$src)).add(key);
90
+
91
+ // $src functions always produce computed entries (they return values)
92
+ computedEntries.push([key, "() => { return " + key + "(state); }"]);
93
+ } else if (d.body && d.body.includes("return")) {
94
+ // Body contains return → computed
95
+ computedEntries.push([key, "() => { " + d.body + " }"]);
96
+ } else {
97
+ // No return → event handler
98
+ onEntries.push([key, { args: args ?? ["state"], body: d.body }]);
99
+ }
100
+ continue;
101
+ }
102
+
103
+ // Pure schema-only type def → skip
104
+ if (isSchemaOnly(d)) continue;
105
+
106
+ // Expanded signal with default
107
+ if ("default" in d && !d.$prototype) {
108
+ stateEntries.push([key, d.default]);
109
+ continue;
110
+ }
111
+
112
+ // $prototype: "LocalStorage" / "SessionStorage"
113
+ if (d.$prototype === "LocalStorage" || d.$prototype === "SessionStorage") {
114
+ const storeName = d.$prototype === "LocalStorage" ? "localStorage" : "sessionStorage";
115
+ const storageKey = d.key ?? key;
116
+ const defaultVal = d.default ?? null;
117
+ stateEntries.push([key, null]);
118
+ initBlocks.push(emitStorageInit(key, storeName, storageKey, defaultVal));
119
+ continue;
120
+ }
121
+
122
+ // $prototype: "Request"
123
+ if (d.$prototype === "Request") {
124
+ stateEntries.push([key, null]);
125
+ initBlocks.push(emitRequestInit(key, d));
126
+ continue;
127
+ }
128
+
129
+ // $prototype: "Cookie"
130
+ if (d.$prototype === "Cookie") {
131
+ stateEntries.push([key, null]);
132
+ initBlocks.push(emitCookieInit(key, d.name ?? key, d.default ?? null));
133
+ continue;
134
+ }
135
+
136
+ // Plain object → reactive state
137
+ stateEntries.push([key, d]);
138
+ }
139
+
140
+ // Build HTML tree with data-bind markers
141
+ const bodyContent = buildClientNode(raw, raw, context, bindings, handlers, counter);
142
+
143
+ // Merge inline-discovered bindings/handlers
144
+ for (const [key, expr] of bindings) {
145
+ if (!bindEntries.some(([k]) => k === key)) {
146
+ bindEntries.push([key, expr]);
147
+ }
148
+ }
149
+ for (const [key, def] of handlers) {
150
+ if (!onEntries.some(([k]) => k === key)) {
151
+ onEntries.push([key, def]);
152
+ }
153
+ }
154
+
155
+ // Generate the JS module
156
+ const moduleContent = emitClientModule(
157
+ stateEntries,
158
+ computedEntries,
159
+ bindEntries,
160
+ onEntries,
161
+ initBlocks,
162
+ srcImportMap,
163
+ counter,
164
+ reactivitySrc,
165
+ );
166
+
167
+ // Build importmap entries
168
+ const importmapEntries = [` "@vue/reactivity": "${reactivitySrc}"`];
169
+ if (counter.needsLit) {
170
+ importmapEntries.push(` "lit-html": "${litHtmlSrc}"`);
171
+ }
172
+
173
+ const html = `<!DOCTYPE html>
174
+ <html lang="en">
175
+ <head>
176
+ <meta charset="utf-8">
177
+ <meta name="viewport" content="width=device-width, initial-scale=1">
178
+ <title>${escapeHtml(title)}</title>
179
+ <script type="importmap">
180
+ {
181
+ "imports": {
182
+ ${importmapEntries.join(",\n")}
183
+ }
184
+ }
185
+ </script>
186
+ ${styleBlock}
187
+ <script type="module" src="./${modulePath}"></script>
188
+ </head>
189
+ <body>
190
+ ${bodyContent}
191
+ </body>
192
+ </html>`;
193
+
194
+ return { html, files: [{ path: modulePath, content: moduleContent }] };
195
+ }
196
+
197
+ // ─── HTML tree walker ─────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * @param {any} def
201
+ * @param {any} raw
202
+ * @param {any} context
203
+ * @param {Map<string, string>} bindings
204
+ * @param {Map<string, any>} handlers
205
+ * @param {any} counter
206
+ * @returns {string}
207
+ */
208
+ function buildClientNode(def, raw, context, bindings, handlers, counter) {
209
+ // String children are text nodes
210
+ if (typeof def === "string") {
211
+ return escapeHtml(def);
212
+ }
213
+ if (typeof def === "number" || typeof def === "boolean") {
214
+ return escapeHtml(String(def));
215
+ }
216
+ if (!def || typeof def !== "object") return "";
217
+
218
+ const nextContext = createCompileContext(
219
+ raw,
220
+ context.scope,
221
+ raw?.state ?? context.scopeDefs,
222
+ raw?.$media ?? context.media,
223
+ );
224
+
225
+ const tag = def.tagName ?? "div";
226
+ const bindAttrs = [];
227
+ let needsBind = false;
228
+
229
+ // textContent bindings
230
+ if (def.textContent !== undefined) {
231
+ const tc = raw?.textContent ?? def.textContent;
232
+ if (isRefObject(tc)) {
233
+ const key = refToBindingKey(tc.$ref);
234
+ bindAttrs.push(`:text-content="${key}"`);
235
+ addRefBinding(bindings, key, tc.$ref);
236
+ needsBind = true;
237
+ } else if (isTemplateString(tc)) {
238
+ const key = `_t${counter.t++}`;
239
+ bindAttrs.push(`:text-content="${key}"`);
240
+ bindings.set(key, "() => `" + tc + "`");
241
+ needsBind = true;
242
+ }
243
+ }
244
+
245
+ // Event handlers (onclick, oninput, etc.)
246
+ for (const [prop, val] of Object.entries(def)) {
247
+ if (!prop.startsWith("on") || prop === "observedAttributes") continue;
248
+ const eventName = prop.slice(2).toLowerCase();
249
+ if (isRefObject(val)) {
250
+ const key = refToBindingKey(/** @type {any} */ (val).$ref);
251
+ bindAttrs.push(`@${eventName}="${key}"`);
252
+ needsBind = true;
253
+ } else if (
254
+ val &&
255
+ typeof val === "object" &&
256
+ /** @type {any} */ (val).$prototype === "Function"
257
+ ) {
258
+ const v = /** @type {any} */ (val);
259
+ const key = `_h${counter.h++}`;
260
+ bindAttrs.push(`@${eventName}="${key}"`);
261
+ handlers.set(key, { args: v.parameters ?? v.arguments ?? ["state", "event"], body: v.body });
262
+ needsBind = true;
263
+ }
264
+ }
265
+
266
+ // Dynamic style properties
267
+ if (def.style && typeof def.style === "object") {
268
+ for (const [prop, val] of Object.entries(def.style)) {
269
+ if (
270
+ prop.startsWith(":") ||
271
+ prop.startsWith(".") ||
272
+ prop.startsWith("&") ||
273
+ prop.startsWith("[") ||
274
+ prop.startsWith("@")
275
+ )
276
+ continue;
277
+ if (val === null || typeof val === "object") continue;
278
+ if (isTemplateString(val)) {
279
+ const key = `_s${counter.s++}`;
280
+ bindAttrs.push(`:style.${camelToKebab(prop)}="${key}"`);
281
+ bindings.set(key, "() => `" + val + "`");
282
+ needsBind = true;
283
+ }
284
+ }
285
+ }
286
+
287
+ // Dynamic attributes
288
+ if (def.attributes && typeof def.attributes === "object") {
289
+ for (const [attr, val] of Object.entries(def.attributes)) {
290
+ if (isRefObject(val)) {
291
+ const key = refToBindingKey(/** @type {any} */ (val).$ref);
292
+ bindAttrs.push(`:attr.${attr}="${key}"`);
293
+ addRefBinding(bindings, key, /** @type {any} */ (val).$ref);
294
+ needsBind = true;
295
+ } else if (isTemplateString(val)) {
296
+ const key = `_t${counter.t++}`;
297
+ bindAttrs.push(`:attr.${attr}="${key}"`);
298
+ bindings.set(key, "() => `" + val + "`");
299
+ needsBind = true;
300
+ }
301
+ }
302
+ }
303
+
304
+ // Dynamic non-reserved properties (hidden, value, etc.)
305
+ for (const [prop, val] of Object.entries(def)) {
306
+ if (
307
+ RESERVED_KEYS.has(prop) ||
308
+ prop.startsWith("on") ||
309
+ prop.startsWith("$") ||
310
+ prop === "tagName" ||
311
+ prop === "id" ||
312
+ prop === "className" ||
313
+ prop === "style" ||
314
+ prop === "children" ||
315
+ prop === "textContent" ||
316
+ prop === "innerHTML" ||
317
+ prop === "attributes"
318
+ )
319
+ continue;
320
+ if (isRefObject(val)) {
321
+ const key = refToBindingKey(/** @type {any} */ (val).$ref);
322
+ bindAttrs.push(`:${camelToKebab(prop)}="${key}"`);
323
+ addRefBinding(bindings, key, /** @type {any} */ (val).$ref);
324
+ needsBind = true;
325
+ } else if (isTemplateString(val)) {
326
+ const key = `_t${counter.t++}`;
327
+ bindAttrs.push(`:${camelToKebab(prop)}="${key}"`);
328
+ bindings.set(key, "() => `" + val + "`");
329
+ needsBind = true;
330
+ }
331
+ }
332
+
333
+ // Build static attrs
334
+ const staticAttrs = buildAttrs(def, nextContext.scope);
335
+ const dataBindAttr = needsBind ? " data-bind" : "";
336
+ const bindAttrStr = bindAttrs.length > 0 ? " " + bindAttrs.join(" ") : "";
337
+
338
+ // Inner content
339
+ let inner = "";
340
+ const source = raw ?? def;
341
+ if (source.textContent !== undefined && !needsBind) {
342
+ const value = resolveStaticValue(source.textContent, nextContext.scope);
343
+ inner = value == null ? "" : escapeHtml(String(value));
344
+ } else if (source.textContent !== undefined && needsBind) {
345
+ try {
346
+ const value = resolveStaticValue(source.textContent, nextContext.scope);
347
+ inner = value == null ? "" : escapeHtml(String(value));
348
+ } catch {
349
+ inner = "";
350
+ }
351
+ } else if (source.innerHTML) {
352
+ inner = resolveStaticValue(source.innerHTML, nextContext.scope) ?? "";
353
+ } else if (
354
+ source.children &&
355
+ typeof source.children === "object" &&
356
+ !Array.isArray(source.children) &&
357
+ source.children.$prototype === "Array"
358
+ ) {
359
+ // ─── Mapped array → lit-html render binding ───
360
+ counter.needsLit = true;
361
+ const listKey = `_list${counter.l++}`;
362
+ const arrayDef = source.children;
363
+
364
+ // Resolve items source expression
365
+ let itemsExpr;
366
+ if (isRefObject(arrayDef.items)) {
367
+ const path = refToBindingKey(arrayDef.items.$ref);
368
+ itemsExpr = "state." + path;
369
+ } else {
370
+ itemsExpr = JSON.stringify(arrayDef.items);
371
+ }
372
+
373
+ // Compile the map template to a lit-html template string
374
+ const litTemplate = emitLitMapTemplate(arrayDef.map);
375
+ bindings.set(
376
+ listKey,
377
+ "() => (" + itemsExpr + " ?? []).map((item, index) => html`" + litTemplate + "`)",
378
+ );
379
+
380
+ bindAttrs.push(`:render="${listKey}"`);
381
+ needsBind = true;
382
+ // Re-derive the data-bind/attr strings since we added to bindAttrs
383
+ const dataBindAttr2 = " data-bind";
384
+ const bindAttrStr2 = " " + bindAttrs.join(" ");
385
+ const selfClosing = new Set(["input", "br", "hr", "img", "meta", "link"]);
386
+ if (selfClosing.has(tag)) {
387
+ return `<${tag}${staticAttrs}${dataBindAttr2}${bindAttrStr2}>`;
388
+ }
389
+ return `<${tag}${staticAttrs}${dataBindAttr2}${bindAttrStr2}></${tag}>`;
390
+ } else if (Array.isArray(source.children)) {
391
+ const rawChildren = raw?.children;
392
+ inner = source.children
393
+ .map((/** @type {any} */ c, /** @type {number} */ i) => {
394
+ const childRaw = rawChildren?.[i] ?? c;
395
+ return buildClientNode(c, childRaw, nextContext, bindings, handlers, counter);
396
+ })
397
+ .join("\n ");
398
+ }
399
+
400
+ // Self-closing tags
401
+ const selfClosing = new Set(["input", "br", "hr", "img", "meta", "link"]);
402
+ if (selfClosing.has(tag)) {
403
+ return `<${tag}${staticAttrs}${dataBindAttr}${bindAttrStr}>`;
404
+ }
405
+
406
+ return `<${tag}${staticAttrs}${dataBindAttr}${bindAttrStr}>${inner}</${tag}>`;
407
+ }
408
+
409
+ // ─── Lit-html map template generation ─────────────────────────────────────────
410
+
411
+ /**
412
+ * Compile a map definition to a lit-html template string. Converts $map.item → item, $map.index →
413
+ * index.
414
+ *
415
+ * @param {any} def
416
+ * @returns {string}
417
+ */
418
+ function emitLitMapTemplate(def) {
419
+ if (!def) return "";
420
+ const tag = def.tagName ?? "div";
421
+ let attrs = "";
422
+
423
+ if (def.id) attrs += ' id="' + def.id + '"';
424
+ if (def.className) attrs += ' class="' + mapRefsToLit(def.className) + '"';
425
+
426
+ // attributes object
427
+ if (def.attributes && typeof def.attributes === "object") {
428
+ for (const [k, v] of Object.entries(def.attributes)) {
429
+ if (typeof v === "string" && isTemplateString(v)) {
430
+ attrs += " " + k + '="' + mapRefsToLit(v) + '"';
431
+ } else {
432
+ attrs += " " + k + '="' + escapeHtml(String(v)) + '"';
433
+ }
434
+ }
435
+ }
436
+
437
+ // style → inline CSS
438
+ if (def.style && typeof def.style === "object") {
439
+ /** @type {string[]} */
440
+ const parts = [];
441
+ for (const [k, v] of Object.entries(def.style)) {
442
+ if (
443
+ k.startsWith(":") ||
444
+ k.startsWith(".") ||
445
+ k.startsWith("&") ||
446
+ k.startsWith("[") ||
447
+ k.startsWith("@")
448
+ )
449
+ continue;
450
+ if (v === null || typeof v === "object") continue;
451
+ const cssProp = camelToKebab(k);
452
+ if (isTemplateString(String(v))) {
453
+ parts.push(cssProp + ": " + mapRefsToLit(String(v)));
454
+ } else {
455
+ parts.push(cssProp + ": " + v);
456
+ }
457
+ }
458
+ if (parts.length > 0) {
459
+ attrs += ' style="' + parts.join("; ") + '"';
460
+ }
461
+ }
462
+
463
+ // Event handlers in map template
464
+ for (const [prop, val] of Object.entries(def)) {
465
+ if (!prop.startsWith("on") || prop === "observedAttributes") continue;
466
+ const eventName = prop.slice(2).toLowerCase();
467
+ if (isRefObject(val)) {
468
+ const key = refToBindingKey(/** @type {any} */ (val).$ref);
469
+ attrs += " @" + eventName + "=${(e) => { state.$map = { item, index }; on." + key + "(e); }}";
470
+ } else if (
471
+ val &&
472
+ typeof val === "object" &&
473
+ /** @type {any} */ (val).$prototype === "Function"
474
+ ) {
475
+ const body = mapRefsToLit(/** @type {any} */ (val).body);
476
+ attrs += " @" + eventName + "=${(e) => { " + body + " }}";
477
+ }
478
+ }
479
+
480
+ // Non-reserved properties that render as attributes
481
+ if (def.contentEditable) {
482
+ attrs += ' contenteditable="' + def.contentEditable + '"';
483
+ }
484
+
485
+ // Inner content
486
+ let inner = "";
487
+ if (def.textContent !== undefined) {
488
+ const tc = String(def.textContent);
489
+ if (isTemplateString(tc)) {
490
+ inner = mapRefsToLit(tc);
491
+ } else if (isRefObject(def.textContent)) {
492
+ const path = refToBindingKey(def.textContent.$ref);
493
+ inner = "${state." + path + "}";
494
+ } else {
495
+ inner = escapeHtml(tc);
496
+ }
497
+ } else if (def.innerHTML) {
498
+ inner = mapRefsToLit(String(def.innerHTML));
499
+ } else if (Array.isArray(def.children)) {
500
+ inner =
501
+ "\n " +
502
+ def.children.map((/** @type {any} */ c) => emitLitMapTemplate(c)).join("\n ") +
503
+ "\n ";
504
+ }
505
+
506
+ const voidTags = new Set(["input", "br", "hr", "img", "meta", "link"]);
507
+ if (voidTags.has(tag)) return "<" + tag + attrs + ">";
508
+ return "<" + tag + attrs + ">" + inner + "</" + tag + ">";
509
+ }
510
+
511
+ /**
512
+ * Replace $map references: $map.item → item, $map.index → index
513
+ *
514
+ * @param {string} str
515
+ * @returns {string}
516
+ */
517
+ function mapRefsToLit(str) {
518
+ return str.replace(/\$map\./g, "");
519
+ }
520
+
521
+ // ─── JS module generation ─────────────────────────────────────────────────────
522
+
523
+ /**
524
+ * @param {[string, any][]} stateEntries
525
+ * @param {[string, string][]} computedEntries
526
+ * @param {[string, string][]} bindEntries
527
+ * @param {[string, any][]} onEntries
528
+ * @param {string[]} initBlocks
529
+ * @param {Map<string, Set<string>>} srcImportMap
530
+ * @param {any} counter
531
+ * @param {string} _reactivitySrc
532
+ * @returns {string}
533
+ */
534
+ function emitClientModule(
535
+ stateEntries,
536
+ computedEntries,
537
+ bindEntries,
538
+ onEntries,
539
+ initBlocks,
540
+ srcImportMap,
541
+ counter,
542
+ _reactivitySrc,
543
+ ) {
544
+ /** @type {string[]} */
545
+ const lines = [];
546
+ const needsLit = counter.needsLit;
547
+ const needsComputed = computedEntries.length > 0;
548
+
549
+ lines.push("// Generated by @jxsuite/compiler — do not edit manually");
550
+
551
+ // Reactivity imports
552
+ const reactivityImports = ["reactive", "effect"];
553
+ if (needsComputed) reactivityImports.push("computed");
554
+ lines.push("import { " + reactivityImports.join(", ") + " } from '@vue/reactivity';");
555
+
556
+ // lit-html imports (only when arrays are present)
557
+ if (needsLit) {
558
+ lines.push("import { html, render } from 'lit-html';");
559
+ }
560
+
561
+ // $src imports
562
+ for (const [src, names] of srcImportMap) {
563
+ lines.push("import { " + [...names].join(", ") + " } from '" + src + "';");
564
+ }
565
+
566
+ lines.push("");
567
+
568
+ // state — reactive state
569
+ lines.push("const state = reactive({");
570
+ for (const [key, val] of stateEntries) {
571
+ lines.push(" " + key + ": " + JSON.stringify(val) + ",");
572
+ }
573
+ lines.push("});");
574
+ lines.push("");
575
+
576
+ // Prototype init blocks (Request fetch, LocalStorage read, etc.)
577
+ if (initBlocks.length > 0) {
578
+ for (const block of initBlocks) {
579
+ lines.push(block);
580
+ }
581
+ lines.push("");
582
+ }
583
+
584
+ // Computed signals on state
585
+ if (computedEntries.length > 0) {
586
+ for (const [key, expr] of computedEntries) {
587
+ lines.push("state." + key + " = computed(" + expr + ");");
588
+ }
589
+ lines.push("");
590
+ }
591
+
592
+ // bind — DOM getters
593
+ if (bindEntries.length > 0) {
594
+ lines.push("const bind = {");
595
+ for (const [key, expr] of bindEntries) {
596
+ lines.push(" " + key + ": " + expr + ",");
597
+ }
598
+ lines.push("};");
599
+ } else {
600
+ lines.push("const bind = {};");
601
+ }
602
+ lines.push("");
603
+
604
+ // on — event handlers
605
+ if (onEntries.length > 0) {
606
+ lines.push("const on = {");
607
+ for (const [key, def] of onEntries) {
608
+ if (def.imported) {
609
+ const argNames = def.args ?? ["state"];
610
+ const callArgs = argNames
611
+ .map((/** @type {string} */ a) => (a === "state" ? "state" : "e"))
612
+ .join(", ");
613
+ lines.push(" " + key + ": (e) => { " + key + "(" + callArgs + "); },");
614
+ } else {
615
+ const argNames = def.args ?? ["state"];
616
+ const callArgs = argNames
617
+ .map((/** @type {string} */ a) => (a === "state" ? "state" : "e"))
618
+ .join(", ");
619
+ lines.push(
620
+ " " +
621
+ key +
622
+ ": (e) => { const fn = (" +
623
+ argNames.join(", ") +
624
+ ") => { " +
625
+ def.body +
626
+ " }; fn(" +
627
+ callArgs +
628
+ "); },",
629
+ );
630
+ }
631
+ }
632
+ lines.push("};");
633
+ } else {
634
+ lines.push("const on = {};");
635
+ }
636
+ lines.push("");
637
+
638
+ // Hydration function
639
+ lines.push("function hydrate(root) {");
640
+ lines.push(" root.querySelectorAll('[data-bind]').forEach(el => {");
641
+ lines.push(" [...el.attributes].forEach(a => {");
642
+ lines.push(" if (a.name.startsWith(':')) {");
643
+ lines.push(" const parts = a.name.slice(1).split('.');");
644
+ lines.push(" const key = a.value;");
645
+ if (needsLit) {
646
+ lines.push(" if (parts[0] === 'render') {");
647
+ lines.push(" effect(() => { render(bind[key](), el); });");
648
+ lines.push(" } else if (parts[0] === 'style' && parts.length > 1) {");
649
+ } else {
650
+ lines.push(" if (parts[0] === 'style' && parts.length > 1) {");
651
+ }
652
+ lines.push(" effect(() => { el.style[parts[1]] = bind[key](); });");
653
+ lines.push(" } else if (parts[0] === 'attr' && parts.length > 1) {");
654
+ lines.push(" effect(() => { el.setAttribute(parts[1], bind[key]()); });");
655
+ lines.push(" } else {");
656
+ lines.push(" const prop = parts[0].replace(/-([a-z])/g, (_, c) => c.toUpperCase());");
657
+ lines.push(" effect(() => { el[prop] = bind[key](); });");
658
+ lines.push(" }");
659
+ lines.push(" } else if (a.name.startsWith('@')) {");
660
+ lines.push(" el.addEventListener(a.name.slice(1), on[a.value]);");
661
+ lines.push(" }");
662
+ lines.push(" });");
663
+ lines.push(" });");
664
+ lines.push("}");
665
+ lines.push("");
666
+ lines.push("hydrate(document);");
667
+ lines.push("");
668
+
669
+ return lines.join("\n");
670
+ }
671
+
672
+ // ─── Prototype init emitters ─────────────────────────────────────────────────
673
+
674
+ /**
675
+ * @param {string} key
676
+ * @param {any} def
677
+ * @returns {string}
678
+ */
679
+ function emitRequestInit(key, def) {
680
+ const url = def.url;
681
+ const method = def.method ?? "GET";
682
+ const isTemplateUrl = isTemplateString(url);
683
+
684
+ if (def.manual) {
685
+ return "// " + key + ": manual Request — fetch triggered by user action";
686
+ }
687
+
688
+ /** @type {string[]} */
689
+ const lines = [];
690
+ lines.push("// " + key + ": auto-fetch from " + (isTemplateUrl ? "(dynamic URL)" : url));
691
+ lines.push("effect(() => {");
692
+
693
+ if (isTemplateUrl) {
694
+ lines.push(" const url = `" + url + "`;");
695
+ lines.push(' if (!url || url === "undefined" || url.includes("undefined")) return;');
696
+ } else {
697
+ lines.push(" const url = " + JSON.stringify(url) + ";");
698
+ }
699
+
700
+ /** @type {string[]} */
701
+ const fetchOpts = [];
702
+ if (method !== "GET") fetchOpts.push("method: " + JSON.stringify(method));
703
+ if (def.headers) fetchOpts.push("headers: " + JSON.stringify(def.headers));
704
+ if (def.body) {
705
+ const bodyStr =
706
+ typeof def.body === "object"
707
+ ? JSON.stringify(JSON.stringify(def.body))
708
+ : JSON.stringify(def.body);
709
+ fetchOpts.push("body: " + bodyStr);
710
+ }
711
+
712
+ const optsStr = fetchOpts.length > 0 ? ", { " + fetchOpts.join(", ") + " }" : "";
713
+ lines.push(" fetch(url" + optsStr + ")");
714
+ lines.push(" .then(r => r.ok ? r.json() : Promise.reject(r.statusText))");
715
+ lines.push(" .then(d => { state." + key + " = d; })");
716
+ lines.push(" .catch(e => { state." + key + " = { error: String(e) }; });");
717
+ lines.push("});");
718
+
719
+ return lines.join("\n");
720
+ }
721
+
722
+ /**
723
+ * @param {string} key
724
+ * @param {string} storeName
725
+ * @param {string} storageKey
726
+ * @param {any} defaultVal
727
+ * @returns {string}
728
+ */
729
+ function emitStorageInit(key, storeName, storageKey, defaultVal) {
730
+ /** @type {string[]} */
731
+ const lines = [];
732
+ lines.push("// " + key + ": " + storeName + ' (key: "' + storageKey + '")');
733
+ lines.push("try {");
734
+ lines.push(" const _s = " + storeName + ".getItem(" + JSON.stringify(storageKey) + ");");
735
+ lines.push(
736
+ " state." + key + " = _s !== null ? JSON.parse(_s) : " + JSON.stringify(defaultVal) + ";",
737
+ );
738
+ lines.push("} catch { state." + key + " = " + JSON.stringify(defaultVal) + "; }");
739
+ lines.push("effect(() => {");
740
+ lines.push(" const v = state." + key + ";");
741
+ lines.push(" try {");
742
+ lines.push(
743
+ " if (v === null) " + storeName + ".removeItem(" + JSON.stringify(storageKey) + ");",
744
+ );
745
+ lines.push(
746
+ " else " + storeName + ".setItem(" + JSON.stringify(storageKey) + ", JSON.stringify(v));",
747
+ );
748
+ lines.push(" } catch {}");
749
+ lines.push("});");
750
+ return lines.join("\n");
751
+ }
752
+
753
+ /**
754
+ * @param {string} key
755
+ * @param {string} cookieName
756
+ * @param {any} defaultVal
757
+ * @returns {string}
758
+ */
759
+ function emitCookieInit(key, cookieName, defaultVal) {
760
+ /** @type {string[]} */
761
+ const lines = [];
762
+ lines.push("// " + key + ': Cookie (name: "' + cookieName + '")');
763
+ lines.push("{");
764
+ lines.push(
765
+ ' const _m = document.cookie.match(new RegExp("(?:^|; )' + cookieName + '=([^;]*)"));',
766
+ );
767
+ lines.push(
768
+ " try { state." +
769
+ key +
770
+ " = _m ? JSON.parse(decodeURIComponent(_m[1])) : " +
771
+ JSON.stringify(defaultVal) +
772
+ "; }",
773
+ );
774
+ lines.push(" catch { state." + key + " = _m ? _m[1] : " + JSON.stringify(defaultVal) + "; }");
775
+ lines.push("}");
776
+ return lines.join("\n");
777
+ }
778
+
779
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
780
+
781
+ /**
782
+ * @param {string} ref
783
+ * @returns {string}
784
+ */
785
+ function refToBindingKey(ref) {
786
+ if (ref.startsWith("#/state/")) {
787
+ return ref.slice("#/state/".length).replace(/\//g, "_");
788
+ }
789
+ return ref.replace(/\//g, "_");
790
+ }
791
+
792
+ /**
793
+ * @param {Map<string, string>} bindings
794
+ * @param {string} key
795
+ * @param {string} ref
796
+ */
797
+ function addRefBinding(bindings, key, ref) {
798
+ if (bindings.has(key)) return;
799
+ if (ref.startsWith("#/state/")) {
800
+ const path = ref.slice("#/state/".length);
801
+ const parts = path.split("/");
802
+ bindings.set(key, "() => state." + parts.join("."));
803
+ } else {
804
+ bindings.set(key, "() => state." + ref);
805
+ }
806
+ }