@jxsuite/runtime 2.0.4 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/runtime",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "upgrade": "bunx npm-check-updates -u && bun install"
32
32
  },
33
33
  "dependencies": {
34
- "@jxsuite/schema": "^1.8.0",
34
+ "@jxsuite/schema": "^1.9.0",
35
35
  "@vue/reactivity": "3.5.41"
36
36
  },
37
37
  "devDependencies": {
package/src/runtime.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  isTemplateString,
43
43
  paramNames,
44
44
  } from "@jxsuite/schema/guards";
45
+ import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
45
46
  import { runStatements } from "./statements.ts";
46
47
  import { readCookie, serializeCookie } from "./cookie.ts";
47
48
  import type {
@@ -331,6 +332,119 @@ function canvasAttrName(el: HTMLElement, key: string): string {
331
332
  return key;
332
333
  }
333
334
 
335
+ /**
336
+ * Rewrite one asset reference for a host that does not serve the site's own URL space.
337
+ *
338
+ * The runtime writes a reference VERBATIM — `el.src = "/hero.jpg"` — so the browser resolves it
339
+ * against the document, and the document is whatever page the renderer happens to be running in. On
340
+ * a real site and on an editing server that serves the project tree, that is correct and this hook
341
+ * stays null. On a multi-tenant editor origin it is not: `/hero.jpg` resolves against the editor,
342
+ * misses, and — behind a single-page-app fallback — comes back as HTML at HTTP 200, so the image
343
+ * renders broken and nothing is logged.
344
+ *
345
+ * A function rather than data because it cannot be one: the values it must fix are produced INSIDE
346
+ * reactive effects (`{"$ref": "#/state/hero"}` is not a string until the effect runs), so no
347
+ * document walk can see them. The Studio canvas iframe sets this at render time.
348
+ *
349
+ * Returning null leaves the value exactly as written, and a null resolver makes every call site
350
+ * below the identity — so production rendering is byte-identical.
351
+ *
352
+ * @param {CanvasAssetResolver | null} fn - The resolver, or null to restore verbatim rendering
353
+ */
354
+ export type CanvasAssetResolver = (value: string, key: string) => string | null;
355
+
356
+ let _canvasAssetResolver: CanvasAssetResolver | null = null;
357
+ export function setCanvasAssetResolver(fn: CanvasAssetResolver | null) {
358
+ _canvasAssetResolver = fn;
359
+ }
360
+
361
+ /** Attribute/property names whose value is an asset reference, on any element. */
362
+ const ASSET_KEYS = new Set(["src", "poster", "data"]);
363
+
364
+ /** Names whose value is a `srcset`-shaped candidate list rather than one reference. */
365
+ const SRCSET_KEYS = new Set(["srcset", "imagesrcset"]);
366
+
367
+ /**
368
+ * Elements on which `href` names an ASSET rather than a destination.
369
+ *
370
+ * `<a href>` and `<area href>` are places to go, not files to load; rewriting one would send a
371
+ * click at the file that backs the page instead of at the page. `<link href>` is the opposite — a
372
+ * stylesheet, an icon, a preload — and is the reason `$head` needs this at all.
373
+ */
374
+ const HREF_ASSET_TAGS = new Set(["LINK"]);
375
+
376
+ /** True when writing `key` on a `<tagName>` writes an asset reference. */
377
+ function isAssetKey(tagName: string, key: string): boolean {
378
+ return ASSET_KEYS.has(key) || (key === "href" && HREF_ASSET_TAGS.has(tagName));
379
+ }
380
+
381
+ /**
382
+ * The value to actually write for `key` on a `<tagName>` — resolved when it is an asset reference,
383
+ * and the input itself in every other case, including whenever no resolver is installed.
384
+ *
385
+ * `srcset` is split into its candidates and resolved one at a time, because it is N references in
386
+ * one attribute and a resolver handed the whole string would have to re-implement HTML's parser to
387
+ * find them.
388
+ */
389
+ function canvasAssetValue(tagName: string, key: string, value: string): string {
390
+ if (!_canvasAssetResolver || value === "") {
391
+ return value;
392
+ }
393
+ const name = key.toLowerCase();
394
+ if (SRCSET_KEYS.has(name)) {
395
+ const candidates = parseSrcset(value);
396
+ let changed = false;
397
+ const next = candidates.map((candidate) => {
398
+ const resolved = _canvasAssetResolver?.(candidate.url, name) ?? null;
399
+ if (resolved === null || resolved === candidate.url) {
400
+ return candidate;
401
+ }
402
+ changed = true;
403
+ return { descriptor: candidate.descriptor, url: resolved };
404
+ });
405
+ return changed ? formatSrcset(next) : value;
406
+ }
407
+ if (!isAssetKey(tagName, name)) {
408
+ return value;
409
+ }
410
+ return _canvasAssetResolver(value, name) ?? value;
411
+ }
412
+
413
+ /** Every `url(...)` inside a CSS value, unquoted or quoted with either quote. */
414
+ const CSS_URL_RE = /\burl\(\s*(?<q>["']?)(?<u>[^"')]*)\k<q>\s*\)/g;
415
+
416
+ /**
417
+ * Resolve every `url()` inside a CSS value.
418
+ *
419
+ * Applied to CSS rather than to a list of properties because in CSS a `url()` is ALWAYS a resource
420
+ * reference — `background-image`, `mask`, `cursor`, `@font-face src`, `list-style-image` and every
421
+ * shorthand that contains them — so enumerating the properties would be a list that goes stale
422
+ * while the syntax does not.
423
+ */
424
+ function canvasStyleUrls(value: string): string {
425
+ if (!_canvasAssetResolver || !value.includes("url(")) {
426
+ return value;
427
+ }
428
+ return value.replaceAll(CSS_URL_RE, (whole, quote: string, url: string) => {
429
+ const resolved = url === "" ? null : (_canvasAssetResolver?.(url, "url()") ?? null);
430
+ return resolved === null ? whole : `url(${quote}${resolved}${quote})`;
431
+ });
432
+ }
433
+
434
+ /**
435
+ * A style scalar as the canvas wants it written: assets resolved, then viewport units transposed.
436
+ *
437
+ * Exported because the canvas emits some CSS itself — the site-level `style` block, which never
438
+ * passes through {@link applyStyle} — and a second implementation of "what the canvas does to a
439
+ * style value" is a second thing to keep in step.
440
+ *
441
+ * @param {string} value - A CSS declaration value
442
+ * @returns {string} The value as the canvas should write it
443
+ */
444
+ export function canvasStyleValue(value: string): string {
445
+ return transposeCanvasUnits(canvasStyleUrls(value));
446
+ }
447
+
334
448
  /**
335
449
  * Studio-canvas prop-binding markers. When on, `bindProperty` stamps `data-jx-bound-prop="<key>"`
336
450
  * on elements whose `textContent` is an invertible single-key state binding — the pure template
@@ -761,6 +875,32 @@ export const RESERVED_KEYS = new Set([
761
875
  /** Refs already warned about, so the §13 diagnostic fires once per distinct target. */
762
876
  const warnedRefChildren = new Set<string>();
763
877
 
878
+ /** Foreign-content namespaces the HTML parser switches into, and the runtime must too. */
879
+ const SVG_NS = "http://www.w3.org/2000/svg";
880
+ const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
881
+
882
+ /**
883
+ * The namespace an element belongs in, given the one its parent established.
884
+ *
885
+ * The static build emits markup text, so the HTML parser applied foreign-content rules for it and
886
+ * SVG worked there. The runtime built every node with `document.createElement`, which is HTML-only,
887
+ * so the same document rendered blank in the Studio canvas and in any hydrated component — the
888
+ * editor and the deployed site disagreeing, with only the editor wrong.
889
+ *
890
+ * @param {string} tagName
891
+ * @param {string | null | undefined} inherited - The namespace the parent element established
892
+ * @returns {string | null} Null means HTML
893
+ */
894
+ function elementNamespace(tagName: string, inherited: string | null | undefined): string | null {
895
+ if (tagName === "svg") {
896
+ return SVG_NS;
897
+ }
898
+ if (tagName === "math") {
899
+ return MATHML_NS;
900
+ }
901
+ return inherited ?? null;
902
+ }
903
+
764
904
  export function renderNode(
765
905
  def: JxElement | string | number | boolean,
766
906
  state: JxScope,
@@ -843,7 +983,19 @@ export function renderNode(
843
983
  return renderSwitch(def, localState, options);
844
984
  }
845
985
 
846
- const el = document.createElement(tagName);
986
+ const ns = elementNamespace(tagName, options?._ns);
987
+ /*
988
+ * `foreignObject` is itself an SVG element, and is the documented way back into HTML: its
989
+ * DESCENDANTS are HTML, so the element and its children carry different namespaces.
990
+ */
991
+ const childNs = tagName === "foreignObject" ? null : ns;
992
+ /*
993
+ * A namespaced node comes back as Element; every apply* helper below is typed for HTMLElement
994
+ * and touches only members both share (style, dataset, setAttribute, append).
995
+ */
996
+ const el = (ns
997
+ ? document.createElementNS(ns, tagName)
998
+ : document.createElement(tagName)) as unknown as HTMLElement;
847
999
 
848
1000
  if (options?.onNodeCreated) {
849
1001
  options.onNodeCreated(el, path, def, localState);
@@ -862,12 +1014,16 @@ export function renderNode(
862
1014
  if (isMappedArray(kids)) {
863
1015
  // Legacy whole-children repeater: the items render directly into `el` (which keeps its own
864
1016
  // TagName, e.g. <ul>), with no extra wrapper element.
865
- const arrOpts = options ? { ...options, _path: [...path, "children"] } : undefined;
1017
+ const arrOpts =
1018
+ options || childNs ? { ...options, _ns: childNs, _path: [...path, "children"] } : undefined;
866
1019
  renderMappedArrayInto(el, kids, localState, arrOpts);
867
1020
  } else if (Array.isArray(kids)) {
868
1021
  for (let i = 0; i < kids.length; i++) {
869
1022
  const child = kids[i]!;
870
- const childOpts = options ? { ...options, _path: [...path, "children", i] } : undefined;
1023
+ const childOpts =
1024
+ options || childNs
1025
+ ? { ...options, _ns: childNs, _path: [...path, "children", i] }
1026
+ : undefined;
871
1027
  if (isMappedArray(child)) {
872
1028
  // Array pseudo-element among siblings: expand inline, no wrapper.
873
1029
  renderMappedArrayInto(el, child, localState, childOpts);
@@ -957,6 +1113,24 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
957
1113
  if (boundProp) {
958
1114
  el.dataset.jxBoundProp = boundProp;
959
1115
  }
1116
+ /* An `<img>` in a document says `{"tagName": "img", "src": "/hero.jpg"}`, so `src` arrives HERE
1117
+ as a top-level key and is written as a DOM PROPERTY — `attributes: { src }` is the rarer
1118
+ spelling. Both are the same reference and both need the same resolution. */
1119
+ const asAsset = (resolved: unknown): unknown =>
1120
+ typeof resolved === "string" ? canvasAssetValue(el.tagName, key, resolved) : resolved;
1121
+ /*
1122
+ * `className` on an SVGElement is a read-only SVGAnimatedString, so assigning it throws in the
1123
+ * strict mode an ES module always runs under. It is the most common Jx element property, so the
1124
+ * attribute form is the only one that works on both.
1125
+ */
1126
+ const write = (resolved: unknown): void => {
1127
+ const node = el as unknown as Element;
1128
+ if (key === "className" && !(node instanceof HTMLElement)) {
1129
+ node.setAttribute("class", resolved == null ? "" : String(resolved));
1130
+ return;
1131
+ }
1132
+ target[key] = resolved;
1133
+ };
960
1134
  if (isRefObj(val)) {
961
1135
  const refVal = val as { $ref: string };
962
1136
  if (key === "id") {
@@ -964,7 +1138,7 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
964
1138
  return;
965
1139
  }
966
1140
  effect(() => {
967
- target[key] = resolveRef(refVal.$ref, state);
1141
+ write(asAsset(resolveRef(refVal.$ref, state)));
968
1142
  });
969
1143
  return;
970
1144
  }
@@ -972,12 +1146,12 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
972
1146
  // Universal ${} reactivity — template strings in element properties
973
1147
  if (isTemplateString(val)) {
974
1148
  effect(() => {
975
- target[key] = evaluateTemplate(val as string, state);
1149
+ write(asAsset(evaluateTemplate(val as string, state)));
976
1150
  });
977
1151
  return;
978
1152
  }
979
1153
 
980
- target[key] = val;
1154
+ write(asAsset(val));
981
1155
  }
982
1156
 
983
1157
  /**
@@ -1038,22 +1212,22 @@ export function applyStyle(
1038
1212
  if (prop.startsWith("--")) {
1039
1213
  if (isTemplateString(val)) {
1040
1214
  effect(() => {
1041
- el.style.setProperty(prop, transposeCanvasUnits(evaluateTemplate(val, state)));
1215
+ el.style.setProperty(prop, canvasStyleValue(evaluateTemplate(val, state)));
1042
1216
  });
1043
1217
  } else {
1044
- el.style.setProperty(prop, transposeCanvasUnits(scalar));
1218
+ el.style.setProperty(prop, canvasStyleValue(scalar));
1045
1219
  }
1046
1220
  } else if (isTemplateString(val)) {
1047
1221
  effect(() => {
1048
- (el.style as unknown as Record<string, string>)[prop] = transposeCanvasUnits(
1222
+ (el.style as unknown as Record<string, string>)[prop] = canvasStyleValue(
1049
1223
  evaluateTemplate(val, state),
1050
1224
  );
1051
1225
  });
1052
1226
  } else if (mediaOverriddenProps.has(prop)) {
1053
- // Goes through toCSSText (which transposes) — don't double-transpose here.
1227
+ // Goes through toCSSText (which resolves and transposes) — don't do either twice here.
1054
1228
  baseDecls[prop] = scalar;
1055
1229
  } else {
1056
- (el.style as unknown as Record<string, string>)[prop] = transposeCanvasUnits(scalar);
1230
+ (el.style as unknown as Record<string, string>)[prop] = canvasStyleValue(scalar);
1057
1231
  }
1058
1232
  }
1059
1233
 
@@ -1134,11 +1308,7 @@ export function applyStyle(
1134
1308
  if (key === "@--") {
1135
1309
  continue;
1136
1310
  } // Base canvas width, not a real media query
1137
- const query = key.startsWith("@--")
1138
- ? (mediaQueries[key.slice(1)] ?? key.slice(1))
1139
- : key.startsWith("@(")
1140
- ? key.slice(1)
1141
- : null;
1311
+ const query = resolveAtQuery(key, mediaQueries);
1142
1312
  const atRule = query === null ? key : `@media ${query}`;
1143
1313
  const scope = `[data-jx="${uid}"]`;
1144
1314
  const scheme = query === null ? null : pureSchemeOf(query);
@@ -1201,12 +1371,17 @@ export function reapplyStyle(
1201
1371
  function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue>, state: JxScope) {
1202
1372
  for (const [k, v] of Object.entries(attrs)) {
1203
1373
  const attr = canvasAttrName(el, k);
1374
+ /* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
1375
+ effect runs — which is exactly why the document walk this replaced could never see it. */
1376
+ const write = (resolved: unknown) => {
1377
+ el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved ?? "")));
1378
+ };
1204
1379
  if (isRefObj(v)) {
1205
- effect(() => el.setAttribute(attr, String(resolveRef(v.$ref, state) ?? "")));
1380
+ effect(() => write(resolveRef(v.$ref, state)));
1206
1381
  } else if (isTemplateString(v)) {
1207
- effect(() => el.setAttribute(attr, String(evaluateTemplate(v, state))));
1382
+ effect(() => write(evaluateTemplate(v, state)));
1208
1383
  } else {
1209
- el.setAttribute(attr, String(v));
1384
+ write(v);
1210
1385
  }
1211
1386
  }
1212
1387
  }
@@ -2282,10 +2457,43 @@ export function camelToKebab(s: string) {
2282
2457
  export function toCSSText(rules: Record<string, unknown> | object) {
2283
2458
  return Object.entries(rules)
2284
2459
  .filter(([k, v]) => !isNestedSelector(k) && (v === null || typeof v !== "object"))
2285
- .map(([p, v]) => `${camelToKebab(p)}: ${transposeCanvasUnits(String(v))}`)
2460
+ .map(([p, v]) => `${camelToKebab(p)}: ${canvasStyleValue(String(v))}`)
2286
2461
  .join("; ");
2287
2462
  }
2288
2463
 
2464
+ // ─── Style At-Rules ───────────────────────────────────────────────────────────
2465
+
2466
+ /**
2467
+ * The four CSS media types. A media _type_ is bare — `@media print` — while a media _feature_ is
2468
+ * parenthesised, so `@media (print)` reads as a boolean feature named `print`, which does not exist
2469
+ * and evaluates false.
2470
+ */
2471
+ const MEDIA_TYPES = new Set(["all", "print", "screen", "speech"]);
2472
+
2473
+ /**
2474
+ * The media query an `@`-prefixed style key names, or null when the key is some other at-rule
2475
+ * (`@supports`, `@starting-style`) that is emitted verbatim.
2476
+ *
2477
+ * `@--name` resolves through the project's `$media` map. `@(…)` carries its own parentheses, so
2478
+ * they are stripped only when what they wrap is a bare media type: `@(print)` must become `@media
2479
+ * print`, while `@(min-width: 40rem)` keeps them.
2480
+ *
2481
+ * @param {string} atKey
2482
+ * @param {Record<string, string>} mediaQueries
2483
+ * @returns {string | null}
2484
+ * @docs framework/concepts/styling
2485
+ */
2486
+ export function resolveAtQuery(atKey: string, mediaQueries: Record<string, string>): string | null {
2487
+ if (atKey.startsWith("@--")) {
2488
+ return mediaQueries[atKey.slice(1)] ?? atKey.slice(1);
2489
+ }
2490
+ if (atKey.startsWith("@(")) {
2491
+ const inner = atKey.slice(2, -1).trim();
2492
+ return MEDIA_TYPES.has(inner.toLowerCase()) ? inner : atKey.slice(1);
2493
+ }
2494
+ return null;
2495
+ }
2496
+
2289
2497
  // ─── Color Schemes ────────────────────────────────────────────────────────────
2290
2498
 
2291
2499
  /**
@@ -2424,15 +2632,17 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
2424
2632
  // Resolve href/src: bare npm specifiers -> /node_modules/ path
2425
2633
  for (const key of ["href", "src"] as const) {
2426
2634
  const v = attrs[key];
2427
- if (
2428
- typeof v === "string" &&
2429
- v &&
2430
- !v.startsWith("/") &&
2431
- !v.startsWith(".") &&
2432
- !v.startsWith("http")
2433
- ) {
2635
+ if (typeof v !== "string" || v === "") {
2636
+ continue;
2637
+ }
2638
+ if (!v.startsWith("/") && !v.startsWith(".") && !v.startsWith("http")) {
2639
+ /* The bare-specifier lane owns `/node_modules/<pkg>`, which is the HOST's URL space and
2640
+ not the project's. A canvas resolver must never claim it: the file is not in the
2641
+ repository, so there is nothing for it to resolve to. */
2434
2642
  attrs[key] = `/node_modules/${v}`;
2643
+ continue;
2435
2644
  }
2645
+ attrs[key] = canvasAssetValue(tag.toUpperCase(), key, v);
2436
2646
  }
2437
2647
 
2438
2648
  // Deduplicate: skip if an identical element already exists
@@ -2522,7 +2732,8 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2522
2732
 
2523
2733
  const state = await buildScope(def, {}, base);
2524
2734
 
2525
- // Read properties from directive encoding (markdown → data-jx-props)
2735
+ // Read properties from the data-jx-props payload the site build writes on a
2736
+ // Non-static instance, so an upgrade re-renders with the authored props, not the defaults.
2526
2737
  const propsAttr = this.dataset.jxProps;
2527
2738
  if (propsAttr) {
2528
2739
  try {
@@ -2608,6 +2819,14 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2608
2819
  applyStyle(this, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
2609
2820
  applyAttributes(this, def.attributes ?? {}, state);
2610
2821
 
2822
+ /*
2823
+ * Root-level `textContent` is a definition's content just as much as `children` is — it is
2824
+ * the shape spec.md §17.2 recommends when every child would be a bare string — and it was
2825
+ * dropped here, so such a component rendered empty everywhere the interpreter runs.
2826
+ */
2827
+ if (!Array.isArray(def.children) && def.textContent !== undefined) {
2828
+ bindProperty(this, "textContent", def.textContent, state);
2829
+ }
2611
2830
  const children = Array.isArray(def.children) ? def.children : [];
2612
2831
  for (const childDef of children) {
2613
2832
  this.append(renderNode(childDef, state));
@@ -2680,20 +2899,27 @@ function renderCustomElementWithProps(
2680
2899
  if (refusePrivateProp(key, "$props")) {
2681
2900
  continue;
2682
2901
  }
2902
+ /* The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
2903
+ element is an asset reference authored at the USAGE site, so it belongs to the document that
2904
+ wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
2905
+ inside the component's render, against the same context. */
2906
+ const write = (resolved: unknown): void => {
2907
+ (el as unknown as Record<string, unknown>)[key] =
2908
+ typeof resolved === "string" ? canvasAssetValue(el.tagName, key, resolved) : resolved;
2909
+ };
2683
2910
  if (isRefObj(val)) {
2684
2911
  const refVal = val;
2685
- const resolved = resolveRef(refVal.$ref, state);
2686
- (el as unknown as Record<string, unknown>)[key] = resolved;
2912
+ write(resolveRef(refVal.$ref, state));
2687
2913
  // Reactive forwarding: re-set the property when the source changes
2688
2914
  effect(() => {
2689
- (el as unknown as Record<string, unknown>)[key] = resolveRef(refVal.$ref, state);
2915
+ write(resolveRef(refVal.$ref, state));
2690
2916
  });
2691
2917
  } else if (isTemplateString(val)) {
2692
2918
  effect(() => {
2693
- (el as unknown as Record<string, unknown>)[key] = evaluateTemplate(val, state);
2919
+ write(evaluateTemplate(val, state));
2694
2920
  });
2695
2921
  } else {
2696
- (el as unknown as Record<string, unknown>)[key] = val;
2922
+ write(val);
2697
2923
  }
2698
2924
  }
2699
2925
 
package/src/types.ts CHANGED
@@ -15,6 +15,8 @@ export type JxEventHandler = (scope: JxScope, event: Event) => unknown;
15
15
 
16
16
  export interface JxRenderOptions {
17
17
  _path?: JxPath;
18
+ /** The namespace the parent element established — SVG and MathML descendants inherit it. */
19
+ _ns?: string | null;
18
20
  /**
19
21
  * Called for each created node. `state` is the local scope the node's children render with —
20
22
  * callers can capture it to re-render a subtree in isolation later.