@jxsuite/runtime 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.
package/src/runtime.js ADDED
@@ -0,0 +1,1728 @@
1
+ /**
2
+ * Jx — JSON-native reactive web component runtime
3
+ * @version 3.0.0
4
+ * @license MIT
5
+ *
6
+ * Four-step pipeline:
7
+ * 1. resolve — fetch JSON source (or accept raw object)
8
+ * 2. buildScope — state detection + reactive proxy construction
9
+ * 3. render — walk resolved tree, build DOM, wire reactive effects
10
+ * 4. output — append to target
11
+ *
12
+ * @module jx
13
+ */
14
+
15
+ import { reactive, ref, computed, effect, isRef, onEffectCleanup } from "@vue/reactivity";
16
+
17
+ // ─── Public API ───────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Mount a Jx document into a DOM container.
21
+ *
22
+ * @example
23
+ * import { Jx } from "@jxplatform/runtime";
24
+ * const state = await Jx("./counter.json", document.getElementById("app"));
25
+ *
26
+ * @param {string | Record<string, any>} source - Path to .json file, URL, or raw document object
27
+ * @param {HTMLElement} [target] Default is `document.body`
28
+ * @param {any} [options]
29
+ * @returns {Promise<Record<string, any>>} Resolves with the live component scope (state reactive
30
+ * proxy)
31
+ */
32
+ export async function Jx(source, target = document.body, options) {
33
+ const base = typeof source === "string" ? new URL(source, location.href).href : location.href;
34
+ const doc = await resolve(source);
35
+
36
+ // Register custom elements declared in $elements (depth-first)
37
+ if (doc.$elements) {
38
+ await registerElements(doc.$elements, base);
39
+ }
40
+
41
+ // Inject <head> elements declared in $head (link, meta, script, etc.)
42
+ if (doc.$head) {
43
+ injectHead(doc.$head, base);
44
+ }
45
+
46
+ const state = await buildScope(doc, {}, base);
47
+ target.appendChild(renderNode(doc, state, options));
48
+ if (typeof state.onMount === "function") state.onMount(state);
49
+ return state;
50
+ }
51
+
52
+ // ─── Step 1: Resolve ──────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Fetch and parse a Jx JSON source. Accepts a URL string, absolute URL, or a pre-parsed object.
56
+ *
57
+ * @param {string | Record<string, any>} source
58
+ * @returns {Promise<any>}
59
+ */
60
+ export async function resolve(source) {
61
+ if (typeof source !== "string") return source;
62
+ const res = await fetch(source);
63
+ if (!res.ok) throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
64
+ return res.json();
65
+ }
66
+
67
+ // ─── Step 2: Build scope ──────────────────────────────────────────────────────
68
+
69
+ /** JSON Schema keywords used to identify pure type definitions (Shape 2b). */
70
+ const SCHEMA_KEYWORDS = new Set([
71
+ "type",
72
+ "properties",
73
+ "items",
74
+ "enum",
75
+ "minimum",
76
+ "maximum",
77
+ "minLength",
78
+ "maxLength",
79
+ "pattern",
80
+ "required",
81
+ "examples",
82
+ ]);
83
+
84
+ /**
85
+ * Build the reactive scope (state) from the document using the five-shape detection algorithm.
86
+ *
87
+ * @param {Record<string, any>} doc
88
+ * @param {Record<string, any>} [parentScope] Default is `{}`
89
+ * @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
90
+ * @returns {Promise<Record<string, any>>} Reactive proxy (state)
91
+ */
92
+ export async function buildScope(doc, parentScope = {}, base = location.href) {
93
+ /** @type {Record<string, any>} */
94
+ const raw = {};
95
+
96
+ // Merge parent scope properties
97
+ for (const [key, val] of Object.entries(parentScope)) {
98
+ raw[key] = val;
99
+ }
100
+
101
+ const defs = doc.state ?? {};
102
+
103
+ // Pass 0: resolve bare $prototype names via import map
104
+ const imports = doc.imports ?? {};
105
+ for (const [, def] of Object.entries(defs)) {
106
+ if (
107
+ def &&
108
+ typeof def === "object" &&
109
+ !Array.isArray(def) &&
110
+ def.$prototype &&
111
+ def.$prototype !== "Function" &&
112
+ !def.$src
113
+ ) {
114
+ const mapped = imports[def.$prototype];
115
+ if (mapped) {
116
+ if (typeof mapped !== "string" || !mapped.endsWith(".class.json")) {
117
+ console.warn(
118
+ `Jx: import "${def.$prototype}" must map to a .class.json path, got "${mapped}"`,
119
+ );
120
+ continue;
121
+ }
122
+ def.$src = mapped;
123
+ }
124
+ }
125
+ }
126
+
127
+ // First pass: collect naked values, expanded defaults, plain objects
128
+ for (const [key, def] of Object.entries(defs)) {
129
+ // 1. String value
130
+ if (typeof def === "string") {
131
+ if (!def.includes("${")) raw[key] = def; // Shape 1: naked string
132
+ continue; // template strings handled in second pass
133
+ }
134
+
135
+ // 2. Number, boolean, null
136
+ if (typeof def === "number" || typeof def === "boolean" || def === null) {
137
+ raw[key] = def;
138
+ continue;
139
+ }
140
+
141
+ // 3. Array
142
+ if (Array.isArray(def)) {
143
+ raw[key] = def;
144
+ continue;
145
+ }
146
+
147
+ // 4. Object
148
+ if (typeof def === "object") {
149
+ if (def.$prototype) continue; // handled in later passes
150
+ if (def.timing === "server" && def.$src && def.$export) continue; // handled in fifth pass
151
+ if ("default" in def) {
152
+ raw[key] = def.default;
153
+ continue;
154
+ } // Shape 2: expanded signal
155
+ if (hasSchemaKeywords(def)) continue; // Shape 2b: pure type def
156
+ raw[key] = def; // Shape 1: plain object
157
+ }
158
+ }
159
+
160
+ // Wrap in Vue reactive proxy — deep reactivity from this point on
161
+ const state = reactive(raw);
162
+
163
+ // Second pass: template strings → computed
164
+ for (const [key, def] of Object.entries(defs)) {
165
+ if (typeof def === "string" && def.includes("${")) {
166
+ state[key] = computed(() => evaluateTemplate(def, state));
167
+ }
168
+ }
169
+
170
+ // Third pass: $prototype: "Function" entries
171
+ for (const [key, def] of Object.entries(defs)) {
172
+ if (typeof def === "object" && def?.$prototype === "Function") {
173
+ state[key] = await resolveFunction(def, state, key, base);
174
+ }
175
+ }
176
+
177
+ // Fourth pass: other $prototype entries (Request, Set, Map, etc.)
178
+ for (const [key, def] of Object.entries(defs)) {
179
+ if (typeof def === "object" && def?.$prototype && def.$prototype !== "Function") {
180
+ state[key] = await resolvePrototype(def, state, key, base);
181
+ }
182
+ }
183
+
184
+ // Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
185
+ for (const [key, def] of Object.entries(defs)) {
186
+ if (
187
+ def != null &&
188
+ typeof def === "object" &&
189
+ def.timing === "server" &&
190
+ def.$src &&
191
+ def.$export &&
192
+ !def.$prototype
193
+ ) {
194
+ state[key] = await resolveServerFunction(def, state, key, base);
195
+ }
196
+ }
197
+
198
+ if (doc.$media) {
199
+ state["$media"] = doc.$media;
200
+ }
201
+
202
+ return state;
203
+ }
204
+
205
+ /**
206
+ * Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
207
+ * type definition) from Shape 1 (naked object).
208
+ *
209
+ * @param {Record<string, any>} obj
210
+ * @returns {boolean}
211
+ */
212
+ function hasSchemaKeywords(obj) {
213
+ for (const k of Object.keys(obj)) {
214
+ if (SCHEMA_KEYWORDS.has(k)) return true;
215
+ }
216
+ return false;
217
+ }
218
+ export { hasSchemaKeywords };
219
+
220
+ /**
221
+ * Evaluate a template string in the context of state and optional $map. Templates use
222
+ * `state.varName` and `$map.item` syntax.
223
+ *
224
+ * @param {string} str
225
+ * @param {Record<string, any>} state
226
+ * @returns {any}
227
+ */
228
+ function evaluateTemplate(str, state) {
229
+ const fn = new Function("state", "$map", `return \`${str}\``);
230
+ return fn(state, state?.$map);
231
+ }
232
+
233
+ // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
234
+
235
+ /** Module cache for $src imports (shared with external class resolution). */
236
+ const _moduleCache = new Map();
237
+
238
+ /**
239
+ * Resolve a $prototype: "Function" entry into a function or computed.
240
+ *
241
+ * Functions receive state as their first parameter at call time. Functions with a return statement
242
+ * in their body are wrapped in computed() for reactive evaluation.
243
+ *
244
+ * @param {Record<string, any>} def - State entry with $prototype: "Function"
245
+ * @param {Record<string, any>} state - Reactive scope proxy
246
+ * @param {string} key - Def key name
247
+ * @param {string} [base] - Base URL for resolving $src imports
248
+ * @returns {Promise<any>}
249
+ */
250
+ async function resolveFunction(def, state, key, base) {
251
+ if (def.body && def.$src) {
252
+ throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
253
+ }
254
+ if (!def.body && !def.$src) {
255
+ throw new Error(`Jx: '${key}' is a Function prototype with no body or $src`);
256
+ }
257
+
258
+ let fn;
259
+
260
+ if (def.body) {
261
+ const params = resolveParamNames(def);
262
+ fn = new Function(...params, def.body);
263
+ Object.defineProperty(fn, "name", { value: def.name ?? key, configurable: true });
264
+ } else {
265
+ // $src: dynamic import
266
+ const src = def.$src;
267
+ const exportName = def.$export ?? key;
268
+ let mod;
269
+ if (_moduleCache.has(src)) {
270
+ mod = _moduleCache.get(src);
271
+ } else {
272
+ try {
273
+ mod = await import(src);
274
+ } catch {
275
+ if (base) {
276
+ const resolvedSrc = new URL(src, base).href;
277
+ mod = await import(resolvedSrc);
278
+ } else {
279
+ throw new Error(`Jx: failed to import '$src' "${src}" for "${key}"`);
280
+ }
281
+ }
282
+ _moduleCache.set(src, mod);
283
+ }
284
+ fn = mod[exportName] ?? mod.default?.[exportName];
285
+ if (typeof fn !== "function") {
286
+ throw new Error(`Jx: export "${exportName}" not found or not a function in "${src}"`);
287
+ }
288
+ }
289
+
290
+ // Detect computed: body contains a return statement
291
+ if (def.body && /\breturn\b/.test(def.body)) {
292
+ return computed(() => fn(state));
293
+ }
294
+
295
+ return fn;
296
+ }
297
+
298
+ // ─── Step 3: Render ───────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Extract parameter names from a function definition. Supports both legacy "arguments" (string
302
+ * array) and CEM-compatible "parameters" (object array). Always ensures "state" is the first
303
+ * parameter.
304
+ *
305
+ * @param {Record<string, any>} def
306
+ * @returns {string[]}
307
+ */
308
+ function resolveParamNames(def) {
309
+ const raw = def.parameters ?? def.arguments ?? [];
310
+ let names;
311
+ if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === "object") {
312
+ // CEM-style: [{name: "event", type: {...}}, ...]
313
+ names = raw.map((p) => p.name ?? p.identifier ?? "arg");
314
+ } else {
315
+ // Legacy string array: ["state", "event"] or ["event"]
316
+ names = raw;
317
+ }
318
+ return names.length > 0 && names[0] === "state" ? names : ["state", ...names];
319
+ }
320
+
321
+ /**
322
+ * Reserved Jx keys — never set as DOM properties.
323
+ *
324
+ * @type {Set<string>}
325
+ */
326
+ export const RESERVED_KEYS = new Set([
327
+ "$schema",
328
+ "$id",
329
+ "$defs",
330
+ "state",
331
+ "$ref",
332
+ "$props",
333
+ "$elements",
334
+ "$switch",
335
+ "$prototype",
336
+ "$src",
337
+ "$export",
338
+ "$media",
339
+ "$map",
340
+ "timing",
341
+ "default",
342
+ "description",
343
+ "body",
344
+ "parameters",
345
+ "arguments",
346
+ "name",
347
+ "tagName",
348
+ "children",
349
+ "style",
350
+ "attributes",
351
+ "items",
352
+ "map",
353
+ "filter",
354
+ "sort",
355
+ "cases",
356
+ "observedAttributes",
357
+ ]);
358
+
359
+ /**
360
+ * Recursively render a Jx element definition into a DOM element.
361
+ *
362
+ * @param {Record<string, any>} def
363
+ * @param {Record<string, any>} state - Reactive scope proxy (or child scope via Object.create)
364
+ * @param {any} [options]
365
+ * @returns {HTMLElement | Text}
366
+ */
367
+ export function renderNode(def, state, options) {
368
+ const path = options?._path ?? [];
369
+
370
+ // Text node children: bare strings/numbers/booleans produce DOM Text nodes
371
+ if (typeof def === "string" || typeof def === "number" || typeof def === "boolean") {
372
+ const textNode = document.createTextNode(String(def));
373
+ if (typeof def === "string" && isTemplateString(def)) {
374
+ effect(() => {
375
+ textNode.textContent = evaluateTemplate(def, state);
376
+ });
377
+ }
378
+ return textNode;
379
+ }
380
+
381
+ // Extend scope with any $-prefixed local bindings declared on this node
382
+ let localState = state;
383
+ for (const [key, val] of Object.entries(def)) {
384
+ if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
385
+ if (localState === state) localState = Object.create(state);
386
+ localState[key] = isRefObj(val) ? resolveRef(val.$ref, state) : val;
387
+ }
388
+ }
389
+
390
+ // Custom element with $props: set JS properties on the element instance
391
+ const tagName = def.tagName ?? "div";
392
+ const isCustomEl = tagName.includes("-") && customElements.get(tagName);
393
+
394
+ if (def.$props && isCustomEl) {
395
+ return renderCustomElementWithProps(def, localState, options, path);
396
+ }
397
+
398
+ if (def.$props) {
399
+ const { $props: _$props, ...rest } = def;
400
+ return renderNode(rest, mergeProps(def, localState), options);
401
+ }
402
+ if (def.$switch) return renderSwitch(def, localState, options);
403
+ if (def.children?.$prototype === "Array") return renderMappedArray(def, localState, options);
404
+
405
+ const el = document.createElement(tagName);
406
+
407
+ if (options?.onNodeCreated) options.onNodeCreated(el, path, def);
408
+
409
+ applyProperties(el, def, localState);
410
+ applyStyle(el, def.style ?? {}, localState["$media"] ?? {}, localState);
411
+ applyAttributes(el, def.attributes ?? {}, localState);
412
+
413
+ const children = Array.isArray(def.children) ? def.children : [];
414
+ for (let i = 0; i < children.length; i++) {
415
+ const childOpts = options ? { ...options, _path: [...path, "children", i] } : undefined;
416
+ el.appendChild(renderNode(children[i], localState, childOpts));
417
+ }
418
+
419
+ return el;
420
+ }
421
+
422
+ // ─── Template string utilities ────────────────────────────────────────────────
423
+
424
+ /**
425
+ * Check if a value is a template string (contains ${}).
426
+ *
427
+ * @param {any} val
428
+ * @returns {boolean}
429
+ */
430
+ function isTemplateString(val) {
431
+ return typeof val === "string" && val.includes("${");
432
+ }
433
+
434
+ // ─── Property / style / attribute application ─────────────────────────────────
435
+
436
+ /**
437
+ * @param {HTMLElement} el
438
+ * @param {Record<string, any>} def
439
+ * @param {Record<string, any>} state
440
+ */
441
+ function applyProperties(el, def, state) {
442
+ for (const [key, val] of Object.entries(def)) {
443
+ if (RESERVED_KEYS.has(key)) continue;
444
+ if (key.startsWith("$")) continue; // scope bindings — handled in renderNode
445
+
446
+ if (key.startsWith("on")) {
447
+ // Event handler: $ref to a function
448
+ if (isRefObj(val)) {
449
+ const handler = resolveRef(val.$ref, state);
450
+ if (typeof handler === "function") {
451
+ const scope = state;
452
+ el.addEventListener(key.slice(2), (e) => handler(scope, e));
453
+ }
454
+ continue;
455
+ }
456
+ // Event handler: inline $prototype: "Function"
457
+ if (val && typeof val === "object" && val.$prototype === "Function" && val.body) {
458
+ const params = resolveParamNames(val);
459
+ const fn = new Function(...params, val.body);
460
+ const scope = state;
461
+ el.addEventListener(key.slice(2), (e) => fn(scope, e));
462
+ continue;
463
+ }
464
+ }
465
+
466
+ bindProperty(el, key, val, state);
467
+ }
468
+ }
469
+
470
+ /**
471
+ * @param {any} el
472
+ * @param {string} key
473
+ * @param {any} val
474
+ * @param {Record<string, any>} state
475
+ */
476
+ function bindProperty(el, key, val, state) {
477
+ if (isRefObj(val)) {
478
+ if (key === "id") {
479
+ el[key] = resolveRef(val.$ref, state);
480
+ return;
481
+ }
482
+ effect(() => {
483
+ el[key] = resolveRef(val.$ref, state);
484
+ });
485
+ return;
486
+ }
487
+
488
+ // Universal ${} reactivity — template strings in element properties
489
+ if (isTemplateString(val)) {
490
+ effect(() => {
491
+ el[key] = evaluateTemplate(val, state);
492
+ });
493
+ return;
494
+ }
495
+
496
+ el[key] = val;
497
+ }
498
+
499
+ /**
500
+ * Apply inline styles and emit a scoped <style> block for nested CSS selectors and @custom-media
501
+ * breakpoint rules.
502
+ *
503
+ * @param {HTMLElement} el
504
+ * @param {Record<string, any>} styleDef
505
+ * @param {Record<string, any>} [mediaQueries] Named breakpoints from root $media. Default is `{}`
506
+ * @param {Record<string, any>} [state] Component scope for template string evaluation. Default is
507
+ * `{}`
508
+ */
509
+ export function applyStyle(el, styleDef, mediaQueries = {}, state = {}) {
510
+ /** @type {Record<string, any>} */
511
+ const nested = {};
512
+ /** @type {Record<string, any>} */
513
+ const media = {};
514
+
515
+ for (const [prop, val] of Object.entries(styleDef)) {
516
+ if (prop.startsWith("@")) media[prop] = val;
517
+ else if (isNestedSelector(prop)) nested[prop] = val;
518
+ else if (prop.startsWith("--")) {
519
+ if (isTemplateString(val))
520
+ effect(() => {
521
+ el.style.setProperty(prop, evaluateTemplate(val, state));
522
+ });
523
+ else el.style.setProperty(prop, val);
524
+ } else if (isTemplateString(val))
525
+ effect(() => {
526
+ /** @type {any} */ (el.style)[prop] = evaluateTemplate(val, state);
527
+ });
528
+ else /** @type {any} */ (el.style)[prop] = val;
529
+ }
530
+
531
+ const hasNested = Object.keys(nested).length > 0;
532
+ const hasMedia = Object.keys(media).length > 0;
533
+ if (!hasNested && !hasMedia) return;
534
+
535
+ const uid = `jx-${Math.random().toString(36).slice(2, 7)}`;
536
+ el.dataset.jx = uid;
537
+
538
+ let css = "";
539
+
540
+ for (const [sel, rules] of Object.entries(nested)) {
541
+ const resolved = sel.startsWith("&")
542
+ ? sel.replace("&", `[data-jx="${uid}"]`)
543
+ : sel.startsWith("[")
544
+ ? `[data-jx="${uid}"]${sel}`
545
+ : `[data-jx="${uid}"] ${sel}`;
546
+ css += `${resolved} { ${toCSSText(rules)} }\n`;
547
+ }
548
+
549
+ for (const [key, rules] of Object.entries(media)) {
550
+ if (key === "@--") continue; // base canvas width, not a real media query
551
+ const query = key.startsWith("@--")
552
+ ? (mediaQueries[key.slice(1)] ?? key.slice(1))
553
+ : key.slice(1);
554
+ const scope = `[data-jx="${uid}"]`;
555
+ css += `@media ${query} { ${scope} { ${toCSSText(rules)} } }\n`;
556
+ for (const [sel, nestedRules] of Object.entries(rules)) {
557
+ if (!isNestedSelector(sel)) continue;
558
+ const resolved = sel.startsWith("&")
559
+ ? sel.replace("&", scope)
560
+ : sel.startsWith("[")
561
+ ? `${scope}${sel}`
562
+ : `${scope} ${sel}`;
563
+ css += `@media ${query} { ${resolved} { ${toCSSText(nestedRules)} } }\n`;
564
+ }
565
+ }
566
+
567
+ const tag = document.createElement("style");
568
+ tag.textContent = css;
569
+ document.head.appendChild(tag);
570
+ }
571
+
572
+ /**
573
+ * @param {HTMLElement} el
574
+ * @param {Record<string, any>} attrs
575
+ * @param {Record<string, any>} state
576
+ */
577
+ function applyAttributes(el, attrs, state) {
578
+ for (const [k, v] of Object.entries(attrs)) {
579
+ if (isRefObj(v)) {
580
+ effect(() => el.setAttribute(k, String(resolveRef(v.$ref, state) ?? "")));
581
+ } else if (isTemplateString(v)) {
582
+ effect(() => el.setAttribute(k, String(evaluateTemplate(v, state))));
583
+ } else {
584
+ el.setAttribute(k, String(v));
585
+ }
586
+ }
587
+ }
588
+
589
+ // ─── Array mapping ────────────────────────────────────────────────────────────
590
+
591
+ /**
592
+ * @param {Record<string, any>} def
593
+ * @param {Record<string, any>} state
594
+ * @param {any} [options]
595
+ * @returns {HTMLElement}
596
+ */
597
+ function renderMappedArray(def, state, options) {
598
+ const path = options?._path ?? [];
599
+ const container = document.createElement(def.tagName ?? "div");
600
+
601
+ if (options?.onNodeCreated) options.onNodeCreated(container, path, def);
602
+
603
+ applyProperties(container, def, state);
604
+ applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
605
+ applyAttributes(container, def.attributes ?? {}, state);
606
+ const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = def.children;
607
+
608
+ effect(() => {
609
+ container.innerHTML = "";
610
+ let items;
611
+ if (isRefObj(itemsSrc)) {
612
+ items = resolveRef(itemsSrc.$ref, state);
613
+ } else {
614
+ items = itemsSrc;
615
+ }
616
+ if (!Array.isArray(items)) return;
617
+ if (filterRef) {
618
+ const fn = resolveRef(filterRef.$ref, state);
619
+ if (typeof fn === "function") items = items.filter(fn);
620
+ }
621
+ if (sortRef) {
622
+ const fn = resolveRef(sortRef.$ref, state);
623
+ if (typeof fn === "function") items = [...items].sort(fn);
624
+ }
625
+
626
+ items.forEach((item, index) => {
627
+ const child = Object.create(state);
628
+ child.$map = { item, index };
629
+ child["$map/item"] = item;
630
+ child["$map/index"] = index;
631
+ const childOpts = options
632
+ ? { ...options, _path: [...path, "children", "map", index] }
633
+ : undefined;
634
+ container.appendChild(renderNode(mapDef, child, childOpts));
635
+ });
636
+ });
637
+
638
+ return container;
639
+ }
640
+
641
+ // ─── $switch ──────────────────────────────────────────────────────────────────
642
+
643
+ /**
644
+ * @param {Record<string, any>} def
645
+ * @param {Record<string, any>} state
646
+ * @param {any} [options]
647
+ * @returns {HTMLElement}
648
+ */
649
+ function renderSwitch(def, state, options) {
650
+ const path = options?._path ?? [];
651
+ const container = document.createElement(def.tagName ?? "div");
652
+
653
+ if (options?.onNodeCreated) options.onNodeCreated(container, path, def);
654
+
655
+ applyProperties(container, def, state);
656
+ applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
657
+ applyAttributes(container, def.attributes ?? {}, state);
658
+ let generation = 0;
659
+
660
+ effect(() => {
661
+ container.innerHTML = "";
662
+ const key = resolveRef(def.$switch.$ref, state);
663
+ const caseDef = def.cases?.[key];
664
+ if (!caseDef) return;
665
+
666
+ if (isRefObj(caseDef)) {
667
+ // External $ref — fetch and render asynchronously
668
+ const gen = ++generation;
669
+ const href = new URL(caseDef.$ref, location.href).href;
670
+ resolve(href)
671
+ .then(async (doc) => {
672
+ if (gen !== generation) return;
673
+ const childScope = await buildScope(doc, {}, href);
674
+ if (gen !== generation) return;
675
+ container.innerHTML = "";
676
+ const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
677
+ container.appendChild(renderNode(doc, childScope, childOpts));
678
+ })
679
+ .catch((/** @type {any} */ e) =>
680
+ console.error("Jx $switch: failed to load external case", caseDef.$ref, e),
681
+ );
682
+ return;
683
+ }
684
+
685
+ const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
686
+ container.appendChild(renderNode(caseDef, state, childOpts));
687
+ });
688
+
689
+ return container;
690
+ }
691
+
692
+ // ─── Prototype namespaces (Shape 5) ──────────────────────────────────────────
693
+
694
+ /**
695
+ * Resolve a $prototype definition into a value for the reactive scope.
696
+ *
697
+ * Returns a ref() for async/persistent entries (Request, Storage, Cookie, IndexedDB), or a plain
698
+ * value for simple entries (Set, Map, FormData, Blob).
699
+ *
700
+ * @param {Record<string, any>} def - State entry with $prototype
701
+ * @param {Record<string, any>} state - Reactive scope proxy
702
+ * @param {string} key - Def key (for diagnostics)
703
+ * @param {string} [base] - Base URL for resolving $src imports
704
+ * @returns {Promise<any>}
705
+ */
706
+ export async function resolvePrototype(def, state, key, base) {
707
+ // ── External class via $src ─────────────────────────────────────────────────
708
+ if (def.$src) {
709
+ return resolveExternalPrototype(def, state, key, base);
710
+ }
711
+
712
+ switch (def.$prototype) {
713
+ case "Request": {
714
+ /** @type {import("@vue/reactivity").Ref<any>} */
715
+ const s = ref(null);
716
+ const debounceMs = def.debounce ?? 0;
717
+ /** @type {any} */
718
+ let debounceTimer = null;
719
+
720
+ if (!def.manual) {
721
+ effect(() => {
722
+ let url;
723
+ if (isTemplateString(def.url)) {
724
+ url = evaluateTemplate(def.url, state);
725
+ } else {
726
+ url = def.url;
727
+ }
728
+ if (!url || url === "undefined" || url.includes("undefined")) return;
729
+
730
+ const controller = new AbortController();
731
+ onEffectCleanup(() => {
732
+ controller.abort();
733
+ clearTimeout(debounceTimer);
734
+ });
735
+
736
+ const doFetch = () =>
737
+ fetch(url, {
738
+ signal: controller.signal,
739
+ method: def.method ?? "GET",
740
+ ...(def.headers && { headers: def.headers }),
741
+ ...(def.body && {
742
+ body: typeof def.body === "object" ? JSON.stringify(def.body) : def.body,
743
+ }),
744
+ })
745
+ .then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
746
+ .then((d) => {
747
+ s.value = d;
748
+ })
749
+ .catch((/** @type {any} */ e) => {
750
+ if (e.name !== "AbortError") s.value = { error: String(e) };
751
+ });
752
+
753
+ if (debounceMs > 0) {
754
+ debounceTimer = setTimeout(doFetch, debounceMs);
755
+ } else {
756
+ doFetch();
757
+ }
758
+ });
759
+ }
760
+
761
+ return s;
762
+ }
763
+
764
+ case "URLSearchParams":
765
+ return computed(() => {
766
+ /** @type {Record<string, string>} */
767
+ const p = {};
768
+ for (const [k, v] of Object.entries(def)) {
769
+ if (k !== "$prototype") {
770
+ p[k] = isRefObj(v)
771
+ ? resolveRef(v.$ref, state)
772
+ : isTemplateString(v)
773
+ ? evaluateTemplate(v, state)
774
+ : v;
775
+ }
776
+ }
777
+ return new URLSearchParams(p).toString();
778
+ });
779
+
780
+ case "LocalStorage":
781
+ case "SessionStorage": {
782
+ const store = def.$prototype === "LocalStorage" ? localStorage : sessionStorage;
783
+ const k = def.key ?? key;
784
+ let init;
785
+ try {
786
+ const s = store.getItem(k);
787
+ init = s !== null ? JSON.parse(s) : (def.default ?? null);
788
+ } catch {
789
+ init = def.default ?? null;
790
+ }
791
+ /** @type {import("@vue/reactivity").Ref<any>} */
792
+ const storageState = ref(init);
793
+ // Persist on change
794
+ effect(() => {
795
+ const v = storageState.value;
796
+ if (v === null) {
797
+ try {
798
+ store.removeItem(k);
799
+ } catch {}
800
+ } else {
801
+ try {
802
+ store.setItem(k, JSON.stringify(v));
803
+ } catch {}
804
+ }
805
+ });
806
+ return storageState;
807
+ }
808
+
809
+ case "Cookie": {
810
+ const name = def.name ?? key;
811
+ const read = () => {
812
+ const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
813
+ if (!m) return null;
814
+ try {
815
+ return JSON.parse(decodeURIComponent(m[1]));
816
+ } catch {
817
+ return m[1];
818
+ }
819
+ };
820
+ /** @type {import("@vue/reactivity").Ref<any>} */
821
+ const cookieState = ref(read() ?? def.default ?? null);
822
+ // Persist on change
823
+ effect(() => {
824
+ const v = cookieState.value;
825
+ let s = `${name}=${encodeURIComponent(JSON.stringify(v))}`;
826
+ if (def.maxAge !== undefined) s += `; Max-Age=${def.maxAge}`;
827
+ if (def.path) s += `; Path=${def.path}`;
828
+ if (def.domain) s += `; Domain=${def.domain}`;
829
+ if (def.secure) s += `; Secure`;
830
+ if (def.sameSite) s += `; SameSite=${def.sameSite}`;
831
+ document.cookie = s;
832
+ });
833
+ return cookieState;
834
+ }
835
+
836
+ case "IndexedDB": {
837
+ /** @type {import("@vue/reactivity").Ref<any>} */
838
+ const idbState = ref(null);
839
+ const {
840
+ database,
841
+ store,
842
+ version = 1,
843
+ keyPath = "id",
844
+ autoIncrement = true,
845
+ indexes = [],
846
+ } = def;
847
+ const req = indexedDB.open(database, version);
848
+ req.onupgradeneeded = (e) => {
849
+ /** @type {any} */
850
+ const db = /** @type {any} */ (e.target)?.result;
851
+ if (!db.objectStoreNames.contains(store)) {
852
+ const os = db.createObjectStore(store, { keyPath, autoIncrement });
853
+ for (const i of indexes) os.createIndex(i.name, i.keyPath, { unique: i.unique ?? false });
854
+ }
855
+ };
856
+ req.onsuccess = (e) => {
857
+ /** @type {any} */
858
+ const db = /** @type {any} */ (e.target)?.result;
859
+ idbState.value = {
860
+ database,
861
+ store,
862
+ version,
863
+ isReady: true,
864
+ getStore: (/** @type {string} */ mode = "readwrite") =>
865
+ Promise.resolve(db.transaction(store, mode).objectStore(store)),
866
+ };
867
+ };
868
+ req.onerror = () => {
869
+ idbState.value = { error: req.error?.message };
870
+ };
871
+ return idbState;
872
+ }
873
+
874
+ case "Set":
875
+ return new Set(def.default ?? []);
876
+
877
+ case "Map":
878
+ return new Map(Object.entries(def.default ?? {}));
879
+
880
+ case "FormData": {
881
+ const fd = new FormData();
882
+ for (const [k, v] of Object.entries(def.fields ?? {})) fd.append(k, v);
883
+ return fd;
884
+ }
885
+
886
+ case "Blob":
887
+ return new Blob(def.parts ?? [], { type: def.type ?? "text/plain" });
888
+
889
+ case "ReadableStream":
890
+ return null;
891
+
892
+ default:
893
+ console.warn(
894
+ `Jx: unknown $prototype "${def.$prototype}" for "${key}". Did you mean to add '$src'?`,
895
+ );
896
+ return ref(null);
897
+ }
898
+ }
899
+
900
+ // ─── External class resolution ────────────────────────────────────────────────
901
+
902
+ /** Reserved keys stripped from the config object passed to external class constructors. */
903
+ const EXTERNAL_RESERVED = new Set([
904
+ "$prototype",
905
+ "$src",
906
+ "$export",
907
+ "timing",
908
+ "default",
909
+ "description",
910
+ "body",
911
+ "parameters",
912
+ "arguments",
913
+ "name",
914
+ ]);
915
+
916
+ /**
917
+ * Resolve an external class prototype via $src.
918
+ *
919
+ * @param {Record<string, any>} def
920
+ * @param {Record<string, any>} state
921
+ * @param {string} key
922
+ * @param {string} [base]
923
+ * @returns {Promise<any>}
924
+ */
925
+ async function resolveExternalPrototype(def, state, key, base) {
926
+ const src = def.$src;
927
+
928
+ // Non-Function $prototype must use .class.json as entrypoint
929
+ if (!src.endsWith(".class.json")) {
930
+ throw new Error(
931
+ `Jx: $prototype "${def.$prototype}" requires a .class.json $src, got "${src}". ` +
932
+ `Wrap the class in a .class.json schema with $implementation.`,
933
+ );
934
+ }
935
+
936
+ return resolveClassJson(def, state, key, base);
937
+ }
938
+
939
+ /**
940
+ * Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
941
+ * $implementation.
942
+ *
943
+ * @param {Record<string, any>} def - Original state entry (for config extraction)
944
+ * @param {string} src - JS module URL to import
945
+ * @param {string} exportName - Export name to look up
946
+ * @param {string} [base] - Base URL for resolution
947
+ * @returns {Promise<any>}
948
+ */
949
+ async function importAndInstantiate(def, src, exportName, base) {
950
+ let mod;
951
+ if (_moduleCache.has(src)) {
952
+ mod = _moduleCache.get(src);
953
+ } else {
954
+ try {
955
+ mod = await import(src);
956
+ } catch {
957
+ if (base) {
958
+ const resolvedSrc = new URL(src, base).href;
959
+ mod = await import(resolvedSrc);
960
+ } else {
961
+ throw new Error(`Failed to import "${src}"`);
962
+ }
963
+ }
964
+ _moduleCache.set(src, mod);
965
+ }
966
+
967
+ const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
968
+ if (!ExportedClass) {
969
+ throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
970
+ }
971
+ if (typeof ExportedClass !== "function") {
972
+ throw new Error(`Jx: "${exportName}" from "${src}" is not a class`);
973
+ }
974
+
975
+ /** @type {Record<string, any>} */
976
+ const config = {};
977
+ for (const [k, v] of Object.entries(def)) {
978
+ if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
979
+ }
980
+
981
+ const instance = new ExportedClass(config);
982
+
983
+ let value;
984
+ if (typeof instance.resolve === "function") {
985
+ value = await instance.resolve();
986
+ } else if ("value" in instance) {
987
+ value = instance.value;
988
+ } else {
989
+ value = instance;
990
+ }
991
+
992
+ // Always wrap in ref for reactivity with external classes
993
+ /** @type {import("@vue/reactivity").Ref<any>} */
994
+ const s = ref(value);
995
+ if (typeof instance.subscribe === "function") {
996
+ instance.subscribe((/** @type {any} */ newVal) => {
997
+ s.value = newVal;
998
+ });
999
+ }
1000
+ return s;
1001
+ }
1002
+
1003
+ /**
1004
+ * Resolve a .class.json schema-defined class. Fetches the schema, follows $implementation if
1005
+ * hybrid, or constructs dynamically if self-contained.
1006
+ *
1007
+ * @param {Record<string, any>} def
1008
+ * @param {Record<string, any>} state
1009
+ * @param {string} key
1010
+ * @param {string} [base]
1011
+ * @returns {Promise<any>}
1012
+ */
1013
+ async function resolveClassJson(def, state, key, base) {
1014
+ const src = def.$src;
1015
+ let classDef;
1016
+
1017
+ // Try fetching the .class.json file directly
1018
+ try {
1019
+ const url = base ? new URL(src, base).href : src;
1020
+ const res = await fetch(url);
1021
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1022
+ classDef = await res.json();
1023
+ } catch {
1024
+ // Fall back to dev proxy (server will handle .class.json resolution)
1025
+ return resolveViaDevProxy(def, state, key, base);
1026
+ }
1027
+
1028
+ // Hybrid mode: $implementation points to the real JS module
1029
+ if (classDef.$implementation) {
1030
+ const schemaUrl = base ? new URL(src, base).href : new URL(src, location.href).href;
1031
+ const implSrc = new URL(classDef.$implementation, schemaUrl).href;
1032
+ const exportName = def.$export ?? classDef.title ?? def.$prototype;
1033
+ try {
1034
+ return await importAndInstantiate(def, implSrc, exportName, base);
1035
+ } catch {
1036
+ // Browser can't import the JS module — fall back to dev proxy with original .class.json def
1037
+ return resolveViaDevProxy(def, state, key, base);
1038
+ }
1039
+ }
1040
+
1041
+ // Self-contained: construct class dynamically from schema
1042
+ const DynClass = classFromSchema(classDef);
1043
+ /** @type {Record<string, any>} */
1044
+ const config = {};
1045
+ for (const [k, v] of Object.entries(def)) {
1046
+ if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
1047
+ }
1048
+ const instance = new DynClass(config);
1049
+
1050
+ let value;
1051
+ if (typeof instance.resolve === "function") {
1052
+ value = await instance.resolve();
1053
+ } else if ("value" in instance) {
1054
+ value = instance.value;
1055
+ } else {
1056
+ value = instance;
1057
+ }
1058
+
1059
+ // Always wrap in ref for reactivity
1060
+ /** @type {import("@vue/reactivity").Ref<any>} */
1061
+ const s = ref(value);
1062
+ if (typeof instance.subscribe === "function") {
1063
+ instance.subscribe((/** @type {any} */ newVal) => {
1064
+ s.value = newVal;
1065
+ });
1066
+ }
1067
+ return s;
1068
+ }
1069
+
1070
+ /**
1071
+ * Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
1072
+ * fields to _-prefixed public fields.
1073
+ *
1074
+ * @param {Record<string, any>} classDef
1075
+ * @returns {any}
1076
+ */
1077
+ function classFromSchema(classDef) {
1078
+ const fields = classDef.$defs?.fields ?? {};
1079
+ const ctor = classDef.$defs?.constructor;
1080
+ const methods = classDef.$defs?.methods ?? {};
1081
+
1082
+ class DynClass {
1083
+ constructor(/** @type {Record<string, any>} */ config = {}) {
1084
+ for (const [key, field] of Object.entries(fields)) {
1085
+ /** @type {any} */
1086
+ const typedField = field;
1087
+ const id = typedField.identifier ?? key;
1088
+ const propName = typedField.access === "private" ? `_${id}` : id;
1089
+ if (config[id] !== undefined) /** @type {any} */ (this)[propName] = config[id];
1090
+ else if (typedField.initializer !== undefined)
1091
+ /** @type {any} */ (this)[propName] = typedField.initializer;
1092
+ else if (typedField.default !== undefined)
1093
+ /** @type {any} */ (this)[propName] = structuredClone(typedField.default);
1094
+ else /** @type {any} */ (this)[propName] = null;
1095
+ }
1096
+ if (ctor?.body) {
1097
+ const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
1098
+ new Function("config", bodyStr).call(this, config);
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ for (const [key, method] of Object.entries(methods)) {
1104
+ /** @type {any} */
1105
+ const typedMethod = method;
1106
+ const name = typedMethod.identifier ?? key;
1107
+ const params = (typedMethod.parameters ?? []).map((/** @type {any} */ p) => {
1108
+ if (p.$ref) return p.$ref.split("/").pop();
1109
+ return p.identifier ?? p.name ?? "arg";
1110
+ });
1111
+ const bodyStr = Array.isArray(typedMethod.body)
1112
+ ? typedMethod.body.join("\n")
1113
+ : (typedMethod.body ?? "");
1114
+
1115
+ if (typedMethod.role === "accessor") {
1116
+ /** @type {PropertyDescriptor} */
1117
+ const descriptor = {};
1118
+ if (typedMethod.getter)
1119
+ descriptor.get = /** @type {any} */ (new Function(typedMethod.getter.body));
1120
+ if (typedMethod.setter) {
1121
+ const sp = (typedMethod.setter.parameters ?? []).map(
1122
+ (/** @type {any} */ p) => p.$ref?.split("/").pop() ?? "v",
1123
+ );
1124
+ descriptor.set = /** @type {any} */ (new Function(...sp, typedMethod.setter.body));
1125
+ }
1126
+ Object.defineProperty(DynClass.prototype, name, { ...descriptor, configurable: true });
1127
+ } else if (typedMethod.scope === "static") {
1128
+ /** @type {any} */ (DynClass)[name] = new Function(...params, bodyStr);
1129
+ } else {
1130
+ /** @type {any} */ (DynClass.prototype)[name] = new Function(...params, bodyStr);
1131
+ }
1132
+ }
1133
+
1134
+ Object.defineProperty(DynClass, "name", { value: classDef.title, configurable: true });
1135
+ return DynClass;
1136
+ }
1137
+
1138
+ /**
1139
+ * Dev-mode fallback: when an $src module cannot run in the browser, proxy the resolve() call
1140
+ * through the Jx dev server (POST /**jx_resolve**). Supports reactive template strings in config
1141
+ * values via Vue effect().
1142
+ *
1143
+ * @param {Record<string, any>} def
1144
+ * @param {Record<string, any>} state
1145
+ * @param {string} key
1146
+ * @param {string} [base]
1147
+ * @returns {Promise<any>}
1148
+ */
1149
+ async function resolveViaDevProxy(def, state, key, base) {
1150
+ /** @type {Record<string, any>} */
1151
+ const config = {};
1152
+ for (const [k, v] of Object.entries(def)) {
1153
+ if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
1154
+ }
1155
+
1156
+ const hasTemplates = Object.values(config).some((/** @type {any} */ v) => isTemplateString(v));
1157
+
1158
+ /** @param {Record<string, any>} resolvedConfig */
1159
+ const doResolve = (resolvedConfig) =>
1160
+ fetch("/__jx_resolve__", {
1161
+ method: "POST",
1162
+ headers: { "Content-Type": "application/json" },
1163
+ body: JSON.stringify({
1164
+ $src: def.$src,
1165
+ $prototype: def.$prototype,
1166
+ $export: def.$export,
1167
+ $base: base,
1168
+ ...resolvedConfig,
1169
+ }),
1170
+ }).then((r) => {
1171
+ if (!r.ok) throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
1172
+ return r.json();
1173
+ });
1174
+
1175
+ // Always wrap in ref for reactivity
1176
+ /** @type {import("@vue/reactivity").Ref<any>} */
1177
+ const s = ref(null);
1178
+ if (hasTemplates) {
1179
+ effect(() => {
1180
+ /** @type {Record<string, any>} */
1181
+ const resolvedConfig = {};
1182
+ for (const [k, v] of Object.entries(config)) {
1183
+ resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
1184
+ }
1185
+ doResolve(resolvedConfig)
1186
+ .then((/** @type {any} */ value) => {
1187
+ s.value = value;
1188
+ })
1189
+ .catch((/** @type {any} */ e) => console.error("Jx dev proxy:", e));
1190
+ });
1191
+ } else {
1192
+ doResolve(config)
1193
+ .then((/** @type {any} */ value) => {
1194
+ s.value = value;
1195
+ })
1196
+ .catch((/** @type {any} */ e) => console.error("Jx dev proxy:", e));
1197
+ }
1198
+ return s;
1199
+ }
1200
+
1201
+ // ─── Server function resolution (dev mode) ────────────────────────────────────
1202
+
1203
+ /**
1204
+ * Resolve a timing: "server" entry in dev mode by executing the function client-side. In
1205
+ * production, the compiler replaces this with a fetch to the generated server handler.
1206
+ *
1207
+ * @param {Record<string, any>} def
1208
+ * @param {Record<string, any>} state
1209
+ * @param {string} key
1210
+ * @param {string} [base]
1211
+ * @returns {Promise<any>}
1212
+ */
1213
+ async function resolveServerFunction(def, state, key, base) {
1214
+ const src = def.$src;
1215
+ const exportName = def.$export;
1216
+
1217
+ let mod;
1218
+ if (_moduleCache.has(src)) {
1219
+ mod = _moduleCache.get(src);
1220
+ } else {
1221
+ try {
1222
+ mod = await import(src);
1223
+ } catch {
1224
+ if (base) {
1225
+ try {
1226
+ const resolvedSrc = new URL(src, base).href;
1227
+ mod = await import(resolvedSrc);
1228
+ } catch {
1229
+ // Module cannot run in the browser — fall back to dev server proxy
1230
+ return resolveServerFunctionViaProxy(def, state, key, base);
1231
+ }
1232
+ } else {
1233
+ return resolveServerFunctionViaProxy(def, state, key, base);
1234
+ }
1235
+ }
1236
+ _moduleCache.set(src, mod);
1237
+ }
1238
+
1239
+ const fn = mod[exportName] ?? mod.default?.[exportName];
1240
+ if (!fn) throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
1241
+ if (typeof fn !== "function")
1242
+ throw new Error(`Jx: "${exportName}" from "${src}" is not a function`);
1243
+
1244
+ const rawArgs = def.arguments ?? {};
1245
+ const hasReactiveArg = Object.values(rawArgs).some((/** @type {any} */ v) => isRefObj(v));
1246
+ const resolveArgs = () => {
1247
+ /** @type {Record<string, any>} */
1248
+ const args = {};
1249
+ for (const [k, v] of Object.entries(rawArgs)) {
1250
+ args[k] = isRefObj(v) ? resolveRef(/** @type {any} */ (v).$ref, state) : v;
1251
+ }
1252
+ return args;
1253
+ };
1254
+
1255
+ // Always wrap in ref for reactivity
1256
+ /** @type {import("@vue/reactivity").Ref<any>} */
1257
+ const s = ref(null);
1258
+ if (hasReactiveArg) {
1259
+ effect(() => {
1260
+ const args = resolveArgs();
1261
+ onEffectCleanup(() => {});
1262
+ fn(args)
1263
+ .then((/** @type {any} */ result) => {
1264
+ s.value = result;
1265
+ })
1266
+ .catch(() => {});
1267
+ });
1268
+ } else {
1269
+ s.value = await fn(resolveArgs());
1270
+ }
1271
+ return s;
1272
+ }
1273
+
1274
+ /**
1275
+ * Dev-mode fallback: when a timing: "server" module cannot run in the browser, proxy the function
1276
+ * call through the Jx dev server (POST /**jx_server**). Supports reactive $ref arguments via Vue
1277
+ * effect().
1278
+ *
1279
+ * @param {Record<string, any>} def
1280
+ * @param {Record<string, any>} state
1281
+ * @param {string} key
1282
+ * @param {string} [base]
1283
+ * @returns {Promise<any>}
1284
+ */
1285
+ async function resolveServerFunctionViaProxy(def, state, key, base) {
1286
+ const rawArgs = def.arguments ?? {};
1287
+ const hasReactiveArg = Object.values(rawArgs).some((/** @type {any} */ v) => isRefObj(v));
1288
+
1289
+ const resolveArgs = () => {
1290
+ /** @type {Record<string, any>} */
1291
+ const args = {};
1292
+ for (const [k, v] of Object.entries(rawArgs)) {
1293
+ args[k] = isRefObj(v) ? resolveRef(/** @type {any} */ (v).$ref, state) : v;
1294
+ }
1295
+ return args;
1296
+ };
1297
+
1298
+ /** @param {Record<string, any>} args */
1299
+ const doResolve = (args) =>
1300
+ fetch("/__jx_server__", {
1301
+ method: "POST",
1302
+ headers: { "Content-Type": "application/json" },
1303
+ body: JSON.stringify({
1304
+ $src: def.$src,
1305
+ $export: def.$export,
1306
+ $base: base,
1307
+ arguments: args,
1308
+ }),
1309
+ }).then((r) => {
1310
+ if (!r.ok) throw new Error(`Jx server proxy ${r.status} for "${key}"`);
1311
+ return r.json();
1312
+ });
1313
+
1314
+ // Always wrap in ref for reactivity
1315
+ /** @type {import("@vue/reactivity").Ref<any>} */
1316
+ const s = ref(null);
1317
+ if (hasReactiveArg) {
1318
+ effect(() => {
1319
+ const args = resolveArgs();
1320
+ onEffectCleanup(() => {});
1321
+ doResolve(args)
1322
+ .then((/** @type {any} */ result) => {
1323
+ s.value = result;
1324
+ })
1325
+ .catch((/** @type {any} */ e) => console.error("Jx server proxy:", e));
1326
+ });
1327
+ } else {
1328
+ doResolve(resolveArgs())
1329
+ .then((/** @type {any} */ result) => {
1330
+ s.value = result;
1331
+ })
1332
+ .catch((/** @type {any} */ e) => console.error("Jx server proxy:", e));
1333
+ }
1334
+ return s;
1335
+ }
1336
+
1337
+ /**
1338
+ * Resolve a $ref string to a value in scope.
1339
+ *
1340
+ * With Vue reactivity, this reads directly from the reactive proxy. When called inside a effect or
1341
+ * computed, the read is tracked.
1342
+ *
1343
+ * @param {string} ref
1344
+ * @param {Record<string, any>} state - Reactive scope proxy (or child scope)
1345
+ * @returns {any}
1346
+ */
1347
+ export function resolveRef(ref, state) {
1348
+ if (typeof ref !== "string") return ref;
1349
+ if (ref.startsWith("$map/")) {
1350
+ const parts = ref.split("/");
1351
+ const key = parts[1]; // 'item' or 'index'
1352
+ const base = state.$map?.[key] ?? state["$map/" + key];
1353
+ return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
1354
+ }
1355
+ if (ref.startsWith("#/state/")) {
1356
+ const sub = ref.slice("#/state/".length);
1357
+ const slash = sub.indexOf("/");
1358
+ if (slash < 0) return state[sub];
1359
+ return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
1360
+ }
1361
+ if (ref.startsWith("parent#/")) return state[ref.slice("parent#/".length)];
1362
+ if (ref.startsWith("window#/")) return getPath(globalThis.window, ref.slice("window#/".length));
1363
+ if (ref.startsWith("document#/"))
1364
+ return getPath(globalThis.document, ref.slice("document#/".length));
1365
+ return state[ref] ?? null;
1366
+ }
1367
+
1368
+ // ─── Utilities ────────────────────────────────────────────────────────────────
1369
+
1370
+ /**
1371
+ * Check if v is a Vue ref (including computed).
1372
+ *
1373
+ * @param {any} v
1374
+ * @returns {boolean}
1375
+ */
1376
+ export function isSignal(v) {
1377
+ return isRef(v);
1378
+ }
1379
+
1380
+ /**
1381
+ * @param {any} v
1382
+ * @returns {boolean}
1383
+ */
1384
+ function isRefObj(v) {
1385
+ return v !== null && typeof v === "object" && typeof v.$ref === "string";
1386
+ }
1387
+
1388
+ /**
1389
+ * @param {string} k
1390
+ * @returns {boolean}
1391
+ */
1392
+ function isNestedSelector(k) {
1393
+ return k.startsWith(":") || k.startsWith(".") || k.startsWith("&") || k.startsWith("[");
1394
+ }
1395
+
1396
+ /**
1397
+ * @param {any} obj
1398
+ * @param {string} path
1399
+ * @returns {any}
1400
+ */
1401
+ function getPath(obj, path) {
1402
+ return path.split(/[./]/).reduce((o, k) => o?.[k], obj);
1403
+ }
1404
+
1405
+ /**
1406
+ * @param {Record<string, any>} def
1407
+ * @param {Record<string, any>} parentState
1408
+ * @returns {Record<string, any>}
1409
+ */
1410
+ function mergeProps(def, parentState) {
1411
+ const child = Object.create(parentState);
1412
+ for (const [k, v] of Object.entries(def.$props ?? {})) {
1413
+ child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
1414
+ }
1415
+ return child;
1416
+ }
1417
+
1418
+ /**
1419
+ * Convert camelCase to kebab-case.
1420
+ *
1421
+ * @param {string} s
1422
+ * @returns {string}
1423
+ */
1424
+ export function camelToKebab(s) {
1425
+ return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
1426
+ }
1427
+
1428
+ /**
1429
+ * Convert a style rules object to a CSS text string (skipping nested selectors).
1430
+ *
1431
+ * @param {Record<string, any>} rules
1432
+ * @returns {string}
1433
+ */
1434
+ export function toCSSText(rules) {
1435
+ return Object.entries(rules)
1436
+ .filter(([k]) => !isNestedSelector(k))
1437
+ .map(([p, v]) => `${camelToKebab(p)}: ${v}`)
1438
+ .join("; ");
1439
+ }
1440
+
1441
+ // ─── Custom Element Registration ──────────────────────────────────────────────
1442
+
1443
+ const _elementDefs = new Map();
1444
+
1445
+ /**
1446
+ * Resolve and register $elements entries (depth-first).
1447
+ *
1448
+ * @param {any[]} elements
1449
+ * @param {string} base
1450
+ * @returns {Promise<void>}
1451
+ */
1452
+ async function registerElements(elements, base) {
1453
+ for (const entry of elements) {
1454
+ // Bare string: npm package side-effect import (registers custom elements)
1455
+ if (typeof entry === "string") {
1456
+ try {
1457
+ // Bare specifiers need a URL path for the browser; the dev server resolves
1458
+ // /node_modules/<pkg> to the package entry point via exports/module/main.
1459
+ const specifier =
1460
+ entry.startsWith("/") || entry.startsWith(".") ? entry : `/node_modules/${entry}`;
1461
+ await import(specifier);
1462
+ } catch (e) {
1463
+ console.warn(`Jx: failed to import package "${entry}"`, e);
1464
+ }
1465
+ continue;
1466
+ }
1467
+ if (!isRefObj(entry)) continue;
1468
+ const href = new URL(entry.$ref, base).href;
1469
+ const doc = await resolve(href);
1470
+ if (!doc.tagName || !doc.tagName.includes("-")) continue;
1471
+ if (customElements.get(doc.tagName)) continue;
1472
+
1473
+ // Depth-first: register sub-dependencies first
1474
+ if (doc.$elements) {
1475
+ await registerElements(doc.$elements, href);
1476
+ }
1477
+
1478
+ await defineElement(doc, href);
1479
+ }
1480
+ }
1481
+
1482
+ /**
1483
+ * Inject head elements from $head declarations. Each entry is { tagName, attributes } — bare npm
1484
+ * specifiers in href/src are rewritten to /node_modules/ paths for the dev server.
1485
+ *
1486
+ * @param {any[]} entries
1487
+ * @param {string} _base - Document base URL for resolving relative paths
1488
+ */
1489
+ function injectHead(entries, _base) {
1490
+ for (const entry of entries) {
1491
+ if (!entry || !entry.tagName) continue;
1492
+ const tag = entry.tagName.toLowerCase();
1493
+ const attrs = { ...entry.attributes };
1494
+ // Resolve href/src: bare npm specifiers -> /node_modules/ path
1495
+ for (const key of ["href", "src"]) {
1496
+ if (
1497
+ attrs[key] &&
1498
+ !attrs[key].startsWith("/") &&
1499
+ !attrs[key].startsWith(".") &&
1500
+ !attrs[key].startsWith("http")
1501
+ ) {
1502
+ attrs[key] = `/node_modules/${attrs[key]}`;
1503
+ }
1504
+ }
1505
+
1506
+ // Deduplicate: skip if an identical element already exists
1507
+ const selector = `${tag}${attrs.href ? `[href="${attrs.href}"]` : ""}${attrs.src ? `[src="${attrs.src}"]` : ""}`;
1508
+ if (selector !== tag && document.head.querySelector(selector)) continue;
1509
+
1510
+ const el = document.createElement(tag);
1511
+ for (const [k, v] of Object.entries(attrs)) {
1512
+ el.setAttribute(k, /** @type {string} */ (v));
1513
+ }
1514
+ if (entry.textContent) el.textContent = entry.textContent;
1515
+ document.head.appendChild(el);
1516
+ }
1517
+ }
1518
+
1519
+ /**
1520
+ * Register a custom element from a Jx document.
1521
+ *
1522
+ * @param {string | Record<string, any>} source - URL to .json file, or raw document object
1523
+ * @param {string} [base] - Base URL for resolving $src imports
1524
+ * @returns {Promise<void>}
1525
+ */
1526
+ export async function defineElement(source, base) {
1527
+ if (typeof source === "string") {
1528
+ base = new URL(source, base ?? location.href).href;
1529
+ source = await resolve(source);
1530
+ }
1531
+ base = base ?? location.href;
1532
+
1533
+ /** @type {Record<string, any>} */
1534
+ const source_ = /** @type {Record<string, any>} */ (source);
1535
+
1536
+ const tagName = source_.tagName;
1537
+ if (!tagName || !tagName.includes("-")) {
1538
+ throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
1539
+ }
1540
+ if (customElements.get(tagName)) return;
1541
+
1542
+ // Register sub-dependencies first
1543
+ if (source_.$elements) {
1544
+ await registerElements(source_.$elements, base);
1545
+ }
1546
+
1547
+ _elementDefs.set(tagName, { doc: source_, base });
1548
+
1549
+ const def = source_;
1550
+ const observedAttrs = def.observedAttributes ?? [];
1551
+
1552
+ const ElementClass = class extends HTMLElement {
1553
+ static get observedAttributes() {
1554
+ return observedAttrs;
1555
+ }
1556
+
1557
+ async connectedCallback() {
1558
+ const state = await buildScope(def, {}, base);
1559
+
1560
+ // Merge $props set as JS properties by parent before connection
1561
+ for (const key of Object.keys(def.state ?? {})) {
1562
+ if (key in this && /** @type {any} */ (this)[key] !== undefined) {
1563
+ state[key] = /** @type {any} */ (this)[key];
1564
+ }
1565
+ }
1566
+ // Set up property getters/setters that forward into reactive state
1567
+ for (const key of Object.keys(def.state ?? {})) {
1568
+ if (!(key in HTMLElement.prototype)) {
1569
+ Object.defineProperty(this, key, {
1570
+ get: () => state[key],
1571
+ set: (/** @type {any} */ v) => {
1572
+ state[key] = v;
1573
+ },
1574
+ configurable: true,
1575
+ });
1576
+ }
1577
+ }
1578
+
1579
+ /** @type {any} */ (this)._state = state;
1580
+
1581
+ // Capture light DOM children (for slot distribution) before rendering
1582
+ const slottedChildren = Array.from(this.childNodes);
1583
+ this.innerHTML = "";
1584
+
1585
+ // Render template into light DOM (once, not in effect — inner effects handle reactivity)
1586
+ applyStyle(this, def.style ?? {}, state["$media"] ?? {}, state);
1587
+ applyAttributes(this, def.attributes ?? {}, state);
1588
+
1589
+ const children = Array.isArray(def.children) ? def.children : [];
1590
+ for (const childDef of children) {
1591
+ this.appendChild(renderNode(childDef, state));
1592
+ }
1593
+
1594
+ // Slot distribution (light DOM)
1595
+ distributeSlots(this, slottedChildren);
1596
+
1597
+ // Lifecycle: onMount
1598
+ if (typeof state.onMount === "function") {
1599
+ queueMicrotask(() => state.onMount(state));
1600
+ }
1601
+ }
1602
+
1603
+ disconnectedCallback() {
1604
+ /** @type {any} */
1605
+ const self = this;
1606
+ if (typeof self._state?.onUnmount === "function") {
1607
+ self._state.onUnmount(self._state);
1608
+ }
1609
+ }
1610
+
1611
+ adoptedCallback() {
1612
+ /** @type {any} */
1613
+ const self = this;
1614
+ if (typeof self._state?.onAdopted === "function") {
1615
+ self._state.onAdopted(self._state);
1616
+ }
1617
+ }
1618
+
1619
+ attributeChangedCallback(
1620
+ /** @type {string} */ name,
1621
+ /** @type {string | null} */ oldVal,
1622
+ /** @type {string | null} */ newVal,
1623
+ ) {
1624
+ /** @type {any} */
1625
+ const self = this;
1626
+ if (!self._state || oldVal === newVal) return;
1627
+ const camelKey = name.replace(
1628
+ /-([a-z])/g,
1629
+ (/** @type {string} */ _, /** @type {string} */ c) => c.toUpperCase(),
1630
+ );
1631
+ const current = self._state[camelKey];
1632
+ if (typeof current === "number") self._state[camelKey] = Number(newVal);
1633
+ else if (typeof current === "boolean")
1634
+ self._state[camelKey] = newVal !== null && newVal !== "false";
1635
+ else self._state[camelKey] = newVal;
1636
+ }
1637
+ };
1638
+
1639
+ customElements.define(tagName, ElementClass);
1640
+ }
1641
+
1642
+ /**
1643
+ * Render a registered custom element with $props (property-first interface).
1644
+ *
1645
+ * @param {Record<string, any>} def
1646
+ * @param {Record<string, any>} state
1647
+ * @param {any} [options]
1648
+ * @param {any[]} [path]
1649
+ * @returns {HTMLElement}
1650
+ */
1651
+ function renderCustomElementWithProps(def, state, options, path) {
1652
+ /** @type {any} */
1653
+ const el = document.createElement(def.tagName);
1654
+
1655
+ if (options?.onNodeCreated) options.onNodeCreated(el, path, def);
1656
+
1657
+ // Set JS properties from $props (before connection)
1658
+ for (const [key, val] of Object.entries(def.$props ?? {})) {
1659
+ if (isRefObj(val)) {
1660
+ const resolved = resolveRef(val.$ref, state);
1661
+ el[key] = resolved;
1662
+ // Reactive forwarding: re-set the property when the source changes
1663
+ effect(() => {
1664
+ el[key] = resolveRef(val.$ref, state);
1665
+ });
1666
+ } else if (isTemplateString(val)) {
1667
+ effect(() => {
1668
+ el[key] = evaluateTemplate(val, state);
1669
+ });
1670
+ } else {
1671
+ el[key] = val;
1672
+ }
1673
+ }
1674
+
1675
+ // Apply host-level style and attributes from the usage site
1676
+ applyStyle(el, def.style ?? {}, state["$media"] ?? {}, state);
1677
+ applyAttributes(el, def.attributes ?? {}, state);
1678
+
1679
+ // Append slotted children
1680
+ const children = Array.isArray(def.children) ? def.children : [];
1681
+ for (let i = 0; i < children.length; i++) {
1682
+ el.appendChild(renderNode(children[i], state, options));
1683
+ }
1684
+
1685
+ return el;
1686
+ }
1687
+
1688
+ /**
1689
+ * Light DOM slot distribution.
1690
+ *
1691
+ * @param {HTMLElement} host
1692
+ * @param {ChildNode[]} slottedChildren
1693
+ */
1694
+ function distributeSlots(host, slottedChildren) {
1695
+ if (slottedChildren.length === 0) return;
1696
+
1697
+ const slots = host.querySelectorAll("slot");
1698
+ if (slots.length === 0) return;
1699
+
1700
+ /** @type {Map<string | null, ChildNode[]>} */
1701
+ const named = new Map();
1702
+ /** @type {ChildNode[]} */
1703
+ const unnamed = [];
1704
+
1705
+ for (const child of slottedChildren) {
1706
+ if (
1707
+ child.nodeType === Node.ELEMENT_NODE &&
1708
+ /** @type {Element} */ (child).getAttribute("slot")
1709
+ ) {
1710
+ const name = /** @type {Element} */ (child).getAttribute("slot");
1711
+ if (!named.has(name)) named.set(name, []);
1712
+ /** @type {ChildNode[]} */ (named.get(name)).push(child);
1713
+ } else {
1714
+ unnamed.push(child);
1715
+ }
1716
+ }
1717
+
1718
+ for (const slot of slots) {
1719
+ const name = slot.getAttribute("name");
1720
+ const matches = name ? (named.get(name) ?? []) : unnamed;
1721
+ if (matches.length > 0) {
1722
+ slot.innerHTML = "";
1723
+ for (const child of matches) {
1724
+ slot.appendChild(child);
1725
+ }
1726
+ }
1727
+ }
1728
+ }