@jxsuite/runtime 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/runtime",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,8 @@
14
14
  ],
15
15
  "type": "module",
16
16
  "exports": {
17
- ".": "./src/runtime.js"
17
+ ".": "./src/runtime.js",
18
+ "./expression": "./src/expression.js"
18
19
  },
19
20
  "publishConfig": {
20
21
  "provenance": true
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Jx Declarative Expression Engine (spec §19)
3
+ *
4
+ * Shared module used by both runtime (evaluateExpression) and compiler (compileExpression).
5
+ *
6
+ * @module expression
7
+ */
8
+
9
+ /** @typedef {{ operator: string; target: any; value?: any; initial?: any }} ExpressionNode */
10
+
11
+ const MUTATING_OPS = new Set([
12
+ "=",
13
+ "+=",
14
+ "-=",
15
+ "*=",
16
+ "/=",
17
+ "push",
18
+ "pop",
19
+ "shift",
20
+ "unshift",
21
+ "splice",
22
+ ]);
23
+ const UNARY_OPS = new Set(["!", "-"]);
24
+ const BINARY_OPS = new Set([
25
+ "+",
26
+ "-",
27
+ "*",
28
+ "/",
29
+ "%",
30
+ "===",
31
+ "!==",
32
+ "<",
33
+ "<=",
34
+ ">",
35
+ ">=",
36
+ "&&",
37
+ "||",
38
+ ]);
39
+ const ASSIGNMENT_OPS = new Set(["=", "+=", "-=", "*=", "/="]);
40
+ const ARRAY_METHOD_OPS = new Set(["push", "pop", "shift", "unshift", "splice"]);
41
+ const AGGREGATE_OPS = new Set(["reduce", "map", "filter"]);
42
+
43
+ export const BLESSED_OPERATORS = new Set([
44
+ ...MUTATING_OPS,
45
+ ...UNARY_OPS,
46
+ ...BINARY_OPS,
47
+ ...AGGREGATE_OPS,
48
+ ]);
49
+
50
+ /**
51
+ * @param {string} op
52
+ * @returns {boolean}
53
+ */
54
+ export function isMutating(op) {
55
+ return MUTATING_OPS.has(op);
56
+ }
57
+
58
+ // ─── Runtime Evaluation ──────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Resolve an operand to its runtime value.
62
+ *
63
+ * @param {any} operand
64
+ * @param {Record<string, any>} state
65
+ * @param {Event | null} event
66
+ * @param {{ acc?: any; item?: any; index?: number }} [iterCtx]
67
+ * @returns {any}
68
+ */
69
+ function resolveOperand(operand, state, event, iterCtx) {
70
+ if (operand === null || operand === undefined) return operand;
71
+
72
+ // Nested expression node
73
+ if (typeof operand === "object" && !Array.isArray(operand) && "operator" in operand) {
74
+ return evaluateExpression(operand, state, event, iterCtx);
75
+ }
76
+
77
+ // $ref pointer
78
+ if (typeof operand === "object" && !Array.isArray(operand) && "$ref" in operand) {
79
+ return resolveExprRef(operand.$ref, state, event, iterCtx);
80
+ }
81
+
82
+ // Array of operands (e.g., splice args)
83
+ if (Array.isArray(operand)) {
84
+ return operand.map((o) => resolveOperand(o, state, event, iterCtx));
85
+ }
86
+
87
+ // Literal
88
+ return operand;
89
+ }
90
+
91
+ /**
92
+ * Resolve a $ref string within expression context. Handles event#/, $reduce/acc, $map/, #/state/,
93
+ * and other schemes.
94
+ *
95
+ * @param {string} ref
96
+ * @param {Record<string, any>} state
97
+ * @param {Event | null} event
98
+ * @param {{ acc?: any; item?: any; index?: number }} [iterCtx]
99
+ * @returns {any}
100
+ */
101
+ function resolveExprRef(ref, state, event, iterCtx) {
102
+ if (ref === "$reduce/acc") {
103
+ return iterCtx?.acc;
104
+ }
105
+ if (ref.startsWith("$reduce/")) {
106
+ return iterCtx?.acc;
107
+ }
108
+ if (ref.startsWith("event#/")) {
109
+ const path = ref.slice("event#/".length);
110
+ return getPath(event, path);
111
+ }
112
+ if (ref.startsWith("$map/")) {
113
+ const parts = ref.split("/");
114
+ const key = parts[1];
115
+ let base;
116
+ if (key === "item") base = iterCtx?.item ?? state.$map?.item ?? state["$map/item"];
117
+ else if (key === "index") base = iterCtx?.index ?? state.$map?.index ?? state["$map/index"];
118
+ else base = state.$map?.[key] ?? state["$map/" + key];
119
+ return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
120
+ }
121
+ if (ref.startsWith("#/state/")) {
122
+ const sub = ref.slice("#/state/".length);
123
+ const slash = sub.indexOf("/");
124
+ if (slash < 0) return state[sub];
125
+ return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
126
+ }
127
+ if (ref.startsWith("parent#/")) return state[ref.slice("parent#/".length)];
128
+ if (ref.startsWith("window#/")) return getPath(globalThis.window, ref.slice("window#/".length));
129
+ if (ref.startsWith("document#/"))
130
+ return getPath(globalThis.document, ref.slice("document#/".length));
131
+ return state[ref] ?? null;
132
+ }
133
+
134
+ /**
135
+ * Resolve a $ref to a writable location — returns { obj, key } for assignment.
136
+ *
137
+ * @param {string} ref
138
+ * @param {Record<string, any>} state
139
+ * @param {Event | null} event
140
+ * @param {{ acc?: any; item?: any; index?: number }} [iterCtx]
141
+ * @returns {{ obj: any; key: string }}
142
+ */
143
+ function resolveWritableRef(ref, state, event, iterCtx) {
144
+ if (ref.startsWith("$map/")) {
145
+ const parts = ref.split("/");
146
+ const key = parts[1];
147
+ let base;
148
+ if (key === "item") base = iterCtx?.item ?? state.$map?.item ?? state["$map/item"];
149
+ else if (key === "index") base = iterCtx?.index ?? state.$map?.index ?? state["$map/index"];
150
+ else base = state.$map?.[key] ?? state["$map/" + key];
151
+ if (parts.length > 2) {
152
+ const pathParts = parts.slice(2);
153
+ const lastKey = pathParts.pop();
154
+ const obj = pathParts.length > 0 ? getPath(base, pathParts.join("/")) : base;
155
+ return { obj, key: /** @type {string} */ (lastKey) };
156
+ }
157
+ return { obj: state.$map ?? state, key: key === "item" ? "$map/item" : "$map/index" };
158
+ }
159
+ if (ref.startsWith("#/state/")) {
160
+ const sub = ref.slice("#/state/".length);
161
+ const slash = sub.indexOf("/");
162
+ if (slash < 0) return { obj: state, key: sub };
163
+ const parts = sub.split("/");
164
+ const lastKey = parts.pop();
165
+ let obj = state;
166
+ for (const p of parts) obj = obj[p];
167
+ return { obj, key: /** @type {string} */ (lastKey) };
168
+ }
169
+ return { obj: state, key: ref };
170
+ }
171
+
172
+ /**
173
+ * Evaluate an expression node at runtime.
174
+ *
175
+ * @param {ExpressionNode} node
176
+ * @param {Record<string, any>} state
177
+ * @param {Event | null} event
178
+ * @param {{ acc?: any; item?: any; index?: number }} [iterCtx]
179
+ * @returns {any}
180
+ */
181
+ export function evaluateExpression(node, state, event, iterCtx) {
182
+ const { operator, target, value, initial } = node;
183
+
184
+ if (!BLESSED_OPERATORS.has(operator)) {
185
+ throw new Error(`$expression: unknown operator "${operator}"`);
186
+ }
187
+
188
+ // ─── Unary ───
189
+ if (UNARY_OPS.has(operator) && !("value" in node)) {
190
+ const operand = resolveOperand(target, state, event, iterCtx);
191
+ if (operator === "!") return !operand;
192
+ if (operator === "-") return -operand;
193
+ }
194
+
195
+ // ─── Binary (pure) ───
196
+ if (BINARY_OPS.has(operator)) {
197
+ const left = resolveOperand(target, state, event, iterCtx);
198
+ const right = resolveOperand(value, state, event, iterCtx);
199
+ switch (operator) {
200
+ case "+":
201
+ return left + right;
202
+ case "-":
203
+ return left - right;
204
+ case "*":
205
+ return left * right;
206
+ case "/":
207
+ return left / right;
208
+ case "%":
209
+ return left % right;
210
+ case "===":
211
+ return left === right;
212
+ case "!==":
213
+ return left !== right;
214
+ case "<":
215
+ return left < right;
216
+ case "<=":
217
+ return left <= right;
218
+ case ">":
219
+ return left > right;
220
+ case ">=":
221
+ return left >= right;
222
+ case "&&":
223
+ return left && right;
224
+ case "||":
225
+ return left || right;
226
+ }
227
+ }
228
+
229
+ // ─── Assignment ───
230
+ if (ASSIGNMENT_OPS.has(operator)) {
231
+ const rhs = resolveOperand(value, state, event, iterCtx);
232
+ const { obj, key } = resolveWritableRef(target.$ref, state, event, iterCtx);
233
+ switch (operator) {
234
+ case "=":
235
+ obj[key] = rhs;
236
+ break;
237
+ case "+=":
238
+ obj[key] += rhs;
239
+ break;
240
+ case "-=":
241
+ obj[key] -= rhs;
242
+ break;
243
+ case "*=":
244
+ obj[key] *= rhs;
245
+ break;
246
+ case "/=":
247
+ obj[key] /= rhs;
248
+ break;
249
+ }
250
+ return;
251
+ }
252
+
253
+ // ─── Array methods ───
254
+ if (ARRAY_METHOD_OPS.has(operator)) {
255
+ const arr = resolveOperand(target, state, event, iterCtx);
256
+ switch (operator) {
257
+ case "push":
258
+ return arr.push(resolveOperand(value, state, event, iterCtx));
259
+ case "unshift":
260
+ return arr.unshift(resolveOperand(value, state, event, iterCtx));
261
+ case "pop":
262
+ return arr.pop();
263
+ case "shift":
264
+ return arr.shift();
265
+ case "splice": {
266
+ const args = resolveOperand(value, state, event, iterCtx);
267
+ return arr.splice(...args);
268
+ }
269
+ }
270
+ }
271
+
272
+ // ─── Aggregates (pure) ───
273
+ if (AGGREGATE_OPS.has(operator)) {
274
+ const arr = resolveOperand(target, state, event, iterCtx);
275
+ if (operator === "reduce") {
276
+ const seed = resolveOperand(initial, state, event, iterCtx);
277
+ return arr.reduce(
278
+ (/** @type {any} */ acc, /** @type {any} */ item, /** @type {number} */ index) => {
279
+ return evaluateExpression(value, state, event, { acc, item, index });
280
+ },
281
+ seed,
282
+ );
283
+ }
284
+ if (operator === "map") {
285
+ return arr.map((/** @type {any} */ item, /** @type {number} */ index) => {
286
+ return evaluateExpression(value, state, event, { ...iterCtx, item, index });
287
+ });
288
+ }
289
+ if (operator === "filter") {
290
+ return arr.filter((/** @type {any} */ item, /** @type {number} */ index) => {
291
+ return evaluateExpression(value, state, event, { ...iterCtx, item, index });
292
+ });
293
+ }
294
+ }
295
+ }
296
+
297
+ // ─── Compiler: Expression → JS Source ────────────────────────────────────────
298
+
299
+ /** @typedef {{ statePrefix?: string; eventParam?: string }} CompileOpts */
300
+
301
+ /**
302
+ * Compile an operand to a JS source string.
303
+ *
304
+ * @param {any} operand
305
+ * @param {CompileOpts} opts
306
+ * @returns {string}
307
+ */
308
+ function compileOperand(operand, opts) {
309
+ if (operand === null) return "null";
310
+ if (operand === undefined) return "undefined";
311
+
312
+ // Nested expression node
313
+ if (typeof operand === "object" && !Array.isArray(operand) && "operator" in operand) {
314
+ return compileExpression(operand, opts);
315
+ }
316
+
317
+ // $ref pointer
318
+ if (typeof operand === "object" && !Array.isArray(operand) && "$ref" in operand) {
319
+ return compileRef(operand.$ref, opts);
320
+ }
321
+
322
+ // Array of operands
323
+ if (Array.isArray(operand)) {
324
+ return operand.map((o) => compileOperand(o, opts)).join(", ");
325
+ }
326
+
327
+ // Literal
328
+ return JSON.stringify(operand);
329
+ }
330
+
331
+ /**
332
+ * Compile a $ref to its JS equivalent.
333
+ *
334
+ * @param {string} ref
335
+ * @param {CompileOpts} opts
336
+ * @returns {string}
337
+ */
338
+ function compileRef(ref, opts) {
339
+ const s = opts.statePrefix ?? "state";
340
+ const e = opts.eventParam ?? "event";
341
+
342
+ if (ref === "$reduce/acc") return "_acc";
343
+ if (ref.startsWith("$reduce/")) return "_acc";
344
+
345
+ if (ref.startsWith("event#/")) {
346
+ const path = ref.slice("event#/".length);
347
+ return `${e}.${path.replace(/\//g, ".")}`;
348
+ }
349
+
350
+ if (ref.startsWith("$map/")) {
351
+ const parts = ref.split("/");
352
+ const key = parts[1];
353
+ if (key === "item") {
354
+ return parts.length > 2 ? `_item.${parts.slice(2).join(".")}` : "_item";
355
+ }
356
+ if (key === "index") return "_index";
357
+ return `_${key}`;
358
+ }
359
+
360
+ if (ref.startsWith("#/state/")) {
361
+ const path = ref.slice("#/state/".length);
362
+ return `${s}.${path.replace(/\//g, ".")}`;
363
+ }
364
+
365
+ if (ref.startsWith("parent#/")) return `${s}.${ref.slice("parent#/".length)}`;
366
+ if (ref.startsWith("window#/"))
367
+ return `window.${ref.slice("window#/".length).replace(/\//g, ".")}`;
368
+ if (ref.startsWith("document#/"))
369
+ return `document.${ref.slice("document#/".length).replace(/\//g, ".")}`;
370
+
371
+ return `${s}.${ref}`;
372
+ }
373
+
374
+ /**
375
+ * Compile a writable $ref target to its JS equivalent (for LHS of assignment).
376
+ *
377
+ * @param {any} target
378
+ * @param {CompileOpts} opts
379
+ * @returns {string}
380
+ */
381
+ function compileTarget(target, opts) {
382
+ if (typeof target === "object" && "$ref" in target) {
383
+ return compileRef(target.$ref, opts);
384
+ }
385
+ return compileOperand(target, opts);
386
+ }
387
+
388
+ /**
389
+ * Compile an expression node to a JS source string. Returns a statement for mutating ops, a value
390
+ * expression for pure ops.
391
+ *
392
+ * @param {ExpressionNode} node
393
+ * @param {CompileOpts} [opts]
394
+ * @returns {string}
395
+ */
396
+ export function compileExpression(node, opts = {}) {
397
+ const { operator, target, value, initial } = node;
398
+
399
+ // ─── Unary ───
400
+ if (UNARY_OPS.has(operator) && !("value" in node)) {
401
+ const operand = compileOperand(target, opts);
402
+ if (operator === "!") return `!(${operand})`;
403
+ if (operator === "-") return `-(${operand})`;
404
+ }
405
+
406
+ // ─── Binary (pure) ───
407
+ if (BINARY_OPS.has(operator)) {
408
+ const left = compileOperand(target, opts);
409
+ const right = compileOperand(value, opts);
410
+ return `(${left} ${operator} ${right})`;
411
+ }
412
+
413
+ // ─── Assignment ───
414
+ if (ASSIGNMENT_OPS.has(operator)) {
415
+ const lhs = compileTarget(target, opts);
416
+ const rhs = compileOperand(value, opts);
417
+ return `${lhs} ${operator} ${rhs}`;
418
+ }
419
+
420
+ // ─── Array methods ───
421
+ if (ARRAY_METHOD_OPS.has(operator)) {
422
+ const arr = compileTarget(target, opts);
423
+ switch (operator) {
424
+ case "push":
425
+ return `${arr}.push(${compileOperand(value, opts)})`;
426
+ case "unshift":
427
+ return `${arr}.unshift(${compileOperand(value, opts)})`;
428
+ case "pop":
429
+ return `${arr}.pop()`;
430
+ case "shift":
431
+ return `${arr}.shift()`;
432
+ case "splice": {
433
+ const args = Array.isArray(value)
434
+ ? value.map((o) => compileOperand(o, opts)).join(", ")
435
+ : compileOperand(value, opts);
436
+ return `${arr}.splice(${args})`;
437
+ }
438
+ }
439
+ }
440
+
441
+ // ─── Aggregates (pure) ───
442
+ if (AGGREGATE_OPS.has(operator)) {
443
+ const arr = compileOperand(target, opts);
444
+ const itemExpr = compileExpression(value, opts);
445
+ if (operator === "reduce") {
446
+ const seed = compileOperand(initial, opts);
447
+ return `${arr}.reduce((_acc, _item, _index) => ${itemExpr}, ${seed})`;
448
+ }
449
+ if (operator === "map") {
450
+ return `${arr}.map((_item, _index) => ${itemExpr})`;
451
+ }
452
+ if (operator === "filter") {
453
+ return `${arr}.filter((_item, _index) => ${itemExpr})`;
454
+ }
455
+ }
456
+
457
+ return "undefined";
458
+ }
459
+
460
+ // ─── Utilities ───────────────────────────────────────────────────────────────
461
+
462
+ /**
463
+ * @param {any} obj
464
+ * @param {string} path
465
+ * @returns {any}
466
+ */
467
+ function getPath(obj, path) {
468
+ return path.split(/[./]/).reduce((o, k) => /** @type {any} */ (o)?.[k], obj);
469
+ }
package/src/runtime.js CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { reactive, ref, computed, effect, isRef, onEffectCleanup } from "@vue/reactivity";
16
+ import { evaluateExpression, isMutating } from "./expression.js";
16
17
 
17
18
  // ─── Public API ───────────────────────────────────────────────────────────────
18
19
 
@@ -161,6 +162,7 @@ export async function buildScope(doc, parentScope = {}, base = location.href) {
161
162
  // 4. Object
162
163
  if (typeof def === "object") {
163
164
  if (def.$prototype) continue; // handled in later passes
165
+ if ("$expression" in def) continue; // handled in pass 2.5
164
166
  if (def.timing === "server" && def.$src && def.$export) continue; // handled in fifth pass
165
167
  if ("default" in def) {
166
168
  raw[key] = def.default;
@@ -181,6 +183,19 @@ export async function buildScope(doc, parentScope = {}, base = location.href) {
181
183
  }
182
184
  }
183
185
 
186
+ // Pass 2.5: $expression entries (Shape 5)
187
+ for (const [key, def] of Object.entries(defs)) {
188
+ if (def && typeof def === "object" && !Array.isArray(def) && "$expression" in def) {
189
+ const node = /** @type {any} */ (def).$expression;
190
+ if (isMutating(node.operator)) {
191
+ state[key] = (/** @type {any} */ s, /** @type {any} */ event) =>
192
+ evaluateExpression(node, s, event);
193
+ } else {
194
+ state[key] = computed(() => evaluateExpression(node, state, null));
195
+ }
196
+ }
197
+ }
198
+
184
199
  // Third pass: $prototype: "Function" entries
185
200
  for (const [key, def] of Object.entries(defs)) {
186
201
  if (typeof def === "object" && def?.$prototype === "Function") {
@@ -495,6 +510,13 @@ function applyProperties(el, def, state) {
495
510
  el.addEventListener(key.slice(2), (e) => fn(scope, e));
496
511
  continue;
497
512
  }
513
+ // Event handler: inline $expression
514
+ if (val && typeof val === "object" && "$expression" in val) {
515
+ const node = /** @type {any} */ (val).$expression;
516
+ const scope = state;
517
+ el.addEventListener(key.slice(2), (e) => evaluateExpression(node, scope, e));
518
+ continue;
519
+ }
498
520
  }
499
521
 
500
522
  bindProperty(el, key, val, state);
@@ -548,6 +570,27 @@ export function applyStyle(el, styleDef, mediaQueries = {}, state = {}) {
548
570
  const nested = {};
549
571
  /** @type {Record<string, any>} */
550
572
  const media = {};
573
+ /** @type {Record<string, string>} */
574
+ const baseDecls = {};
575
+
576
+ // Collect properties overridden by media queries so we can avoid inline styles for them
577
+ /** @type {Set<string>} */
578
+ const mediaOverriddenProps = new Set();
579
+ for (const [prop, val] of Object.entries(styleDef)) {
580
+ if (prop.startsWith("@") && val && typeof val === "object") {
581
+ for (const k of Object.keys(val)) {
582
+ if (
583
+ !k.startsWith(":") &&
584
+ !k.startsWith(".") &&
585
+ !k.startsWith("&") &&
586
+ !k.startsWith("[") &&
587
+ !k.startsWith("@")
588
+ ) {
589
+ mediaOverriddenProps.add(k);
590
+ }
591
+ }
592
+ }
593
+ }
551
594
 
552
595
  for (const [prop, val] of Object.entries(styleDef)) {
553
596
  if (prop.startsWith("@")) media[prop] = val;
@@ -564,17 +607,21 @@ export function applyStyle(el, styleDef, mediaQueries = {}, state = {}) {
564
607
  /** @type {Record<string, string>} */ (/** @type {unknown} */ (el.style))[prop] =
565
608
  evaluateTemplate(val, state);
566
609
  });
610
+ else if (mediaOverriddenProps.has(prop)) baseDecls[prop] = val;
567
611
  else /** @type {Record<string, string>} */ (/** @type {unknown} */ (el.style))[prop] = val;
568
612
  }
569
613
 
570
614
  const hasNested = Object.keys(nested).length > 0;
571
615
  const hasMedia = Object.keys(media).length > 0;
572
- if (!hasNested && !hasMedia) return;
616
+ const hasBaseDecls = Object.keys(baseDecls).length > 0;
617
+ if (!hasNested && !hasMedia && !hasBaseDecls) return;
573
618
 
574
619
  const uid = `jx-${Math.random().toString(36).slice(2, 7)}`;
575
620
  el.dataset.jx = uid;
576
621
 
577
622
  let css = "";
623
+ const baseCSS = toCSSText(baseDecls);
624
+ if (baseCSS) css += `[data-jx="${uid}"] { ${baseCSS} }\n`;
578
625
 
579
626
  function emitNested(/** @type {string} */ scope, /** @type {Record<string, any>} */ rules) {
580
627
  const props = toCSSText(rules);
@@ -675,8 +722,7 @@ function renderMappedArray(def, state, options) {
675
722
  applyProperties(container, def, state);
676
723
  applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
677
724
  applyAttributes(container, def.attributes ?? {}, state);
678
- const { items: _items, of: _of, map: mapDef, filter: filterRef, sort: sortRef } = def.children;
679
- const itemsSrc = _items ?? _of;
725
+ const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = def.children;
680
726
 
681
727
  effect(() => {
682
728
  container.innerHTML = "";