@jxsuite/runtime 6.3.0 → 7.0.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": "6.3.0",
3
+ "version": "7.0.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,12 +33,12 @@
33
33
  "upgrade": "bun update --latest && bun install"
34
34
  },
35
35
  "dependencies": {
36
- "@jxsuite/schema": "^5.6.0",
36
+ "@jxsuite/schema": "^6.0.0",
37
37
  "@vue/reactivity": "3.5.44"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@happy-dom/global-registrator": "^21.0.4",
41
- "@jxsuite/erase": "^0.2.0",
41
+ "@jxsuite/erase": "^0.3.0",
42
42
  "@jxsuite/test-support": "^0.0.0"
43
43
  }
44
44
  }
@@ -7,6 +7,9 @@
7
7
  * with {@link setBodyEraserLoader}, which is fetched only when a document has a body that needs it.
8
8
  * With neither, a body compiles as written, and one that does not compile says what would fix it.
9
9
  *
10
+ * The same file carries what imports a module written in TypeScript (`spec.md` §23.2), so the same
11
+ * loader brings it: {@link moduleLoaderFor} is what the runtime asks before importing one.
12
+ *
10
13
  * @docs framework/concepts/functions
11
14
  */
12
15
  import { isFunctionEntry, isJsonObject, isString } from "@jxsuite/schema/guards";
@@ -25,9 +28,27 @@ export type BodyEraser = (text: string) => {
25
28
  problems: readonly BodyEraserProblem[];
26
29
  };
27
30
 
31
+ /** What a host tells {@link ModuleLoader} about one import. */
32
+ export interface ModuleLoadOptions {
33
+ /** The URL a relative specifier, and a document's path in a `jx-script:` one, resolves against. */
34
+ base?: string | undefined;
35
+ /** Where the host serves a package, as given to `setNpmModuleResolver`. */
36
+ resolveNpm?: ((specifier: string) => string) | null | undefined;
37
+ /** The module's text when the runtime already holds it, so nothing is fetched for it. */
38
+ text?: string | undefined;
39
+ }
40
+
41
+ /**
42
+ * What `loadModule` from `@jxsuite/erase/browser` is: a TypeScript module's URL, or a document
43
+ * script's `jx-script:` specifier, in and its namespace out (`spec.md` §23.2).
44
+ */
45
+ export type ModuleLoader = (specifier: string, options: ModuleLoadOptions) => Promise<object>;
46
+
28
47
  /** What importing `@jxsuite/erase/browser` gives. */
29
48
  export interface BodyEraserModule {
30
49
  eraseBody: BodyEraser;
50
+ /** Absent from an eraser built before document scripts; a TypeScript module then cannot load. */
51
+ loadModule?: ModuleLoader;
31
52
  }
32
53
 
33
54
  /** A compiled body: called with what its caller passes, returning what its body returns. */
@@ -37,6 +58,7 @@ export type CompiledBody = (...args: never[]) => JxScopeValue;
37
58
  export type BodyEraserLoader = () => Promise<BodyEraserModule>;
38
59
 
39
60
  let eraser: BodyEraser | null = null;
61
+ let moduleLoader: ModuleLoader | null = null;
40
62
  let loader: BodyEraserLoader | null = null;
41
63
  let loading: Promise<void> | null = null;
42
64
 
@@ -57,6 +79,53 @@ export function setBodyEraser(fn: BodyEraser | null): void {
57
79
  }
58
80
  }
59
81
 
