@jxsuite/runtime 2.0.5 → 3.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": "2.0.5",
3
+ "version": "3.0.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,7 +18,8 @@
18
18
  "./expression": "./src/expression.ts",
19
19
  "./pointer": "./src/pointer.ts",
20
20
  "./statements": "./src/statements.ts",
21
- "./types": "./src/types.ts"
21
+ "./types": "./src/types.ts",
22
+ "./dist/runtime.js": "./dist/runtime.js"
22
23
  },
23
24
  "publishConfig": {
24
25
  "provenance": true
@@ -28,13 +29,13 @@
28
29
  "build:metafile": "bun build ./src/runtime.ts --outdir dist --target browser --sourcemap=linked --metafile=metafile.json",
29
30
  "test": "bun test --isolate",
30
31
  "test:coverage": "bun test --isolate --coverage",
31
- "upgrade": "bunx npm-check-updates -u && bun install"
32
+ "upgrade": "bun update --latest && bun install"
32
33
  },
33
34
  "dependencies": {
34
- "@jxsuite/schema": "^1.8.1",
35
+ "@jxsuite/schema": "^2.0.0",
35
36
  "@vue/reactivity": "3.5.41"
36
37
  },
37
38
  "devDependencies": {
38
- "@happy-dom/global-registrator": "^20.11.6"
39
+ "@happy-dom/global-registrator": "^20.11.8"
39
40
  }
