@jxsuite/runtime 2.1.0 → 3.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.1.0",
3
+ "version": "3.1.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,10 +15,12 @@
15
15
  "type": "module",
16
16
  "exports": {
17
17
  ".": "./src/runtime.ts",
18
+ "./css": "./src/css.ts",
18
19
  "./expression": "./src/expression.ts",
19
20
  "./pointer": "./src/pointer.ts",
20
21
  "./statements": "./src/statements.ts",
21
- "./types": "./src/types.ts"
22
+ "./types": "./src/types.ts",
23
+ "./dist/runtime.js": "./dist/runtime.js"
22
24
  },
23
25
  "publishConfig": {
24
26
  "provenance": true
@@ -28,13 +30,13 @@
28
30
  "build:metafile": "bun build ./src/runtime.ts --outdir dist --target browser --sourcemap=linked --metafile=metafile.json",
29
31
  "test": "bun test --isolate",
30
32
  "test:coverage": "bun test --isolate --coverage",
31
- "upgrade": "bunx npm-check-updates -u && bun install"
33
+ "upgrade": "bun update --latest && bun install"
32
34
  },
33
35
  "dependencies": {
34
- "@jxsuite/schema": "^1.9.0",
35
- "@vue/reactivity": "3.5.41"
36
+ "@jxsuite/schema": "^2.1.0",
37
+ "@vue/reactivity": "3.5.42"
36
38
  },
37
39
  "devDependencies": {
38
- "@happy-dom/global-registrator": "^20.11.6"
40
+ "@happy-dom/global-registrator": "^20.12.0"
39
41
  }
40
42
  }
