@jxsuite/runtime 0.28.2 → 0.28.4

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.ts CHANGED
@@ -13,9 +13,33 @@
13
13
  * @module jx
14
14
  */
15
15
 
16
- import { reactive, ref, computed, effect, isRef, onEffectCleanup } from "@vue/reactivity";
16
+ import { computed, effect, isRef, onEffectCleanup, reactive, ref } from "@vue/reactivity";
17
17
  import { evaluateExpression, isMutating } from "./expression.ts";
18
- import type { JxRenderOptions, DynamicClass, JxPath } from "./types.ts";
18
+ import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
19
+ import {
20
+ isExpressionDef,
21
+ isFunctionDef,
22
+ isJsonObject,
23
+ isMappedArray,
24
+ isPrototypeDef,
25
+ isRef as isRefValue,
26
+ isServerFnDef,
27
+ paramNames,
28
+ } from "@jxsuite/schema/guards";
29
+ import type {
30
+ JxAttributeValue,
31
+ JxClassDef,
32
+ JxDocument,
33
+ JxElement,
34
+ JxFunctionDef,
35
+ JxHeadEntry,
36
+ JxMappedArray,
37
+ JxPrototypeDef,
38
+ JxRef,
39
+ JxServerFnDef,
40
+ JxStyle,
41
+ } from "@jxsuite/schema/types";
42
+ import type { Ref } from "@vue/reactivity";
19
43
 
20
44
  // ─── Public API ───────────────────────────────────────────────────────────────
21
45
 
@@ -26,14 +50,14 @@ import type { JxRenderOptions, DynamicClass, JxPath } from "./types.ts";
26
50
  * import { Jx } from "@jxsuite/runtime";
27
51
  * const state = await Jx("./counter.json", document.getElementById("app"));
28
52
  *
29
- * @param {string | Record<string, any>} source - Path to .json file, URL, or raw document object
53
+ * @param {string | JxDocument} source - Path to .json file, URL, or raw document object
30
54
  * @param {HTMLElement} [target] Default is `document.body`
31
55
  * @param {JxRenderOptions} [options]
32
- * @returns {Promise<Record<string, any>>} Resolves with the live component scope (state reactive
33
- * proxy)
56
+ * @returns {Promise<JxScope>} Resolves with the live component scope (state reactive
57
+ * proxy)
34
58
  */
35
59
  export async function Jx(
36
- source: string | Record<string, any>,
60
+ source: string | JxDocument,
37
61
  target: HTMLElement = document.body,
38
62
  options?: JxRenderOptions,
39
63
  ) {
@@ -55,21 +79,30 @@ export async function Jx(
55
79
  }
56
80
 
57
81
  const state = await buildScope(doc, {}, base);
58
- target.appendChild(renderNode(doc, state, options));
59
- if (typeof state.onMount === "function") state.onMount(state);
82
+ target.append(renderNode(doc, state, options));
83
+ if (typeof state.onMount === "function") {
84
+ state.onMount(state);
85
+ }
60
86
  return state;
61
87
  }
62
88
 
63
89
  // ─── Step 1: Resolve ──────────────────────────────────────────────────────────
64
90
 
65
- const _resolveCache = new Map<string, Promise<any>>();
91
+ const _resolveCache = new Map<string, Promise<JxDocument>>();
66
92
 
