@jxsuite/runtime 1.3.2 → 2.0.2

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
@@ -23,7 +23,8 @@ import {
23
23
  ref,
24
24
  toRaw,
25
25
  } from "@vue/reactivity";
26
- import { evaluateExpression, isMutating } from "./expression.ts";
26
+ import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
27
+ import { readPath } from "./pointer.ts";
27
28
  import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
28
29
  import {
29
30
  bodyReturnsValue,
@@ -33,13 +34,16 @@ import {
33
34
  isJsonObject,
34
35
  isMappedArray,
35
36
  isNamedFormulaDef,
37
+ isPrivateStateKey,
36
38
  isPrototypeDef,
37
39
  isRef as isRefValue,
38
40
  isServerFnDef,
41
+ isTagExpression,
39
42
  isTemplateString,
40
43
  paramNames,
41
44
  } from "@jxsuite/schema/guards";
42
45
  import { runStatements } from "./statements.ts";
46
+ import { readCookie, serializeCookie } from "./cookie.ts";
43
47
  import type {
44
48
  JxAttributeValue,
45
49
  JxClassDef,
@@ -783,8 +787,18 @@ export function renderNode(
783
787
  }
784
788
  }
785
789
 
786
- // Custom element with $props: set JS properties on the element instance
787
- const tagName = def.tagName ?? "div";
790
+ /* RESOLVED ONCE, HERE, before anything branches on it.
791
+ A tag may be a name or a choice between names (`ElementTagName`). Resolving at the top means
792
+ every test below — the hyphen check that routes to the custom-element path, `customElements.get`,
793
+ `createElement` itself — sees a literal, exactly as it did when a tag could only be written out.
794
+
795
+ It is not tracked. `tagName` is in RESERVED_KEYS so no binding sweep reaches it, and a tag that
796
+ changed after mount would mean replacing the node: the subtree's listeners, its focus, its typed
797
+ input values and its component instances all go with it, and this runtime has no dispose walk to
798
+ pay that bill (renderSwitch, two hundred lines up, still leaks its previous case's effects).
799
+ `jx validate` warns when a tag discriminant is also an assignment target, so the case where this
800
+ rule bites is caught before it ships rather than found as a `<div>` that never became an `<a>`. */
801
+ const tagName = resolveTagName(def.tagName, localState);
788
802
  const isCustomEl = tagName.includes("-") && customElements.get(tagName);
789
803
 
790
804
  if (def.$props && isCustomEl) {
@@ -1249,6 +1263,34 @@ function renderMappedArrayInto(
1249
1263
  });
1250
1264
  }
1251
1265
 
1266
+ /**
1267
+ * An element's tag: a name, or the branch of a {@link JxTagExpression} the state selects.
1268
+ *
1269
+ * Every branch of the expression is itself a `TagName`, so this can only ever return something the
1270
+ * schema already held to the tag-name pattern — the property that lets the compiler enumerate the
1271
+ * candidates and lets `createElement` be called without a second thought here.
1272
+ *
1273
+ * @param {unknown} tagName The element's declared tag.
1274
+ * @param {JxScope} scope The state the discriminant reads.
1275
+ * @returns {string} A literal tag name; `"div"` when nothing is declared.
1276
+ */
1277
+ export function resolveTagName(tagName: unknown, scope: JxScope): string {
1278
+ if (typeof tagName === "string") {
1279
+ return tagName;
1280
+ }
1281
+ if (!isTagExpression(tagName)) {
1282
+ return "div";
1283
+ }
1284
+ const expression = tagName.$expression;
1285
+ if (expression.operator === "?:") {
1286
+ // `evaluateOperand`, not `evaluateExpression`: the discriminant is an OPERAND — a pointer, a
1287
+ // Literal or a nested node — exactly as it is for a `$switch`, whose docstring names this.
1288
+ return evaluateOperand(expression.target, scope, null) ? expression.value : expression.initial;
1289
+ }
1290
+ const key = evaluateOperand(expression.target, scope, null);
1291
+ return expression.cases[String(key)] ?? expression.default;
1292
+ }
1293
+
1252
1294
  // ─── $switch ──────────────────────────────────────────────────────────────────
1253
1295
 