package/src/css.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The CSS-authoring rules that are not the DOM.
3
+ *
4
+ * `@jxsuite/runtime` renders a document in a browser, and every consumer of its root export pays
5
+ * for that: the element registry, the reactive scope, `@vue/reactivity`. These six exports need
6
+ * none of it. They are pure string and regex math over what a `style` block MEANS — how a property
7
+ * name is spelled in CSS, which media query an `@`-key resolves to, and which selector pair a
8
+ * scheme-conditional rule dual-emits as.
9
+ *
10
+ * They are a subpath of their own because of who else needs them. `@jxsuite/site/site-style` turns
11
+ * `project.json`'s `style` into a stylesheet, and it runs in places the DOM runtime cannot go — a
12
+ * static build, and a Cloudflare Worker serving a live preview. Importing the root export for
13
+ * `camelToKebab` would put the whole renderer plus `@vue/reactivity` inside a Worker script for
14
+ * four pure functions.
15
+ *
16
+ * `./runtime` re-exports all of these, so nothing that already imported them from the root export
17
+ * has to change. This is a narrow door beside the wide one, not a replacement for it.
18
+ */
19
+
20
+ /**
21
+ * Convert camelCase to kebab-case.
22
+ *
23
+ * @param {string} s
24
+ * @returns {string}
25
+ */
26
+ export function camelToKebab(s: string) {
27
+ return s.replaceAll(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
28
+ }
29
+
30
+ // ─── Style At-Rules ───────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * The four CSS media types. A media _type_ is bare — `@media print` — while a media _feature_ is
34
+ * parenthesised, so `@media (print)` reads as a boolean feature named `print`, which does not exist
35
+ * and evaluates false.
36
+ */
37
+ const MEDIA_TYPES = new Set(["all", "print", "screen", "speech"]);
38
+
39
+ /**
40
+ * The media query an `@`-prefixed style key names, or null when the key is some other at-rule
41
+ * (`@supports`, `@starting-style`) that is emitted verbatim.
42
+ *
43
+ * `@--name` resolves through the project's `$media` map. `@(…)` carries its own parentheses, so
44
+ * they are stripped only when what they wrap is a bare media type: `@(print)` must become `@media
45
+ * print`, while `@(min-width: 40rem)` keeps them.
46
+ *
47
+ * @param {string} atKey
48
+ * @param {Record<string, string>} mediaQueries
49
+ * @returns {string | null}
50
+ * @docs framework/concepts/styling
51
+ */
52
+ export function resolveAtQuery(atKey: string, mediaQueries: Record<string, string>): string | null {
53
+ if (atKey.startsWith("@--")) {
54
+ return mediaQueries[atKey.slice(1)] ?? atKey.slice(1);
55
+ }
56
+ if (atKey.startsWith("@(")) {
57
+ const inner = atKey.slice(2, -1).trim();
58
+ return MEDIA_TYPES.has(inner.toLowerCase()) ? inner : atKey.slice(1);
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // ─── Color Schemes ────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Attribute on the root element (`<html>`) that forces a color scheme. Absent = auto (follow the OS
67
+ * `prefers-color-scheme`). Values: "light" | "dark".
68
+ *
69
+ * @docs framework/concepts/color-schemes
70
+ */
71
+ export const COLOR_SCHEME_ATTR = "data-color-scheme";
72
+
73
+ /**
74
+ * The localStorage key a site switcher persists the visitor's forced scheme under; read by the
75
+ * compiler-injected pre-paint script. Values: "light" | "dark" (absent = auto).
76
+ *
77
+ * @docs framework/concepts/color-schemes
78
+ */
79
+ export const COLOR_SCHEME_STORAGE_KEY = "jx-color-scheme";
80
+
81
+ /**
82
+ * The scheme a _pure_ `prefers-color-scheme` media query targets. Compound queries (any other
83
+ * conditions attached) return null — they are not eligible for forced-scheme dual emission.
84
+ *
85
+ * @param {string} query
86
+ * @returns {"light" | "dark" | null}
87
+ * @docs framework/concepts/color-schemes
88
+ */
89
+ export function pureSchemeOf(query: string): "light" | "dark" | null {
90
+ const m = /^\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)$/.exec(query.trim());
91
+ return (m?.[1] as "light" | "dark") ?? null;
92
+ }
93
+
94
+ /**
95
+ * Selector pair for a scheme-conditional rule: `auto` applies inside the scheme's media query only
96
+ * while no scheme is forced; `forced` applies unconditionally when the root attribute forces the
97
+ * scheme. Guards are wrapped in `:where()` so specificity matches the unguarded selector and source
98
+ * order decides the cascade.
99
+ *
100
+ * @param {string} selector
101
+ * @param {"light" | "dark"} scheme
102
+ * @returns {{ auto: string; forced: string }}
103
+ * @docs framework/concepts/color-schemes
104
+ */
105
+ export function schemeSelectors(
106
+ selector: string,
107
+ scheme: "light" | "dark",
108
+ ): { auto: string; forced: string } {
109
+ if (selector === ":root" || selector === "html") {
110
+ return {
111
+ auto: `${selector}:where(:not([${COLOR_SCHEME_ATTR}]))`,
112
+ forced: `${selector}:where([${COLOR_SCHEME_ATTR}="${scheme}"])`,
113
+ };
114
+ }
115
+ return {
116
+ auto: `:where(:root:not([${COLOR_SCHEME_ATTR}])) ${selector}`,
117
+ forced: `:where(:root[${COLOR_SCHEME_ATTR}="${scheme}"]) ${selector}`,
118
+ };
119
+ }
package/src/runtime.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  ref,
24
24
  toRaw,
25
25
  } from "@vue/reactivity";
26
+ import { camelToKebab, pureSchemeOf, resolveAtQuery, schemeSelectors } from "./css.ts";
26
27
  import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
27
28
  import { readPath } from "./pointer.ts";
28
29
  import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
