@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/dist/runtime.js +824 -97
- package/dist/runtime.js.map +9 -8
- package/package.json +3 -3
- package/src/body-eraser.ts +70 -6
- package/src/css.ts +2 -1
- package/src/expression.ts +140 -7
- package/src/runtime.ts +1214 -117
- package/src/types.ts +58 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/runtime",
|
|
3
|
-
"version": "
|
|
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": "^
|
|
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.
|
|
41
|
+
"@jxsuite/erase": "^0.3.0",
|
|
42
42
|
"@jxsuite/test-support": "^0.0.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/body-eraser.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
|