@jxsuite/runtime 6.3.0 → 7.1.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.1.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",
37
- "@vue/reactivity": "3.5.44"
36
+ "@jxsuite/schema": "^6.0.2",
37
+ "@vue/reactivity": "3.5.45"
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.2",
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
@@ -506,8 +507,8 @@ export interface CssRule {
506
507
  * It is NOT in {@link CssRule.text}, and that is the point. `text` is what a sheet inserts and
507
508
  * what {@link hashCss} interns, so prose there would make two identical rules two rules and put a
508
509
  * paragraph in every adopted sheet at runtime. A static emitter — the one writing a stylesheet a
509
- * person will read — renders it as a comment; every other consumer ignores it, exactly as a
510
- * document's own `$description` is ignored.
510
+ * person will read — renders it as a comment through {@link commentedRuleText}; every other
511
+ * consumer ignores it, exactly as a document's own `$description` is ignored.
511
512
  */
512
513
  description?: string;
513
514
  /** See {@link CssRuleTarget}. */
@@ -520,8 +521,8 @@ export interface CssRule {
520
521
  export interface CssBuildOptions {
521
522
  /**
522
523
  * The selector the style object hangs off. Every nested key resolves against it, so the runtime
523
- * passes a placeholder it substitutes afterwards and the compiler passes `#id` / `.jx-N` / a
524
- * tag.
524
+ * passes a placeholder it substitutes afterwards and the compiler passes an element's generated
525
+ * class or a component's tag.
525
526
  */
526
527
  scope?: string | null;
527
528
  /** The project's `$media` map, for resolving `@--name` keys. */
@@ -613,6 +614,25 @@ export function cssRuleText(
613
614
  return text;
614
615
  }
615
616
 
617
+ /**
618
+ * A rule as a static build writes it: its `$description` as a comment on the line above, then its
619
+ * text (spec.md §9.2). An undescribed rule is its text, byte for byte.
620
+ *
621
+ * CSS comments have no escape, so a comment terminator in the prose (an asterisk and then a slash)
622
+ * is written with a space between the two: breaking the sequence is the only way to keep the
623
+ * comment from ending early. The prose is otherwise verbatim, newlines included.
624
+ *
625
+ * @param {Pick<CssRule, "text" | "description">} rule
626
+ * @returns {string}
627
+ * @docs framework/concepts/styling
628
+ */
629
+ export function commentedRuleText(rule: Pick<CssRule, "text" | "description">): string {
630
+ if (rule.description === undefined) {
631
+ return rule.text;
632
+ }
633
+ return `/* ${rule.description.replaceAll("*/", "* /")} */\n${rule.text}`;
634
+ }
635
+
616
636
  /**
617
637
  * Serialize one `@keyframes` block: every stop, in authored order, inside ONE at-rule, wrapped in
618
638
  * its enclosing conditions.
@@ -814,6 +834,10 @@ export function buildStyleRules(style: JxStyle, options: CssBuildOptions = {}):
814
834
  return declarations;
815
835
  };
816
836
 
837
+ /** A block's `$description` as the member a rule carries, when it wrote a non-empty one. */
838
+ const describedBy = (description: JxStyle[string]): { description?: string } =>
839
+ isString(description) && description !== "" ? { description } : {};
840
+
817
841
  const emit = (
818
842
  conditions: readonly string[],
819
843
  selector: string | null,
@@ -841,10 +865,7 @@ export function buildStyleRules(style: JxStyle, options: CssBuildOptions = {}):
841
865
  target,
842
866
  key: hashCss(text),
843
867
  };
844
- if (isString(description) && description !== "") {
845
- rule.description = description;
846
- }
847
- rules.push(rule);
868
+ rules.push({ ...rule, ...describedBy(description) });
848
869
  };
849
870
 
850
871
  /**
@@ -883,6 +904,7 @@ export function buildStyleRules(style: JxStyle, options: CssBuildOptions = {}):
883
904
  return;
884
905
  }
885
906
  const text = cssKeyframesText(conditions, name, blocks);
907
+ // The block's own `$description` documents the rule; a stop's is ignored, as a stop is no rule.
886
908
  rules.push({
887
909
  blocks,
888
910
  conditions: [...conditions],
@@ -891,6 +913,7 @@ export function buildStyleRules(style: JxStyle, options: CssBuildOptions = {}):
891
913
  selector: null,
892
914
  target: "unscoped",
893
915
  text,
916
+ ...describedBy(block["$description"]),
894
917
  });
895
918
  };
896
919
 
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