67
- export async function resolve(source: string | Record<string, any>) {
68
- if (typeof source !== "string") return source;
69
- if (_resolveCache.has(source)) return _resolveCache.get(source)!;
93
+ export async function resolve(source: string | JxDocument): Promise<JxDocument> {
94
+ if (typeof source !== "string") {
95
+ return source;
96
+ }
97
+ if (_resolveCache.has(source)) {
98
+ return _resolveCache.get(source)!;
99
+ }
70
100
  const p = fetch(source).then((res) => {
71
- if (!res.ok) throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
72
- return res.json();
101
+ if (!res.ok) {
102
+ throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
103
+ }
104
+ // Trust boundary: fetched sources are Jx documents by contract.
105
+ return res.json() as Promise<JxDocument>;
73
106
  });
74
107
  _resolveCache.set(source, p);
75
108
  return p;
@@ -100,22 +133,24 @@ export function setSkipServerFunctions(v: boolean) {
100
133
  }
101
134
 
102
135
  /** @deprecated No longer needed — ContentCollection/ContentEntry resolve via generic class path */
103
- export function setSkipContentResolution(_v: boolean) {}
136
+ export function setSkipContentResolution(_v: boolean) {
137
+ // No-op retained for API compatibility
138
+ }
104
139
 
105
140
  /**
106
141
  * Build the reactive scope (state) from the document using the five-shape detection algorithm.
107
142
  *
108
- * @param {Record<string, any>} doc
109
- * @param {Record<string, any>} [parentScope] Default is `{}`
143
+ * @param {JxDocument} doc
144
+ * @param {JxScope} [parentScope] Default is `{}`
110
145
  * @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
111
- * @returns {Promise<Record<string, any>>} Reactive proxy (state)
146
+ * @returns {Promise<JxScope>} Reactive proxy (state)
112
147
  */
113
148
  export async function buildScope(
114
- doc: Record<string, any>,
115
- parentScope: Record<string, any> = {},
149
+ doc: JxDocument,
150
+ parentScope: JxScope = {},
116
151
  base: string = location.href,
117
152
  ) {
118
- const raw: Record<string, any> = {};
153
+ const raw: JxScope = {};
119
154
 
120
155
  // Merge parent scope properties
121
156
  for (const [key, val] of Object.entries(parentScope)) {
@@ -127,23 +162,16 @@ export async function buildScope(
127
162
  // Pass 0: resolve bare $prototype names via import map
128
163
  const imports = doc.imports ?? {};
129
164
  for (const [, def] of Object.entries(defs)) {
130
- if (
131
- def &&
132
- typeof def === "object" &&
133
- !Array.isArray(def) &&
134
- (def as Record<string, unknown>).$prototype &&
135
- (def as Record<string, unknown>).$prototype !== "Function" &&
136
- !(def as Record<string, unknown>).$src
137
- ) {
138
- const mapped = imports[(def as Record<string, unknown>).$prototype as string];
165
+ if (isPrototypeDef(def) && !def.$src) {
166
+ const mapped = imports[def.$prototype];
139
167
  if (mapped) {
140
- if (typeof mapped !== "string" || !mapped.endsWith(".class.json")) {
168
+ if (!mapped.endsWith(".class.json")) {
141
169
  console.warn(
142
- `Jx: import "${(def as Record<string, unknown>).$prototype}" must map to a .class.json path, got "${mapped}"`,
170
+ `Jx: import "${def.$prototype}" must map to a .class.json path, got "${mapped}"`,
143
171
  );
144
172
  continue;
145
173
  }
146
- (def as Record<string, unknown>).$src = mapped;
174
+ def.$src = mapped;
147
175
  }
148
176
  }
149
177
  }
@@ -152,8 +180,10 @@ export async function buildScope(
152
180
  for (const [key, def] of Object.entries(defs)) {
153
181
  // 1. String value
154
182
  if (typeof def === "string") {
155
- if (!def.includes("${")) raw[key] = def; // Shape 1: naked string
156
- continue; // template strings handled in second pass
183
+ if (!def.includes("${")) {
184
+ raw[key] = def;
185
+ } // Shape 1: naked string
186
+ continue; // Template strings handled in second pass
157
187
  }
158
188
 
159
189
  // 2. Number, boolean, null
@@ -170,15 +200,22 @@ export async function buildScope(
170
200
 
171
201
  // 4. Object
172
202
  if (typeof def === "object") {
173
- const d = def as Record<string, unknown>;
174
- if (d.$prototype) continue; // handled in later passes
175
- if ("$expression" in d) continue; // handled in pass 2.5
176
- if (d.timing === "server" && d.$src && d.$export) continue; // handled in fifth pass
177
- if ("default" in d) {
178
- raw[key] = d.default;
203
+ if (def.$prototype) {
204
+ continue;
205
+ } // Handled in later passes
206
+ if (isExpressionDef(def)) {
207
+ continue;
208
+ } // Handled in pass 2.5
209
+ if (isServerFnDef(def)) {
210
+ continue;
211
+ } // Handled in fifth pass
212
+ if ("default" in def) {
213
+ raw[key] = def.default;
179
214
  continue;
180
215
  } // Shape 2: expanded signal
181
- if (hasSchemaKeywords(d as Record<string, any>)) continue; // Shape 2b: pure type def
216
+ if (hasSchemaKeywords(def)) {
217
+ continue;
218
+ } // Shape 2b: pure type def
182
219
  raw[key] = def; // Shape 1: plain object
183
220
  }
184
221
  }
@@ -195,14 +232,11 @@ export async function buildScope(
195
232
 
196
233
  // Pass 2.5: $expression entries (Shape 5)
197
234
  for (const [key, def] of Object.entries(defs)) {
198
- if (def && typeof def === "object" && !Array.isArray(def) && "$expression" in def) {
199
- const node = (def as Record<string, unknown>).$expression as {
200
- operator: string;
201
- target: unknown;
202
- value?: unknown;
203
- };
235
+ if (isExpressionDef(def)) {
236
+ const node = def.$expression;
204
237
  if (isMutating(node.operator)) {
205
- state[key] = (s: Record<string, any>, event: Event) => evaluateExpression(node, s, event);
238
+ const handler: JxEventHandler = (s, event) => evaluateExpression(node, s, event);
239
+ state[key] = handler;
206
240
  } else {
207
241
  state[key] = computed(() => evaluateExpression(node, state, null));
208
242
  }
@@ -211,34 +245,23 @@ export async function buildScope(
211
245
 
212
246
  // Third pass: $prototype: "Function" entries
213
247
  for (const [key, def] of Object.entries(defs)) {
214
- if (typeof def === "object" && (def as Record<string, unknown>)?.$prototype === "Function") {
215
- state[key] = await resolveFunction(def as Record<string, any>, state, key, base);
248
+ if (isFunctionDef(def)) {
249
+ state[key] = await resolveFunction(def, state, key, base);
216
250
  }
217
251
  }
218
252
 
219
253
  // Fourth pass: other $prototype entries (Request, Set, Map, etc.)
220
254
  for (const [key, def] of Object.entries(defs)) {
221
- if (
222
- typeof def === "object" &&
223
- (def as Record<string, unknown>)?.$prototype &&
224
- (def as Record<string, unknown>).$prototype !== "Function"
225
- ) {
226
- state[key] = await resolvePrototype(def as Record<string, any>, state, key, base);
255
+ if (isPrototypeDef(def)) {
256
+ state[key] = await resolvePrototype(def, state, key, base);
227
257
  }
228
258
  }
229
259
 
230
260
  // Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
231
261
  if (!_serverFnConfig.skip) {
232
262
  for (const [key, def] of Object.entries(defs)) {
233
- if (
234
- def != null &&
235
- typeof def === "object" &&
236
- (def as Record<string, unknown>).timing === "server" &&
237
- (def as Record<string, unknown>).$src &&
238
- (def as Record<string, unknown>).$export &&
239
- !(def as Record<string, unknown>).$prototype
240
- ) {
241
- state[key] = await resolveServerFunction(def as Record<string, any>, state, key, base);
263
+ if (isServerFnDef(def)) {
264
+ state[key] = await resolveServerFunction(def, state, key, base);
242
265
  }
243
266
  }
244
267
  }
@@ -256,12 +279,14 @@ export async function buildScope(
256
279
  * Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
257
280
  * type definition) from Shape 1 (naked object).
258
281
  *
259
- * @param {Record<string, any>} obj
282
+ * @param {JxScope} obj
260
283
  * @returns {boolean}
261
284
  */
262
- function hasSchemaKeywords(obj: Record<string, any>) {
285
+ function hasSchemaKeywords(obj: object) {
263
286
  for (const k of Object.keys(obj)) {
264
- if (SCHEMA_KEYWORDS.has(k)) return true;
287
+ if (SCHEMA_KEYWORDS.has(k)) {
288
+ return true;
289
+ }
265
290
  }
266
291
  return false;
267
292
  }
@@ -272,11 +297,11 @@ export { hasSchemaKeywords };
272
297
  * `state.varName` and `$map.item` syntax.
273
298
  *
274
299
  * @param {string} str
275
- * @param {Record<string, any>} state
300
+ * @param {JxScope} state
276
301
  * @returns {string}
277
302
  */
278
- function evaluateTemplate(str: string, state: Record<string, any>) {
279
- const $map = state?.$map;
303
+ function evaluateTemplate(str: string, state: JxScope) {
304
+ const $map = state?.$map as { item?: unknown; index?: number } | undefined;
280
305
  const fn = new Function("state", "$map", "item", "index", `return \`${str}\``);
281
306
  return fn(state, $map, $map?.item, $map?.index);
282
307
  }
@@ -292,18 +317,13 @@ const _moduleCache = new Map();
292
317
  * Functions receive state as their first parameter at call time. Functions with a return statement
293
318
  * in their body are wrapped in computed() for reactive evaluation.
294
319
  *
295
- * @param {Record<string, any>} def - State entry with $prototype: "Function"
296
- * @param {Record<string, any>} state - Reactive scope proxy
320
+ * @param {JxFunctionDef} def - State entry with $prototype: "Function"
321
+ * @param {JxScope} state - Reactive scope proxy
297
322
  * @param {string} key - Def key name
298
323
  * @param {string} [base] - Base URL for resolving $src imports
299
324
  * @returns {Promise<unknown>}
300
325
  */
301
- async function resolveFunction(
302
- def: Record<string, any>,
303
- state: Record<string, any>,
304
- key: string,
305
- base?: string,
306
- ) {
326
+ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
307
327
  if (def.body && def.$src) {
308
328
  throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
309
329
  }
@@ -311,8 +331,8 @@ async function resolveFunction(
311
331
  const params = resolveParamNames(def);
312
332
  const noop = new Function(...params, "");
313
333
  Object.defineProperty(noop, "name", {
314
- value: def.name ?? key,
315
334
  configurable: true,
335
+ value: def.name ?? key,
316
336
  });
317
337
  return noop;
318
338
  }
@@ -323,12 +343,15 @@ async function resolveFunction(
323
343
  const params = resolveParamNames(def);
324
344
  fn = new Function(...params, def.body);
325
345
  Object.defineProperty(fn, "name", {
326
- value: def.name ?? key,
327
346
  configurable: true,
347
+ value: def.name ?? key,
328
348
  });
329
349
  } else {
330
- // $src: dynamic import
350
+ // $src: dynamic import (the body/$src dichotomy was validated above)
331
351
  const src = def.$src;
352
+ if (!src) {
353
+ throw new Error(`Jx: '${key}' has neither body nor $src`);
354
+ }
332
355
  const exportName = def.$export ?? key;
333
356
  let mod;
334
357
  if (_moduleCache.has(src)) {
@@ -348,7 +371,7 @@ async function resolveFunction(
348
371
  }
349
372
  fn = mod[exportName] ?? mod.default?.[exportName];
350
373
  if (typeof fn !== "function") {
351
- throw new Error(`Jx: export "${exportName}" not found or not a function in "${src}"`);
374
+ throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
352
375
  }
353
376
  }
354
377
 
@@ -377,19 +400,11 @@ async function resolveFunction(
377
400
  * array) and CEM-compatible "parameters" (object array). Always ensures "state" is the first
378
401
  * parameter.
379
402
  *
380
- * @param {Record<string, any>} def
403
+ * @param {JxFunctionDef | JxPrototypeDef} def
381
404
  * @returns {string[]}
382
405
  */
383
- function resolveParamNames(def: Record<string, any>) {
384
- const raw = def.parameters ?? def.arguments ?? [];
385
- let names;
386
- if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === "object") {
387
- // CEM-style: [{name: "event", type: {...}}, ...]
388
- names = raw.map((p) => p.name ?? p.identifier ?? "arg");
389
- } else {
390
- // Legacy string array: ["state", "event"] or ["event"]
391
- names = raw;
392
- }
406
+ function resolveParamNames(def: JxFunctionDef | JxPrototypeDef) {
407
+ const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
393
408
  return names.length > 0 && names[0] === "state" ? names : ["state", ...names];
394
409
  }
395
410
 
@@ -436,16 +451,16 @@ export const RESERVED_KEYS = new Set([
436
451
  /**
437
452
  * Recursively render a Jx element definition into a DOM element.
438
453
  *
439
- * @param {Record<string, any>} def
440
- * @param {Record<string, any>} state - Reactive scope proxy (or child scope via Object.create)
454
+ * @param {JxElement | string | number | boolean} def
455
+ * @param {JxScope} state - Reactive scope proxy (or child scope via Object.create)
441
456
  * @param {JxRenderOptions} [options]
442
457
  * @returns {HTMLElement | Text}
443
458
  */
444
459
  export function renderNode(
445
- def: Record<string, any>,
446
- state: Record<string, any>,
460
+ def: JxElement | string | number | boolean,
461
+ state: JxScope,
447
462
  options?: JxRenderOptions,
448
- ) {
463
+ ): HTMLElement | Text {
449
464
  const path = options?._path ?? [];
450
465
 
451
466
  // Text node children: bare strings/numbers/booleans produce DOM Text nodes
@@ -463,7 +478,9 @@ export function renderNode(
463
478
  let localState = state;
464
479
  for (const [key, val] of Object.entries(def)) {
465
480
  if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
466
- if (localState === state) localState = Object.create(state);
481
+ if (localState === state) {
482
+ localState = Object.create(state);
483
+ }
467
484
  localState[key] = isRefObj(val) ? resolveRef(val.$ref, state) : val;
468
485
  }
469
486
  }
@@ -480,21 +497,32 @@ export function renderNode(
480
497
  const { $props: _$props, ...rest } = def;
481
498
  return renderNode(rest, mergeProps(def, localState), options);
482
499
  }
483
- if (def.$switch) return renderSwitch(def, localState, options);
484
- if (def.children?.$prototype === "Array") return renderMappedArray(def, localState, options);
500
+ if (def.$switch) {
501
+ return renderSwitch(def, localState, options);
502
+ }
503
+ if (isMappedArray(def.children)) {
504
+ return renderMappedArray(def, def.children, localState, options);
505
+ }
485
506
 
486
507
  const el = document.createElement(tagName);
487
508
 
488
- if (options?.onNodeCreated) options.onNodeCreated(el, path, def);
509
+ if (options?.onNodeCreated) {
510
+ options.onNodeCreated(el, path, def);
511
+ }
489
512
 
490
513
  applyProperties(el, def, localState);
491
- applyStyle(el, def.style ?? {}, localState["$media"] ?? {}, localState);
514
+ applyStyle(
515
+ el,
516
+ def.style ?? {},
517
+ (localState["$media"] as Record<string, string>) ?? {},
518
+ localState,
519
+ );
492
520
  applyAttributes(el, def.attributes ?? {}, localState);
493
521
 
494
522
  const children = Array.isArray(def.children) ? def.children : [];
495
523
  for (let i = 0; i < children.length; i++) {
496
524
  const childOpts = options ? { ...options, _path: [...path, "children", i] } : undefined;
497
- el.appendChild(renderNode(children[i], localState, childOpts));
525
+ el.append(renderNode(children[i], localState, childOpts));
498
526
  }
499
527
 
500
528
  return el;
@@ -508,7 +536,7 @@ export function renderNode(
508
536
  * @param {unknown} val
509
537
  * @returns {boolean}
510
538
  */
511
- function isTemplateString(val: unknown) {
539
+ function isTemplateString(val: unknown): val is string {
512
540
  return typeof val === "string" && val.includes("${");
513
541
  }
514
542
 
@@ -516,13 +544,17 @@ function isTemplateString(val: unknown) {
516
544
 
517
545
  /**
518
546
  * @param {HTMLElement} el
519
- * @param {Record<string, any>} def
520
- * @param {Record<string, any>} state
547
+ * @param {JxElement} def
548
+ * @param {JxScope} state
521
549
  */
522
- function applyProperties(el: HTMLElement, def: Record<string, any>, state: Record<string, any>) {
550
+ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
523
551
  for (const [key, val] of Object.entries(def)) {
524
- if (RESERVED_KEYS.has(key)) continue;
525
- if (key.startsWith("$")) continue; // scope bindings — handled in renderNode
552
+ if (RESERVED_KEYS.has(key)) {
553
+ continue;
554
+ }
555
+ if (key.startsWith("$")) {
556
+ continue;
557
+ } // Scope bindings — handled in renderNode
526
558
 
527
559
  if (key.startsWith("on")) {
528
560
  // Event handler: $ref to a function
@@ -535,7 +567,7 @@ function applyProperties(el: HTMLElement, def: Record<string, any>, state: Recor
535
567
  continue;
536
568
  }
537
569
  // Event handler: inline $prototype: "Function"
538
- if (val && typeof val === "object" && val.$prototype === "Function" && val.body) {
570
+ if (isFunctionDef(val) && val.body) {
539
571
  const params = resolveParamNames(val);
540
572
  const fn = new Function(...params, val.body);
541
573
  const scope = state;
@@ -543,12 +575,8 @@ function applyProperties(el: HTMLElement, def: Record<string, any>, state: Recor
543
575
  continue;
544
576
  }
545
577
  // Event handler: inline $expression
546
- if (val && typeof val === "object" && "$expression" in val) {
547
- const node = (val as Record<string, unknown>).$expression as {
548
- operator: string;
549
- target: unknown;
550
- value?: unknown;
551
- };
578
+ if (isExpressionDef(val)) {
579
+ const node = val.$expression;
552
580
  const scope = state;
553
581
  el.addEventListener(key.slice(2), (e) => evaluateExpression(node, scope, e));
554
582
  continue;
@@ -563,9 +591,9 @@ function applyProperties(el: HTMLElement, def: Record<string, any>, state: Recor
563
591
  * @param {HTMLElement} el
564
592
  * @param {string} key
565
593
  * @param {unknown} val
566
- * @param {Record<string, any>} state
594
+ * @param {JxScope} state
567
595
  */
568
- function bindProperty(el: HTMLElement, key: string, val: unknown, state: Record<string, any>) {
596
+ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope) {
569
597
  const target = el as unknown as Record<string, unknown>;
570
598
  if (isRefObj(val)) {
571
599
  const refVal = val as { $ref: string };
@@ -595,23 +623,23 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: Record<
595
623
  * breakpoint rules.
596
624
  *
597
625
  * @param {HTMLElement} el
598
- * @param {Record<string, any>} styleDef
599
- * @param {Record<string, any>} [mediaQueries] Named breakpoints from root $media. Default is `{}`
600
- * @param {Record<string, any>} [state] Component scope for template string evaluation. Default is
626
+ * @param {JxStyle} styleDef
627
+ * @param {Record<string, string>} [mediaQueries] Named breakpoints from root $media. Default is
601
628
  * `{}`
629
+ * @param {JxScope} [state] Component scope for template string evaluation. Default is `{}`
602
630
  */
603
631
  export function applyStyle(
604
632
  el: HTMLElement,
605
- styleDef: Record<string, any>,
606
- mediaQueries: Record<string, any> = {},
607
- state: Record<string, any> = {},
633
+ styleDef: JxStyle,
634
+ mediaQueries: Record<string, string> = {},
635
+ state: JxScope = {},
608
636
  ) {
609
- const nested: Record<string, any> = {};
610
- const media: Record<string, any> = {};
637
+ const nested: Record<string, JxStyle> = {};
638
+ const media: Record<string, JxStyle> = {};
611
639
  const baseDecls: Record<string, string> = {};
612
640
 
613
641
  // Collect properties overridden by media queries so we can avoid inline styles for them
614
- const mediaOverriddenProps: Set<string> = new Set();
642
+ const mediaOverriddenProps = new Set<string>();
615
643
  for (const [prop, val] of Object.entries(styleDef)) {
616
644
  if (prop.startsWith("@") && val && typeof val === "object") {
617
645
  for (const k of Object.keys(val)) {
@@ -629,41 +657,69 @@ export function applyStyle(
629
657
  }
630
658
 
631
659
  for (const [prop, val] of Object.entries(styleDef)) {
632
- if (prop.startsWith("@")) media[prop] = val;
633
- else if (isNestedSelector(prop)) nested[prop] = val;
634
- else if (val !== null && typeof val === "object" && !Array.isArray(val)) nested[prop] = val;
635
- else if (prop.startsWith("--")) {
636
- if (isTemplateString(val))
660
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
661
+ if (prop.startsWith("@")) {
662
+ media[prop] = val;
663
+ } else {
664
+ nested[prop] = val;
665
+ }
666
+ continue;
667
+ }
668
+ if (prop.startsWith("@") || isNestedSelector(prop)) {
669
+ // Scalar under a selector/media key — invalid style shape, drop it.
670
+ continue;
671
+ }
672
+ if (val === undefined) {
673
+ continue;
674
+ }
675
+ const scalar = String(val);
676
+ if (prop.startsWith("--")) {
677
+ if (isTemplateString(val)) {
637
678
  effect(() => {
638
679
  el.style.setProperty(prop, evaluateTemplate(val, state));
639
680
  });
640
- else el.style.setProperty(prop, val);
641
- } else if (isTemplateString(val))
681
+ } else {
682
+ el.style.setProperty(prop, scalar);
683
+ }
684
+ } else if (isTemplateString(val)) {
642
685
  effect(() => {
643
686
  (el.style as unknown as Record<string, string>)[prop] = evaluateTemplate(val, state);
644
687
  });
645
- else if (mediaOverriddenProps.has(prop)) baseDecls[prop] = val;
646
- else (el.style as unknown as Record<string, string>)[prop] = val;
688
+ } else if (mediaOverriddenProps.has(prop)) {
689
+ baseDecls[prop] = scalar;
690
+ } else {
691
+ (el.style as unknown as Record<string, string>)[prop] = scalar;
692
+ }
647
693
  }
648
694
 
649
695
  const hasNested = Object.keys(nested).length > 0;
650
696
  const hasMedia = Object.keys(media).length > 0;
651
697
  const hasBaseDecls = Object.keys(baseDecls).length > 0;
652
- if (!hasNested && !hasMedia && !hasBaseDecls) return;
698
+ if (!hasNested && !hasMedia && !hasBaseDecls) {
699
+ return;
700
+ }
653
701
 
654
702
  const uid = `jx-${Math.random().toString(36).slice(2, 7)}`;
655
703
  el.dataset.jx = uid;
656
704
 
657
705
  let css = "";
658
706
  const baseCSS = toCSSText(baseDecls);
659
- if (baseCSS) css += `[data-jx="${uid}"] { ${baseCSS} }\n`;
707
+ if (baseCSS) {
708
+ css += `[data-jx="${uid}"] { ${baseCSS} }\n`;
709
+ }
660
710
 
661
- function emitNested(scope: string, rules: Record<string, any>) {
711
+ function emitNested(scope: string, rules: JxStyle) {
662
712
  const props = toCSSText(rules);
663
- if (props) css += `${scope} { ${props} }\n`;
713
+ if (props) {
714
+ css += `${scope} { ${props} }\n`;
715
+ }
664
716
  for (const [sel, sub] of Object.entries(rules)) {
665
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
666
- if (sel.startsWith("@")) continue;
717
+ if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
718
+ continue;
719
+ }
720
+ if (sel.startsWith("@")) {
721
+ continue;
722
+ }
667
723
  const resolved = sel.startsWith("&")
668
724
  ? sel.replace("&", scope)
669
725
  : sel.startsWith("[")
@@ -686,8 +742,33 @@ export function applyStyle(
686
742
  emitNested(resolved, rules);
687
743
  }
688
744
 
745
+ function emitMediaNested(atRule: string, parentSel: string, obj: JxStyle) {
746
+ for (const [sel, sub] of Object.entries(obj)) {
747
+ if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
748
+ continue;
749
+ }
750
+ if (sel.startsWith("@")) {
751
+ continue;
752
+ }
753
+ const resolved = sel.startsWith("&")
754
+ ? sel.replace("&", parentSel)
755
+ : sel.startsWith("[")
756
+ ? `${parentSel}${sel}`
757
+ : sel.startsWith(":") || sel.startsWith(".")
758
+ ? `${parentSel}${sel}`
759
+ : `${parentSel} ${sel}`;
760
+ const props = toCSSText(sub);
761
+ if (props) {
762
+ css += `${atRule} { ${resolved} { ${props} } }\n`;
763
+ }
764
+ emitMediaNested(atRule, resolved, sub);
765
+ }
766
+ }
767
+
689
768
  for (const [key, rules] of Object.entries(media)) {
690
- if (key === "@--") continue; // base canvas width, not a real media query
769
+ if (key === "@--") {
770
+ continue;
771
+ } // Base canvas width, not a real media query
691
772
  const atRule = key.startsWith("@--")
692
773
  ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
693
774
  : key.startsWith("@(")
@@ -695,37 +776,20 @@ export function applyStyle(
695
776
  : key;
696
777
  const scope = `[data-jx="${uid}"]`;
697
778
  css += `${atRule} { ${scope} { ${toCSSText(rules)} } }\n`;
698
-
699
- function emitMediaNested(parentSel: string, obj: Record<string, any>) {
700
- for (const [sel, sub] of Object.entries(obj)) {
701
- if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
702
- if (sel.startsWith("@")) continue;
703
- const resolved = sel.startsWith("&")
704
- ? sel.replace("&", parentSel)
705
- : sel.startsWith("[")
706
- ? `${parentSel}${sel}`
707
- : sel.startsWith(":") || sel.startsWith(".")
708
- ? `${parentSel}${sel}`
709
- : `${parentSel} ${sel}`;
710
- const props = toCSSText(sub);
711
- if (props) css += `${atRule} { ${resolved} { ${props} } }\n`;
712
- emitMediaNested(resolved, sub);
713
- }
714
- }
715
- emitMediaNested(scope, rules);
779
+ emitMediaNested(atRule, scope, rules);
716
780
  }
717
781
 
718
782
  const tag = document.createElement("style");
719
783
  tag.textContent = css;
720
- document.head.appendChild(tag);
784
+ document.head.append(tag);
721
785
  }
722
786
 
723
787
  /**
724
788
  * @param {HTMLElement} el
725
- * @param {Record<string, any>} attrs
726
- * @param {Record<string, any>} state
789
+ * @param {Record<string, import("@jxsuite/schema/types").JxAttributeValue>} attrs
790
+ * @param {JxScope} state
727
791
  */
728
- function applyAttributes(el: HTMLElement, attrs: Record<string, any>, state: Record<string, any>) {
792
+ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue>, state: JxScope) {
729
793
  for (const [k, v] of Object.entries(attrs)) {
730
794
  if (isRefObj(v)) {
731
795
  effect(() => el.setAttribute(k, String(resolveRef(v.$ref, state) ?? "")));
@@ -740,55 +804,62 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, any>, state: Rec
740
804
  // ─── Array mapping ────────────────────────────────────────────────────────────
741
805
 
742
806
  /**
743
- * @param {Record<string, any>} def
744
- * @param {Record<string, any>} state
807
+ * @param {JxElement} def
808
+ * @param {import("@jxsuite/schema/types").JxMappedArray} arrayDef
809
+ * @param {JxScope} state
745
810
  * @param {JxRenderOptions} [options]
746
811
  * @returns {HTMLElement}
747
812
  */
748
813
  function renderMappedArray(
749
- def: Record<string, any>,
750
- state: Record<string, any>,
814
+ def: JxElement,
815
+ arrayDef: JxMappedArray,
816
+ state: JxScope,
751
817
  options?: JxRenderOptions,
752
818
  ) {
753
819
  const path = options?._path ?? [];
754
820
  const container = document.createElement(def.tagName ?? "div");
755
821
 
756
- if (options?.onNodeCreated) options.onNodeCreated(container, path, def);
822
+ if (options?.onNodeCreated) {
823
+ options.onNodeCreated(container, path, def);
824
+ }
757
825
 
758
826
  applyProperties(container, def, state);
759
- applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
827
+ applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
760
828
  applyAttributes(container, def.attributes ?? {}, state);
761
- const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = def.children;
829
+ const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = arrayDef;
762
830
 
763
831
  effect(() => {
764
832
  container.innerHTML = "";
765
- let items;
766
- if (isRefObj(itemsSrc)) {
767
- items = resolveRef(itemsSrc.$ref, state);
768
- } else {
769
- items = itemsSrc;
833
+ let items: unknown;
834
+ items = isRefObj(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
835
+ if (!Array.isArray(items)) {
836
+ return;
770
837
  }
771
- if (!Array.isArray(items)) return;
772
- if (filterRef) {
838
+ if (isRefObj(filterRef)) {
773
839
  const fn = resolveRef(filterRef.$ref, state);
774
- if (typeof fn === "function") items = items.filter(/** @type {(v: unknown) => boolean} */ fn);
840
+ if (typeof fn === "function") {
841
+ items = items.filter(fn as (v: unknown) => boolean);
842
+ }
775
843
  }
776
- if (sortRef) {
844
+ if (isRefObj(sortRef)) {
777
845
  const fn = resolveRef(sortRef.$ref, state);
778
- if (typeof fn === "function")
779
- items = [...items].sort(/** @type {(a: unknown, b: unknown) => number} */ fn);
846
+ if (typeof fn === "function") {
847
+ items = [...(items as unknown[])].toSorted(fn as (a: unknown, b: unknown) => number);
848
+ }
780
849
  }
781
850
 
782
- items.forEach((item, index) => {
851
+ for (const [index, item] of (items as unknown[]).entries()) {
783
852
  const child = Object.create(state);
784
- child.$map = { item, index };
853
+ child.$map = { index, item };
785
854
  child["$map/item"] = item;
786
855
  child["$map/index"] = index;
787
856
  const childOpts = options
788
857
  ? { ...options, _path: [...path, "children", "map", index] }
789
858
  : undefined;
790
- container.appendChild(renderNode(mapDef, child, childOpts));
791
- });
859
+ if (mapDef) {
860
+ container.append(renderNode(mapDef, child, childOpts));
861
+ }
862
+ }
792
863
  });
793
864
 
794
865
  return container;
@@ -797,53 +868,60 @@ function renderMappedArray(
797
868
  // ─── $switch ──────────────────────────────────────────────────────────────────
798
869
 
799
870
  /**
800
- * @param {Record<string, any>} def
801
- * @param {Record<string, any>} state
871
+ * @param {JxElement} def
872
+ * @param {JxScope} state
802
873
  * @param {JxRenderOptions} [options]
803
874
  * @returns {HTMLElement}
804
875
  */
805
- function renderSwitch(
806
- def: Record<string, any>,
807
- state: Record<string, any>,
808
- options?: JxRenderOptions,
809
- ) {
876
+ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions) {
810
877
  const path = options?._path ?? [];
811
878
  const container = document.createElement(def.tagName ?? "div");
812
879
 
813
- if (options?.onNodeCreated) options.onNodeCreated(container, path, def);
880
+ if (options?.onNodeCreated) {
881
+ options.onNodeCreated(container, path, def);
882
+ }
814
883
 
815
884
  applyProperties(container, def, state);
816
- applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
885
+ applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
817
886
  applyAttributes(container, def.attributes ?? {}, state);
818
887
  let generation = 0;
819
888
 
820
889
  effect(() => {
821
890
  container.innerHTML = "";
891
+ if (!isRefObj(def.$switch)) {
892
+ return;
893
+ }
822
894
  const key = resolveRef(def.$switch.$ref, state) as string;
823
895
  const caseDef = def.cases?.[key];
824
- if (!caseDef) return;
896
+ if (!caseDef) {
897
+ return;
898
+ }
825
899
 
826
900
  if (isRefObj(caseDef)) {
827
901
  // External $ref — fetch and render asynchronously
828
- const gen = ++generation;
829
- const href = new URL(caseDef.$ref, location.href).href;
902
+ const gen = (generation += 1);
903
+ const { href } = new URL(caseDef.$ref, location.href);
830
904
  resolve(href)
831
905
  .then(async (doc) => {
832
- if (gen !== generation) return;
906
+ if (gen !== generation) {
907
+ return;
908
+ }
833
909
  const childScope = await buildScope(doc, {}, href);
834
- if (gen !== generation) return;
910
+ if (gen !== generation) {
911
+ return;
912
+ }
835
913
  container.innerHTML = "";
836
914
  const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
837
- container.appendChild(renderNode(doc, childScope, childOpts));
915
+ container.append(renderNode(doc, childScope, childOpts));
838
916
  })
839
- .catch((e: unknown) =>
840
- console.error("Jx $switch: failed to load external case", caseDef.$ref, e),
917
+ .catch((error: unknown) =>
918
+ console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
841
919
  );
842
920
  return;
843
921
  }
844
922
 
845
923
  const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
846
- container.appendChild(renderNode(caseDef, state, childOpts));
924
+ container.append(renderNode(caseDef, state, childOpts));
847
925
  });
848
926
 
849
927
  return container;
@@ -857,15 +935,15 @@ function renderSwitch(
857
935
  * Returns a ref() for async/persistent entries (Request, Storage, Cookie, IndexedDB), or a plain
858
936
  * value for simple entries (Set, Map, FormData, Blob).
859
937
  *
860
- * @param {Record<string, any>} def - State entry with $prototype
861
- * @param {Record<string, any>} state - Reactive scope proxy
938
+ * @param {JxPrototypeDef} def - State entry with $prototype
939
+ * @param {JxScope} state - Reactive scope proxy
862
940
  * @param {string} key - Def key (for diagnostics)
863
941
  * @param {string} [base] - Base URL for resolving $src imports
864
942
  * @returns {Promise<unknown>}
865
943
  */
866
944
  export async function resolvePrototype(
867
- def: Record<string, any>,
868
- state: Record<string, any>,
945
+ def: JxPrototypeDef,
946
+ state: JxScope,
869
947
  key: string,
870
948
  base?: string,
871
949
  ) {
@@ -876,7 +954,7 @@ export async function resolvePrototype(
876
954
 
877
955
  switch (def.$prototype) {
878
956
  case "Request": {
879
- const s: import("@vue/reactivity").Ref<unknown> = ref(null);
957
+ const s: Ref<unknown> = ref(null);
880
958
  const debounceMs = def.debounce ?? 0;
881
959
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
882
960
 
@@ -886,20 +964,24 @@ export async function resolvePrototype(
886
964
  if (isTemplateString(def.url)) {
887
965
  url = evaluateTemplate(def.url, state);
888
966
  } else {
889
- url = def.url;
967
+ ({ url } = def);
968
+ }
969
+ if (!url || url === "undefined" || url.includes("undefined")) {
970
+ return;
890
971
  }
891
- if (!url || url === "undefined" || url.includes("undefined")) return;
892
972
 
893
973
  const controller = new AbortController();
894
974
  onEffectCleanup(() => {
895
975
  controller.abort();
896
- if (debounceTimer !== null) clearTimeout(debounceTimer);
976
+ if (debounceTimer !== null) {
977
+ clearTimeout(debounceTimer);
978
+ }
897
979
  });
898
980
 
899
981
  const doFetch = () =>
900
982
  fetch(url, {
901
- signal: controller.signal,
902
983
  method: def.method ?? "GET",
984
+ signal: controller.signal,
903
985
  ...(def.headers && { headers: def.headers }),
904
986
  ...(def.body && {
905
987
  body: typeof def.body === "object" ? JSON.stringify(def.body) : def.body,
@@ -909,8 +991,10 @@ export async function resolvePrototype(
909
991
  .then((d) => {
910
992
  s.value = d;
911
993
  })
912
- .catch((e: unknown) => {
913
- if ((e as Error).name !== "AbortError") s.value = { error: String(e) };
994
+ .catch((error: unknown) => {
995
+ if (!(error instanceof Error && error.name === "AbortError")) {
996
+ s.value = { error: String(error) };
997
+ }
914
998
  });
915
999
 
916
1000
  if (debounceMs > 0) {
@@ -924,7 +1008,7 @@ export async function resolvePrototype(
924
1008
  return s;
925
1009
  }
926
1010
 
927
- case "URLSearchParams":
1011
+ case "URLSearchParams": {
928
1012
  return computed(() => {
929
1013
  const p: Record<string, string> = {};
930
1014
  for (const [k, v] of Object.entries(def)) {
@@ -938,6 +1022,7 @@ export async function resolvePrototype(
938
1022
  }
939
1023
  return new URLSearchParams(p).toString();
940
1024
  });
1025
+ }
941
1026
 
942
1027
  case "LocalStorage":
943
1028
  case "SessionStorage": {
@@ -950,7 +1035,7 @@ export async function resolvePrototype(
950
1035
  } catch {
951
1036
  init = def.default ?? null;
952
1037
  }
953
- const storageState: import("@vue/reactivity").Ref<any> = ref(init);
1038
+ const storageState: Ref<unknown> = ref(init);
954
1039
  // Persist on change
955
1040
  effect(() => {
956
1041
  const v = storageState.value;
@@ -970,31 +1055,44 @@ export async function resolvePrototype(
970
1055
  case "Cookie": {
971
1056
  const name = def.name ?? key;
972
1057
  const read = () => {
973
- const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
974
- if (!m) return null;
1058
+ const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
1059
+ if (!m) {
1060
+ return null;
1061
+ }
975
1062
  try {
976
1063
  return JSON.parse(decodeURIComponent(m[1]));
977
1064
  } catch {
978
1065
  return m[1];
979
1066
  }
980
1067
  };
981
- const cookieState: import("@vue/reactivity").Ref<any> = ref(read() ?? def.default ?? null);
1068
+ const cookieState: Ref<unknown> = ref(read() ?? def.default ?? null);
982
1069
  // Persist on change
983
1070
  effect(() => {
984
1071
  const v = cookieState.value;
985
1072
  let s = `${name}=${encodeURIComponent(JSON.stringify(v))}`;
986
- if (def.maxAge !== undefined) s += `; Max-Age=${def.maxAge}`;
987
- if (def.path) s += `; Path=${def.path}`;
988
- if (def.domain) s += `; Domain=${def.domain}`;
989
- if (def.secure) s += `; Secure`;
990
- if (def.sameSite) s += `; SameSite=${def.sameSite}`;
1073
+ if (def.maxAge !== undefined) {
1074
+ s += `; Max-Age=${def.maxAge}`;
1075
+ }
1076
+ if (def.path) {
1077
+ s += `; Path=${def.path}`;
1078
+ }
1079
+ if (def.domain) {
1080
+ s += `; Domain=${def.domain}`;
1081
+ }
1082
+ if (def.secure) {
1083
+ s += `; Secure`;
1084
+ }
1085
+ if (def.sameSite) {
1086
+ s += `; SameSite=${def.sameSite}`;
1087
+ }
1088
+ // oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
991
1089
  document.cookie = s;
992
1090
  });
993
1091
  return cookieState;
994
1092
  }
995
1093
 
996
1094
  case "IndexedDB": {
997
- const idbState: import("@vue/reactivity").Ref<any> = ref(null);
1095
+ const idbState: Ref<unknown> = ref(null);
998
1096
  const {
999
1097
  database,
1000
1098
  store,
@@ -1003,54 +1101,68 @@ export async function resolvePrototype(
1003
1101
  autoIncrement = true,
1004
1102
  indexes = [],
1005
1103
  } = def;
1104
+ if (!database || !store) {
1105
+ throw new Error(`Jx: IndexedDB entry '${key}' requires database and store`);
1106
+ }
1006
1107
  const req = indexedDB.open(database, version);
1007
- req.onupgradeneeded = (e) => {
1108
+ req.addEventListener("upgradeneeded", (e) => {
1008
1109
  const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
1009
1110
  if (!db.objectStoreNames.contains(store)) {
1010
- const os = db.createObjectStore(store, { keyPath, autoIncrement });
1011
- for (const i of indexes) os.createIndex(i.name, i.keyPath, { unique: i.unique ?? false });
1111
+ const os = db.createObjectStore(store, { autoIncrement, keyPath });
1112
+ for (const i of indexes) {
1113
+ os.createIndex(i.name, i.keyPath, { unique: i.unique ?? false });
1114
+ }
1012
1115
  }
1013
- };
1014
- req.onsuccess = (e) => {
1116
+ });
1117
+ req.addEventListener("success", (e) => {
1015
1118
  const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
1016
1119
  idbState.value = {
1017
1120
  database,
1018
- store,
1019
- version,
1020
- isReady: true,
1021
1121
  getStore: (mode: IDBTransactionMode = "readwrite") =>
1022
1122
  Promise.resolve(db.transaction(store, mode).objectStore(store)),
1123
+ isReady: true,
1124
+ store,
1125
+ version,
1023
1126
  };
1024
- };
1025
- req.onerror = () => {
1127
+ });
1128
+ req.addEventListener("error", () => {
1026
1129
  idbState.value = { error: req.error?.message };
1027
- };
1130
+ });
1028
1131
  return idbState;
1029
1132
  }
1030
1133
 
1031
- case "Set":
1032
- return new Set(def.default ?? []);
1134
+ case "Set": {
1135
+ return new Set(Array.isArray(def.default) ? def.default : []);
1136
+ }
1033
1137
 
1034
- case "Map":
1035
- return new Map(Object.entries(def.default ?? {}));
1138
+ case "Map": {
1139
+ return new Map(Object.entries(isJsonObject(def.default) ? def.default : {}));
1140
+ }
1036
1141
 
1037
1142
  case "FormData": {
1038
1143
  const fd = new FormData();
1039
- for (const [k, v] of Object.entries(def.fields ?? {})) fd.append(k, v as string);
1144
+ for (const [k, v] of Object.entries(def.fields ?? {})) {
1145
+ fd.append(k, v as string);
1146
+ }
1040
1147
  return fd;
1041
1148
  }
1042
1149
 
1043
- case "Blob":
1044
- return new Blob(def.parts ?? [], { type: def.type ?? "text/plain" });
1150
+ case "Blob": {
1151
+ return new Blob((def.parts as BlobPart[]) ?? [], {
1152
+ type: typeof def.type === "string" ? def.type : "text/plain",
1153
+ });
1154
+ }
1045
1155
 
1046
- case "ReadableStream":
1156
+ case "ReadableStream": {
1047
1157
  return null;
1158
+ }
1048
1159
 
1049
- default:
1160
+ default: {
1050
1161
  console.warn(
1051
1162
  `Jx: unknown $prototype "${def.$prototype}" for "${key}". Did you mean to add '$src'?`,
1052
1163
  );
1053
1164
  return ref(null);
1165
+ }
1054
1166
  }
1055
1167
  }
1056
1168
 
@@ -1073,22 +1185,22 @@ const EXTERNAL_RESERVED = new Set([
1073
1185
  /**
1074
1186
  * Resolve an external class prototype via $src.
1075
1187
  *
1076
- * @param {Record<string, any>} def
1077
- * @param {Record<string, any>} state
1188
+ * @param {JxPrototypeDef} def
1189
+ * @param {JxScope} state
1078
1190
  * @param {string} key
1079
1191
  * @param {string} [base]
1080
1192
  * @returns {Promise<unknown>}
1081
1193
  */
1082
1194
  async function resolveExternalPrototype(
1083
- def: Record<string, any>,
1084
- state: Record<string, any>,
1195
+ def: JxPrototypeDef,
1196
+ state: JxScope,
1085
1197
  key: string,
1086
1198
  base?: string,
1087
1199
  ) {
1088
1200
  const src = def.$src;
1089
1201
 
1090
1202
  // Non-Function $prototype must use .class.json as entrypoint
1091
- if (!src.endsWith(".class.json")) {
1203
+ if (!src || !src.endsWith(".class.json")) {
1092
1204
  throw new Error(
1093
1205
  `Jx: $prototype "${def.$prototype}" requires a .class.json $src, got "${src}". ` +
1094
1206
  `Wrap the class in a .class.json schema with $implementation.`,
@@ -1102,18 +1214,13 @@ async function resolveExternalPrototype(
1102
1214
  * Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
1103
1215
  * $implementation.
1104
1216
  *
1105
- * @param {Record<string, any>} def - Original state entry (for config extraction)
1217
+ * @param {JxScope} def - Original state entry (for config extraction)
1106
1218
  * @param {string} src - JS module URL to import
1107
1219
  * @param {string} exportName - Export name to look up
1108
1220
  * @param {string} [base] - Base URL for resolution
1109
1221
  * @returns {Promise<unknown>}
1110
1222
  */
1111
- async function importAndInstantiate(
1112
- def: Record<string, any>,
1113
- src: string,
1114
- exportName: string,
1115
- base?: string,
1116
- ) {
1223
+ async function importAndInstantiate(def: JxScope, src: string, exportName: string, base?: string) {
1117
1224
  let mod;
1118
1225
  if (_moduleCache.has(src)) {
1119
1226
  mod = _moduleCache.get(src);
@@ -1136,12 +1243,14 @@ async function importAndInstantiate(
1136
1243
  throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
1137
1244
  }
1138
1245
  if (typeof ExportedClass !== "function") {
1139
- throw new Error(`Jx: "${exportName}" from "${src}" is not a class`);
1246
+ throw new TypeError(`Jx: "${exportName}" from "${src}" is not a class`);
1140
1247
  }
1141
1248
 
1142
- const config: Record<string, any> = {};
1249
+ const config: JxScope = {};
1143
1250
  for (const [k, v] of Object.entries(def)) {
1144
- if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
1251
+ if (!EXTERNAL_RESERVED.has(k)) {
1252
+ config[k] = v;
1253
+ }
1145
1254
  }
1146
1255
 
1147
1256
  const instance = new ExportedClass(config);
@@ -1150,13 +1259,13 @@ async function importAndInstantiate(
1150
1259
  if (typeof instance.resolve === "function") {
1151
1260
  value = await instance.resolve();
1152
1261
  } else if ("value" in instance) {
1153
- value = instance.value;
1262
+ ({ value } = instance);
1154
1263
  } else {
1155
1264
  value = instance;
1156
1265
  }
1157
1266
 
1158
1267
  // Always wrap in ref for reactivity with external classes
1159
- const s: import("@vue/reactivity").Ref<any> = ref(value);
1268
+ const s: Ref<unknown> = ref(value);
1160
1269
  if (typeof instance.subscribe === "function") {
1161
1270
  instance.subscribe((newVal: unknown) => {
1162
1271
  s.value = newVal;
@@ -1169,23 +1278,21 @@ async function importAndInstantiate(
1169
1278
  * Resolve a .class.json schema-defined class. Fetches the schema, follows $implementation if
1170
1279
  * hybrid, or constructs dynamically if self-contained.
1171
1280
  *
1172
- * @param {Record<string, any>} def
1173
- * @param {Record<string, any>} state
1281
+ * @param {JxPrototypeDef} def
1282
+ * @param {JxScope} state
1174
1283
  * @param {string} key
1175
1284
  * @param {string} [base]
1176
1285
  * @returns {Promise<unknown>}
1177
1286
  */
1178
- async function resolveClassJson(
1179
- def: Record<string, any>,
1180
- state: Record<string, any>,
1181
- key: string,
1182
- base?: string,
1183
- ) {
1287
+ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
1184
1288
  const src = def.$src;
1185
- let classDef;
1289
+ if (!src) {
1290
+ throw new Error(`Jx: class entry '${key}' has no $src`);
1291
+ }
1292
+ let classDef: JxClassDef;
1186
1293
 
1187
1294
  // Bare specifiers (package references like @scope/pkg/file) can't be fetched directly —
1188
- // go straight to dev proxy which can resolve them via node_modules.
1295
+ // Go straight to dev proxy which can resolve them via node_modules.
1189
1296
  const isBareSpecifier =
1190
1297
  !src.startsWith(".") &&
1191
1298
  !src.startsWith("/") &&
@@ -1199,8 +1306,11 @@ async function resolveClassJson(
1199
1306
  try {
1200
1307
  const url = base ? new URL(src, base).href : src;
1201
1308
  const res = await fetch(url);
1202
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
1203
- classDef = await res.json();
1309
+ if (!res.ok) {
1310
+ throw new Error(`HTTP ${res.status}`);
1311
+ }
1312
+ // Trust boundary: fetched .class.json sources are class definitions by contract.
1313
+ classDef = (await res.json()) as JxClassDef;
1204
1314
  } catch {
1205
1315
  // Fall back to dev proxy (server will handle .class.json resolution)
1206
1316
  return resolveViaDevProxy(def, state, key, base);
@@ -1209,7 +1319,7 @@ async function resolveClassJson(
1209
1319
  // Hybrid mode: $implementation points to the real JS module
1210
1320
  if (classDef.$implementation) {
1211
1321
  // If the schema references $context (e.g. content types), the browser cannot provide
1212
- // the required server-side context — go directly to dev proxy.
1322
+ // The required server-side context — go directly to dev proxy.
1213
1323
  const schemaStr = JSON.stringify(classDef);
1214
1324
  if (schemaStr.includes('"#/$context/')) {
1215
1325
  return resolveViaDevProxy(def, state, key, base);
@@ -1226,9 +1336,11 @@ async function resolveClassJson(
1226
1336
 
1227
1337
  // Self-contained: construct class dynamically from schema
1228
1338
  const DynClass = classFromSchema(classDef);
1229
- const config: Record<string, any> = {};
1339
+ const config: JxScope = {};
1230
1340
  for (const [k, v] of Object.entries(def)) {
1231
- if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
1341
+ if (!EXTERNAL_RESERVED.has(k)) {
1342
+ config[k] = v;
1343
+ }
1232
1344
  }
1233
1345
  const instance = new DynClass(config);
1234
1346
 
@@ -1236,13 +1348,13 @@ async function resolveClassJson(
1236
1348
  if (typeof instance.resolve === "function") {
1237
1349
  value = await instance.resolve();
1238
1350
  } else if ("value" in instance) {
1239
- value = instance.value;
1351
+ ({ value } = instance);
1240
1352
  } else {
1241
1353
  value = instance;
1242
1354
  }
1243
1355
 
1244
1356
  // Always wrap in ref for reactivity
1245
- const s: import("@vue/reactivity").Ref<any> = ref(value);
1357
+ const s: Ref<unknown> = ref(value);
1246
1358
  if (typeof instance.subscribe === "function") {
1247
1359
  instance.subscribe((newVal: unknown) => {
1248
1360
  s.value = newVal;
@@ -1255,43 +1367,31 @@ async function resolveClassJson(
1255
1367
  * Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
1256
1368
  * fields to _-prefixed public fields.
1257
1369
  *
1258
- * @param {Record<string, any>} classDef
1370
+ * @param {import("@jxsuite/schema/types").JxClassDef} classDef
1259
1371
  * @returns {DynamicClass}
1260
1372
  */
1261
- function classFromSchema(classDef: Record<string, any>) {
1373
+ function classFromSchema(classDef: JxClassDef) {
1262
1374
  const fields = classDef.$defs?.fields ?? {};
1263
- const ctor = classDef.$defs?.constructor;
1375
+ // JSON objects inherit Object.prototype.constructor — only an own object value counts.
1376
+ const rawCtor = classDef.$defs?.constructor;
1377
+ const ctor = typeof rawCtor === "object" ? rawCtor : undefined;
1264
1378
  const methods = classDef.$defs?.methods ?? {};
1265
1379
 
1266
- interface FieldDef {
1267
- identifier?: string;
1268
- access?: string;
1269
- initializer?: unknown;
1270
- default?: unknown;
1271
- }
1272
-
1273
- interface MethodDef {
1274
- identifier?: string;
1275
- parameters?: Record<string, unknown>[];
1276
- body?: string | string[];
1277
- role?: string;
1278
- scope?: string;
1279
- getter?: { body: string };
1280
- setter?: { body: string; parameters?: Record<string, unknown>[] };
1281
- }
1282
-
1380
+ // oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
1283
1381
  class DynClass {
1284
1382
  constructor(config: Record<string, unknown> = {}) {
1285
- for (const [key, field] of Object.entries(fields)) {
1286
- const typedField = field as FieldDef;
1383
+ for (const [key, typedField] of Object.entries(fields)) {
1287
1384
  const id = typedField.identifier ?? key;
1288
1385
  const propName = typedField.access === "private" ? `_${id}` : id;
1289
- if (config[id] !== undefined) (this as Record<string, unknown>)[propName] = config[id];
1290
- else if (typedField.initializer !== undefined)
1386
+ if (config[id] !== undefined) {
1387
+ (this as Record<string, unknown>)[propName] = config[id];
1388
+ } else if (typedField.initializer !== undefined) {
1291
1389
  (this as Record<string, unknown>)[propName] = typedField.initializer;
1292
- else if (typedField.default !== undefined)
1390
+ } else if (typedField.default !== undefined) {
1293
1391
  (this as Record<string, unknown>)[propName] = structuredClone(typedField.default);
1294
- else (this as Record<string, unknown>)[propName] = null;
1392
+ } else {
1393
+ (this as Record<string, unknown>)[propName] = null;
1394
+ }
1295
1395
  }
1296
1396
  if (ctor?.body) {
1297
1397
  const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
@@ -1300,26 +1400,29 @@ function classFromSchema(classDef: Record<string, any>) {
1300
1400
  }
1301
1401
  }
1302
1402
 
1303
- for (const [key, method] of Object.entries(methods)) {
1304
- const typedMethod = method as MethodDef;
1403
+ for (const [key, typedMethod] of Object.entries(methods)) {
1305
1404
  const name = typedMethod.identifier ?? key;
1306
- const params = (typedMethod.parameters ?? []).map((p: Record<string, unknown>) => {
1307
- if (p.$ref) return (p.$ref as string).split("/").pop() as string;
1308
- return (p.identifier ?? p.name ?? "arg") as string;
1309
- }) as string[];
1405
+ const params = (typedMethod.parameters ?? []).map((p) => {
1406
+ if (p.$ref) {
1407
+ return p.$ref.split("/").pop() as string;
1408
+ }
1409
+ const n = p.identifier ?? p.name;
1410
+ return typeof n === "string" ? n : "arg";
1411
+ });
1310
1412
  const bodyStr = Array.isArray(typedMethod.body)
1311
1413
  ? typedMethod.body.join("\n")
1312
1414
  : (typedMethod.body ?? "");
1313
1415
 
1314
1416
  if (typedMethod.role === "accessor") {
1315
1417
  const descriptor: PropertyDescriptor = {};
1316
- if (typedMethod.getter)
1317
- descriptor.get = new Function(typedMethod.getter.body) as () => unknown;
1418
+ if (typedMethod.getter) {
1419
+ descriptor.get = new Function(typedMethod.getter.body ?? "") as () => unknown;
1420
+ }
1318
1421
  if (typedMethod.setter) {
1319
1422
  const sp = (typedMethod.setter.parameters ?? []).map(
1320
- (p: Record<string, unknown>) => (p.$ref as string)?.split("/").pop() ?? "v",
1423
+ (p) => p.$ref?.split("/").pop() ?? "v",
1321
1424
  );
1322
- descriptor.set = new Function(...sp, typedMethod.setter.body) as (v: unknown) => void;
1425
+ descriptor.set = new Function(...sp, typedMethod.setter.body ?? "") as (v: unknown) => void;
1323
1426
  }
1324
1427
  Object.defineProperty(DynClass.prototype, name, {
1325
1428
  ...descriptor,
@@ -1333,8 +1436,8 @@ function classFromSchema(classDef: Record<string, any>) {
1333
1436
  }
1334
1437
 
1335
1438
  Object.defineProperty(DynClass, "name", {
1336
- value: classDef.title,
1337
1439
  configurable: true,
1440
+ value: classDef.title,
1338
1441
  });
1339
1442
  const dynCtor = DynClass as unknown as DynamicClass;
1340
1443
  return dynCtor;
@@ -1345,47 +1448,46 @@ function classFromSchema(classDef: Record<string, any>) {
1345
1448
  * through the Jx dev server (POST /**jx_resolve**). Supports reactive template strings in config
1346
1449
  * values via Vue effect().
1347
1450
  *
1348
- * @param {Record<string, any>} def
1349
- * @param {Record<string, any>} state
1451
+ * @param {JxPrototypeDef} def
1452
+ * @param {JxScope} state
1350
1453
  * @param {string} key
1351
1454
  * @param {string} [base]
1352
1455
  * @returns {Promise<unknown>}
1353
1456
  */
1354
- async function resolveViaDevProxy(
1355
- def: Record<string, any>,
1356
- state: Record<string, any>,
1357
- key: string,
1358
- base?: string,
1359
- ) {
1360
- const config: Record<string, any> = {};
1457
+ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
1458
+ const config: JxScope = {};
1361
1459
  for (const [k, v] of Object.entries(def)) {
1362
- if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
1460
+ if (!EXTERNAL_RESERVED.has(k)) {
1461
+ config[k] = v;
1462
+ }
1363
1463
  }
1364
1464
 
1365
1465
  const hasTemplates = Object.values(config).some((v: unknown) => isTemplateString(v));
1366
1466
 
1367
- /** @param {Record<string, any>} resolvedConfig */
1368
- const doResolve = (resolvedConfig: Record<string, any>) =>
1467
+ /** @param {JxScope} resolvedConfig */
1468
+ const doResolve = (resolvedConfig: JxScope) =>
1369
1469
  fetch("/__jx_resolve__", {
1370
- method: "POST",
1371
- headers: { "Content-Type": "application/json" },
1372
1470
  body: JSON.stringify({
1373
- $src: def.$src,
1374
- $prototype: def.$prototype,
1375
- $export: def.$export,
1376
1471
  $base: base,
1472
+ $export: def.$export,
1473
+ $prototype: def.$prototype,
1474
+ $src: def.$src,
1377
1475
  ...resolvedConfig,
1378
1476
  }),
1477
+ headers: { "Content-Type": "application/json" },
1478
+ method: "POST",
1379
1479
  }).then((r) => {
1380
- if (!r.ok) throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
1480
+ if (!r.ok) {
1481
+ throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
1482
+ }
1381
1483
  return r.json();
1382
1484
  });
1383
1485
 
1384
1486
  // Always wrap in ref for reactivity
1385
- const s: import("@vue/reactivity").Ref<any> = ref(null);
1487
+ const s: Ref<unknown> = ref(null);
1386
1488
  if (hasTemplates) {
1387
1489
  effect(() => {
1388
- const resolvedConfig: Record<string, any> = {};
1490
+ const resolvedConfig: JxScope = {};
1389
1491
  for (const [k, v] of Object.entries(config)) {
1390
1492
  resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
1391
1493
  }
@@ -1393,14 +1495,14 @@ async function resolveViaDevProxy(
1393
1495
  .then((value: unknown) => {
1394
1496
  s.value = value;
1395
1497
  })
1396
- .catch((e: unknown) => console.error("Jx dev proxy:", e));
1498
+ .catch((error: unknown) => console.error("Jx dev proxy:", error));
1397
1499
  });
1398
1500
  } else {
1399
1501
  doResolve(config)
1400
1502
  .then((value: unknown) => {
1401
1503
  s.value = value;
1402
1504
  })
1403
- .catch((e: unknown) => console.error("Jx dev proxy:", e));
1505
+ .catch((error: unknown) => console.error("Jx dev proxy:", error));
1404
1506
  }
1405
1507
  return s;
1406
1508
  }
@@ -1411,15 +1513,15 @@ async function resolveViaDevProxy(
1411
1513
  * Resolve a timing: "server" entry in dev mode by executing the function client-side. In
1412
1514
  * production, the compiler replaces this with a fetch to the generated server handler.
1413
1515
  *
1414
- * @param {Record<string, any>} def
1415
- * @param {Record<string, any>} state
1516
+ * @param {JxScope} def
1517
+ * @param {JxScope} state
1416
1518
  * @param {string} key
1417
1519
  * @param {string} [base]
1418
1520
  * @returns {Promise<unknown>}
1419
1521
  */
1420
1522
  async function resolveServerFunction(
1421
- def: Record<string, any>,
1422
- state: Record<string, any>,
1523
+ def: JxServerFnDef,
1524
+ state: JxScope,
1423
1525
  key: string,
1424
1526
  base?: string,
1425
1527
  ) {
@@ -1449,22 +1551,25 @@ async function resolveServerFunction(
1449
1551
  }
1450
1552
 
1451
1553
  const fn = mod[exportName] ?? mod.default?.[exportName];
1452
- if (!fn) throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
1453
- if (typeof fn !== "function")
1454
- throw new Error(`Jx: "${exportName}" from "${src}" is not a function`);
1554
+ if (!fn) {
1555
+ throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
1556
+ }
1557
+ if (typeof fn !== "function") {
1558
+ throw new TypeError(`Jx: "${exportName}" from "${src}" is not a function`);
1559
+ }
1455
1560
 
1456
1561
  const rawArgs = def.arguments ?? {};
1457
1562
  const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
1458
1563
  const resolveArgs = () => {
1459
- const args: Record<string, any> = {};
1564
+ const args: JxScope = {};
1460
1565
  for (const [k, v] of Object.entries(rawArgs)) {
1461
- args[k] = isRefObj(v) ? resolveRef((v as { $ref: string }).$ref, state) : v;
1566
+ args[k] = isRefObj(v) ? resolveRef(v.$ref, state) : v;
1462
1567
  }
1463
1568
  return args;
1464
1569
  };
1465
1570
 
1466
1571
  // Always wrap in ref for reactivity
1467
- const s: import("@vue/reactivity").Ref<any> = ref(null);
1572
+ const s: Ref<unknown> = ref(null);
1468
1573
  if (hasReactiveArg) {
1469
1574
  effect(() => {
1470
1575
  const args = resolveArgs();
@@ -1486,15 +1591,15 @@ async function resolveServerFunction(
1486
1591
  * call through the Jx dev server (POST /**jx_server**). Supports reactive $ref arguments via Vue
1487
1592
  * effect().
1488
1593
  *
1489
- * @param {Record<string, any>} def
1490
- * @param {Record<string, any>} state
1594
+ * @param {JxScope} def
1595
+ * @param {JxScope} state
1491
1596
  * @param {string} key
1492
1597
  * @param {string} [base]
1493
1598
  * @returns {Promise<unknown>}
1494
1599
  */
1495
1600
  async function resolveServerFunctionViaProxy(
1496
- def: Record<string, any>,
1497
- state: Record<string, any>,
1601
+ def: JxScope,
1602
+ state: JxScope,
1498
1603
  key: string,
1499
1604
  base?: string,
1500
1605
  ) {
@@ -1502,31 +1607,33 @@ async function resolveServerFunctionViaProxy(
1502
1607
  const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
1503
1608
 
1504
1609
  const resolveArgs = () => {
1505
- const args: Record<string, any> = {};
1610
+ const args: JxScope = {};
1506
1611
  for (const [k, v] of Object.entries(rawArgs)) {
1507
1612
  args[k] = isRefObj(v) ? resolveRef((v as { $ref: string }).$ref, state) : v;
1508
1613
  }
1509
1614
  return args;
1510
1615
  };
1511
1616
 
1512
- /** @param {Record<string, any>} args */
1513
- const doResolve = (args: Record<string, any>) =>
1617
+ /** @param {JxScope} args */
1618
+ const doResolve = (args: JxScope) =>
1514
1619
  fetch("/__jx_server__", {
1515
- method: "POST",
1516
- headers: { "Content-Type": "application/json" },
1517
1620
  body: JSON.stringify({
1518
- $src: def.$src,
1519
- $export: def.$export,
1520
1621
  $base: base,
1622
+ $export: def.$export,
1623
+ $src: def.$src,
1521
1624
  arguments: args,
1522
1625
  }),
1626
+ headers: { "Content-Type": "application/json" },
1627
+ method: "POST",
1523
1628
  }).then((r) => {
1524
- if (!r.ok) throw new Error(`Jx server proxy ${r.status} for "${key}"`);
1629
+ if (!r.ok) {
1630
+ throw new Error(`Jx server proxy ${r.status} for "${key}"`);
1631
+ }
1525
1632
  return r.json();
1526
1633
  });
1527
1634
 
1528
1635
  // Always wrap in ref for reactivity
1529
- const s: import("@vue/reactivity").Ref<any> = ref(null);
1636
+ const s: Ref<unknown> = ref(null);
1530
1637
  if (hasReactiveArg) {
1531
1638
  effect(() => {
1532
1639
  const args = resolveArgs();
@@ -1535,14 +1642,14 @@ async function resolveServerFunctionViaProxy(
1535
1642
  .then((result: unknown) => {
1536
1643
  s.value = result;
1537
1644
  })
1538
- .catch((e: unknown) => console.error("Jx server proxy:", e));
1645
+ .catch((error: unknown) => console.error("Jx server proxy:", error));
1539
1646
  });
1540
1647
  } else {
1541
1648
  doResolve(resolveArgs())
1542
1649
  .then((result: unknown) => {
1543
1650
  s.value = result;
1544
1651
  })
1545
- .catch((e: unknown) => console.error("Jx server proxy:", e));
1652
+ .catch((error: unknown) => console.error("Jx server proxy:", error));
1546
1653
  }
1547
1654
  return s;
1548
1655
  }
@@ -1554,28 +1661,38 @@ async function resolveServerFunctionViaProxy(
1554
1661
  * computed, the read is tracked.
1555
1662
  *
1556
1663
  * @param {string} ref
1557
- * @param {Record<string, any>} state - Reactive scope proxy (or child scope)
1664
+ * @param {JxScope} state - Reactive scope proxy (or child scope)
1558
1665
  * @returns {unknown}
1559
1666
  */
1560
- export function resolveRef(ref: string, state: Record<string, any>) {
1561
- if (typeof ref !== "string") return ref;
1562
- if (ref.startsWith("$map/")) {
1563
- const parts = ref.split("/");
1564
- const key = parts[1]; // 'item' or 'index'
1565
- const base = state.$map?.[key] ?? state["$map/" + key];
1667
+ export function resolveRef(refPath: string, state: JxScope) {
1668
+ if (typeof refPath !== "string") {
1669
+ return refPath;
1670
+ }
1671
+ if (refPath.startsWith("$map/")) {
1672
+ const parts = refPath.split("/");
1673
+ const [, key] = parts; // "item" or "index"
1674
+ const map = state.$map as Record<string, unknown> | undefined;
1675
+ const base = map?.[key] ?? state[`$map/${key}`];
1566
1676
  return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
1567
1677
  }
1568
- if (ref.startsWith("#/state/")) {
1569
- const sub = ref.slice("#/state/".length);
1678
+ if (refPath.startsWith("#/state/")) {
1679
+ const sub = refPath.slice("#/state/".length);
1570
1680
  const slash = sub.indexOf("/");
1571
- if (slash < 0) return state[sub];
1681
+ if (slash === -1) {
1682
+ return state[sub];
1683
+ }
1572
1684
  return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
1573
1685
  }
1574
- if (ref.startsWith("parent#/")) return state[ref.slice("parent#/".length)];
1575
- if (ref.startsWith("window#/")) return getPath(globalThis.window, ref.slice("window#/".length));
1576
- if (ref.startsWith("document#/"))
1577
- return getPath(globalThis.document, ref.slice("document#/".length));
1578
- return state[ref] ?? null;
1686
+ if (refPath.startsWith("parent#/")) {
1687
+ return state[refPath.slice("parent#/".length)];
1688
+ }
1689
+ if (refPath.startsWith("window#/")) {
1690
+ return getPath(globalThis.window, refPath.slice("window#/".length));
1691
+ }
1692
+ if (refPath.startsWith("document#/")) {
1693
+ return getPath(globalThis.document, refPath.slice("document#/".length));
1694
+ }
1695
+ return state[refPath] ?? null;
1579
1696
  }
1580
1697
 
1581
1698
  // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -1592,12 +1709,10 @@ export function isSignal(v: unknown) {
1592
1709
 
1593
1710
  /**
1594
1711
  * @param {unknown} v
1595
- * @returns {boolean}
1712
+ * @returns {v is import("@jxsuite/schema/types").JxRef}
1596
1713
  */
1597
- function isRefObj(v: unknown) {
1598
- return (
1599
- v !== null && typeof v === "object" && typeof (v as Record<string, unknown>).$ref === "string"
1600
- );
1714
+ function isRefObj(v: unknown): v is JxRef {
1715
+ return isRefValue(v);
1601
1716
  }
1602
1717
 
1603
1718
  /**
@@ -1614,18 +1729,22 @@ function isNestedSelector(k: string) {
1614
1729
  * @returns {unknown}
1615
1730
  */
1616
1731
  function getPath(obj: unknown, path: string) {
1617
- return path.split(/[./]/).reduce((o, k) => (o as Record<string, unknown>)?.[k], obj);
1732
+ let current: unknown = obj;
1733
+ for (const k of path.split(/[./]/)) {
1734
+ current = (current as Record<string, unknown>)?.[k];
1735
+ }
1736
+ return current;
1618
1737
  }
1619
1738
 
1620
1739
  /**
1621
- * @param {Record<string, any>} def
1622
- * @param {Record<string, any>} parentState
1623
- * @returns {Record<string, any>}
1740
+ * @param {JxElement} def
1741
+ * @param {JxScope} parentState
1742
+ * @returns {JxScope}
1624
1743
  */
1625
- function mergeProps(def: Record<string, any>, parentState: Record<string, any>) {
1744
+ function mergeProps(def: JxElement, parentState: JxScope): JxScope {
1626
1745
  const child = Object.create(parentState);
1627
1746
  for (const [k, v] of Object.entries(def.$props ?? {})) {
1628
- child[k] = isRefObj(v) ? resolveRef((v as { $ref: string }).$ref, parentState) : v;
1747
+ child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
1629
1748
  }
1630
1749
  return child;
1631
1750
  }
@@ -1637,7 +1756,7 @@ function mergeProps(def: Record<string, any>, parentState: Record<string, any>)
1637
1756
  * @returns {string}
1638
1757
  */
1639
1758
  export function camelToKebab(s: string) {
1640
- return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
1759
+ return s.replaceAll(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
1641
1760
  }
1642
1761
 
1643
1762
  /**
@@ -1661,11 +1780,11 @@ const _elementDefs = new Map();
1661
1780
  /**
1662
1781
  * Resolve and register $elements entries (depth-first).
1663
1782
  *
1664
- * @param {any[]} elements
1783
+ * @param {JxDocument["$elements"]} elements
1665
1784
  * @param {string} base
1666
1785
  * @returns {Promise<void>}
1667
1786
  */
1668
- async function registerElements(elements: any[], base: string) {
1787
+ async function registerElements(elements: NonNullable<JxDocument["$elements"]>, base: string) {
1669
1788
  for (const entry of elements) {
1670
1789
  // Bare string: npm package side-effect import (registers custom elements)
1671
1790
  if (typeof entry === "string") {
@@ -1675,16 +1794,22 @@ async function registerElements(elements: any[], base: string) {
1675
1794
  const specifier =
1676
1795
  entry.startsWith("/") || entry.startsWith(".") ? entry : `/node_modules/${entry}`;
1677
1796
  await import(specifier);
1678
- } catch (e) {
1679
- console.warn(`Jx: failed to import package "${entry}"`, e);
1797
+ } catch (error) {
1798
+ console.warn(`Jx: failed to import package "${entry}"`, error);
1680
1799
  }
1681
1800
  continue;
1682
1801
  }
1683
- if (!isRefObj(entry)) continue;
1684
- const href = new URL(entry.$ref, base).href;
1802
+ if (!isRefObj(entry)) {
1803
+ continue;
1804
+ }
1805
+ const { href } = new URL(entry.$ref, base);
1685
1806
  const doc = await resolve(href);
1686
- if (!doc.tagName || !doc.tagName.includes("-")) continue;
1687
- if (customElements.get(doc.tagName)) continue;
1807
+ if (!doc.tagName || !doc.tagName.includes("-")) {
1808
+ continue;
1809
+ }
1810
+ if (customElements.get(doc.tagName)) {
1811
+ continue;
1812
+ }
1688
1813
 
1689
1814
  // Depth-first: register sub-dependencies first
1690
1815
  if (doc.$elements) {
@@ -1699,99 +1824,117 @@ async function registerElements(elements: any[], base: string) {
1699
1824
  * Inject head elements from $head declarations. Each entry is { tagName, attributes } — bare npm
1700
1825
  * specifiers in href/src are rewritten to /node_modules/ paths for the dev server.
1701
1826
  *
1702
- * @param {any[]} entries
1827
+ * @param {import("@jxsuite/schema/types").JxHeadEntry[]} entries
1703
1828
  * @param {string} _base - Document base URL for resolving relative paths
1704
1829
  */
1705
- function injectHead(entries: any[], _base: string) {
1830
+ function injectHead(entries: JxHeadEntry[], _base: string) {
1706
1831
  for (const entry of entries) {
1707
- if (!entry || !entry.tagName) continue;
1832
+ if (!entry || !entry.tagName) {
1833
+ continue;
1834
+ }
1708
1835
  const tag = entry.tagName.toLowerCase();
1709
1836
  const attrs = { ...entry.attributes };
1710
1837
  // Resolve href/src: bare npm specifiers -> /node_modules/ path
1711
- for (const key of ["href", "src"]) {
1838
+ for (const key of ["href", "src"] as const) {
1839
+ const v = attrs[key];
1712
1840
  if (
1713
- attrs[key] &&
1714
- !attrs[key].startsWith("/") &&
1715
- !attrs[key].startsWith(".") &&
1716
- !attrs[key].startsWith("http")
1841
+ typeof v === "string" &&
1842
+ v &&
1843
+ !v.startsWith("/") &&
1844
+ !v.startsWith(".") &&
1845
+ !v.startsWith("http")
1717
1846
  ) {
1718
- attrs[key] = `/node_modules/${attrs[key]}`;
1847
+ attrs[key] = `/node_modules/${v}`;
1719
1848
  }
1720
1849
  }
1721
1850
 
1722
1851
  // Deduplicate: skip if an identical element already exists
1723
1852
  const selector = `${tag}${attrs.href ? `[href="${attrs.href}"]` : ""}${attrs.src ? `[src="${attrs.src}"]` : ""}`;
1724
- if (selector !== tag && document.head.querySelector(selector)) continue;
1853
+ if (selector !== tag && document.head.querySelector(selector)) {
1854
+ continue;
1855
+ }
1725
1856
 
1726
1857
  const el = document.createElement(tag);
1727
1858
  for (const [k, v] of Object.entries(attrs)) {
1728
- el.setAttribute(k, /** @type {string} */ v);
1859
+ el.setAttribute(k, String(v));
1729
1860
  }
1730
- if (entry.textContent) el.textContent = entry.textContent;
1731
- document.head.appendChild(el);
1861
+ if (entry.textContent) {
1862
+ el.textContent = entry.textContent;
1863
+ }
1864
+ document.head.append(el);
1732
1865
  }
1733
1866
  }
1734
1867
 
1735
1868
  /**
1736
1869
  * Register a custom element from a Jx document.
1737
1870
  *
1738
- * @param {string | Record<string, any>} source - URL to .json file, or raw document object
1871
+ * @param {string | JxDocument} source - URL to .json file, or raw document object
1739
1872
  * @param {string} [base] - Base URL for resolving $src imports
1740
1873
  * @returns {Promise<void>}
1741
1874
  */
1742
1875
  const _definedSources = new Set<string>();
1743
1876
 
1744
- export async function defineElement(source: string | Record<string, any>, base?: string) {
1877
+ export async function defineElement(source: string | JxDocument, baseUrl?: string) {
1878
+ let base = baseUrl;
1879
+ let doc = source;
1745
1880
  if (typeof source === "string") {
1746
1881
  base = new URL(source, base ?? location.href).href;
1747
- if (_definedSources.has(base)) return;
1882
+ if (_definedSources.has(base)) {
1883
+ return;
1884
+ }
1748
1885
  _definedSources.add(base);
1749
- source = await resolve(source);
1886
+ doc = await resolve(source);
1750
1887
  }
1751
- base = base ?? location.href;
1888
+ base ??= location.href;
1752
1889
 
1753
- const source_: Record<string, any> = source as Record<string, any>;
1890
+ const source_: JxDocument = doc as JxDocument;
1754
1891
 
1755
- const tagName = source_.tagName;
1892
+ const { tagName } = source_;
1756
1893
  if (!tagName || !tagName.includes("-")) {
1757
1894
  throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
1758
1895
  }
1759
- if (customElements.get(tagName)) return;
1896
+ if (customElements.get(tagName)) {
1897
+ return;
1898
+ }
1760
1899
 
1761
1900
  // Register sub-dependencies first
1762
1901
  if (source_.$elements) {
1763
1902
  await registerElements(source_.$elements, base);
1764
1903
  }
1765
1904
 
1766
- _elementDefs.set(tagName, { doc: source_, base });
1905
+ _elementDefs.set(tagName, { base, doc: source_ });
1767
1906
 
1768
1907
  const def = source_;
1769
1908
  const observedAttrs = def.observedAttributes ?? [];
1770
1909
 
1771
1910
  const ElementClass = class extends HTMLElement {
1772
1911
  _jxInitialized = false;
1773
- _state: Record<string, any> | null = null;
1912
+ _state: JxScope | null = null;
1774
1913
 
1775
1914
  static get observedAttributes() {
1776
1915
  return observedAttrs;
1777
1916
  }
1778
1917
 
1779
1918
  async connectedCallback() {
1780
- if (this._jxInitialized) return;
1919
+ if (this._jxInitialized) {
1920
+ return;
1921
+ }
1781
1922
  this._jxInitialized = true;
1782
1923
 
1783
1924
  const state = await buildScope(def, {}, base);
1784
1925
 
1785
1926
  // Read properties from directive encoding (markdown → data-jx-props)
1786
- const propsAttr = this.getAttribute("data-jx-props");
1927
+ const propsAttr = this.dataset.jxProps;
1787
1928
  if (propsAttr) {
1788
1929
  try {
1789
1930
  const props = JSON.parse(propsAttr);
1790
1931
  for (const [key, val] of Object.entries(props)) {
1791
- if (key in (def.state ?? {})) state[key] = val;
1932
+ if (key in (def.state ?? {})) {
1933
+ state[key] = val;
1934
+ }
1792
1935
  }
1793
1936
  } catch {}
1794
- this.removeAttribute("data-jx-props");
1937
+ delete this.dataset.jxProps;
1795
1938
  }
1796
1939
 
1797
1940
  // Merge $props set as JS properties by parent before connection
@@ -1804,11 +1947,11 @@ export async function defineElement(source: string | Record<string, any>, base?:
1804
1947
  for (const key of Object.keys(def.state ?? {})) {
1805
1948
  if (!(key in HTMLElement.prototype)) {
1806
1949
  Object.defineProperty(this, key, {
1950
+ configurable: true,
1807
1951
  get: () => state[key],
1808
1952
  set: (v: unknown) => {
1809
1953
  state[key] = v;
1810
1954
  },
1811
- configurable: true,
1812
1955
  });
1813
1956
  }
1814
1957
  }
@@ -1816,29 +1959,32 @@ export async function defineElement(source: string | Record<string, any>, base?:
1816
1959
  this._state = state;
1817
1960
 
1818
1961
  // Capture light DOM children (for slot distribution) before rendering
1819
- const slottedChildren = Array.from(this.childNodes);
1962
+ const slottedChildren = [...this.childNodes];
1820
1963
  this.innerHTML = "";
1821
1964
 
1822
1965
  // Custom elements default to display:inline — use block so they behave as
1823
- // containers (matching <div> semantics). The component's own style can
1824
- // override this if needed.
1825
- if (!this.style.display) this.style.display = "block";
1966
+ // Containers (matching <div> semantics). The component's own style can
1967
+ // Override this if needed.
1968
+ if (!this.style.display) {
1969
+ this.style.display = "block";
1970
+ }
1826
1971
 
1827
1972
  // Render template into light DOM (once, not in effect — inner effects handle reactivity)
1828
- applyStyle(this, def.style ?? {}, state["$media"] ?? {}, state);
1973
+ applyStyle(this, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
1829
1974
  applyAttributes(this, def.attributes ?? {}, state);
1830
1975
 
1831
1976
  const children = Array.isArray(def.children) ? def.children : [];
1832
1977
  for (const childDef of children) {
1833
- this.appendChild(renderNode(childDef, state));
1978
+ this.append(renderNode(childDef, state));
1834
1979
  }
1835
1980
 
1836
1981
  // Slot distribution (light DOM)
1837
1982
  distributeSlots(this, slottedChildren);
1838
1983
 
1839
1984
  // Lifecycle: onMount
1840
- if (typeof state.onMount === "function") {
1841
- queueMicrotask(() => state.onMount(state));
1985
+ const { onMount } = state;
1986
+ if (typeof onMount === "function") {
1987
+ queueMicrotask(() => onMount(state));
1842
1988
  }
1843
1989
  }
1844
1990
 
@@ -1855,13 +2001,18 @@ export async function defineElement(source: string | Record<string, any>, base?:
1855
2001
  }
1856
2002
 
1857
2003
  attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
1858
- if (!this._state || oldVal === newVal) return;
1859
- const camelKey = name.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
2004
+ if (!this._state || oldVal === newVal) {
2005
+ return;
2006
+ }
2007
+ const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
1860
2008
  const current = this._state[camelKey];
1861
- if (typeof current === "number") this._state[camelKey] = Number(newVal);
1862
- else if (typeof current === "boolean")
2009
+ if (typeof current === "number") {
2010
+ this._state[camelKey] = Number(newVal);
2011
+ } else if (typeof current === "boolean") {
1863
2012
  this._state[camelKey] = newVal !== null && newVal !== "false";
1864
- else this._state[camelKey] = newVal;
2013
+ } else {
2014
+ this._state[camelKey] = newVal;
2015
+ }
1865
2016
  }
1866
2017
  };
1867
2018
 
@@ -1871,50 +2022,52 @@ export async function defineElement(source: string | Record<string, any>, base?:
1871
2022
  /**
1872
2023
  * Render a registered custom element with $props (property-first interface).
1873
2024
  *
1874
- * @param {Record<string, any>} def
1875
- * @param {Record<string, any>} state
2025
+ * @param {JxElement} def
2026
+ * @param {JxScope} state
1876
2027
  * @param {JxRenderOptions} [options]
1877
2028
  * @param {JxPath} [path]
1878
2029
  * @returns {HTMLElement}
1879
2030
  */
1880
2031
  function renderCustomElementWithProps(
1881
- def: Record<string, any>,
1882
- state: Record<string, any>,
2032
+ def: JxElement,
2033
+ state: JxScope,
1883
2034
  options?: JxRenderOptions,
1884
2035
  path?: JxPath,
1885
2036
  ) {
1886
- const el = document.createElement(def.tagName);
2037
+ const el = document.createElement(def.tagName ?? "div");
1887
2038
 
1888
- if (options?.onNodeCreated) options.onNodeCreated(el, path ?? [], def);
2039
+ if (options?.onNodeCreated) {
2040
+ options.onNodeCreated(el, path ?? [], def);
2041
+ }
1889
2042
 
1890
2043
  // Set JS properties from $props (before connection)
1891
2044
  for (const [key, val] of Object.entries(def.$props ?? {})) {
1892
2045
  if (isRefObj(val)) {
1893
- const refVal = val as { $ref: string };
2046
+ const refVal = val;
1894
2047
  const resolved = resolveRef(refVal.$ref, state);
1895
- (el as Record<string, unknown>)[key] = resolved;
2048
+ (el as unknown as Record<string, unknown>)[key] = resolved;
1896
2049
  // Reactive forwarding: re-set the property when the source changes
1897
2050
  effect(() => {
1898
- (el as Record<string, unknown>)[key] = resolveRef(refVal.$ref, state);
2051
+ (el as unknown as Record<string, unknown>)[key] = resolveRef(refVal.$ref, state);
1899
2052
  });
1900
2053
  } else if (isTemplateString(val)) {
1901
2054
  effect(() => {
1902
- (el as Record<string, unknown>)[key] = evaluateTemplate(val as string, state);
2055
+ (el as unknown as Record<string, unknown>)[key] = evaluateTemplate(val, state);
1903
2056
  });
1904
2057
  } else {
1905
- (el as Record<string, unknown>)[key] = val;
2058
+ (el as unknown as Record<string, unknown>)[key] = val;
1906
2059
  }
1907
2060
  }
1908
2061
 
1909
2062
  // Apply host-level style and attributes from the usage site
1910
- applyStyle(el, def.style ?? {}, state["$media"] ?? {}, state);
2063
+ applyStyle(el, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
1911
2064
  applyAttributes(el, def.attributes ?? {}, state);
1912
2065
 
1913
2066
  // Append slotted children
1914
2067
  const children = Array.isArray(def.children) ? def.children : [];
1915
2068
  for (let i = 0; i < children.length; i++) {
1916
2069
  const childOpts = options && path ? { ...options, _path: [...path, "children", i] } : undefined;
1917
- el.appendChild(renderNode(children[i], state, childOpts));
2070
+ el.append(renderNode(children[i], state, childOpts));
1918
2071
  }
1919
2072
 
1920
2073
  return el;
@@ -1927,18 +2080,24 @@ function renderCustomElementWithProps(
1927
2080
  * @param {ChildNode[]} slottedChildren
1928
2081
  */
1929
2082
  function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
1930
- if (slottedChildren.length === 0) return;
2083
+ if (slottedChildren.length === 0) {
2084
+ return;
2085
+ }
1931
2086
 
1932
2087
  const slots = host.querySelectorAll("slot");
1933
- if (slots.length === 0) return;
2088
+ if (slots.length === 0) {
2089
+ return;
2090
+ }
1934
2091
 
1935
- const named: Map<string | null, ChildNode[]> = new Map();
2092
+ const named = new Map<string | null, ChildNode[]>();
1936
2093
  const unnamed: ChildNode[] = [];
1937
2094
 
1938
2095
  for (const child of slottedChildren) {
1939
2096
  if (child.nodeType === Node.ELEMENT_NODE && (child as Element).getAttribute("slot")) {
1940
2097
  const name = (child as Element).getAttribute("slot");
1941
- if (!named.has(name)) named.set(name, []);
2098
+ if (!named.has(name)) {
2099
+ named.set(name, []);
2100
+ }
1942
2101
  (named.get(name) as ChildNode[]).push(child);
1943
2102
  } else {
1944
2103
  unnamed.push(child);
@@ -1951,7 +2110,7 @@ function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
1951
2110
  if (matches.length > 0) {
1952
2111
  slot.innerHTML = "";
1953
2112
  for (const child of matches) {
1954
- slot.appendChild(child);
2113
+ slot.append(child);
1955
2114
  }
1956
2115
  }
1957
2116
  }