1254
1296
  /**
@@ -1259,7 +1301,7 @@ function renderMappedArrayInto(
1259
1301
  */
1260
1302
  function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions) {
1261
1303
  const path = options?._path ?? [];
1262
- const container = document.createElement(def.tagName ?? "div");
1304
+ const container = document.createElement(resolveTagName(def.tagName, state));
1263
1305
 
1264
1306
  if (options?.onNodeCreated) {
1265
1307
  options.onNodeCreated(container, path, def, state);
@@ -1271,7 +1313,11 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1271
1313
  let generation = 0;
1272
1314
 
1273
1315
  effect(() => {
1274
- container.innerHTML = "";
1316
+ /* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
1317
+ Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
1318
+ needs a policy even when the string is empty. Four sinks that were never injecting anything
1319
+ is four fewer things a policy has to be permissive about. */
1320
+ container.replaceChildren();
1275
1321
  if (!isRefObj(def.$switch)) {
1276
1322
  return;
1277
1323
  }
@@ -1283,7 +1329,8 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1283
1329
 
1284
1330
  if (isRefObj(caseDef)) {
1285
1331
  // External $ref — fetch and render asynchronously
1286
- const gen = (generation += 1);
1332
+ generation += 1;
1333
+ const gen = generation;
1287
1334
  const { href } = new URL(caseDef.$ref, location.href);
1288
1335
  resolve(href)
1289
1336
  .then(async (doc) => {
@@ -1294,7 +1341,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
1294
1341
  if (gen !== generation) {
1295
1342
  return;
1296
1343
  }
1297
- container.innerHTML = "";
1344
+ container.replaceChildren();
1298
1345
  const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
1299
1346
  container.append(renderNode(doc, childScope, childOpts));
1300
1347
  })
@@ -1441,38 +1488,21 @@ export async function resolvePrototype(
1441
1488
  case "Cookie": {
1442
1489
  const name = def.name ?? key;
1443
1490
  const read = () => {
1444
- const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
1445
- if (!m) {
1491
+ const raw = readCookie(document.cookie, name);
1492
+ if (raw === null) {
1446
1493
  return null;
1447
1494
  }
1448
1495
  try {
1449
- return JSON.parse(decodeURIComponent(m[1]!));
1496
+ return JSON.parse(decodeURIComponent(raw));
1450
1497
  } catch {
1451
- return m[1];
1498
+ return raw;
1452
1499
  }
1453
1500
  };
1454
1501
  const cookieState: Ref<unknown> = ref(read() ?? def.default ?? null);
1455
1502
  // Persist on change
1456
1503
  effect(() => {
1457
- const v = cookieState.value;
1458
- let s = `${name}=${encodeURIComponent(JSON.stringify(v))}`;
1459
- if (def.maxAge !== undefined) {
1460
- s += `; Max-Age=${def.maxAge}`;
1461
- }
1462
- if (def.path) {
1463
- s += `; Path=${def.path}`;
1464
- }
1465
- if (def.domain) {
1466
- s += `; Domain=${def.domain}`;
1467
- }
1468
- if (def.secure) {
1469
- s += `; Secure`;
1470
- }
1471
- if (def.sameSite) {
1472
- s += `; SameSite=${def.sameSite}`;
1473
- }
1474
1504
  // oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
1475
- document.cookie = s;
1505
+ document.cookie = serializeCookie(name, cookieState.value, def);
1476
1506
  });
1477
1507
  return cookieState;
1478
1508
  }
@@ -2069,15 +2099,16 @@ export function resolveRef(refPath: string, state: JxScope) {
2069
2099
  return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
2070
2100
  }
2071
2101
  if (refPath.startsWith("#/state/")) {
2072
- const sub = refPath.slice("#/state/".length);
2073
- const slash = sub.indexOf("/");
2074
- if (slash === -1) {
2075
- return state[sub];
2076
- }
2077
- return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
2102
+ // One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
2103
+ // `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
2104
+ return readPath(state, refPath.slice("#/state/".length));
2078
2105
  }
2079
2106
  if (refPath.startsWith("parent#/")) {
2080
- return state[refPath.slice("parent#/".length)];
2107
+ /*
2108
+ * A prop name may be a path into the prop. This read the whole path as one key and returned
2109
+ * undefined for `parent#/user/name`, while the compiler lowered it to a walk.
2110
+ */
2111
+ return readPath(state, refPath.slice("parent#/".length));
2081
2112
  }
2082
2113
  if (refPath.startsWith("window#/")) {
2083
2114
  return getPath(globalThis.window, refPath.slice("window#/".length));
@@ -2085,7 +2116,7 @@ export function resolveRef(refPath: string, state: JxScope) {
2085
2116
  if (refPath.startsWith("document#/")) {
2086
2117
  return getPath(globalThis.document, refPath.slice("document#/".length));
2087
2118
  }
2088
- return state[refPath] ?? null;
2119
+ return readPath(state, refPath) ?? null;
2089
2120
  }
2090
2121
 
2091
2122
  // ─── Utilities ────────────────────────────────────────────────────────────────
@@ -2117,16 +2148,51 @@ function isNestedSelector(k: string) {
2117
2148
  }
2118
2149
 
2119
2150
  /**
2151
+ * Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
2152
+ * `/[./]/`, which no other path in the codebase agreed with.
2153
+ *
2120
2154
  * @param {unknown} obj
2121
2155
  * @param {string} path
2122
2156
  * @returns {unknown}
2123
2157
  */
2124
2158
  function getPath(obj: unknown, path: string) {
2125
- let current: unknown = obj;
2126
- for (const k of path.split(/[./]/)) {
2127
- current = (current as Record<string, unknown>)?.[k];
2159
+ return readPath(obj, path);
2160
+ }
2161
+
2162
+ /** Keys already reported, so a component rendered in a loop warns once rather than per instance. */
2163
+ const _privatePropWarned = new Set<string>();
2164
+
2165
+ /**
2166
+ * Refuse a `$props` write against a private (`#`) state key — `spec.md` §5.6.
2167
+ *
2168
+ * Ignored rather than thrown. A `$props` block naming a private entry is an authoring mistake, not
2169
+ * a broken document: the rest of the instance is well-formed and refusing to render it would turn a
2170
+ * typo into a blank page. But silence was the previous behavior and it is the wrong kind — the
2171
+ * author sees a prop that looks accepted and does nothing — so the write is dropped and named.
2172
+ *
2173
+ * @param {string} key
2174
+ * @param {string} [where] - The site refusing it, for a message that points somewhere
2175
+ * @returns {boolean} True when the key is private and the caller must skip it
2176
+ */
2177
+ function refusePrivateProp(key: string, where?: string): boolean {
2178
+ if (!isPrivateStateKey(key)) {
2179
+ return false;
2180
+ }
2181
+ const seen = where === undefined ? key : `${where}:${key}`;
2182
+ if (!_privatePropWarned.has(seen)) {
2183
+ _privatePropWarned.add(seen);
2184
+ console.warn(
2185
+ `Jx: "${key}" is private state (spec.md §5.6) and cannot be set through $props` +
2186
+ `${where === undefined ? "" : ` (${where})`}. The write was ignored; rename the entry ` +
2187
+ "without the leading # to make it part of the component's interface.",
2188
+ );
2128
2189
  }
2129
- return current;
2190
+ return true;
2191
+ }
2192
+
2193
+ /** Tests only — the warn-once set outlives a single render by design. */
2194
+ export function _resetPrivatePropWarnings(): void {
2195
+ _privatePropWarned.clear();
2130
2196
  }
2131
2197
 
2132
2198
  /**
@@ -2137,6 +2203,9 @@ function getPath(obj: unknown, path: string) {
2137
2203
  function mergeProps(def: JxElement, parentState: JxScope): JxScope {
2138
2204
  const child = Object.create(parentState) as JxScope;
2139
2205
  for (const [k, v] of Object.entries(def.$props ?? {})) {
2206
+ if (refusePrivateProp(k, "$props")) {
2207
+ continue;
2208
+ }
2140
2209
  child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
2141
2210
  }
2142
2211
  return child;
@@ -2325,7 +2394,12 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
2325
2394
  el.setAttribute(k, String(v));
2326
2395
  }
2327
2396
  if (entry.textContent) {
2328
- el.textContent = entry.textContent;
2397
+ // An object is a structured data block (JSON-LD, site-architecture.md §8.5). The compiler
2398
+ // Serializes it the same way, so the interpreted page and the built page agree.
2399
+ el.textContent =
2400
+ typeof entry.textContent === "object"
2401
+ ? JSON.stringify(entry.textContent, null, 2)
2402
+ : entry.textContent;
2329
2403
  }
2330
2404
  document.head.append(el);
2331
2405
  }
@@ -2402,6 +2476,9 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2402
2476
  try {
2403
2477
  const props = JSON.parse(propsAttr) as Record<string, unknown>;
2404
2478
  for (const [key, val] of Object.entries(props)) {
2479
+ if (refusePrivateProp(key, "data-jx-props")) {
2480
+ continue;
2481
+ }
2405
2482
  if (key in (def.state ?? {})) {
2406
2483
  state[key] = val;
2407
2484
  }
@@ -2419,6 +2496,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2419
2496
  );
2420
2497
  for (const name of propAttrNames) {
2421
2498
  const key = name.slice("props.".length);
2499
+ if (refusePrivateProp(key, "props.* attribute")) {
2500
+ this.removeAttribute(name);
2501
+ continue;
2502
+ }
2422
2503
  if (key in (def.state ?? {})) {
2423
2504
  state[key] = this.getAttribute(name);
2424
2505
  this.removeAttribute(name);
@@ -2427,12 +2508,22 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2427
2508
 
2428
2509
  // Merge $props set as JS properties by parent before connection
2429
2510
  for (const key of Object.keys(def.state ?? {})) {
2511
+ if (isPrivateStateKey(key)) {
2512
+ continue;
2513
+ }
2430
2514
  if (key in this && (this as Record<string, unknown>)[key] !== undefined) {
2431
2515
  state[key] = (this as Record<string, unknown>)[key];
2432
2516
  }
2433
2517
  }
2434
- // Set up property getters/setters that forward into reactive state
2518
+ /*
2519
+ * Set up property getters/setters that forward into reactive state. Private entries get NO
2520
+ * accessor: the property-first interface IS the props mechanism, so defining one would leave
2521
+ * `el["#cache"] = x` writing straight through everything above.
2522
+ */
2435
2523
  for (const key of Object.keys(def.state ?? {})) {
2524
+ if (isPrivateStateKey(key)) {
2525
+ continue;
2526
+ }
2436
2527
  if (!(key in HTMLElement.prototype)) {
2437
2528
  Object.defineProperty(this, key, {
2438
2529
  configurable: true,
@@ -2448,7 +2539,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2448
2539
 
2449
2540
  // Capture light DOM children (for slot distribution) before rendering
2450
2541
  const slottedChildren = [...this.childNodes];
2451
- this.innerHTML = "";
2542
+ this.replaceChildren();
2452
2543
 
2453
2544
  // Custom elements default to display:inline — use block so they behave as
2454
2545
  // Containers (matching <div> semantics). The component's own style can
@@ -2522,7 +2613,7 @@ function renderCustomElementWithProps(
2522
2613
  options?: JxRenderOptions,
2523
2614
  path?: JxPath,
2524
2615
  ) {
2525
- const el = document.createElement(def.tagName ?? "div");
2616
+ const el = document.createElement(resolveTagName(def.tagName, state));
2526
2617
 
2527
2618
  if (options?.onNodeCreated) {
2528
2619
  options.onNodeCreated(el, path ?? [], def, state);
@@ -2530,6 +2621,9 @@ function renderCustomElementWithProps(
2530
2621
 
2531
2622
  // Set JS properties from $props (before connection)
2532
2623
  for (const [key, val] of Object.entries(def.$props ?? {})) {
2624
+ if (refusePrivateProp(key, "$props")) {
2625
+ continue;
2626
+ }
2533
2627
  if (isRefObj(val)) {
2534
2628
  const refVal = val;
2535
2629
  const resolved = resolveRef(refVal.$ref, state);
@@ -2596,7 +2690,7 @@ function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
2596
2690
  const name = slot.getAttribute("name");
2597
2691
  const matches = name ? (named.get(name) ?? []) : unnamed;
2598
2692
  if (matches.length > 0) {
2599
- slot.innerHTML = "";
2693
+ slot.replaceChildren();
2600
2694
  for (const child of matches) {
2601
2695
  slot.append(child);
2602
2696
  }