@jxsuite/runtime 2.1.0 → 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.1.0",
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.9.0",
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
@@ -103,7 +103,11 @@ export async function Jx(
103
103
  target: HTMLElement = document.body,
104
104
  options?: JxRenderOptions,
105
105
  ) {
106
- 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;
107
111
  const doc = await resolve(source);
108
112
  checkSchemaVersion(doc.$schema);
109
113
 
@@ -711,6 +715,71 @@ function evaluateTemplate(str: string, state: JxScope): string {
711
715
  return fn(state, $map, $map?.item, $map?.index);
712
716
  }
713
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
+
714
783
  // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
715
784
 
716
785
  /** Shape of a dynamically imported module: named exports plus an optional default. */
@@ -1363,6 +1432,45 @@ export function reapplyStyle(
1363
1432
  applyStyle(el, styleDef ?? {}, mediaQueries, state);
1364
1433
  }
1365
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
+
1366
1474
  /**
1367
1475
  * @param {HTMLElement} el
1368
1476
  * @param {Record<string, import("@jxsuite/schema/types").JxAttributeValue>} attrs
@@ -1374,12 +1482,25 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
1374
1482
  /* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
1375
1483
  effect runs — which is exactly why the document walk this replaced could never see it. */
1376
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
+ }
1377
1498
  el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved ?? "")));
1378
1499
  };
1379
1500
  if (isRefObj(v)) {
1380
1501
  effect(() => write(resolveRef(v.$ref, state)));
1381
1502
  } else if (isTemplateString(v)) {
1382
- effect(() => write(evaluateTemplate(v, state)));
1503
+ effect(() => write(evaluateAttrTemplate(v, state)));
1383
1504
  } else {
1384
1505
  write(v);
1385
1506
  }
package/src/types.ts CHANGED
@@ -15,6 +15,17 @@ 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;
18
29
  /** The namespace the parent element established — SVG and MathML descendants inherit it. */
19
30
  _ns?: string | null;
20
31
  /**