40
41
  }
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 {
@@ -102,7 +103,11 @@ export async function Jx(
102
103
  target: HTMLElement = document.body,
103
104
  options?: JxRenderOptions,
104
105
  ) {
105
- const base = typeof source === "string" ? new URL(source, location.href).href : location.href;
106
+ const base = options?.base
107
+ ? new URL(options.base, location.href).href
108
+ : typeof source === "string"
109
+ ? new URL(source, location.href).href
110
+ : location.href;
106
111
  const doc = await resolve(source);
107
112
  checkSchemaVersion(doc.$schema);
108
113
 
@@ -331,6 +336,119 @@ function canvasAttrName(el: HTMLElement, key: string): string {
331
336
  return key;
332
337
  }
333
338
 
339
+ /**
340
+ * Rewrite one asset reference for a host that does not serve the site's own URL space.
341
+ *
342
+ * The runtime writes a reference VERBATIM — `el.src = "/hero.jpg"` — so the browser resolves it
343
+ * against the document, and the document is whatever page the renderer happens to be running in. On
344
+ * a real site and on an editing server that serves the project tree, that is correct and this hook
345
+ * stays null. On a multi-tenant editor origin it is not: `/hero.jpg` resolves against the editor,
346
+ * misses, and — behind a single-page-app fallback — comes back as HTML at HTTP 200, so the image
347
+ * renders broken and nothing is logged.
348
+ *
349
+ * A function rather than data because it cannot be one: the values it must fix are produced INSIDE
350
+ * reactive effects (`{"$ref": "#/state/hero"}` is not a string until the effect runs), so no
351
+ * document walk can see them. The Studio canvas iframe sets this at render time.
352
+ *
353
+ * Returning null leaves the value exactly as written, and a null resolver makes every call site
354
+ * below the identity — so production rendering is byte-identical.
355
+ *
356
+ * @param {CanvasAssetResolver | null} fn - The resolver, or null to restore verbatim rendering
357
+ */
358
+ export type CanvasAssetResolver = (value: string, key: string) => string | null;
359
+
360
+ let _canvasAssetResolver: CanvasAssetResolver | null = null;
361
+ export function setCanvasAssetResolver(fn: CanvasAssetResolver | null) {
362
+ _canvasAssetResolver = fn;
363
+ }
364
+
365
+ /** Attribute/property names whose value is an asset reference, on any element. */
366
+ const ASSET_KEYS = new Set(["src", "poster", "data"]);
367
+
368
+ /** Names whose value is a `srcset`-shaped candidate list rather than one reference. */
369
+ const SRCSET_KEYS = new Set(["srcset", "imagesrcset"]);
370
+
371
+ /**
372
+ * Elements on which `href` names an ASSET rather than a destination.
373
+ *
374
+ * `<a href>` and `<area href>` are places to go, not files to load; rewriting one would send a
375
+ * click at the file that backs the page instead of at the page. `<link href>` is the opposite — a
376
+ * stylesheet, an icon, a preload — and is the reason `$head` needs this at all.
377
+ */
378
+ const HREF_ASSET_TAGS = new Set(["LINK"]);
379
+
380
+ /** True when writing `key` on a `<tagName>` writes an asset reference. */
381
+ function isAssetKey(tagName: string, key: string): boolean {
382
+ return ASSET_KEYS.has(key) || (key === "href" && HREF_ASSET_TAGS.has(tagName));
383
+ }
384
+
385
+ /**
386
+ * The value to actually write for `key` on a `<tagName>` — resolved when it is an asset reference,
387
+ * and the input itself in every other case, including whenever no resolver is installed.
388
+ *
389
+ * `srcset` is split into its candidates and resolved one at a time, because it is N references in
390
+ * one attribute and a resolver handed the whole string would have to re-implement HTML's parser to
391
+ * find them.
392
+ */
393
+ function canvasAssetValue(tagName: string, key: string, value: string): string {
394
+ if (!_canvasAssetResolver || value === "") {
395
+ return value;
396
+ }
397
+ const name = key.toLowerCase();
398
+ if (SRCSET_KEYS.has(name)) {
399
+ const candidates = parseSrcset(value);
400
+ let changed = false;
401
+ const next = candidates.map((candidate) => {
402
+ const resolved = _canvasAssetResolver?.(candidate.url, name) ?? null;
403
+ if (resolved === null || resolved === candidate.url) {
404
+ return candidate;
405
+ }
406
+ changed = true;
407
+ return { descriptor: candidate.descriptor, url: resolved };
408
+ });
409
+ return changed ? formatSrcset(next) : value;
410
+ }
411
+ if (!isAssetKey(tagName, name)) {
412
+ return value;
413
+ }
414
+ return _canvasAssetResolver(value, name) ?? value;
415
+ }
416
+
417
+ /** Every `url(...)` inside a CSS value, unquoted or quoted with either quote. */
418
+ const CSS_URL_RE = /\burl\(\s*(?<q>["']?)(?<u>[^"')]*)\k<q>\s*\)/g;
419
+
420
+ /**
421
+ * Resolve every `url()` inside a CSS value.
422
+ *
423
+ * Applied to CSS rather than to a list of properties because in CSS a `url()` is ALWAYS a resource
424
+ * reference — `background-image`, `mask`, `cursor`, `@font-face src`, `list-style-image` and every
425
+ * shorthand that contains them — so enumerating the properties would be a list that goes stale
426
+ * while the syntax does not.
427
+ */
428
+ function canvasStyleUrls(value: string): string {
429
+ if (!_canvasAssetResolver || !value.includes("url(")) {
430
+ return value;
431
+ }
432
+ return value.replaceAll(CSS_URL_RE, (whole, quote: string, url: string) => {
433
+ const resolved = url === "" ? null : (_canvasAssetResolver?.(url, "url()") ?? null);
434
+ return resolved === null ? whole : `url(${quote}${resolved}${quote})`;
435
+ });
436
+ }
437
+
438
+ /**
439
+ * A style scalar as the canvas wants it written: assets resolved, then viewport units transposed.
440
+ *
441
+ * Exported because the canvas emits some CSS itself — the site-level `style` block, which never
442
+ * passes through {@link applyStyle} — and a second implementation of "what the canvas does to a
443
+ * style value" is a second thing to keep in step.
444
+ *
445
+ * @param {string} value - A CSS declaration value
446
+ * @returns {string} The value as the canvas should write it
447
+ */
448
+ export function canvasStyleValue(value: string): string {
449
+ return transposeCanvasUnits(canvasStyleUrls(value));
450
+ }
451
+
334
452
  /**
335
453
  * Studio-canvas prop-binding markers. When on, `bindProperty` stamps `data-jx-bound-prop="<key>"`
336
454
  * on elements whose `textContent` is an invertible single-key state binding — the pure template
@@ -597,6 +715,71 @@ function evaluateTemplate(str: string, state: JxScope): string {
597
715
  return fn(state, $map, $map?.item, $map?.index);
598
716
  }
599
717
 
718
+ /**
719
+ * Whether a template is ONE expression end to end — `"${a.b}"` rather than `"a ${b}"` or
720
+ * `"${a}${b}"`.
721
+ *
722
+ * Depth-counted rather than pattern-matched. A greedy `/^\$\{(.+)\}$/` also matched `"${a} /
723
+ * ${b}"`, spliced the interior into `return (a} / ${b)`, and the SyntaxError became a silent null:
724
+ * the node rendered empty and a `$head` entry shipped its own template text. A brace scan is exact
725
+ * where a tightened regex is not — `"${`${a}-x`}"` is still one expression, and must keep its raw
726
+ * value.
727
+ *
728
+ * Lives here rather than in the compiler because both renderers must draw the line in the same
729
+ * place. It is what decides whether a value keeps its own type, and a value that is a boolean in
730
+ * one renderer and the string `"false"` in the other is a page that changes meaning as it
731
+ * hydrates.
732
+ *
733
+ * @param {string} str - Template source
734
+ * @returns {boolean} True when the whole string is a single `${…}`
735
+ */
736
+ export function isSingleExpression(str: string): boolean {
737
+ if (!str.startsWith("${") || !str.endsWith("}")) {
738
+ return false;
739
+ }
740
+ let depth = 0;
741
+ for (let i = 1; i < str.length; i += 1) {
742
+ const c = str[i];
743
+ if (c === "{") {
744
+ depth += 1;
745
+ } else if (c === "}") {
746
+ depth -= 1;
747
+ if (depth === 0) {
748
+ return i === str.length - 1;
749
+ }
750
+ }
751
+ }
752
+ return false;
753
+ }
754
+
755
+ /**
756
+ * Evaluate a template for an ATTRIBUTE, keeping the expression's own type.
757
+ *
758
+ * `evaluateTemplate` interpolates into a string, which is what text wants and what an attribute
759
+ * cannot afford: `"${state.expanded}"` has to arrive as the boolean `false`, or `booleanAttrValue`
760
+ * never sees a boolean, `open="false"` is written, and HTML reads that as open. Only a single
761
+ * expression can carry a type at all — `"a ${b}"` is text by construction — which is the same line
762
+ * `evaluateStaticTemplate` draws in the compiler, and drawing it identically is what keeps a
763
+ * prerendered page and the same document rendered live in agreement.
764
+ *
765
+ * @param {string} str - Template source
766
+ * @param {JxScope} state - Reactive scope
767
+ * @returns {unknown} The expression's value, or the interpolated string
768
+ */
769
+ function evaluateAttrTemplate(str: string, state: JxScope): unknown {
770
+ if (!isSingleExpression(str)) {
771
+ return evaluateTemplate(str, state);
772
+ }
773
+ const $map = state?.$map as { item?: unknown; index?: number } | undefined;
774
+ const fn = new Function("state", "$map", "item", "index", `return (${str.slice(2, -1)})`) as (
775
+ state: JxScope,
776
+ $map: unknown,
777
+ item: unknown,
778
+ index: number | undefined,
779
+ ) => unknown;
780
+ return fn(state, $map, $map?.item, $map?.index);
781
+ }
782
+
600
783
  // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
601
784
 
602
785
  /** Shape of a dynamically imported module: named exports plus an optional default. */
@@ -761,6 +944,32 @@ export const RESERVED_KEYS = new Set([
761
944
  /** Refs already warned about, so the §13 diagnostic fires once per distinct target. */
762
945
  const warnedRefChildren = new Set<string>();
763
946
 
947
+ /** Foreign-content namespaces the HTML parser switches into, and the runtime must too. */
948
+ const SVG_NS = "http://www.w3.org/2000/svg";
949
+ const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
950
+
951
+ /**
952
+ * The namespace an element belongs in, given the one its parent established.
953
+ *
954
+ * The static build emits markup text, so the HTML parser applied foreign-content rules for it and
955
+ * SVG worked there. The runtime built every node with `document.createElement`, which is HTML-only,
956
+ * so the same document rendered blank in the Studio canvas and in any hydrated component — the
957
+ * editor and the deployed site disagreeing, with only the editor wrong.
958
+ *
959
+ * @param {string} tagName
960
+ * @param {string | null | undefined} inherited - The namespace the parent element established
961
+ * @returns {string | null} Null means HTML
962
+ */
963
+ function elementNamespace(tagName: string, inherited: string | null | undefined): string | null {
964
+ if (tagName === "svg") {
965
+ return SVG_NS;
966
+ }
967
+ if (tagName === "math") {
968
+ return MATHML_NS;
969
+ }
970
+ return inherited ?? null;
971
+ }
972
+
764
973
  export function renderNode(
765
974
  def: JxElement | string | number | boolean,
766
975
  state: JxScope,
@@ -843,7 +1052,19 @@ export function renderNode(
843
1052
  return renderSwitch(def, localState, options);
844
1053
  }
845
1054
 
846
- const el = document.createElement(tagName);
1055
+ const ns = elementNamespace(tagName, options?._ns);
1056
+ /*
1057
+ * `foreignObject` is itself an SVG element, and is the documented way back into HTML: its
1058
+ * DESCENDANTS are HTML, so the element and its children carry different namespaces.
1059
+ */
1060
+ const childNs = tagName === "foreignObject" ? null : ns;
1061
+ /*
1062
+ * A namespaced node comes back as Element; every apply* helper below is typed for HTMLElement
1063
+ * and touches only members both share (style, dataset, setAttribute, append).
1064
+ */
1065
+ const el = (ns
1066
+ ? document.createElementNS(ns, tagName)
1067
+ : document.createElement(tagName)) as unknown as HTMLElement;
847
1068
 
848
1069
  if (options?.onNodeCreated) {
849
1070
  options.onNodeCreated(el, path, def, localState);
@@ -862,12 +1083,16 @@ export function renderNode(
862
1083
  if (isMappedArray(kids)) {
863
1084
  // Legacy whole-children repeater: the items render directly into `el` (which keeps its own
864
1085
  // TagName, e.g. <ul>), with no extra wrapper element.
865
- const arrOpts = options ? { ...options, _path: [...path, "children"] } : undefined;
1086
+ const arrOpts =
1087
+ options || childNs ? { ...options, _ns: childNs, _path: [...path, "children"] } : undefined;
866
1088
  renderMappedArrayInto(el, kids, localState, arrOpts);
867
1089
  } else if (Array.isArray(kids)) {
868
1090
  for (let i = 0; i < kids.length; i++) {
869
1091
  const child = kids[i]!;
870
- const childOpts = options ? { ...options, _path: [...path, "children", i] } : undefined;
1092
+ const childOpts =
1093
+ options || childNs
1094
+ ? { ...options, _ns: childNs, _path: [...path, "children", i] }
1095
+ : undefined;
871
1096
  if (isMappedArray(child)) {
872
1097
  // Array pseudo-element among siblings: expand inline, no wrapper.
873
1098
  renderMappedArrayInto(el, child, localState, childOpts);
@@ -957,6 +1182,24 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
957
1182
  if (boundProp) {
958
1183
  el.dataset.jxBoundProp = boundProp;
959
1184
  }
1185
+ /* An `<img>` in a document says `{"tagName": "img", "src": "/hero.jpg"}`, so `src` arrives HERE
1186
+ as a top-level key and is written as a DOM PROPERTY — `attributes: { src }` is the rarer
1187
+ spelling. Both are the same reference and both need the same resolution. */
1188
+ const asAsset = (resolved: unknown): unknown =>
1189
+ typeof resolved === "string" ? canvasAssetValue(el.tagName, key, resolved) : resolved;
1190
+ /*
1191
+ * `className` on an SVGElement is a read-only SVGAnimatedString, so assigning it throws in the
1192
+ * strict mode an ES module always runs under. It is the most common Jx element property, so the
1193
+ * attribute form is the only one that works on both.
1194
+ */
1195
+ const write = (resolved: unknown): void => {
1196
+ const node = el as unknown as Element;
1197
+ if (key === "className" && !(node instanceof HTMLElement)) {
1198
+ node.setAttribute("class", resolved == null ? "" : String(resolved));
1199
+ return;
1200
+ }
1201
+ target[key] = resolved;
1202
+ };
960
1203
  if (isRefObj(val)) {
961
1204
  const refVal = val as { $ref: string };
962
1205
  if (key === "id") {
@@ -964,7 +1207,7 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
964
1207
  return;
965
1208
  }
966
1209
  effect(() => {
967
- target[key] = resolveRef(refVal.$ref, state);
1210
+ write(asAsset(resolveRef(refVal.$ref, state)));
968
1211
  });
969
1212
  return;
970
1213
  }
@@ -972,12 +1215,12 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope
972
1215
  // Universal ${} reactivity — template strings in element properties
973
1216
  if (isTemplateString(val)) {
974
1217
  effect(() => {
975
- target[key] = evaluateTemplate(val as string, state);
1218
+ write(asAsset(evaluateTemplate(val as string, state)));
976
1219
  });
977
1220
  return;
978
1221
  }
979
1222
 
980
- target[key] = val;
1223
+ write(asAsset(val));
981
1224
  }
982
1225
 
983
1226
  /**
@@ -1038,22 +1281,22 @@ export function applyStyle(
1038
1281
  if (prop.startsWith("--")) {
1039
1282
  if (isTemplateString(val)) {
1040
1283
  effect(() => {
1041
- el.style.setProperty(prop, transposeCanvasUnits(evaluateTemplate(val, state)));
1284
+ el.style.setProperty(prop, canvasStyleValue(evaluateTemplate(val, state)));
1042
1285
  });
1043
1286
  } else {
1044
- el.style.setProperty(prop, transposeCanvasUnits(scalar));
1287
+ el.style.setProperty(prop, canvasStyleValue(scalar));
1045
1288
  }
1046
1289
  } else if (isTemplateString(val)) {
1047
1290
  effect(() => {
1048
- (el.style as unknown as Record<string, string>)[prop] = transposeCanvasUnits(
1291
+ (el.style as unknown as Record<string, string>)[prop] = canvasStyleValue(
1049
1292
  evaluateTemplate(val, state),
1050
1293
  );
1051
1294
  });
1052
1295
  } else if (mediaOverriddenProps.has(prop)) {
1053
- // Goes through toCSSText (which transposes) — don't double-transpose here.
1296
+ // Goes through toCSSText (which resolves and transposes) — don't do either twice here.
1054
1297
  baseDecls[prop] = scalar;
1055
1298
  } else {
1056
- (el.style as unknown as Record<string, string>)[prop] = transposeCanvasUnits(scalar);
1299
+ (el.style as unknown as Record<string, string>)[prop] = canvasStyleValue(scalar);
1057
1300
  }
1058
1301
  }
1059
1302
 
@@ -1134,11 +1377,7 @@ export function applyStyle(
1134
1377
  if (key === "@--") {
1135
1378
  continue;
1136
1379
  } // 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;
1380
+ const query = resolveAtQuery(key, mediaQueries);
1142
1381
  const atRule = query === null ? key : `@media ${query}`;
1143
1382
  const scope = `[data-jx="${uid}"]`;
1144
1383
  const scheme = query === null ? null : pureSchemeOf(query);
@@ -1193,6 +1432,45 @@ export function reapplyStyle(
1193
1432
  applyStyle(el, styleDef ?? {}, mediaQueries, state);
1194
1433
  }
1195
1434
 
1435
+ /*
1436
+ * HTML carries a boolean two incompatible ways, and writing one as the other inverts it in silence.
1437
+ *
1438
+ * A *boolean attribute* — `open`, `disabled`, `hidden`, `required` — is read by PRESENCE. Any value
1439
+ * at all counts as true, `"false"` included, so `<details open="false">` is an OPEN `<details>`.
1440
+ * The value must never be written; absence is the only way to say false.
1441
+ *
1442
+ * An *enumerated* attribute carries the word in its own text and reads an empty value as unset, so
1443
+ * the presence form is the broken one here: bare `aria-hidden` is NOT hidden, and a dropped
1444
+ * `contenteditable` means "inherit from the parent" rather than `false`. Every `aria-*` is in this
1445
+ * family — ARIA has no presence attributes at all — along with the three HTML attributes below.
1446
+ *
1447
+ * @docs framework/concepts/elements
1448
+ */
1449
+ const ENUMERATED_ATTRS = new Set(["contenteditable", "draggable", "spellcheck"]);
1450
+
1451
+ /**
1452
+ * The text an attribute's boolean value must be written with, or `null` when saying it means
1453
+ * removing the attribute instead.
1454
+ *
1455
+ * An empty string is the bare form: `setAttribute(name, "")` is how the DOM spells the `open` that
1456
+ * HTML source writes with no `=`.
1457
+ *
1458
+ * Shared rather than restated because two renderers must agree. The compiler writes attributes into
1459
+ * a string of HTML and this file writes them onto live elements; were they to disagree about which
1460
+ * family an attribute belongs to, a prerendered page would change meaning as it hydrated.
1461
+ *
1462
+ * @param {string} name - Attribute name, already resolved to what will be written
1463
+ * @param {boolean} value - The resolved boolean
1464
+ * @returns {string | null} Text to write, or null to omit the attribute
1465
+ */
1466
+ export function booleanAttrValue(name: string, value: boolean): string | null {
1467
+ const lower = name.toLowerCase();
1468
+ if (lower.startsWith("aria-") || ENUMERATED_ATTRS.has(lower)) {
1469
+ return String(value);
1470
+ }
1471
+ return value ? "" : null;
1472
+ }
1473
+
1196
1474
  /**
1197
1475
  * @param {HTMLElement} el
1198
1476
  * @param {Record<string, import("@jxsuite/schema/types").JxAttributeValue>} attrs
@@ -1201,12 +1479,30 @@ export function reapplyStyle(
1201
1479
  function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue>, state: JxScope) {
1202
1480
  for (const [k, v] of Object.entries(attrs)) {
1203
1481
  const attr = canvasAttrName(el, k);
1482
+ /* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
1483
+ effect runs — which is exactly why the document walk this replaced could never see it. */
1484
+ const write = (resolved: unknown) => {
1485
+ if (typeof resolved === "boolean") {
1486
+ const text = booleanAttrValue(attr, resolved);
1487
+ /* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
1488
+ take the attribute WITH it: the element is being re-used, so anything left behind is the
1489
+ state the page just said it had left. Asset rewriting is skipped because a boolean names
1490
+ no asset — `canvasAssetValue` reads `src`/`href` values, which are never booleans. */
1491
+ if (text === null) {
1492
+ el.removeAttribute(attr);
1493
+ } else {
1494
+ el.setAttribute(attr, text);
1495
+ }
1496
+ return;
1497
+ }
1498
+ el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved ?? "")));
1499
+ };
1204
1500
  if (isRefObj(v)) {
1205
- effect(() => el.setAttribute(attr, String(resolveRef(v.$ref, state) ?? "")));
1501
+ effect(() => write(resolveRef(v.$ref, state)));
1206
1502
  } else if (isTemplateString(v)) {
1207
- effect(() => el.setAttribute(attr, String(evaluateTemplate(v, state))));
1503
+ effect(() => write(evaluateAttrTemplate(v, state)));
1208
1504
  } else {
1209
- el.setAttribute(attr, String(v));
1505
+ write(v);
1210
1506
  }
1211
1507
  }
1212
1508
  }
@@ -2282,10 +2578,43 @@ export function camelToKebab(s: string) {
2282
2578
  export function toCSSText(rules: Record<string, unknown> | object) {
2283
2579
  return Object.entries(rules)
2284
2580
  .filter(([k, v]) => !isNestedSelector(k) && (v === null || typeof v !== "object"))
2285
- .map(([p, v]) => `${camelToKebab(p)}: ${transposeCanvasUnits(String(v))}`)
2581
+ .map(([p, v]) => `${camelToKebab(p)}: ${canvasStyleValue(String(v))}`)
2286
2582
  .join("; ");
2287
2583
  }
2288
2584
 
2585
+ // ─── Style At-Rules ───────────────────────────────────────────────────────────
2586
+
2587
+ /**
2588
+ * The four CSS media types. A media _type_ is bare — `@media print` — while a media _feature_ is
2589
+ * parenthesised, so `@media (print)` reads as a boolean feature named `print`, which does not exist
2590
+ * and evaluates false.
2591
+ */
2592
+ const MEDIA_TYPES = new Set(["all", "print", "screen", "speech"]);
2593
+
2594
+ /**
2595
+ * The media query an `@`-prefixed style key names, or null when the key is some other at-rule
2596
+ * (`@supports`, `@starting-style`) that is emitted verbatim.
2597
+ *
2598
+ * `@--name` resolves through the project's `$media` map. `@(…)` carries its own parentheses, so
2599
+ * they are stripped only when what they wrap is a bare media type: `@(print)` must become `@media
2600
+ * print`, while `@(min-width: 40rem)` keeps them.
2601
+ *
2602
+ * @param {string} atKey
2603
+ * @param {Record<string, string>} mediaQueries
2604
+ * @returns {string | null}
2605
+ * @docs framework/concepts/styling
2606
+ */
2607
+ export function resolveAtQuery(atKey: string, mediaQueries: Record<string, string>): string | null {
2608
+ if (atKey.startsWith("@--")) {
2609
+ return mediaQueries[atKey.slice(1)] ?? atKey.slice(1);
2610
+ }
2611
+ if (atKey.startsWith("@(")) {
2612
+ const inner = atKey.slice(2, -1).trim();
2613
+ return MEDIA_TYPES.has(inner.toLowerCase()) ? inner : atKey.slice(1);
2614
+ }
2615
+ return null;
2616
+ }
2617
+
2289
2618
  // ─── Color Schemes ────────────────────────────────────────────────────────────
2290
2619
 
2291
2620
  /**
@@ -2424,15 +2753,17 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
2424
2753
  // Resolve href/src: bare npm specifiers -> /node_modules/ path
2425
2754
  for (const key of ["href", "src"] as const) {
2426
2755
  const v = attrs[key];
2427
- if (
2428
- typeof v === "string" &&
2429
- v &&
2430
- !v.startsWith("/") &&
2431
- !v.startsWith(".") &&
2432
- !v.startsWith("http")
2433
- ) {
2756
+ if (typeof v !== "string" || v === "") {
2757
+ continue;
2758
+ }
2759
+ if (!v.startsWith("/") && !v.startsWith(".") && !v.startsWith("http")) {
2760
+ /* The bare-specifier lane owns `/node_modules/<pkg>`, which is the HOST's URL space and
2761
+ not the project's. A canvas resolver must never claim it: the file is not in the
2762
+ repository, so there is nothing for it to resolve to. */
2434
2763
  attrs[key] = `/node_modules/${v}`;
2764
+ continue;
2435
2765
  }
2766
+ attrs[key] = canvasAssetValue(tag.toUpperCase(), key, v);
2436
2767
  }
2437
2768
 
2438
2769
  // Deduplicate: skip if an identical element already exists
@@ -2522,7 +2853,8 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2522
2853
 
2523
2854
  const state = await buildScope(def, {}, base);
2524
2855
 
2525
- // Read properties from directive encoding (markdown → data-jx-props)
2856
+ // Read properties from the data-jx-props payload the site build writes on a
2857
+ // Non-static instance, so an upgrade re-renders with the authored props, not the defaults.
2526
2858
  const propsAttr = this.dataset.jxProps;
2527
2859
  if (propsAttr) {
2528
2860
  try {
@@ -2608,6 +2940,14 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2608
2940
  applyStyle(this, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
2609
2941
  applyAttributes(this, def.attributes ?? {}, state);
2610
2942
 
2943
+ /*
2944
+ * Root-level `textContent` is a definition's content just as much as `children` is — it is
2945
+ * the shape spec.md §17.2 recommends when every child would be a bare string — and it was
2946
+ * dropped here, so such a component rendered empty everywhere the interpreter runs.
2947
+ */
2948
+ if (!Array.isArray(def.children) && def.textContent !== undefined) {
2949
+ bindProperty(this, "textContent", def.textContent, state);
2950
+ }
2611
2951
  const children = Array.isArray(def.children) ? def.children : [];
2612
2952
  for (const childDef of children) {
2613
2953
  this.append(renderNode(childDef, state));
@@ -2680,20 +3020,27 @@ function renderCustomElementWithProps(
2680
3020
  if (refusePrivateProp(key, "$props")) {
2681
3021
  continue;
2682
3022
  }
3023
+ /* The same resolution `bindProperty` gives an ordinary element. A `$props: { src }` on a custom
3024
+ element is an asset reference authored at the USAGE site, so it belongs to the document that
3025
+ wrote it — a media prop the component forwards into its own `<img>` is resolved separately,
3026
+ inside the component's render, against the same context. */
3027
+ const write = (resolved: unknown): void => {
3028
+ (el as unknown as Record<string, unknown>)[key] =
3029
+ typeof resolved === "string" ? canvasAssetValue(el.tagName, key, resolved) : resolved;
3030
+ };
2683
3031
  if (isRefObj(val)) {
2684
3032
  const refVal = val;
2685
- const resolved = resolveRef(refVal.$ref, state);
2686
- (el as unknown as Record<string, unknown>)[key] = resolved;
3033
+ write(resolveRef(refVal.$ref, state));
2687
3034
  // Reactive forwarding: re-set the property when the source changes
2688
3035
  effect(() => {
2689
- (el as unknown as Record<string, unknown>)[key] = resolveRef(refVal.$ref, state);
3036
+ write(resolveRef(refVal.$ref, state));
2690
3037
  });
2691
3038
  } else if (isTemplateString(val)) {
2692
3039
  effect(() => {
2693
- (el as unknown as Record<string, unknown>)[key] = evaluateTemplate(val, state);
3040
+ write(evaluateTemplate(val, state));
2694
3041
  });
2695
3042
  } else {
2696
- (el as unknown as Record<string, unknown>)[key] = val;
3043
+ write(val);
2697
3044
  }
2698
3045
  }
2699
3046
 
package/src/types.ts CHANGED
@@ -15,6 +15,19 @@ export type JxEventHandler = (scope: JxScope, event: Event) => unknown;
15
15
 
16
16
  export interface JxRenderOptions {
17
17
  _path?: JxPath;
18
+ /**
19
+ * Base URL the document's own references resolve against — `$ref`, `$elements`, `$head` and the
20
+ * asset paths beneath them.
21
+ *
22
+ * Only {@link Jx} reads it, and only when it was handed a document OBJECT rather than a URL. A URL
23
+ * carries its own base; an object does not, so the base defaulted to `location.href` — which is
24
+ * right for a page served at the project root and wrong everywhere else. A host that composes a
25
+ * document server-side and serves it at `/blog/hello/` needs to say that its references are still
26
+ * root-relative, or every one of them resolves a directory too deep.
27
+ */
28
+ base?: string;
29
+ /** The namespace the parent element established — SVG and MathML descendants inherit it. */
30
+ _ns?: string | null;
18
31
  /**
19
32
  * Called for each created node. `state` is the local scope the node's children render with —
20
33
  * callers can capture it to re-render a subtree in isolation later.