@jxsuite/runtime 6.2.2 → 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.2.2",
3
+ "version": "7.0.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,11 +33,12 @@
33
33
  "upgrade": "bun update --latest && bun install"
34
34
  },
35
35
  "dependencies": {
36
- "@jxsuite/schema": "^5.5.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.3.0",
41
42
  "@jxsuite/test-support": "^0.0.0"
42
43
  }
43
44
  }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The runtime's seam for TypeScript function bodies (`spec.md` §23.1).
3
+ *
4
+ * A string `body` may use erasable TypeScript, and only JavaScript can run, so every body the
5
+ * runtime compiles goes through {@link compileBody}. The runtime carries no stripper: a host hands
6
+ * it one, eagerly with {@link setBodyEraser} (`eraseBody` from `@jxsuite/erase/browser`), or lazily
7
+ * with {@link setBodyEraserLoader}, which is fetched only when a document has a body that needs it.
8
+ * With neither, a body compiles as written, and one that does not compile says what would fix it.
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
+ *
13
+ * @docs framework/concepts/functions
14
+ */
15
+ import { isFunctionEntry, isJsonObject, isString } from "@jxsuite/schema/guards";
16
+ import type { JsonValue, JxClassDef, JxDocument } from "@jxsuite/schema/types";
17
+ import type { JxScopeValue } from "./types.ts";
18
+
19
+ /** Something erasing found. Only `unsupported` stops a body from running. */
20
+ export interface BodyEraserProblem {
21
+ kind: string;
22
+ message: string;
23
+ }
24
+
25
+ /** What `eraseBody` from `@jxsuite/erase/browser` is: TypeScript in, JavaScript out. */
26
+ export type BodyEraser = (text: string) => {
27
+ code: string;
28
+ problems: readonly BodyEraserProblem[];
29
+ };
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
+
47
+ /** What importing `@jxsuite/erase/browser` gives. */
48
+ export interface BodyEraserModule {
49
+ eraseBody: BodyEraser;
50
+ /** Absent from an eraser built before document scripts; a TypeScript module then cannot load. */
51
+ loadModule?: ModuleLoader;
52
+ }
53
+
54
+ /** A compiled body: called with what its caller passes, returning what its body returns. */
55
+ export type CompiledBody = (...args: never[]) => JxScopeValue;
56
+
57
+ /** Fetches the eraser, once, when a document first needs it. */
58
+ export type BodyEraserLoader = () => Promise<BodyEraserModule>;
59
+
60
+ let eraser: BodyEraser | null = null;
61
+ let moduleLoader: ModuleLoader | null = null;
62
+ let loader: BodyEraserLoader | null = null;
63
+ let loading: Promise<void> | null = null;
64
+
65
+ /** Erased bodies by authored text, so a body erases once however often it compiles. */
66
+ const erased = new Map<string, string>();
67
+
68
+ /**
69
+ * Install (or with `null`, remove) the function that erases TypeScript from a body.
70
+ *
71
+ * @param fn `eraseBody` from `@jxsuite/erase/browser`.
72
+ */
73
+ export function setBodyEraser(fn: BodyEraser | null): void {
74
+ eraser = fn;
75
+ erased.clear();
76
+ if (!fn) {
77
+ // Taken away, so a later document that needs one loads it again.
78
+ loading = null;
79
+ }
80
+ }
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
+
129
+ /**
130
+ * Say how to fetch the eraser when a document needs it, so a page with no typed body downloads
131
+ * nothing. The runtime installs what the promise gives.
132
+ *
133
+ * @param load Typically `() => import("/path/to/erase.js")`, or null to take it away.
134
+ */
135
+ export function setBodyEraserLoader(load: BodyEraserLoader | null): void {
136
+ loader = load;
137
+ loading = null;
138
+ }
139
+
140
+ /** Whether text parses as a function body, sync or async. Parsed, never called. */
141
+ function compiles(text: string): boolean {
142
+ for (const source of [text, `return async function () {\n${text}\n}`]) {
143
+ try {
144
+ Reflect.construct(Function, [source]);
145
+ return true;
146
+ } catch {
147
+ // Not as this kind of function; the other may still take it.
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /** `<…>` followed by a call or a tagged template: a generic call, which JavaScript reads otherwise. */
154
+ const GENERIC_CALL = /<[^<>]*>\s*[(`]/u;
155
+
156
+ /**
157
+ * Whether a body needs the eraser before it can run as meant: it does not compile as JavaScript, or
158
+ * it holds what TypeScript reads as a generic call.
159
+ *
160
+ * @param text The body as authored.
161
+ */
162
+ export function bodyNeedsEraser(text: string): boolean {
163
+ return GENERIC_CALL.test(text) || !compiles(text);
164
+ }
165
+
166
+ /**
167
+ * Every function entry's string body in a tree, accessor halves and `string[]` lines included.
168
+ *
169
+ * @param value A document, a class definition, or a part of one.
170
+ * @yields {string} Each body's text.
171
+ */
172
+ function* bodiesOf(value: JsonValue): Generator<string> {
173
+ if (Array.isArray(value)) {
174
+ for (const item of value) {
175
+ yield* bodiesOf(item);
176
+ }
177
+ return;
178
+ }
179
+ if (!isJsonObject(value)) {
180
+ return;
181
+ }
182
+ const isFunction = isFunctionEntry(value);
183
+ for (const [key, child] of Object.entries(value)) {
184
+ if (isFunction && key === "body") {
185
+ if (isString(child)) {
186
+ yield child;
187
+ } else if (Array.isArray(child) && child.every((line) => isString(line))) {
188
+ yield child.join("\n");
189
+ } else {
190
+ yield* bodiesOf(child);
191
+ }
192
+ } else if (isFunction && (key === "getter" || key === "setter") && isJsonObject(child)) {
193
+ if (isString(child.body)) {
194
+ yield child.body;
195
+ }
196
+ } else {
197
+ yield* bodiesOf(child);
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Load the eraser first when anything in a document needs it. Called where the runtime already
204
+ * waits (a fetched document, a fetched class), so nothing that compiles later has to.
205
+ *
206
+ * @param doc A document or class definition.
207
+ */
208
+ export async function prepareBodies(doc: JsonValue | JxDocument | JxClassDef): Promise<void> {
209
+ const load = loader;
210
+ if (eraser || !load) {
211
+ return;
212
+ }
213
+ // SAFETY: a Jx document and a class definition are JSON data; their types name what they hold.
214
+ const tree = doc as JsonValue;
215
+ if (!bodiesOf(tree).some((body) => bodyNeedsEraser(body))) {
216
+ return;
217
+ }
218
+ await fetchEraser(load);
219
+ }
220
+
221
+ /** A body's JavaScript, through the eraser when one is installed. */
222
+ function javaScriptOf(body: string, where: string): string {
223
+ if (!eraser) {
224
+ return body;
225
+ }
226
+ let code = erased.get(body);
227
+ if (code === undefined) {
228
+ const result = eraser(body);
229
+ const refused = result.problems.find((problem) => problem.kind === "unsupported");
230
+ if (refused) {
231
+ throw new SyntaxError(`Jx: ${where} cannot run: ${refused.message}`);
232
+ }
233
+ ({ code } = result);
234
+ erased.set(body, code);
235
+ }
236
+ return code;
237
+ }
238
+
239
+ /**
240
+ * Compile a function body, erasing its TypeScript first when an eraser is installed.
241
+ *
242
+ * @param params The parameter names.
243
+ * @param body The body as authored.
244
+ * @param where Names the body in an error, as `'save'` or `the constructor of Counter`.
245
+ * @throws {SyntaxError} When the body does not compile, naming it, and the call that installs an
246
+ * eraser when none is.
247
+ */
248
+ export function compileBody(params: readonly string[], body: string, where: string): CompiledBody {
249
+ const code = javaScriptOf(body, where);
250
+ try {
251
+ /* SAFETY: `new Function` returns an ordinary function of the named parameters; each caller
252
+ says what it is called with and what it returns. */
253
+ return new Function(...params, code) as CompiledBody;
254
+ } catch (error) {
255
+ if (!(error instanceof SyntaxError) || eraser) {
256
+ throw error;
257
+ }
258
+ throw new SyntaxError(
259
+ `Jx: ${where} does not compile as JavaScript (${error.message}). A function body may be ` +
260
+ `TypeScript: give this page an eraser with setBodyEraser(eraseBody), eraseBody from ` +
261
+ `@jxsuite/erase/browser.`,
262
+ { cause: error },
263
+ );
264
+ }
265
+ }
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