@@ -103,7 +104,11 @@ export async function Jx(
103
104
  target: HTMLElement = document.body,
104
105
  options?: JxRenderOptions,
105
106
  ) {
106
- const base = typeof source === "string" ? new URL(source, location.href).href : location.href;
107
+ const base = options?.base
108
+ ? new URL(options.base, location.href).href
109
+ : typeof source === "string"
110
+ ? new URL(source, location.href).href
111
+ : location.href;
107
112
  const doc = await resolve(source);
108
113
  checkSchemaVersion(doc.$schema);
109
114
 
@@ -711,6 +716,71 @@ function evaluateTemplate(str: string, state: JxScope): string {
711
716
  return fn(state, $map, $map?.item, $map?.index);
712
717
  }
713
718
 
719
+ /**
720
+ * Whether a template is ONE expression end to end — `"${a.b}"` rather than `"a ${b}"` or
721
+ * `"${a}${b}"`.
722
+ *
723
+ * Depth-counted rather than pattern-matched. A greedy `/^\$\{(.+)\}$/` also matched `"${a} /
724
+ * ${b}"`, spliced the interior into `return (a} / ${b)`, and the SyntaxError became a silent null:
725
+ * the node rendered empty and a `$head` entry shipped its own template text. A brace scan is exact
726
+ * where a tightened regex is not — `"${`${a}-x`}"` is still one expression, and must keep its raw
727
+ * value.
728
+ *
729
+ * Lives here rather than in the compiler because both renderers must draw the line in the same
730
+ * place. It is what decides whether a value keeps its own type, and a value that is a boolean in
731
+ * one renderer and the string `"false"` in the other is a page that changes meaning as it
732
+ * hydrates.
733
+ *
734
+ * @param {string} str - Template source
735
+ * @returns {boolean} True when the whole string is a single `${…}`
736
+ */
737
+ export function isSingleExpression(str: string): boolean {
738
+ if (!str.startsWith("${") || !str.endsWith("}")) {
739
+ return false;
740
+ }
741
+ let depth = 0;
742
+ for (let i = 1; i < str.length; i += 1) {
743
+ const c = str[i];
744
+ if (c === "{") {
745
+ depth += 1;
746
+ } else if (c === "}") {
747
+ depth -= 1;
748
+ if (depth === 0) {
749
+ return i === str.length - 1;
750
+ }
751
+ }
752
+ }
753
+ return false;
754
+ }
755
+
756
+ /**
757
+ * Evaluate a template for an ATTRIBUTE, keeping the expression's own type.
758
+ *
759
+ * `evaluateTemplate` interpolates into a string, which is what text wants and what an attribute
760
+ * cannot afford: `"${state.expanded}"` has to arrive as the boolean `false`, or `booleanAttrValue`
761
+ * never sees a boolean, `open="false"` is written, and HTML reads that as open. Only a single
762
+ * expression can carry a type at all — `"a ${b}"` is text by construction — which is the same line
763
+ * `evaluateStaticTemplate` draws in the compiler, and drawing it identically is what keeps a
764
+ * prerendered page and the same document rendered live in agreement.
765
+ *
766
+ * @param {string} str - Template source
767
+ * @param {JxScope} state - Reactive scope
768
+ * @returns {unknown} The expression's value, or the interpolated string
769
+ */
770
+ function evaluateAttrTemplate(str: string, state: JxScope): unknown {
771
+ if (!isSingleExpression(str)) {
772
+ return evaluateTemplate(str, state);
773
+ }
774
+ const $map = state?.$map as { item?: unknown; index?: number } | undefined;
775
+ const fn = new Function("state", "$map", "item", "index", `return (${str.slice(2, -1)})`) as (
776
+ state: JxScope,
777
+ $map: unknown,
778
+ item: unknown,
779
+ index: number | undefined,
780
+ ) => unknown;
781
+ return fn(state, $map, $map?.item, $map?.index);
782
+ }
783
+
714
784
  // ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
715
785
 
716
786
  /** Shape of a dynamically imported module: named exports plus an optional default. */
@@ -1363,6 +1433,45 @@ export function reapplyStyle(
1363
1433
  applyStyle(el, styleDef ?? {}, mediaQueries, state);
1364
1434
  }
1365
1435
 
1436
+ /*
1437
+ * HTML carries a boolean two incompatible ways, and writing one as the other inverts it in silence.
1438
+ *
1439
+ * A *boolean attribute* — `open`, `disabled`, `hidden`, `required` — is read by PRESENCE. Any value
1440
+ * at all counts as true, `"false"` included, so `<details open="false">` is an OPEN `<details>`.
1441
+ * The value must never be written; absence is the only way to say false.
1442
+ *
1443
+ * An *enumerated* attribute carries the word in its own text and reads an empty value as unset, so
1444
+ * the presence form is the broken one here: bare `aria-hidden` is NOT hidden, and a dropped
1445
+ * `contenteditable` means "inherit from the parent" rather than `false`. Every `aria-*` is in this
1446
+ * family — ARIA has no presence attributes at all — along with the three HTML attributes below.
1447
+ *
1448
+ * @docs framework/concepts/elements
1449
+ */
1450
+ const ENUMERATED_ATTRS = new Set(["contenteditable", "draggable", "spellcheck"]);
1451
+
1452
+ /**
1453
+ * The text an attribute's boolean value must be written with, or `null` when saying it means
1454
+ * removing the attribute instead.
1455
+ *
1456
+ * An empty string is the bare form: `setAttribute(name, "")` is how the DOM spells the `open` that
1457
+ * HTML source writes with no `=`.
1458
+ *
1459
+ * Shared rather than restated because two renderers must agree. The compiler writes attributes into
1460
+ * a string of HTML and this file writes them onto live elements; were they to disagree about which
1461
+ * family an attribute belongs to, a prerendered page would change meaning as it hydrated.
1462
+ *
1463
+ * @param {string} name - Attribute name, already resolved to what will be written
1464
+ * @param {boolean} value - The resolved boolean
1465
+ * @returns {string | null} Text to write, or null to omit the attribute
1466
+ */
1467
+ export function booleanAttrValue(name: string, value: boolean): string | null {
1468
+ const lower = name.toLowerCase();
1469
+ if (lower.startsWith("aria-") || ENUMERATED_ATTRS.has(lower)) {
1470
+ return String(value);
1471
+ }
1472
+ return value ? "" : null;
1473
+ }
1474
+
1366
1475
  /**
1367
1476
  * @param {HTMLElement} el
1368
1477
  * @param {Record<string, import("@jxsuite/schema/types").JxAttributeValue>} attrs
@@ -1374,12 +1483,25 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue
1374
1483
  /* Inside the effects, not before them. A `$ref` or `${…}` value is not a string until the
1375
1484
  effect runs — which is exactly why the document walk this replaced could never see it. */
1376
1485
  const write = (resolved: unknown) => {
1486
+ if (typeof resolved === "boolean") {
1487
+ const text = booleanAttrValue(attr, resolved);
1488
+ /* `removeAttribute`, not `setAttribute(attr, "false")`. A binding that flips back has to
1489
+ take the attribute WITH it: the element is being re-used, so anything left behind is the
1490
+ state the page just said it had left. Asset rewriting is skipped because a boolean names
1491
+ no asset — `canvasAssetValue` reads `src`/`href` values, which are never booleans. */
1492
+ if (text === null) {
1493
+ el.removeAttribute(attr);
1494
+ } else {
1495
+ el.setAttribute(attr, text);
1496
+ }
1497
+ return;
1498
+ }
1377
1499
  el.setAttribute(attr, canvasAssetValue(el.tagName, k, String(resolved ?? "")));
1378
1500
  };
1379
1501
  if (isRefObj(v)) {
1380
1502
  effect(() => write(resolveRef(v.$ref, state)));
1381
1503
  } else if (isTemplateString(v)) {
1382
- effect(() => write(evaluateTemplate(v, state)));
1504
+ effect(() => write(evaluateAttrTemplate(v, state)));
1383
1505
  } else {
1384
1506
  write(v);
1385
1507
  }
@@ -2438,15 +2560,19 @@ function mergeProps(def: JxElement, parentState: JxScope): JxScope {
2438
2560
  return child;
2439
2561
  }
2440
2562
 
2441
- /**
2442
- * Convert camelCase to kebab-case.
2443
- *
2444
- * @param {string} s
2445
- * @returns {string}
2563
+ /*
2564
+ * The CSS-authoring rules live in `./css.ts`, which imports nothing at all. They are re-exported
2565
+ * here because they always were part of this module's surface and moving them must not break a
2566
+ * consumer; a host that cannot afford the DOM runtime imports `@jxsuite/runtime/css` instead.
2446
2567
  */
2447
- export function camelToKebab(s: string) {
2448
- return s.replaceAll(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
2449
- }
2568
+ export {
2569
+ camelToKebab,
2570
+ COLOR_SCHEME_ATTR,
2571
+ COLOR_SCHEME_STORAGE_KEY,
2572
+ pureSchemeOf,
2573
+ resolveAtQuery,
2574
+ schemeSelectors,
2575
+ } from "./css.ts";
2450
2576
 
2451
2577
  /**
2452
2578
  * Convert a style rules object to a CSS text string (skipping nested selectors).
@@ -2461,97 +2587,6 @@ export function toCSSText(rules: Record<string, unknown> | object) {
2461
2587
  .join("; ");
2462
2588
  }
2463
2589
 
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
-
2497
- // ─── Color Schemes ────────────────────────────────────────────────────────────
2498
-
2499
- /**
2500
- * Attribute on the root element (`<html>`) that forces a color scheme. Absent = auto (follow the OS
2501
- * `prefers-color-scheme`). Values: "light" | "dark".
2502
- *
2503
- * @docs framework/concepts/color-schemes
2504
- */
2505
- export const COLOR_SCHEME_ATTR = "data-color-scheme";
2506
-
2507
- /**
2508
- * The localStorage key a site switcher persists the visitor's forced scheme under; read by the
2509
- * compiler-injected pre-paint script. Values: "light" | "dark" (absent = auto).
2510
- *
2511
- * @docs framework/concepts/color-schemes
2512
- */
2513
- export const COLOR_SCHEME_STORAGE_KEY = "jx-color-scheme";
2514
-
2515
- /**
2516
- * The scheme a _pure_ `prefers-color-scheme` media query targets. Compound queries (any other
2517
- * conditions attached) return null — they are not eligible for forced-scheme dual emission.
2518
- *
2519
- * @param {string} query
2520
- * @returns {"light" | "dark" | null}
2521
- * @docs framework/concepts/color-schemes
2522
- */
2523
- export function pureSchemeOf(query: string): "light" | "dark" | null {
2524
- const m = /^\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)$/.exec(query.trim());
2525
- return (m?.[1] as "light" | "dark") ?? null;
2526
- }
2527
-
2528
- /**
2529
- * Selector pair for a scheme-conditional rule: `auto` applies inside the scheme's media query only
2530
- * while no scheme is forced; `forced` applies unconditionally when the root attribute forces the
2531
- * scheme. Guards are wrapped in `:where()` so specificity matches the unguarded selector and source
2532
- * order decides the cascade.
2533
- *
2534
- * @param {string} selector
2535
- * @param {"light" | "dark"} scheme
2536
- * @returns {{ auto: string; forced: string }}
2537
- * @docs framework/concepts/color-schemes
2538
- */
2539
- export function schemeSelectors(
2540
- selector: string,
2541
- scheme: "light" | "dark",
2542
- ): { auto: string; forced: string } {
2543
- if (selector === ":root" || selector === "html") {
2544
- return {
2545
- auto: `${selector}:where(:not([${COLOR_SCHEME_ATTR}]))`,
2546
- forced: `${selector}:where([${COLOR_SCHEME_ATTR}="${scheme}"])`,
2547
- };
2548
- }
2549
- return {
2550
- auto: `:where(:root:not([${COLOR_SCHEME_ATTR}])) ${selector}`,
2551
- forced: `:where(:root[${COLOR_SCHEME_ATTR}="${scheme}"]) ${selector}`,
2552
- };
2553
- }
2554
-
2555
2590
  // ─── Custom Element Registration ──────────────────────────────────────────────
2556
2591
 
2557
2592
  let _rootMedia: Record<string, string> = {};
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
  /**