@jxsuite/runtime 0.32.0 → 0.34.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": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,8 +29,8 @@
29
29
  "upgrade": "bunx npm-check-updates -u && bun install"
30
30
  },
31
31
  "dependencies": {
32
- "@jxsuite/schema": "^0.32.0",
33
- "@vue/reactivity": "^3.5.38"
32
+ "@jxsuite/schema": "^0.34.0",
33
+ "@vue/reactivity": "^3.5.39"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@happy-dom/global-registrator": "^20.10.6"
package/src/runtime.ts CHANGED
@@ -140,11 +140,125 @@ export function setSkipServerFunctions(v: boolean) {
140
140
  _serverFnConfig.skip = v;
141
141
  }
142
142
 
143
+ // ─── Dev-proxy auth token (Studio cross-origin canvas) ────────────────────────
144
+ // The Studio canvas iframe is served from a token-gated loopback origin (createProjectServer): its
145
+ // POST /__jx_resolve__ + /__jx_server__ routes 403 without ?token=<rpcToken>. The iframe boot reads
146
+ // The token from its URL and calls setResolveToken so these dev-proxy fetches authenticate. Unset by
147
+ // Default, leaving production + same-origin dev untouched (bare path, no query appended).
148
+ let _resolveToken: string | null = null;
149
+ export function setResolveToken(token: string | null) {
150
+ _resolveToken = token || null;
151
+ }
152
+ /** Append the dev-proxy auth token to a privileged resolve path when one is configured. */
153
+ function resolveProxyPath(path: string): string {
154
+ return _resolveToken ? `${path}?token=${encodeURIComponent(_resolveToken)}` : path;
155
+ }
156
+
143
157
  /** @deprecated No longer needed — ContentCollection/ContentEntry resolve via generic class path */
144
158
  export function setSkipContentResolution(_v: boolean) {
145
159
  // No-op retained for API compatibility
146
160
  }
147
161
 
162
+ /**
163
+ * Observe runtime-created reactive values: runs `fn` immediately inside a reactive effect and
164
+ * re-runs it whenever a tracked value changes; returns a disposer. Dep tracking in @vue/reactivity
165
+ * is per module INSTANCE, so an effect created from another copy of the package (e.g. the studio's
166
+ * own pin) can never track a ref/reactive created here — consumers that want to observe
167
+ * runtime-resolved scope values (the Studio canvas iframe's dataScope re-post) must use this.
168
+ *
169
+ * @param {() => void} fn - Read the reactive values to observe inside this callback.
170
+ * @returns {() => void} Disposer — stops the effect and its scope.
171
+ */
172
+ export function observeScope(fn: () => void): () => void {
173
+ const scope = effectScope(true);
174
+ scope.run(() => {
175
+ effect(fn);
176
+ });
177
+ return () => scope.stop();
178
+ }
179
+
180
+ /**
181
+ * Run `fn` inside a detached reactive scope of THIS module's reactivity instance, returning its
182
+ * result plus a disposer that stops every effect `fn` created. Callers that render via
183
+ * {@link renderNode} and later tear the render down (the Studio canvas full render and its surgical
184
+ * subtree re-renders) MUST use this instead of their own effectScope: scope collection is per
185
+ * vue-reactivity module instance, so a scope from another copy of the package collects NOTHING and
186
+ * its stop() silently leaks every binding effect of the superseded render.
187
+ *
188
+ * @param {() => T} fn - Work that may create reactive effects (typically a renderNode call).
189
+ * @returns {{ result: T; stop: () => void }} The callback result and the scope disposer.
190
+ */
191
+ export function runScoped<T>(fn: () => T): { result: T; stop: () => void } {
192
+ const scope = effectScope(true);
193
+ try {
194
+ const result = scope.run(fn) as T;
195
+ return { result, stop: () => scope.stop() };
196
+ } catch (error) {
197
+ // A throwing fn would otherwise leak the effects it created before failing.
198
+ scope.stop();
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Studio-canvas viewport-unit transpose. The canvas iframe is sized to its document's height, so
205
+ * any viewport unit (`vh`/`vw`/`vmin`/`vmax`/`svh`/…) — which resolves against the iframe ELEMENT —
206
+ * would feed back into an ever-growing height. When this is on, the runtime transposes them to
207
+ * CONTAINER units (`cqh`/`cqw`/…) that resolve against the canvas's fixed-size query container (see
208
+ * `canvas.html`): a predictable, feedback-free stand-in for the viewport. Off (the default) leaves
209
+ * CSS untouched for real production rendering.
210
+ */
211
+ let _canvasViewportTranspose = false;
212
+ export function setCanvasViewportTranspose(on: boolean) {
213
+ _canvasViewportTranspose = on;
214
+ }
215
+
216
+ const VIEWPORT_UNIT_RE = /(-?\d*\.?\d+)(?:s|l|d)?v(h|w|min|max|i|b)\b/gi;
217
+ const VIEWPORT_UNIT_MAP: Record<string, string> = {
218
+ b: "cqb",
219
+ h: "cqh",
220
+ i: "cqi",
221
+ max: "cqmax",
222
+ min: "cqmin",
223
+ w: "cqw",
224
+ };
225
+
226
+ /**
227
+ * Transpose CSS viewport units → container-query units in a value string, but only when the
228
+ * studio-canvas flag is set (otherwise the value is returned untouched). `100vh` → `100cqh`,
229
+ * `50svw` → `50cqw`, `10vmin` → `10cqmin`, etc.
230
+ */
231
+ export function transposeCanvasUnits(value: string): string {
232
+ if (!_canvasViewportTranspose || !value.includes("v")) {
233
+ return value;
234
+ }
235
+ return value.replace(
236
+ VIEWPORT_UNIT_RE,
237
+ (_m, num: string, dim: string) => `${num}${VIEWPORT_UNIT_MAP[dim.toLowerCase()] ?? `cq${dim}`}`,
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Studio-canvas anchor de-linking. In the editor canvas a rendered `<a href>` would navigate the
243
+ * iframe when clicked, fighting element selection. When this is on, the runtime stamps the value on
244
+ * `data-jx-href` instead of `href` so the anchor is inert (selectable, not a live link) while the
245
+ * original target stays recoverable for richer link handling later. Off (the default) leaves
246
+ * production rendering untouched; the studio sets it for design/edit (not preview — see
247
+ * iframe-render).
248
+ */
249
+ let _canvasDelinkAnchors = false;
250
+ export function setCanvasDelinkAnchors(on: boolean) {
251
+ _canvasDelinkAnchors = on;
252
+ }
253
+
254
+ /** The attribute name to stamp `key` on `el` under — `href` → `data-jx-href` on de-linked anchors. */
255
+ function canvasAttrName(el: HTMLElement, key: string): string {
256
+ if (_canvasDelinkAnchors && key === "href" && (el.tagName === "A" || el.tagName === "AREA")) {
257
+ return "data-jx-href";
258
+ }
259
+ return key;
260
+ }
261
+
148
262
  /**
149
263
  * Build the reactive scope (state) from the document using the five-shape detection algorithm.
150
264
  *
@@ -711,19 +825,22 @@ export function applyStyle(
711
825
  if (prop.startsWith("--")) {
712
826
  if (isTemplateString(val)) {
713
827
  effect(() => {
714
- el.style.setProperty(prop, evaluateTemplate(val, state));
828
+ el.style.setProperty(prop, transposeCanvasUnits(evaluateTemplate(val, state)));
715
829
  });
716
830
  } else {
717
- el.style.setProperty(prop, scalar);
831
+ el.style.setProperty(prop, transposeCanvasUnits(scalar));
718
832
  }
719
833
  } else if (isTemplateString(val)) {
720
834
  effect(() => {
721
- (el.style as unknown as Record<string, string>)[prop] = evaluateTemplate(val, state);
835
+ (el.style as unknown as Record<string, string>)[prop] = transposeCanvasUnits(
836
+ evaluateTemplate(val, state),
837
+ );
722
838
  });
723
839
  } else if (mediaOverriddenProps.has(prop)) {
840
+ // Goes through toCSSText (which transposes) — don't double-transpose here.
724
841
  baseDecls[prop] = scalar;
725
842
  } else {
726
- (el.style as unknown as Record<string, string>)[prop] = scalar;
843
+ (el.style as unknown as Record<string, string>)[prop] = transposeCanvasUnits(scalar);
727
844
  }
728
845
  }
729
846
 
@@ -859,12 +976,13 @@ export function reapplyStyle(
859
976
  */
860
977
  function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue>, state: JxScope) {
861
978
  for (const [k, v] of Object.entries(attrs)) {
979
+ const attr = canvasAttrName(el, k);
862
980
  if (isRefObj(v)) {
863
- effect(() => el.setAttribute(k, String(resolveRef(v.$ref, state) ?? "")));
981
+ effect(() => el.setAttribute(attr, String(resolveRef(v.$ref, state) ?? "")));
864
982
  } else if (isTemplateString(v)) {
865
- effect(() => el.setAttribute(k, String(evaluateTemplate(v, state))));
983
+ effect(() => el.setAttribute(attr, String(evaluateTemplate(v, state))));
866
984
  } else {
867
- el.setAttribute(k, String(v));
985
+ el.setAttribute(attr, String(v));
868
986
  }
869
987
  }
870
988
  }
@@ -1549,7 +1667,7 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
1549
1667
 
1550
1668
  /** @param {JxScope} resolvedConfig */
1551
1669
  const doResolve = (resolvedConfig: JxScope) =>
1552
- fetch("/__jx_resolve__", {
1670
+ fetch(resolveProxyPath("/__jx_resolve__"), {
1553
1671
  body: JSON.stringify({
1554
1672
  $base: base,
1555
1673
  $export: def.$export,
@@ -1700,7 +1818,7 @@ async function resolveServerFunctionViaProxy(
1700
1818
 
1701
1819
  /** @param {JxScope} args */
1702
1820
  const doResolve = (args: JxScope) =>
1703
- fetch("/__jx_server__", {
1821
+ fetch(resolveProxyPath("/__jx_server__"), {
1704
1822
  body: JSON.stringify({
1705
1823
  $base: base,
1706
1824
  $export: def.$export,
@@ -1852,15 +1970,30 @@ export function camelToKebab(s: string) {
1852
1970
  export function toCSSText(rules: Record<string, unknown> | object) {
1853
1971
  return Object.entries(rules)
1854
1972
  .filter(([k, v]) => !isNestedSelector(k) && (v === null || typeof v !== "object"))
1855
- .map(([p, v]) => `${camelToKebab(p)}: ${v}`)
1973
+ .map(([p, v]) => `${camelToKebab(p)}: ${transposeCanvasUnits(String(v))}`)
1856
1974
  .join("; ");
1857
1975
  }
1858
1976
 
1859
1977
  // ─── Custom Element Registration ──────────────────────────────────────────────
1860
1978
 
1861
- let _rootMedia = {};
1979
+ let _rootMedia: Record<string, string> = {};
1862
1980
  const _elementDefs = new Map();
1863
1981
 
1982
+ /**
1983
+ * Seed the module-level root `$media` map used as the fallback for components that declare their
1984
+ * own `@--name` style blocks but carry no own `$media` (buildScope ~279-280). `Jx()` sets this from
1985
+ * the document during a full top-level render, but the Studio iframe canvas calls `buildScope`/
1986
+ * `renderNode` directly (never `Jx()`), so without seeding it a component's `@--md` would resolve
1987
+ * to the invalid `@media md`. Callers on the direct path MUST set it (with the merged `$media`)
1988
+ * before `buildScope`, and re-set it every render so a stale map from a previous document cannot
1989
+ * leak.
1990
+ *
1991
+ * @param {Record<string, string>} map
1992
+ */
1993
+ export function setRootMedia(map: Record<string, string>): void {
1994
+ _rootMedia = map ?? {};
1995
+ }
1996
+
1864
1997
  /**
1865
1998
  * Resolve and register $elements entries (depth-first).
1866
1999
  *
@@ -2000,6 +2133,13 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
2000
2133
  }
2001
2134
 
2002
2135
  async connectedCallback() {
2136
+ // An element carrying `data-jx-definition-root` IS the definition being rendered by an
2137
+ // External renderer (the studio canvas editing this component's own document) — its subtree
2138
+ // Is authored DOM, not an instantiation site. Self-initializing here would wipe that tree
2139
+ // And re-render it with default state (the "component editor shows a live instance" bug).
2140
+ if (this.dataset.jxDefinitionRoot !== undefined) {
2141
+ return;
2142
+ }
2003
2143
  if (this._jxInitialized) {
2004
2144
  return;
2005
2145
  }