@jxsuite/runtime 1.3.2 → 2.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/dist/runtime.js +228 -66
- package/dist/runtime.js.map +11 -8
- package/package.json +5 -4
- package/src/cookie.ts +104 -0
- package/src/expression.ts +146 -43
- package/src/pointer.ts +130 -0
- package/src/runtime.ts +140 -46
package/src/runtime.ts
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
ref,
|
|
24
24
|
toRaw,
|
|
25
25
|
} from "@vue/reactivity";
|
|
26
|
-
import { evaluateExpression, isMutating } from "./expression.ts";
|
|
26
|
+
import { evaluateExpression, evaluateOperand, isMutating } from "./expression.ts";
|
|
27
|
+
import { readPath } from "./pointer.ts";
|
|
27
28
|
import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
|
|
28
29
|
import {
|
|
29
30
|
bodyReturnsValue,
|
|
@@ -33,13 +34,16 @@ import {
|
|
|
33
34
|
isJsonObject,
|
|
34
35
|
isMappedArray,
|
|
35
36
|
isNamedFormulaDef,
|
|
37
|
+
isPrivateStateKey,
|
|
36
38
|
isPrototypeDef,
|
|
37
39
|
isRef as isRefValue,
|
|
38
40
|
isServerFnDef,
|
|
41
|
+
isTagExpression,
|
|
39
42
|
isTemplateString,
|
|
40
43
|
paramNames,
|
|
41
44
|
} from "@jxsuite/schema/guards";
|
|
42
45
|
import { runStatements } from "./statements.ts";
|
|
46
|
+
import { readCookie, serializeCookie } from "./cookie.ts";
|
|
43
47
|
import type {
|
|
44
48
|
JxAttributeValue,
|
|
45
49
|
JxClassDef,
|
|
@@ -783,8 +787,18 @@ export function renderNode(
|
|
|
783
787
|
}
|
|
784
788
|
}
|
|
785
789
|
|
|
786
|
-
|
|
787
|
-
|
|
790
|
+
/* RESOLVED ONCE, HERE, before anything branches on it.
|
|
791
|
+
A tag may be a name or a choice between names (`ElementTagName`). Resolving at the top means
|
|
792
|
+
every test below — the hyphen check that routes to the custom-element path, `customElements.get`,
|
|
793
|
+
`createElement` itself — sees a literal, exactly as it did when a tag could only be written out.
|
|
794
|
+
|
|
795
|
+
It is not tracked. `tagName` is in RESERVED_KEYS so no binding sweep reaches it, and a tag that
|
|
796
|
+
changed after mount would mean replacing the node: the subtree's listeners, its focus, its typed
|
|
797
|
+
input values and its component instances all go with it, and this runtime has no dispose walk to
|
|
798
|
+
pay that bill (renderSwitch, two hundred lines up, still leaks its previous case's effects).
|
|
799
|
+
`jx validate` warns when a tag discriminant is also an assignment target, so the case where this
|
|
800
|
+
rule bites is caught before it ships rather than found as a `<div>` that never became an `<a>`. */
|
|
801
|
+
const tagName = resolveTagName(def.tagName, localState);
|
|
788
802
|
const isCustomEl = tagName.includes("-") && customElements.get(tagName);
|
|
789
803
|
|
|
790
804
|
if (def.$props && isCustomEl) {
|
|
@@ -1249,6 +1263,34 @@ function renderMappedArrayInto(
|
|
|
1249
1263
|
});
|
|
1250
1264
|
}
|
|
1251
1265
|
|
|
1266
|
+
/**
|
|
1267
|
+
* An element's tag: a name, or the branch of a {@link JxTagExpression} the state selects.
|
|
1268
|
+
*
|
|
1269
|
+
* Every branch of the expression is itself a `TagName`, so this can only ever return something the
|
|
1270
|
+
* schema already held to the tag-name pattern — the property that lets the compiler enumerate the
|
|
1271
|
+
* candidates and lets `createElement` be called without a second thought here.
|
|
1272
|
+
*
|
|
1273
|
+
* @param {unknown} tagName The element's declared tag.
|
|
1274
|
+
* @param {JxScope} scope The state the discriminant reads.
|
|
1275
|
+
* @returns {string} A literal tag name; `"div"` when nothing is declared.
|
|
1276
|
+
*/
|
|
1277
|
+
export function resolveTagName(tagName: unknown, scope: JxScope): string {
|
|
1278
|
+
if (typeof tagName === "string") {
|
|
1279
|
+
return tagName;
|
|
1280
|
+
}
|
|
1281
|
+
if (!isTagExpression(tagName)) {
|
|
1282
|
+
return "div";
|
|
1283
|
+
}
|
|
1284
|
+
const expression = tagName.$expression;
|
|
1285
|
+
if (expression.operator === "?:") {
|
|
1286
|
+
// `evaluateOperand`, not `evaluateExpression`: the discriminant is an OPERAND — a pointer, a
|
|
1287
|
+
// Literal or a nested node — exactly as it is for a `$switch`, whose docstring names this.
|
|
1288
|
+
return evaluateOperand(expression.target, scope, null) ? expression.value : expression.initial;
|
|
1289
|
+
}
|
|
1290
|
+
const key = evaluateOperand(expression.target, scope, null);
|
|
1291
|
+
return expression.cases[String(key)] ?? expression.default;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1252
1294
|
// ─── $switch ──────────────────────────────────────────────────────────────────
|
|
1253
1295
|
|
|
1254
1296
|
/**
|
|
@@ -1259,7 +1301,7 @@ function renderMappedArrayInto(
|
|
|
1259
1301
|
*/
|
|
1260
1302
|
function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions) {
|
|
1261
1303
|
const path = options?._path ?? [];
|
|
1262
|
-
const container = document.createElement(def.tagName
|
|
1304
|
+
const container = document.createElement(resolveTagName(def.tagName, state));
|
|
1263
1305
|
|
|
1264
1306
|
if (options?.onNodeCreated) {
|
|
1265
1307
|
options.onNodeCreated(container, path, def, state);
|
|
@@ -1271,7 +1313,11 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
1271
1313
|
let generation = 0;
|
|
1272
1314
|
|
|
1273
1315
|
effect(() => {
|
|
1274
|
-
|
|
1316
|
+
/* `replaceChildren()` rather than `innerHTML = ""`: identical semantics, and it is not a
|
|
1317
|
+
Trusted Types injection sink — under `require-trusted-types-for 'script'` an innerHTML write
|
|
1318
|
+
needs a policy even when the string is empty. Four sinks that were never injecting anything
|
|
1319
|
+
is four fewer things a policy has to be permissive about. */
|
|
1320
|
+
container.replaceChildren();
|
|
1275
1321
|
if (!isRefObj(def.$switch)) {
|
|
1276
1322
|
return;
|
|
1277
1323
|
}
|
|
@@ -1283,7 +1329,8 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
1283
1329
|
|
|
1284
1330
|
if (isRefObj(caseDef)) {
|
|
1285
1331
|
// External $ref — fetch and render asynchronously
|
|
1286
|
-
|
|
1332
|
+
generation += 1;
|
|
1333
|
+
const gen = generation;
|
|
1287
1334
|
const { href } = new URL(caseDef.$ref, location.href);
|
|
1288
1335
|
resolve(href)
|
|
1289
1336
|
.then(async (doc) => {
|
|
@@ -1294,7 +1341,7 @@ function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions)
|
|
|
1294
1341
|
if (gen !== generation) {
|
|
1295
1342
|
return;
|
|
1296
1343
|
}
|
|
1297
|
-
container.
|
|
1344
|
+
container.replaceChildren();
|
|
1298
1345
|
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
1299
1346
|
container.append(renderNode(doc, childScope, childOpts));
|
|
1300
1347
|
})
|
|
@@ -1441,38 +1488,21 @@ export async function resolvePrototype(
|
|
|
1441
1488
|
case "Cookie": {
|
|
1442
1489
|
const name = def.name ?? key;
|
|
1443
1490
|
const read = () => {
|
|
1444
|
-
const
|
|
1445
|
-
if (
|
|
1491
|
+
const raw = readCookie(document.cookie, name);
|
|
1492
|
+
if (raw === null) {
|
|
1446
1493
|
return null;
|
|
1447
1494
|
}
|
|
1448
1495
|
try {
|
|
1449
|
-
return JSON.parse(decodeURIComponent(
|
|
1496
|
+
return JSON.parse(decodeURIComponent(raw));
|
|
1450
1497
|
} catch {
|
|
1451
|
-
return
|
|
1498
|
+
return raw;
|
|
1452
1499
|
}
|
|
1453
1500
|
};
|
|
1454
1501
|
const cookieState: Ref<unknown> = ref(read() ?? def.default ?? null);
|
|
1455
1502
|
// Persist on change
|
|
1456
1503
|
effect(() => {
|
|
1457
|
-
const v = cookieState.value;
|
|
1458
|
-
let s = `${name}=${encodeURIComponent(JSON.stringify(v))}`;
|
|
1459
|
-
if (def.maxAge !== undefined) {
|
|
1460
|
-
s += `; Max-Age=${def.maxAge}`;
|
|
1461
|
-
}
|
|
1462
|
-
if (def.path) {
|
|
1463
|
-
s += `; Path=${def.path}`;
|
|
1464
|
-
}
|
|
1465
|
-
if (def.domain) {
|
|
1466
|
-
s += `; Domain=${def.domain}`;
|
|
1467
|
-
}
|
|
1468
|
-
if (def.secure) {
|
|
1469
|
-
s += `; Secure`;
|
|
1470
|
-
}
|
|
1471
|
-
if (def.sameSite) {
|
|
1472
|
-
s += `; SameSite=${def.sameSite}`;
|
|
1473
|
-
}
|
|
1474
1504
|
// oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
|
|
1475
|
-
document.cookie =
|
|
1505
|
+
document.cookie = serializeCookie(name, cookieState.value, def);
|
|
1476
1506
|
});
|
|
1477
1507
|
return cookieState;
|
|
1478
1508
|
}
|
|
@@ -2069,15 +2099,16 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
2069
2099
|
return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
|
|
2070
2100
|
}
|
|
2071
2101
|
if (refPath.startsWith("#/state/")) {
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
return state[sub];
|
|
2076
|
-
}
|
|
2077
|
-
return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
|
|
2102
|
+
// One call, not a hand-split leading token: slicing at the first `/` skipped unescaping it, so
|
|
2103
|
+
// `#/state/a~1b/c` looked for a member called `a~1b` rather than `a/b`.
|
|
2104
|
+
return readPath(state, refPath.slice("#/state/".length));
|
|
2078
2105
|
}
|
|
2079
2106
|
if (refPath.startsWith("parent#/")) {
|
|
2080
|
-
|
|
2107
|
+
/*
|
|
2108
|
+
* A prop name may be a path into the prop. This read the whole path as one key and returned
|
|
2109
|
+
* undefined for `parent#/user/name`, while the compiler lowered it to a walk.
|
|
2110
|
+
*/
|
|
2111
|
+
return readPath(state, refPath.slice("parent#/".length));
|
|
2081
2112
|
}
|
|
2082
2113
|
if (refPath.startsWith("window#/")) {
|
|
2083
2114
|
return getPath(globalThis.window, refPath.slice("window#/".length));
|
|
@@ -2085,7 +2116,7 @@ export function resolveRef(refPath: string, state: JxScope) {
|
|
|
2085
2116
|
if (refPath.startsWith("document#/")) {
|
|
2086
2117
|
return getPath(globalThis.document, refPath.slice("document#/".length));
|
|
2087
2118
|
}
|
|
2088
|
-
return state
|
|
2119
|
+
return readPath(state, refPath) ?? null;
|
|
2089
2120
|
}
|
|
2090
2121
|
|
|
2091
2122
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
@@ -2117,16 +2148,51 @@ function isNestedSelector(k: string) {
|
|
|
2117
2148
|
}
|
|
2118
2149
|
|
|
2119
2150
|
/**
|
|
2151
|
+
* Walk a path off a value. Delegates to the one tokenizer (`pointer.ts`) — this used to split on
|
|
2152
|
+
* `/[./]/`, which no other path in the codebase agreed with.
|
|
2153
|
+
*
|
|
2120
2154
|
* @param {unknown} obj
|
|
2121
2155
|
* @param {string} path
|
|
2122
2156
|
* @returns {unknown}
|
|
2123
2157
|
*/
|
|
2124
2158
|
function getPath(obj: unknown, path: string) {
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2159
|
+
return readPath(obj, path);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/** Keys already reported, so a component rendered in a loop warns once rather than per instance. */
|
|
2163
|
+
const _privatePropWarned = new Set<string>();
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Refuse a `$props` write against a private (`#`) state key — `spec.md` §5.6.
|
|
2167
|
+
*
|
|
2168
|
+
* Ignored rather than thrown. A `$props` block naming a private entry is an authoring mistake, not
|
|
2169
|
+
* a broken document: the rest of the instance is well-formed and refusing to render it would turn a
|
|
2170
|
+
* typo into a blank page. But silence was the previous behavior and it is the wrong kind — the
|
|
2171
|
+
* author sees a prop that looks accepted and does nothing — so the write is dropped and named.
|
|
2172
|
+
*
|
|
2173
|
+
* @param {string} key
|
|
2174
|
+
* @param {string} [where] - The site refusing it, for a message that points somewhere
|
|
2175
|
+
* @returns {boolean} True when the key is private and the caller must skip it
|
|
2176
|
+
*/
|
|
2177
|
+
function refusePrivateProp(key: string, where?: string): boolean {
|
|
2178
|
+
if (!isPrivateStateKey(key)) {
|
|
2179
|
+
return false;
|
|
2180
|
+
}
|
|
2181
|
+
const seen = where === undefined ? key : `${where}:${key}`;
|
|
2182
|
+
if (!_privatePropWarned.has(seen)) {
|
|
2183
|
+
_privatePropWarned.add(seen);
|
|
2184
|
+
console.warn(
|
|
2185
|
+
`Jx: "${key}" is private state (spec.md §5.6) and cannot be set through $props` +
|
|
2186
|
+
`${where === undefined ? "" : ` (${where})`}. The write was ignored; rename the entry ` +
|
|
2187
|
+
"without the leading # to make it part of the component's interface.",
|
|
2188
|
+
);
|
|
2128
2189
|
}
|
|
2129
|
-
return
|
|
2190
|
+
return true;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/** Tests only — the warn-once set outlives a single render by design. */
|
|
2194
|
+
export function _resetPrivatePropWarnings(): void {
|
|
2195
|
+
_privatePropWarned.clear();
|
|
2130
2196
|
}
|
|
2131
2197
|
|
|
2132
2198
|
/**
|
|
@@ -2137,6 +2203,9 @@ function getPath(obj: unknown, path: string) {
|
|
|
2137
2203
|
function mergeProps(def: JxElement, parentState: JxScope): JxScope {
|
|
2138
2204
|
const child = Object.create(parentState) as JxScope;
|
|
2139
2205
|
for (const [k, v] of Object.entries(def.$props ?? {})) {
|
|
2206
|
+
if (refusePrivateProp(k, "$props")) {
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2140
2209
|
child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
|
|
2141
2210
|
}
|
|
2142
2211
|
return child;
|
|
@@ -2325,7 +2394,12 @@ function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
|
2325
2394
|
el.setAttribute(k, String(v));
|
|
2326
2395
|
}
|
|
2327
2396
|
if (entry.textContent) {
|
|
2328
|
-
|
|
2397
|
+
// An object is a structured data block (JSON-LD, site-architecture.md §8.5). The compiler
|
|
2398
|
+
// Serializes it the same way, so the interpreted page and the built page agree.
|
|
2399
|
+
el.textContent =
|
|
2400
|
+
typeof entry.textContent === "object"
|
|
2401
|
+
? JSON.stringify(entry.textContent, null, 2)
|
|
2402
|
+
: entry.textContent;
|
|
2329
2403
|
}
|
|
2330
2404
|
document.head.append(el);
|
|
2331
2405
|
}
|
|
@@ -2402,6 +2476,9 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2402
2476
|
try {
|
|
2403
2477
|
const props = JSON.parse(propsAttr) as Record<string, unknown>;
|
|
2404
2478
|
for (const [key, val] of Object.entries(props)) {
|
|
2479
|
+
if (refusePrivateProp(key, "data-jx-props")) {
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2405
2482
|
if (key in (def.state ?? {})) {
|
|
2406
2483
|
state[key] = val;
|
|
2407
2484
|
}
|
|
@@ -2419,6 +2496,10 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2419
2496
|
);
|
|
2420
2497
|
for (const name of propAttrNames) {
|
|
2421
2498
|
const key = name.slice("props.".length);
|
|
2499
|
+
if (refusePrivateProp(key, "props.* attribute")) {
|
|
2500
|
+
this.removeAttribute(name);
|
|
2501
|
+
continue;
|
|
2502
|
+
}
|
|
2422
2503
|
if (key in (def.state ?? {})) {
|
|
2423
2504
|
state[key] = this.getAttribute(name);
|
|
2424
2505
|
this.removeAttribute(name);
|
|
@@ -2427,12 +2508,22 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2427
2508
|
|
|
2428
2509
|
// Merge $props set as JS properties by parent before connection
|
|
2429
2510
|
for (const key of Object.keys(def.state ?? {})) {
|
|
2511
|
+
if (isPrivateStateKey(key)) {
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2430
2514
|
if (key in this && (this as Record<string, unknown>)[key] !== undefined) {
|
|
2431
2515
|
state[key] = (this as Record<string, unknown>)[key];
|
|
2432
2516
|
}
|
|
2433
2517
|
}
|
|
2434
|
-
|
|
2518
|
+
/*
|
|
2519
|
+
* Set up property getters/setters that forward into reactive state. Private entries get NO
|
|
2520
|
+
* accessor: the property-first interface IS the props mechanism, so defining one would leave
|
|
2521
|
+
* `el["#cache"] = x` writing straight through everything above.
|
|
2522
|
+
*/
|
|
2435
2523
|
for (const key of Object.keys(def.state ?? {})) {
|
|
2524
|
+
if (isPrivateStateKey(key)) {
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2436
2527
|
if (!(key in HTMLElement.prototype)) {
|
|
2437
2528
|
Object.defineProperty(this, key, {
|
|
2438
2529
|
configurable: true,
|
|
@@ -2448,7 +2539,7 @@ export async function defineElement(source: string | JxDocument, baseUrl?: strin
|
|
|
2448
2539
|
|
|
2449
2540
|
// Capture light DOM children (for slot distribution) before rendering
|
|
2450
2541
|
const slottedChildren = [...this.childNodes];
|
|
2451
|
-
this.
|
|
2542
|
+
this.replaceChildren();
|
|
2452
2543
|
|
|
2453
2544
|
// Custom elements default to display:inline — use block so they behave as
|
|
2454
2545
|
// Containers (matching <div> semantics). The component's own style can
|
|
@@ -2522,7 +2613,7 @@ function renderCustomElementWithProps(
|
|
|
2522
2613
|
options?: JxRenderOptions,
|
|
2523
2614
|
path?: JxPath,
|
|
2524
2615
|
) {
|
|
2525
|
-
const el = document.createElement(def.tagName
|
|
2616
|
+
const el = document.createElement(resolveTagName(def.tagName, state));
|
|
2526
2617
|
|
|
2527
2618
|
if (options?.onNodeCreated) {
|
|
2528
2619
|
options.onNodeCreated(el, path ?? [], def, state);
|
|
@@ -2530,6 +2621,9 @@ function renderCustomElementWithProps(
|
|
|
2530
2621
|
|
|
2531
2622
|
// Set JS properties from $props (before connection)
|
|
2532
2623
|
for (const [key, val] of Object.entries(def.$props ?? {})) {
|
|
2624
|
+
if (refusePrivateProp(key, "$props")) {
|
|
2625
|
+
continue;
|
|
2626
|
+
}
|
|
2533
2627
|
if (isRefObj(val)) {
|
|
2534
2628
|
const refVal = val;
|
|
2535
2629
|
const resolved = resolveRef(refVal.$ref, state);
|
|
@@ -2596,7 +2690,7 @@ function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
|
|
|
2596
2690
|
const name = slot.getAttribute("name");
|
|
2597
2691
|
const matches = name ? (named.get(name) ?? []) : unnamed;
|
|
2598
2692
|
if (matches.length > 0) {
|
|
2599
|
-
slot.
|
|
2693
|
+
slot.replaceChildren();
|
|
2600
2694
|
for (const child of matches) {
|
|
2601
2695
|
slot.append(child);
|
|
2602
2696
|
}
|