82
+ /**
83
+ * Install (or with `null`, remove) the function that imports a module written in TypeScript: a
84
+ * `.ts` file a `$src` names, or a document's own client script (`spec.md` §23.2). A host that sets
85
+ * {@link setBodyEraserLoader} needs no call to this: the loader it fetches brings one.
86
+ *
87
+ * @param fn `loadModule` from `@jxsuite/erase/browser`.
88
+ */
89
+ export function setModuleLoader(fn: ModuleLoader | null): void {
90
+ moduleLoader = fn;
91
+ if (!fn) {
92
+ // Taken away, so a later module that needs one loads it again.
93
+ loading = null;
94
+ }
95
+ }
96
+
97
+ /** Whether this page can import a TypeScript module: a loader is installed, or can be fetched. */
98
+ export function canLoadModules(): boolean {
99
+ return moduleLoader !== null || loader !== null;
100
+ }
101
+
102
+ /** Fetch the eraser through the host's loader, once, and install what it gives. */
103
+ function fetchEraser(load: BodyEraserLoader): Promise<void> {
104
+ loading ??= load().then((module) => {
105
+ if (loader !== load) {
106
+ return;
107
+ }
108
+ if (!eraser) {
109
+ setBodyEraser(module.eraseBody);
110
+ }
111
+ if (!moduleLoader && module.loadModule) {
112
+ setModuleLoader(module.loadModule);
113
+ }
114
+ });
115
+ return loading;
116
+ }
117
+
118
+ /**
119
+ * What imports a TypeScript module on this page, fetching the eraser for it when the host said how
120
+ * and nothing is installed yet. Null when the host gave the page neither.
121
+ */
122
+ export async function moduleLoaderFor(): Promise<ModuleLoader | null> {
123
+ if (!moduleLoader && loader) {
124
+ await fetchEraser(loader);
125
+ }
126
+ return moduleLoader;
127
+ }
128
+
60
129
  /**
61
130
  * Say how to fetch the eraser when a document needs it, so a page with no typed body downloads
62
131
  * nothing. The runtime installs what the promise gives.
@@ -146,12 +215,7 @@ export async function prepareBodies(doc: JsonValue | JxDocument | JxClassDef): P
146
215
  if (!bodiesOf(tree).some((body) => bodyNeedsEraser(body))) {
147
216
  return;
148
217
  }
149
- loading ??= load().then((module) => {
150
- if (loader === load) {
151
- setBodyEraser(module.eraseBody);
152
- }
153
- });
154
- await loading;
218
+ await fetchEraser(load);
155
219
  }
156
220
 
157
221
  /** A body's JavaScript, through the eraser when one is installed. */
package/src/css.ts CHANGED
@@ -284,7 +284,8 @@ export function splitSelectorList(selector: string): string[] {
284
284
  * `null` means "do not emit this rule at all", and `::backdrop` is the one thing it is returned
285
285
  * for. There is no backdrop pseudo-element outside the top layer; synthesising one would paint a
286
286
  * full-canvas scrim over the document being edited, and emitting an inert rule would mark the
287
- * selector as "styled" in the Style panel while doing nothing. Preview renders it natively.
287
+ * selector as "styled" in the Style panel while doing nothing. A page in a browser renders it
288
+ * natively.
288
289
  *
289
290
  * @param {string} selector - A fully resolved selector, canvas-side
290
291
  * @returns {string | null} The selector to emit, or null to emit nothing
package/src/expression.ts CHANGED
@@ -13,12 +13,15 @@ import {
13
13
  INTL_HELPER_PATHS,
14
14
  INTL_LOCALE_PARAM,
15
15
  } from "@jxsuite/schema/intl";
16
- import { isFunction, isNonNullObject, isString } from "@jxsuite/schema/guards";
16
+ import { isFunction, isNamedFormulaDef, isNonNullObject, isString } from "@jxsuite/schema/guards";
17
17
  import type {
18
18
  CemParameter,
19
+ JxDocumentValue,
20
+ JxExpressionDef,
19
21
  JxExpressionNode,
20
22
  JxExpressionOperand,
21
23
  JxRef,
24
+ JxStateDefinition,
22
25
  } from "@jxsuite/schema/types";
23
26
  import type { JxScope, JxScopeValue } from "./types.ts";
24
27
  import { readPath, refAccessor, refSegments } from "./pointer.ts";
@@ -341,6 +344,19 @@ function withPageLocale(path: string, args: JxScopeValue[], state: JxScope): JxS
341
344
  return filled;
342
345
  }
343
346
 
