@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/dist/runtime.js +862 -119
- package/dist/runtime.js.map +11 -10
- package/package.json +4 -4
- package/src/body-eraser.ts +70 -6
- package/src/css.ts +32 -9
- package/src/expression.ts +140 -7
- package/src/runtime.ts +1271 -127
- 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.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": "^
|
|
37
|
-
"@vue/reactivity": "3.5.
|
|
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
|
|
41
|
+
"@jxsuite/erase": "^0.3.2",
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|