@jxsuite/runtime 1.0.0 → 1.2.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/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 (isMutating(node.operator)) {
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 (isFunctionDef(def)) {
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") {
@@ -921,14 +1030,25 @@ export function applyStyle(
921
1030
  if (key === "@--") {
922
1031
  continue;
923
1032
  } // Base canvas width, not a real media query
924
- const atRule = key.startsWith("@--")
925
- ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
1033
+ const query = key.startsWith("@--")
1034
+ ? (mediaQueries[key.slice(1)] ?? key.slice(1))
926
1035
  : key.startsWith("@(")
927
- ? `@media ${key.slice(1)}`
928
- : key;
1036
+ ? key.slice(1)
1037
+ : null;
1038
+ const atRule = query === null ? key : `@media ${query}`;
929
1039
  const scope = `[data-jx="${uid}"]`;
930
- css += `${atRule} { ${scope} { ${toCSSText(rules)} } }\n`;
931
- emitMediaNested(atRule, scope, rules);
1040
+ const scheme = query === null ? null : pureSchemeOf(query);
1041
+ if (scheme) {
1042
+ // Dual-emit scheme rules: a media-guarded copy for auto plus a forced-attribute copy.
1043
+ // The forced root attribute wins over the OS preference.
1044
+ const { auto, forced } = schemeSelectors(scope, scheme);
1045
+ css += `${atRule} { ${auto} { ${toCSSText(rules)} } }\n`;
1046
+ emitMediaNested(atRule, auto, rules);
1047
+ emitNested(forced, rules);
1048
+ } else {
1049
+ css += `${atRule} { ${scope} { ${toCSSText(rules)} } }\n`;
1050
+ emitMediaNested(atRule, scope, rules);
1051
+ }
932
1052
  }
933
1053
 
934
1054
  const tag = document.createElement("style");
@@ -1974,6 +2094,64 @@ export function toCSSText(rules: Record<string, unknown> | object) {
1974
2094
  .join("; ");
1975
2095
  }
1976
2096
 
2097
+ // ─── Color Schemes ────────────────────────────────────────────────────────────
2098
+
2099
+ /**
2100
+ * Attribute on the root element (`<html>`) that forces a color scheme. Absent = auto (follow the OS
2101
+ * `prefers-color-scheme`). Values: "light" | "dark".
2102
+ *
2103
+ * @docs framework/concepts/color-schemes
2104
+ */
2105
+ export const COLOR_SCHEME_ATTR = "data-color-scheme";
2106
+
2107
+ /**
2108
+ * The localStorage key a site switcher persists the visitor's forced scheme under; read by the
2109
+ * compiler-injected pre-paint script. Values: "light" | "dark" (absent = auto).
2110
+ *
2111
+ * @docs framework/concepts/color-schemes
2112
+ */
2113
+ export const COLOR_SCHEME_STORAGE_KEY = "jx-color-scheme";
2114
+
2115
+ /**
2116
+ * The scheme a _pure_ `prefers-color-scheme` media query targets. Compound queries (any other
2117
+ * conditions attached) return null — they are not eligible for forced-scheme dual emission.
2118
+ *
2119
+ * @param {string} query
2120
+ * @returns {"light" | "dark" | null}
2121
+ * @docs framework/concepts/color-schemes
2122
+ */
2123
+ export function pureSchemeOf(query: string): "light" | "dark" | null {
2124
+ const m = /^\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)$/.exec(query.trim());
2125
+ return (m?.[1] as "light" | "dark") ?? null;
2126
+ }
2127
+
2128
+ /**
2129
+ * Selector pair for a scheme-conditional rule: `auto` applies inside the scheme's media query only
2130
+ * while no scheme is forced; `forced` applies unconditionally when the root attribute forces the
2131
+ * scheme. Guards are wrapped in `:where()` so specificity matches the unguarded selector and source
2132
+ * order decides the cascade.
2133
+ *
2134
+ * @param {string} selector
2135
+ * @param {"light" | "dark"} scheme
2136
+ * @returns {{ auto: string; forced: string }}
2137
+ * @docs framework/concepts/color-schemes
2138
+ */
2139
+ export function schemeSelectors(
2140
+ selector: string,
2141
+ scheme: "light" | "dark",
2142
+ ): { auto: string; forced: string } {
2143
+ if (selector === ":root" || selector === "html") {
2144
+ return {
2145
+ auto: `${selector}:where(:not([${COLOR_SCHEME_ATTR}]))`,
2146
+ forced: `${selector}:where([${COLOR_SCHEME_ATTR}="${scheme}"])`,
2147
+ };
2148
+ }
2149
+ return {
2150
+ auto: `:where(:root:not([${COLOR_SCHEME_ATTR}])) ${selector}`,
2151
+ forced: `:where(:root[${COLOR_SCHEME_ATTR}="${scheme}"]) ${selector}`,
2152
+ };
2153
+ }
2154
+
1977
2155
  // ─── Custom Element Registration ──────────────────────────────────────────────
1978
2156
 
1979
2157
  let _rootMedia: Record<string, string> = {};
@@ -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
+ }