347
+ /**
348
+ * The object a `window#/…` ref reads from. A site build prerenders in a process with no `window`,
349
+ * where the standard library the blessed list names (Math, JSON, Array, …) hangs off the global
350
+ * object itself, so the read falls back to it and a formula resolves at build as it does in a
351
+ * page.
352
+ */
353
+ function windowRoot(): object {
354
+ /* SAFETY: the DOM lib types `window` as always present; outside a browser the property is simply
355
+ absent, which is the one fact this read exists to observe. */
356
+ const root = (globalThis as { window?: object }).window;
357
+ return root ?? globalThis;
358
+ }
359
+
344
360
  /** Whether a `window#/…` callee ref is on the blessed pure-globals list. */
345
361
  export function isBlessedGlobal(ref: string): boolean {
346
362
  return ref.startsWith("window#/") && BLESSED_GLOBALS.has(ref.slice("window#/".length));
@@ -354,6 +370,125 @@ export function isMutating(op: string) {
354
370
  return MUTATING_OPS.has(op);
355
371
  }
356
372
 
373
+ // ─── Pure named formulas, and the ones a component inherits ─────────────────
374
+
375
+ /** Whether an operand, or any node nested in it, carries a mutating operator. */
376
+ function operandMutates(operand: JxExpressionOperand | undefined): boolean {
377
+ if (Array.isArray(operand)) {
378
+ return operand.some((item) => operandMutates(item));
379
+ }
380
+ if (operand === undefined || !isNodeOperand(operand)) {
381
+ return false;
382
+ }
383
+ return (
384
+ isMutating(operand.operator) ||
385
+ [operand.target, operand.value, operand.initial, operand.default].some((part) =>
386
+ operandMutates(part),
387
+ ) ||
388
+ Object.values(operand.cases ?? {}).some((part) => operandMutates(part))
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Whether a state entry is a pure named formula (spec.md §19.4c): an `$expression` with
394
+ * `parameters` whose body mutates nothing.
395
+ *
396
+ * Such an entry is a function, not reactive state. It holds no value, nothing can write it, and
397
+ * reading it subscribes to nothing, so the entry by itself gives a page nothing to do in the
398
+ * browser: only a read of it that is left for the client does (compiler.md §3).
399
+ *
400
+ * @param {JxDocumentValue | JxStateDefinition | undefined} def
401
+ * @returns {boolean}
402
+ */
403
+ export function isPureNamedFormula(
404
+ def: JxDocumentValue | JxStateDefinition | undefined,
405
+ ): def is JxExpressionDef & { parameters: unknown[] } {
406
+ return isNamedFormulaDef(def) && !operandMutates(def.$expression);
407
+ }
408
+
409
+ /** The ref schemes a formula body reads that are not the scope it is built in (spec.md §7.4). */
410
+ const OUTSIDE_SCOPE_REF = /^(?:\$args\/|\$reduce\/|\$map\/|window#\/|document#\/|event#\/)/;
411
+
412
+ /**
413
+ * Collect into `into` the state key each scope `$ref` in an operand names. A plain-object literal
414
+ * is a value, not a tree (spec.md §19.2), so nothing inside one is a read.
415
+ */
416
+ function collectScopeReads(operand: JxExpressionOperand | undefined, into: Set<string>): void {
417
+ if (Array.isArray(operand)) {
418
+ for (const item of operand) {
419
+ collectScopeReads(item, into);
420
+ }
421
+ return;
422
+ }
423
+ if (operand === undefined || !isNonNullObject(operand)) {
424
+ return;
425
+ }
426
+ if (isRefOperand(operand)) {
427
+ if (!OUTSIDE_SCOPE_REF.test(operand.$ref)) {
428
+ // `#/state/x`, `parent#/x` and a bare `x` all resolve in the scope (spec.md §15.4).
429
+ const path = operand.$ref.replace(/^(?:#\/state\/|parent#\/)/, "");
430
+ into.add(refSegments(path)[0] ?? "");
431
+ }
432
+ return;
433
+ }
434
+ if (isNodeOperand(operand)) {
435
+ const parts = [operand.target, operand.value, operand.initial, operand.default];
436
+ for (const part of [...parts, ...Object.values(operand.cases ?? {})]) {
437
+ collectScopeReads(part, into);
438
+ }
439
+ }
440
+ }
441
+
442
+ /**
443
+ * The state keys a named formula's body reads from the scope it is built in: every `#/state/key`,
444
+ * `parent#/key` or schemeless `$ref` in it, by the key it names. `$args/`, an iteration context and
445
+ * a global are not the scope.
446
+ *
447
+ * @param {JxExpressionDef} def
448
+ * @returns {string[]}
449
+ */
450
+ export function formulaScopeReads(def: JxExpressionDef): string[] {
451
+ const reads = new Set<string>();
452
+ collectScopeReads(def.$expression, reads);
453
+ return [...reads];
454
+ }
455
+
456
+ /**
457
+ * The project `state` entries a component inherits (spec.md §13.4): the pure named formulas that
458
+ * are closed, meaning every scope read in the body names another inherited formula.
459
+ *
460
+ * Only a function crosses the component boundary. A formula that reads project data state would
461
+ * carry that state across with it, so it stays on the page side, and so does anything that calls
462
+ * it. The project's injected context names (`$site`, `$page`) are never entries of this kind.
463
+ *
464
+ * @param {Record<string, JxStateDefinition> | null | undefined} projectState - `project.json`
465
+ * `state`
466
+ * @returns {Record<string, JxStateDefinition>} In the project's order; empty when there are none
467
+ * @docs framework/concepts/props-and-scope
468
+ */
469
+ export function inheritableFormulas(
470
+ projectState: Record<string, JxStateDefinition> | null | undefined,
471
+ ): Record<string, JxStateDefinition> {
472
+ const candidates = new Map<string, JxExpressionDef>();
473
+ for (const [key, def] of Object.entries(projectState ?? {})) {
474
+ if (!key.startsWith("$") && isPureNamedFormula(def)) {
475
+ candidates.set(key, def);
476
+ }
477
+ }
478
+ // Drop what reads outside the set until nothing does: a caller of a dropped formula goes too.
479
+ let settled = false;
480
+ while (!settled) {
481
+ settled = true;
482
+ for (const [key, def] of candidates) {
483
+ if (formulaScopeReads(def).some((read) => !candidates.has(read))) {
484
+ candidates.delete(key);
485
+ settled = false;
486
+ }
487
+ }
488
+ }
489
+ return Object.fromEntries(candidates);
490
+ }
491
+
357
492
  // ─── Runtime Evaluation ──────────────────────────────────────────────────────
358
493
 
359
494
  interface IterCtx {
@@ -526,7 +661,7 @@ function resolveExprRef(ref: string, state: JxScope, event: Event | null, iterCt
526
661
  return readPath(state, ref.slice("parent#/".length));
527
662
  }
528
663
  if (ref.startsWith("window#/")) {
529
- return readPath(globalThis.window, ref.slice("window#/".length));
664
+ return readPath(windowRoot(), ref.slice("window#/".length));
530
665
  }
531
666
  if (ref.startsWith("document#/")) {
532
667
  return readPath(globalThis.document, ref.slice("document#/".length));
@@ -712,14 +847,12 @@ function evaluateNode(
712
847
  ...withPageLocale(globalPath, argValues, state),
713
848
  );
714
849
  }
850
+ const root = windowRoot();
715
851
  /* SAFETY: `isBlessedGlobal` admitted `globalPath`, and every BLESSED_GLOBALS entry without a
716
852
  synthetic helper names a standard-library function on `window` (Math.max, JSON.parse, …). */
717
- const fn = readPath(globalThis.window, globalPath) as FormulaCallable;
853
+ const fn = readPath(root, globalPath) as FormulaCallable;
718
854
  const lastSlash = globalPath.lastIndexOf("/");
719
- const thisArg =
720
- lastSlash === -1
721
- ? globalThis.window
722
- : readPath(globalThis.window, globalPath.slice(0, lastSlash));
855
+ const thisArg = lastSlash === -1 ? root : readPath(root, globalPath.slice(0, lastSlash));
723
856
  return fn.apply(thisArg, argValues);
724
857
  }
725
858