@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/README.md +2 -2
- package/dist/runtime.js +48 -3
- package/dist/runtime.js.map +3 -3
- package/package.json +6 -5
- package/src/runtime.ts +123 -2
- package/src/types.ts +11 -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
|
@@ -103,7 +103,11 @@ export async function Jx(
|
|
|
103
103
|
target: HTMLElement = document.body,
|
|
104
104
|
options?: JxRenderOptions,
|
|
105
105
|
) {
|
|
106
|
-
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;
|
|
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(
|
|
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
|
/**
|