@jxsuite/runtime 1.1.0 → 1.3.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": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,10 +30,10 @@
30
30
  "upgrade": "bunx npm-check-updates -u && bun install"
31
31
  },
32
32
  "dependencies": {
33
- "@jxsuite/schema": "^1.1.0",
34
- "@vue/reactivity": "^3.5.40"
33
+ "@jxsuite/schema": "^1.3.0",
34
+ "@vue/reactivity": "3.5.40"
35
35
  },
36
36
  "devDependencies": {
37
- "@happy-dom/global-registrator": "^20.10.6"
37
+ "@happy-dom/global-registrator": "^20.11.0"
38
38
  }
39
39
  }
package/src/runtime.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import { evaluateExpression, isMutating } from "./expression.ts";
27
27
  import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
28
28
  import {
29
+ bodyReturnsValue,
29
30
  hasStructuredBody,
30
31
  isExpressionDef,
31
32
  isFunctionDef,
@@ -35,6 +36,7 @@ import {
35
36
  isPrototypeDef,
36
37
  isRef as isRefValue,
37
38
  isServerFnDef,
39
+ isTemplateString,
38
40
  paramNames,
39
41
  } from "@jxsuite/schema/guards";
40
42
  import { runStatements } from "./statements.ts";
@@ -68,6 +70,29 @@ import type { Ref } from "@vue/reactivity";
68
70
  * @returns {Promise<JxScope>} Resolves with the live component scope (state reactive
69
71
  * proxy)
70
72
  */
73
+ /** The document-schema version this runtime understands (the `/schema/vN` in a hosted `$schema`). */
74
+ export const SUPPORTED_SCHEMA_VERSION = 1;
75
+ let _schemaVersionWarned = false;
76
+
77
+ /**
78
+ * Warn once when a document declares a hosted `$schema` whose version differs from the one this
79
+ * runtime supports. A relative or non-hosted `$schema` (local dev schema paths) is ignored. This is
80
+ * a diagnostic only — there is no document-format migration story yet (see spec §3.2 / §21.4).
81
+ */
82
+ function checkSchemaVersion($schema: unknown): void {
83
+ if (_schemaVersionWarned || typeof $schema !== "string") {
84
+ return;
85
+ }
86
+ const match = $schema.match(/\/schema\/v(\d+)\b/);
87
+ if (match && Number(match[1]) !== SUPPORTED_SCHEMA_VERSION) {
88
+ _schemaVersionWarned = true;
89
+ console.warn(
90
+ `Jx: document $schema is version v${match[1]} but this runtime supports ` +
91
+ `v${SUPPORTED_SCHEMA_VERSION}. Behavior may be undefined; there is no format migrator yet.`,
92
+ );
93
+ }
94
+ }
95
+
71
96
  export async function Jx(
72
97
  source: string | JxDocument,
73
98
  target: HTMLElement = document.body,
@@ -75,6 +100,7 @@ export async function Jx(
75
100
  ) {
76
101
  const base = typeof source === "string" ? new URL(source, location.href).href : location.href;
77
102
  const doc = await resolve(source);
103
+ checkSchemaVersion(doc.$schema);
78
104
 
79
105
  // Register custom elements declared in $elements (depth-first)
80
106
  if (doc.$elements) {
@@ -615,9 +641,9 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
615
641
  let isComputed = false;
616
642
  if (!hasParams) {
617
643
  if (typeof def.body === "string") {
618
- isComputed = /\breturn\b/.test(def.body);
644
+ isComputed = bodyReturnsValue(def.body);
619
645
  } else if (fn) {
620
- isComputed = fn.length <= 1 && /\breturn\b/.test(fn.toString());
646
+ isComputed = fn.length <= 1 && bodyReturnsValue(fn.toString());
621
647
  }
622
648
  }
623
649
  if (isComputed) {
@@ -690,6 +716,9 @@ export const RESERVED_KEYS = new Set([
690
716
  * @param {JxRenderOptions} [options]
691
717
  * @returns {HTMLElement | Text}
692
718
  */
719
+ /** Refs already warned about, so the §13 diagnostic fires once per distinct target. */
720
+ const warnedRefChildren = new Set<string>();
721
+
693
722
  export function renderNode(
694
723
  def: JxElement | string | number | boolean,
695
724
  state: JxScope,
@@ -719,6 +748,22 @@ export function renderNode(
719
748
  }
720
749
  }
721
750
 
751
+ /*
752
+ * §13 diagnostic: a node-level external `$ref` (the withdrawn component-instance syntax) has no
753
+ * tagName and silently renders an empty <div>. Warn once per target instead of failing silently —
754
+ * the supported mechanism is $elements + a custom-element tag (see spec §13).
755
+ */
756
+ if ("$ref" in def && !def.tagName) {
757
+ const refTarget = String((def as { $ref?: unknown }).$ref ?? "");
758
+ if (!warnedRefChildren.has(refTarget)) {
759
+ warnedRefChildren.add(refTarget);
760
+ console.warn(
761
+ `Jx: a $ref child ("${refTarget}") is not a supported component instance; register the ` +
762
+ `component in $elements and use its custom-element tag instead (spec §13).`,
763
+ );
764
+ }
765
+ }
766
+
722
767
  // Custom element with $props: set JS properties on the element instance
723
768
  const tagName = def.tagName ?? "div";
724
769
  const isCustomEl = tagName.includes("-") && customElements.get(tagName);
@@ -780,10 +825,6 @@ export function renderNode(
780
825
  * @param {unknown} val
781
826
  * @returns {boolean}
782
827
  */
783
- function isTemplateString(val: unknown): val is string {
784
- return typeof val === "string" && val.includes("${");
785
- }
786
-
787
828
  // ─── Property / style / attribute application ─────────────────────────────────
788
829
 
789
830
  /**
@@ -1030,14 +1071,25 @@ export function applyStyle(
1030
1071
  if (key === "@--") {
1031
1072
  continue;
1032
1073
  } // Base canvas width, not a real media query
1033
- const atRule = key.startsWith("@--")
1034
- ? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
1074
+ const query = key.startsWith("@--")
1075
+ ? (mediaQueries[key.slice(1)] ?? key.slice(1))
1035
1076
  : key.startsWith("@(")
1036
- ? `@media ${key.slice(1)}`
1037
- : key;
1077
+ ? key.slice(1)
1078
+ : null;
1079
+ const atRule = query === null ? key : `@media ${query}`;
1038
1080
  const scope = `[data-jx="${uid}"]`;
1039
- css += `${atRule} { ${scope} { ${toCSSText(rules)} } }\n`;
1040
- emitMediaNested(atRule, scope, rules);
1081
+ const scheme = query === null ? null : pureSchemeOf(query);
1082
+ if (scheme) {
1083
+ // Dual-emit scheme rules: a media-guarded copy for auto plus a forced-attribute copy.
1084
+ // The forced root attribute wins over the OS preference.
1085
+ const { auto, forced } = schemeSelectors(scope, scheme);
1086
+ css += `${atRule} { ${auto} { ${toCSSText(rules)} } }\n`;
1087
+ emitMediaNested(atRule, auto, rules);
1088
+ emitNested(forced, rules);
1089
+ } else {
1090
+ css += `${atRule} { ${scope} { ${toCSSText(rules)} } }\n`;
1091
+ emitMediaNested(atRule, scope, rules);
1092
+ }
1041
1093
  }
1042
1094
 
1043
1095
  const tag = document.createElement("style");
@@ -2083,6 +2135,64 @@ export function toCSSText(rules: Record<string, unknown> | object) {
2083
2135
  .join("; ");
2084
2136
  }
2085
2137
 
2138
+ // ─── Color Schemes ────────────────────────────────────────────────────────────
2139
+
2140
+ /**
2141
+ * Attribute on the root element (`<html>`) that forces a color scheme. Absent = auto (follow the OS
2142
+ * `prefers-color-scheme`). Values: "light" | "dark".
2143
+ *
2144
+ * @docs framework/concepts/color-schemes
2145
+ */
2146
+ export const COLOR_SCHEME_ATTR = "data-color-scheme";
2147
+
2148
+ /**
2149
+ * The localStorage key a site switcher persists the visitor's forced scheme under; read by the
2150
+ * compiler-injected pre-paint script. Values: "light" | "dark" (absent = auto).
2151
+ *
2152
+ * @docs framework/concepts/color-schemes
2153
+ */
2154
+ export const COLOR_SCHEME_STORAGE_KEY = "jx-color-scheme";
2155
+
2156
+ /**
2157
+ * The scheme a _pure_ `prefers-color-scheme` media query targets. Compound queries (any other
2158
+ * conditions attached) return null — they are not eligible for forced-scheme dual emission.
2159
+ *
2160
+ * @param {string} query
2161
+ * @returns {"light" | "dark" | null}
2162
+ * @docs framework/concepts/color-schemes
2163
+ */
2164
+ export function pureSchemeOf(query: string): "light" | "dark" | null {
2165
+ const m = /^\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)$/.exec(query.trim());
2166
+ return (m?.[1] as "light" | "dark") ?? null;
2167
+ }
2168
+
2169
+ /**
2170
+ * Selector pair for a scheme-conditional rule: `auto` applies inside the scheme's media query only
2171
+ * while no scheme is forced; `forced` applies unconditionally when the root attribute forces the
2172
+ * scheme. Guards are wrapped in `:where()` so specificity matches the unguarded selector and source
2173
+ * order decides the cascade.
2174
+ *
2175
+ * @param {string} selector
2176
+ * @param {"light" | "dark"} scheme
2177
+ * @returns {{ auto: string; forced: string }}
2178
+ * @docs framework/concepts/color-schemes
2179
+ */
2180
+ export function schemeSelectors(
2181
+ selector: string,
2182
+ scheme: "light" | "dark",
2183
+ ): { auto: string; forced: string } {
2184
+ if (selector === ":root" || selector === "html") {
2185
+ return {
2186
+ auto: `${selector}:where(:not([${COLOR_SCHEME_ATTR}]))`,
2187
+ forced: `${selector}:where([${COLOR_SCHEME_ATTR}="${scheme}"])`,
2188
+ };
2189
+ }
2190
+ return {
2191
+ auto: `:where(:root:not([${COLOR_SCHEME_ATTR}])) ${selector}`,
2192
+ forced: `:where(:root[${COLOR_SCHEME_ATTR}="${scheme}"]) ${selector}`,
2193
+ };
2194
+ }
2195
+
2086
2196
  // ─── Custom Element Registration ──────────────────────────────────────────────
2087
2197
 
2088
2198
  let _rootMedia: Record<string, string> = {};