@jxsuite/runtime 1.0.0 → 1.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 +393 -46
- package/dist/runtime.js.map +9 -8
- package/package.json +4 -3
- package/src/expression.ts +523 -28
- package/src/runtime.ts +114 -5
- package/src/statements.ts +212 -0
package/src/runtime.ts
CHANGED
|
@@ -21,19 +21,23 @@ import {
|
|
|
21
21
|
onEffectCleanup,
|
|
22
22
|
reactive,
|
|
23
23
|
ref,
|
|
24
|
+
toRaw,
|
|
24
25
|
} from "@vue/reactivity";
|
|
25
26
|
import { evaluateExpression, isMutating } from "./expression.ts";
|
|
26
27
|
import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
|
|
27
28
|
import {
|
|
29
|
+
hasStructuredBody,
|
|
28
30
|
isExpressionDef,
|
|
29
31
|
isFunctionDef,
|
|
30
32
|
isJsonObject,
|
|
31
33
|
isMappedArray,
|
|
34
|
+
isNamedFormulaDef,
|
|
32
35
|
isPrototypeDef,
|
|
33
36
|
isRef as isRefValue,
|
|
34
37
|
isServerFnDef,
|
|
35
38
|
paramNames,
|
|
36
39
|
} from "@jxsuite/schema/guards";
|
|
40
|
+
import { runStatements } from "./statements.ts";
|
|
37
41
|
import type {
|
|
38
42
|
JxAttributeValue,
|
|
39
43
|
JxClassDef,
|
|
@@ -259,6 +263,55 @@ function canvasAttrName(el: HTMLElement, key: string): string {
|
|
|
259
263
|
return key;
|
|
260
264
|
}
|
|
261
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Studio-canvas prop-binding markers. When on, `bindProperty` stamps `data-jx-bound-prop="<key>"`
|
|
268
|
+
* on elements whose `textContent` is an invertible single-key state binding — the pure template
|
|
269
|
+
* `"${state.key}"` or `{ "$ref": "#/state/key" }`. The studio canvas uses the marker to offer
|
|
270
|
+
* inline editing of component-instance prop values; the rendered text must BE the prop value
|
|
271
|
+
* verbatim, so mixed templates and deep paths are never stamped. Off (the default) leaves
|
|
272
|
+
* production rendering untouched; the studio sets it for design/edit (not preview — see
|
|
273
|
+
* iframe-render).
|
|
274
|
+
*/
|
|
275
|
+
let _stampPropBindings = false;
|
|
276
|
+
export function setStampPropBindings(on: boolean) {
|
|
277
|
+
_stampPropBindings = on;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The single invertible template form: the whole string is exactly one `${state.key}` access. */
|
|
281
|
+
const PURE_STATE_TEMPLATE = /^\s*\$\{\s*state\.(\w+)\s*\}\s*$/;
|
|
282
|
+
|
|
283
|
+
/** The state key of an invertible `textContent` binding, or null (mixed template, deep path). */
|
|
284
|
+
function boundPropKey(key: string, val: unknown, state: JxScope): string | null {
|
|
285
|
+
if (!_stampPropBindings || key !== "textContent") {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
let sub: string | null = null;
|
|
289
|
+
if (isRefObj(val)) {
|
|
290
|
+
if (!val.$ref.startsWith("#/state/")) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
const rest = val.$ref.slice("#/state/".length);
|
|
294
|
+
sub = rest && !rest.includes("/") ? rest : null;
|
|
295
|
+
} else if (typeof val === "string") {
|
|
296
|
+
sub = PURE_STATE_TEMPLATE.exec(val)?.[1] ?? null;
|
|
297
|
+
}
|
|
298
|
+
if (!sub) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
// Only plain writable data entries are per-instance props. Computed/signal entries live as refs
|
|
302
|
+
// In the raw scope (auto-unwrapped by the reactive proxy), and function/object entries (handlers,
|
|
303
|
+
// $prototype instances) are not prop-overridable either — an instance $props write against any of
|
|
304
|
+
// Them would clobber the definition's behavior, so their bindings are never marked editable.
|
|
305
|
+
const stored = (toRaw(state) as Record<string, unknown>)[sub];
|
|
306
|
+
if (isRef(stored) || typeof stored === "function") {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
if (typeof stored === "object" && stored !== null) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return sub;
|
|
313
|
+
}
|
|
314
|
+
|
|
262
315
|
/**
|
|
263
316
|
* Build the reactive scope (state) from the document using the five-shape detection algorithm.
|
|
264
317
|
*
|
|
@@ -356,7 +409,24 @@ export async function buildScope(
|
|
|
356
409
|
for (const [key, def] of Object.entries(defs)) {
|
|
357
410
|
if (isExpressionDef(def)) {
|
|
358
411
|
const node = def.$expression;
|
|
359
|
-
if (
|
|
412
|
+
if (isNamedFormulaDef(def)) {
|
|
413
|
+
// A named formula: callable with positional args mapped onto its declared parameters.
|
|
414
|
+
const params = def.parameters as (string | { name?: string; default?: unknown })[];
|
|
415
|
+
state[key] = (...argValues: unknown[]) => {
|
|
416
|
+
const args: Record<string, unknown> = {};
|
|
417
|
+
for (const [i, p] of params.entries()) {
|
|
418
|
+
const name = typeof p === "string" ? p : (p?.name ?? "");
|
|
419
|
+
if (!name) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
args[name] =
|
|
423
|
+
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
424
|
+
? p.default
|
|
425
|
+
: argValues[i];
|
|
426
|
+
}
|
|
427
|
+
return evaluateExpression(node, state, null, { args });
|
|
428
|
+
};
|
|
429
|
+
} else if (isMutating(node.operator)) {
|
|
360
430
|
const handler: JxEventHandler = (s, event) => evaluateExpression(node, s, event);
|
|
361
431
|
state[key] = handler;
|
|
362
432
|
} else {
|
|
@@ -367,7 +437,33 @@ export async function buildScope(
|
|
|
367
437
|
|
|
368
438
|
// Third pass: $prototype: "Function" entries
|
|
369
439
|
for (const [key, def] of Object.entries(defs)) {
|
|
370
|
-
if (
|
|
440
|
+
if (hasStructuredBody(def)) {
|
|
441
|
+
// Structured body (spec §20): with parameters → a callable mapping positional args onto
|
|
442
|
+
// $args names (invoked via the call operator); without → an event handler.
|
|
443
|
+
const { body } = def;
|
|
444
|
+
if (Array.isArray(def.parameters) && def.parameters.length > 0) {
|
|
445
|
+
const params = def.parameters as (string | { name?: string; default?: unknown })[];
|
|
446
|
+
state[key] = (...argValues: unknown[]) => {
|
|
447
|
+
const args: Record<string, unknown> = {};
|
|
448
|
+
for (const [i, p] of params.entries()) {
|
|
449
|
+
const name = typeof p === "string" ? p : (p?.name ?? "");
|
|
450
|
+
if (!name) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
args[name] =
|
|
454
|
+
argValues[i] === undefined && typeof p === "object" && p !== null && "default" in p
|
|
455
|
+
? p.default
|
|
456
|
+
: argValues[i];
|
|
457
|
+
}
|
|
458
|
+
return runStatements(body, state, null, { args });
|
|
459
|
+
};
|
|
460
|
+
} else {
|
|
461
|
+
const handler: JxEventHandler = (s, event) => {
|
|
462
|
+
void runStatements(body, s, event ?? null);
|
|
463
|
+
};
|
|
464
|
+
state[key] = handler;
|
|
465
|
+
}
|
|
466
|
+
} else if (isFunctionDef(def)) {
|
|
371
467
|
state[key] = await resolveFunction(def, state, key, base);
|
|
372
468
|
}
|
|
373
469
|
}
|
|
@@ -476,7 +572,7 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
476
572
|
|
|
477
573
|
let fn: ((...args: unknown[]) => unknown) | undefined;
|
|
478
574
|
|
|
479
|
-
if (def.body) {
|
|
575
|
+
if (typeof def.body === "string") {
|
|
480
576
|
const params = resolveParamNames(def);
|
|
481
577
|
fn = new Function(...params, def.body) as (...args: unknown[]) => unknown;
|
|
482
578
|
Object.defineProperty(fn, "name", {
|
|
@@ -518,7 +614,7 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
|
|
|
518
614
|
const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
|
|
519
615
|
let isComputed = false;
|
|
520
616
|
if (!hasParams) {
|
|
521
|
-
if (def.body) {
|
|
617
|
+
if (typeof def.body === "string") {
|
|
522
618
|
isComputed = /\breturn\b/.test(def.body);
|
|
523
619
|
} else if (fn) {
|
|
524
620
|
isComputed = fn.length <= 1 && /\breturn\b/.test(fn.toString());
|
|
@@ -715,8 +811,17 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
|
|
|
715
811
|
}
|
|
716
812
|
continue;
|
|
717
813
|
}
|
|
814
|
+
// Event handler: inline $prototype: "Function" with a structured body (spec §20)
|
|
815
|
+
if (hasStructuredBody(val)) {
|
|
816
|
+
const { body } = val;
|
|
817
|
+
const scope = state;
|
|
818
|
+
el.addEventListener(key.slice(2), (e) => {
|
|
819
|
+
void runStatements(body, scope, e);
|
|
820
|
+
});
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
718
823
|
// Event handler: inline $prototype: "Function"
|
|
719
|
-
if (isFunctionDef(val) && val.body) {
|
|
824
|
+
if (isFunctionDef(val) && typeof val.body === "string") {
|
|
720
825
|
const params = resolveParamNames(val);
|
|
721
826
|
const fn = new Function(...params, val.body) as (s: JxScope, e: Event) => unknown;
|
|
722
827
|
const scope = state;
|
|
@@ -744,6 +849,10 @@ function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
|
|
|
744
849
|
*/
|
|
745
850
|
function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope) {
|
|
746
851
|
const target = el as unknown as Record<string, unknown>;
|
|
852
|
+
const boundProp = boundPropKey(key, val, state);
|
|
853
|
+
if (boundProp) {
|
|
854
|
+
el.dataset.jxBoundProp = boundProp;
|
|
855
|
+
}
|
|
747
856
|
if (isRefObj(val)) {
|
|
748
857
|
const refVal = val as { $ref: string };
|
|
749
858
|
if (key === "id") {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Jx Statement Engine (spec §20) — structured function bodies.
|
|
4
|
+
*
|
|
5
|
+
* A Function entry's `body` may be a statement array instead of opaque JS source. This module is
|
|
6
|
+
* the statements counterpart to expression.ts, with the same dual shape: `runStatements`
|
|
7
|
+
* (interpreter) and `compileStatements` (JS emitter), shared by the runtime and every compiler
|
|
8
|
+
* target. Statement kinds reuse web-platform names only: bare expression nodes (§19), the JSON
|
|
9
|
+
* Schema `if`/`then`/`else` triple, `$switch`/`cases` in statement position, and WHATWG's
|
|
10
|
+
* `dispatchEvent` with `CustomEventInit` members.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
compileExpression,
|
|
15
|
+
compileOperandSource,
|
|
16
|
+
evaluateExpression,
|
|
17
|
+
evaluateOperand,
|
|
18
|
+
} from "./expression.ts";
|
|
19
|
+
|
|
20
|
+
import type { JxStatement } from "@jxsuite/schema/types";
|
|
21
|
+
import type { CompileOpts, ExpressionNode, IterCtx } from "./expression.ts";
|
|
22
|
+
import type { JxScope } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
interface RunOpts {
|
|
25
|
+
/** Named-formula-style arguments bound for $args/<name> refs in the body. */
|
|
26
|
+
args?: Record<string, unknown>;
|
|
27
|
+
/** Dispatch target for dispatchEvent statements; defaults to event?.currentTarget. */
|
|
28
|
+
target?: EventTarget | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function statementKind(statement: JxStatement): "expression" | "if" | "switch" | "dispatch" {
|
|
32
|
+
if ("operator" in statement) {
|
|
33
|
+
return "expression";
|
|
34
|
+
}
|
|
35
|
+
if ("if" in statement) {
|
|
36
|
+
return "if";
|
|
37
|
+
}
|
|
38
|
+
if ("$switch" in statement) {
|
|
39
|
+
return "switch";
|
|
40
|
+
}
|
|
41
|
+
return "dispatch";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Execute a statement list sequentially against the reactive scope. A statement whose value is a
|
|
46
|
+
* thenable is awaited before the next runs (ECMA async/await semantics); purely synchronous bodies
|
|
47
|
+
* complete synchronously before the returned promise settles.
|
|
48
|
+
*/
|
|
49
|
+
export async function runStatements(
|
|
50
|
+
statements: JxStatement[],
|
|
51
|
+
state: JxScope,
|
|
52
|
+
event: Event | null,
|
|
53
|
+
opts: RunOpts = {},
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const iterCtx: IterCtx | undefined = opts.args ? { args: opts.args } : undefined;
|
|
56
|
+
for (const statement of statements) {
|
|
57
|
+
switch (statementKind(statement)) {
|
|
58
|
+
case "expression": {
|
|
59
|
+
const result = evaluateExpression(statement as ExpressionNode, state, event, iterCtx);
|
|
60
|
+
if (result && typeof (result as PromiseLike<unknown>).then === "function") {
|
|
61
|
+
await (result as PromiseLike<unknown>);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "if": {
|
|
66
|
+
const branch = statement as { if: unknown; then: JxStatement[]; else?: JxStatement[] };
|
|
67
|
+
const taken = evaluateOperand(branch.if, state, event, iterCtx) ? branch.then : branch.else;
|
|
68
|
+
if (Array.isArray(taken)) {
|
|
69
|
+
await runStatements(taken, state, event, opts);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "switch": {
|
|
74
|
+
const branch = statement as {
|
|
75
|
+
$switch: unknown;
|
|
76
|
+
cases: Record<string, JxStatement[]>;
|
|
77
|
+
default?: JxStatement[];
|
|
78
|
+
};
|
|
79
|
+
const key = String(evaluateOperand(branch.$switch, state, event, iterCtx));
|
|
80
|
+
const cases = branch.cases ?? {};
|
|
81
|
+
const taken = Object.hasOwn(cases, key) ? cases[key] : branch.default;
|
|
82
|
+
if (Array.isArray(taken)) {
|
|
83
|
+
await runStatements(taken, state, event, opts);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "dispatch": {
|
|
88
|
+
const dispatch = statement as {
|
|
89
|
+
dispatchEvent: string;
|
|
90
|
+
detail?: unknown;
|
|
91
|
+
bubbles?: boolean;
|
|
92
|
+
composed?: boolean;
|
|
93
|
+
};
|
|
94
|
+
const target = opts.target ?? event?.currentTarget;
|
|
95
|
+
if (!target) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const init: CustomEventInit = {};
|
|
99
|
+
if ("detail" in dispatch) {
|
|
100
|
+
init.detail = evaluateOperand(dispatch.detail, state, event, iterCtx);
|
|
101
|
+
}
|
|
102
|
+
if (dispatch.bubbles !== undefined) {
|
|
103
|
+
init.bubbles = dispatch.bubbles;
|
|
104
|
+
}
|
|
105
|
+
if (dispatch.composed !== undefined) {
|
|
106
|
+
init.composed = dispatch.composed;
|
|
107
|
+
}
|
|
108
|
+
target.dispatchEvent(new CustomEvent(dispatch.dispatchEvent, init));
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
default: {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface StatementCompileOpts extends CompileOpts {
|
|
119
|
+
/** JS expression for the dispatchEvent target; defaults to `(event && event.currentTarget)`. */
|
|
120
|
+
dispatchTarget?: string;
|
|
121
|
+
/** Indentation prefix for emitted lines. */
|
|
122
|
+
indent?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Compile a statement list to a JS statement block (no surrounding braces). Mirrors `runStatements`
|
|
127
|
+
* exactly; each statement kind lowers to its genuine ECMAScript form — expression statements,
|
|
128
|
+
* `if`/`else`, `switch` over the discriminant's string form, and `dispatchEvent(new
|
|
129
|
+
* CustomEvent(...))`.
|
|
130
|
+
*/
|
|
131
|
+
export function compileStatements(
|
|
132
|
+
statements: JxStatement[],
|
|
133
|
+
opts: StatementCompileOpts = {},
|
|
134
|
+
): string {
|
|
135
|
+
const indent = opts.indent ?? "";
|
|
136
|
+
const lines: string[] = [];
|
|
137
|
+
for (const statement of statements) {
|
|
138
|
+
switch (statementKind(statement)) {
|
|
139
|
+
case "expression": {
|
|
140
|
+
lines.push(`${indent}${compileExpression(statement as ExpressionNode, opts)};`);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "if": {
|
|
144
|
+
const branch = statement as { if: unknown; then: JxStatement[]; else?: JxStatement[] };
|
|
145
|
+
const test = compileOperandSource(branch.if, opts);
|
|
146
|
+
const inner = { ...opts, indent: `${indent} ` };
|
|
147
|
+
lines.push(`${indent}if (${test}) {`, compileStatements(branch.then ?? [], inner));
|
|
148
|
+
if (branch.else) {
|
|
149
|
+
lines.push(`${indent}} else {`, compileStatements(branch.else, inner));
|
|
150
|
+
}
|
|
151
|
+
lines.push(`${indent}}`);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "switch": {
|
|
155
|
+
const branch = statement as {
|
|
156
|
+
$switch: unknown;
|
|
157
|
+
cases: Record<string, JxStatement[]>;
|
|
158
|
+
default?: JxStatement[];
|
|
159
|
+
};
|
|
160
|
+
const disc = compileOperandSource(branch.$switch, opts);
|
|
161
|
+
const inner = { ...opts, indent: `${indent} ` };
|
|
162
|
+
lines.push(`${indent}switch (String(${disc})) {`);
|
|
163
|
+
for (const [key, caseStatements] of Object.entries(branch.cases ?? {})) {
|
|
164
|
+
lines.push(
|
|
165
|
+
`${indent} case ${JSON.stringify(key)}: {`,
|
|
166
|
+
compileStatements(caseStatements, inner),
|
|
167
|
+
`${indent} break;`,
|
|
168
|
+
`${indent} }`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (branch.default) {
|
|
172
|
+
lines.push(
|
|
173
|
+
`${indent} default: {`,
|
|
174
|
+
compileStatements(branch.default, inner),
|
|
175
|
+
`${indent} }`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
lines.push(`${indent}}`);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "dispatch": {
|
|
182
|
+
const dispatch = statement as {
|
|
183
|
+
dispatchEvent: string;
|
|
184
|
+
detail?: unknown;
|
|
185
|
+
bubbles?: boolean;
|
|
186
|
+
composed?: boolean;
|
|
187
|
+
};
|
|
188
|
+
const eventName = opts.eventParam ?? "event";
|
|
189
|
+
const target = opts.dispatchTarget ?? `(${eventName} && ${eventName}.currentTarget)`;
|
|
190
|
+
const init: string[] = [];
|
|
191
|
+
if ("detail" in dispatch) {
|
|
192
|
+
init.push(`detail: ${compileOperandSource(dispatch.detail, opts)}`);
|
|
193
|
+
}
|
|
194
|
+
if (dispatch.bubbles !== undefined) {
|
|
195
|
+
init.push(`bubbles: ${JSON.stringify(dispatch.bubbles)}`);
|
|
196
|
+
}
|
|
197
|
+
if (dispatch.composed !== undefined) {
|
|
198
|
+
init.push(`composed: ${JSON.stringify(dispatch.composed)}`);
|
|
199
|
+
}
|
|
200
|
+
const initSource = init.length > 0 ? `, { ${init.join(", ")} }` : "";
|
|
201
|
+
lines.push(
|
|
202
|
+
`${indent}${target}?.dispatchEvent(new CustomEvent(${JSON.stringify(dispatch.dispatchEvent)}${initSource}));`,
|
|
203
|
+
);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
default: {
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
}
|