@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/README.md +2 -2
- package/dist/runtime.js +204 -23
- package/dist/runtime.js.map +5 -4
- package/package.json +6 -5
- package/src/runtime.ts +381 -34
- package/src/types.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/runtime",
|
|
3
|
-
"version": "
|
|
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": "
|
|
32
|
+
"upgrade": "bun update --latest && bun install"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@jxsuite/schema": "^
|
|
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.
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1218
|
+
write(asAsset(evaluateTemplate(val as string, state)));
|
|
976
1219
|
});
|
|
977
1220
|
return;
|
|
978
1221
|
}
|
|
979
1222
|
|
|
980
|
-
|
|
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,
|
|
1284
|
+
el.style.setProperty(prop, canvasStyleValue(evaluateTemplate(val, state)));
|
|
1042
1285
|
});
|
|
1043
1286
|
} else {
|
|
1044
|
-
el.style.setProperty(prop,
|
|
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] =
|
|
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
|
|
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] =
|
|
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
|
|
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(() =>
|
|
1501
|
+
effect(() => write(resolveRef(v.$ref, state)));
|
|
1206
1502
|
} else if (isTemplateString(v)) {
|
|
1207
|
-
effect(() =>
|
|
1503
|
+
effect(() => write(evaluateAttrTemplate(v, state)));
|
|
1208
1504
|
} else {
|
|
1209
|
-
|
|
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)}: ${
|
|
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
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
(
|
|
3036
|
+
write(resolveRef(refVal.$ref, state));
|
|
2690
3037
|
});
|
|
2691
3038
|
} else if (isTemplateString(val)) {
|
|
2692
3039
|
effect(() => {
|
|
2693
|
-
(
|
|
3040
|
+
write(evaluateTemplate(val, state));
|
|
2694
3041
|
});
|
|
2695
3042
|
} else {
|
|
2696
|
-
(
|
|
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.
|