@jxsuite/runtime 0.28.2 → 0.28.4
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 +536 -329
- package/dist/runtime.js.map +8 -7
- package/package.json +3 -3
- package/src/expression.ts +198 -97
- package/src/runtime.ts +621 -462
- package/src/types.ts +13 -2
package/src/runtime.ts
CHANGED
|
@@ -13,9 +13,33 @@
|
|
|
13
13
|
* @module jx
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { computed, effect, isRef, onEffectCleanup, reactive, ref } from "@vue/reactivity";
|
|
17
17
|
import { evaluateExpression, isMutating } from "./expression.ts";
|
|
18
|
-
import type {
|
|
18
|
+
import type { DynamicClass, JxEventHandler, JxPath, JxRenderOptions, JxScope } from "./types.ts";
|
|
19
|
+
import {
|
|
20
|
+
isExpressionDef,
|
|
21
|
+
isFunctionDef,
|
|
22
|
+
isJsonObject,
|
|
23
|
+
isMappedArray,
|
|
24
|
+
isPrototypeDef,
|
|
25
|
+
isRef as isRefValue,
|
|
26
|
+
isServerFnDef,
|
|
27
|
+
paramNames,
|
|
28
|
+
} from "@jxsuite/schema/guards";
|
|
29
|
+
import type {
|
|
30
|
+
JxAttributeValue,
|
|
31
|
+
JxClassDef,
|
|
32
|
+
JxDocument,
|
|
33
|
+
JxElement,
|
|
34
|
+
JxFunctionDef,
|
|
35
|
+
JxHeadEntry,
|
|
36
|
+
JxMappedArray,
|
|
37
|
+
JxPrototypeDef,
|
|
38
|
+
JxRef,
|
|
39
|
+
JxServerFnDef,
|
|
40
|
+
JxStyle,
|
|
41
|
+
} from "@jxsuite/schema/types";
|
|
42
|
+
import type { Ref } from "@vue/reactivity";
|
|
19
43
|
|
|
20
44
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
21
45
|
|
|
@@ -26,14 +50,14 @@ import type { JxRenderOptions, DynamicClass, JxPath } from "./types.ts";
|
|
|
26
50
|
* import { Jx } from "@jxsuite/runtime";
|
|
27
51
|
* const state = await Jx("./counter.json", document.getElementById("app"));
|
|
28
52
|
*
|
|
29
|
-
* @param {string |
|
|
53
|
+
* @param {string | JxDocument} source - Path to .json file, URL, or raw document object
|
|
30
54
|
* @param {HTMLElement} [target] Default is `document.body`
|
|
31
55
|
* @param {JxRenderOptions} [options]
|
|
32
|
-
* @returns {Promise<
|
|
33
|
-
*
|
|
56
|
+
* @returns {Promise<JxScope>} Resolves with the live component scope (state reactive
|
|
57
|
+
* proxy)
|
|
34
58
|
*/
|
|
35
59
|
export async function Jx(
|
|
36
|
-
source: string |
|
|
60
|
+
source: string | JxDocument,
|
|
37
61
|
target: HTMLElement = document.body,
|
|
38
62
|
options?: JxRenderOptions,
|
|
39
63
|
) {
|
|
@@ -55,21 +79,30 @@ export async function Jx(
|
|
|
55
79
|
}
|
|
56
80
|
|
|
57
81
|
const state = await buildScope(doc, {}, base);
|
|
58
|
-
target.
|
|
59
|
-
if (typeof state.onMount === "function")
|
|
82
|
+
target.append(renderNode(doc, state, options));
|
|
83
|
+
if (typeof state.onMount === "function") {
|
|
84
|
+
state.onMount(state);
|
|
85
|
+
}
|
|
60
86
|
return state;
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
// ─── Step 1: Resolve ──────────────────────────────────────────────────────────
|
|
64
90
|
|
|
65
|
-
const _resolveCache = new Map<string, Promise<
|
|
91
|
+
const _resolveCache = new Map<string, Promise<JxDocument>>();
|
|
66
92
|
|
|
67
|
-
export async function resolve(source: string |
|
|
68
|
-
if (typeof source !== "string")
|
|
69
|
-
|
|
93
|
+
export async function resolve(source: string | JxDocument): Promise<JxDocument> {
|
|
94
|
+
if (typeof source !== "string") {
|
|
95
|
+
return source;
|
|
96
|
+
}
|
|
97
|
+
if (_resolveCache.has(source)) {
|
|
98
|
+
return _resolveCache.get(source)!;
|
|
99
|
+
}
|
|
70
100
|
const p = fetch(source).then((res) => {
|
|
71
|
-
if (!res.ok)
|
|
72
|
-
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
|
|
103
|
+
}
|
|
104
|
+
// Trust boundary: fetched sources are Jx documents by contract.
|
|
105
|
+
return res.json() as Promise<JxDocument>;
|
|
73
106
|
});
|
|
74
107
|
_resolveCache.set(source, p);
|
|
75
108
|
return p;
|
|
@@ -100,22 +133,24 @@ export function setSkipServerFunctions(v: boolean) {
|
|
|
100
133
|
}
|
|
101
134
|
|
|
102
135
|
/** @deprecated No longer needed — ContentCollection/ContentEntry resolve via generic class path */
|
|
103
|
-
export function setSkipContentResolution(_v: boolean) {
|
|
136
|
+
export function setSkipContentResolution(_v: boolean) {
|
|
137
|
+
// No-op retained for API compatibility
|
|
138
|
+
}
|
|
104
139
|
|
|
105
140
|
/**
|
|
106
141
|
* Build the reactive scope (state) from the document using the five-shape detection algorithm.
|
|
107
142
|
*
|
|
108
|
-
* @param {
|
|
109
|
-
* @param {
|
|
143
|
+
* @param {JxDocument} doc
|
|
144
|
+
* @param {JxScope} [parentScope] Default is `{}`
|
|
110
145
|
* @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
|
|
111
|
-
* @returns {Promise<
|
|
146
|
+
* @returns {Promise<JxScope>} Reactive proxy (state)
|
|
112
147
|
*/
|
|
113
148
|
export async function buildScope(
|
|
114
|
-
doc:
|
|
115
|
-
parentScope:
|
|
149
|
+
doc: JxDocument,
|
|
150
|
+
parentScope: JxScope = {},
|
|
116
151
|
base: string = location.href,
|
|
117
152
|
) {
|
|
118
|
-
const raw:
|
|
153
|
+
const raw: JxScope = {};
|
|
119
154
|
|
|
120
155
|
// Merge parent scope properties
|
|
121
156
|
for (const [key, val] of Object.entries(parentScope)) {
|
|
@@ -127,23 +162,16 @@ export async function buildScope(
|
|
|
127
162
|
// Pass 0: resolve bare $prototype names via import map
|
|
128
163
|
const imports = doc.imports ?? {};
|
|
129
164
|
for (const [, def] of Object.entries(defs)) {
|
|
130
|
-
if (
|
|
131
|
-
def
|
|
132
|
-
typeof def === "object" &&
|
|
133
|
-
!Array.isArray(def) &&
|
|
134
|
-
(def as Record<string, unknown>).$prototype &&
|
|
135
|
-
(def as Record<string, unknown>).$prototype !== "Function" &&
|
|
136
|
-
!(def as Record<string, unknown>).$src
|
|
137
|
-
) {
|
|
138
|
-
const mapped = imports[(def as Record<string, unknown>).$prototype as string];
|
|
165
|
+
if (isPrototypeDef(def) && !def.$src) {
|
|
166
|
+
const mapped = imports[def.$prototype];
|
|
139
167
|
if (mapped) {
|
|
140
|
-
if (
|
|
168
|
+
if (!mapped.endsWith(".class.json")) {
|
|
141
169
|
console.warn(
|
|
142
|
-
`Jx: import "${
|
|
170
|
+
`Jx: import "${def.$prototype}" must map to a .class.json path, got "${mapped}"`,
|
|
143
171
|
);
|
|
144
172
|
continue;
|
|
145
173
|
}
|
|
146
|
-
|
|
174
|
+
def.$src = mapped;
|
|
147
175
|
}
|
|
148
176
|
}
|
|
149
177
|
}
|
|
@@ -152,8 +180,10 @@ export async function buildScope(
|
|
|
152
180
|
for (const [key, def] of Object.entries(defs)) {
|
|
153
181
|
// 1. String value
|
|
154
182
|
if (typeof def === "string") {
|
|
155
|
-
if (!def.includes("${"))
|
|
156
|
-
|
|
183
|
+
if (!def.includes("${")) {
|
|
184
|
+
raw[key] = def;
|
|
185
|
+
} // Shape 1: naked string
|
|
186
|
+
continue; // Template strings handled in second pass
|
|
157
187
|
}
|
|
158
188
|
|
|
159
189
|
// 2. Number, boolean, null
|
|
@@ -170,15 +200,22 @@ export async function buildScope(
|
|
|
170
200
|
|
|
171
201
|
// 4. Object
|
|
172
202
|
if (typeof def === "object") {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
203
|
+
if (def.$prototype) {
|
|
204
|
+
continue;
|
|
205
|
+
} // Handled in later passes
|
|
206
|
+
if (isExpressionDef(def)) {
|
|
207
|
+
continue;
|
|
208
|
+
} // Handled in pass 2.5
|
|
209
|
+
if (isServerFnDef(def)) {
|
|
210
|
+
continue;
|
|
211
|
+
} // Handled in fifth pass
|
|
212
|
+
if ("default" in def) {
|
|
213
|
+
raw[key] = def.default;
|
|
179
214
|
continue;
|
|
180
215
|
} // Shape 2: expanded signal
|
|
181
|
-
if (hasSchemaKeywords(
|
|
216
|
+
if (hasSchemaKeywords(def)) {
|
|
217
|
+
continue;
|
|
218
|
+
} // Shape 2b: pure type def
|
|
182
219
|
raw[key] = def; // Shape 1: plain object
|
|
183
220
|
}
|
|
184
221
|
}
|
|
@@ -195,14 +232,11 @@ export async function buildScope(
|
|
|
195
232
|
|
|
196
233
|
// Pass 2.5: $expression entries (Shape 5)
|
|
197
234
|
for (const [key, def] of Object.entries(defs)) {
|
|
198
|
-
if (
|
|
199
|
-
const node =
|
|
200
|
-
operator: string;
|
|
201
|
-
target: unknown;
|
|
202
|
-
value?: unknown;
|
|
203
|
-
};
|
|
235
|
+
if (isExpressionDef(def)) {
|
|
236
|
+
const node = def.$expression;
|
|
204
237
|
if (isMutating(node.operator)) {
|
|
205
|
-
|
|
238
|
+
const handler: JxEventHandler = (s, event) => evaluateExpression(node, s, event);
|
|
239
|
+
state[key] = handler;
|
|
206
240
|
} else {
|
|
207
241
|
state[key] = computed(() => evaluateExpression(node, state, null));
|
|
208
242
|
}
|
|
@@ -211,34 +245,23 @@ export async function buildScope(
|
|
|
211
245
|
|
|
212
246
|
// Third pass: $prototype: "Function" entries
|
|
213
247
|
for (const [key, def] of Object.entries(defs)) {
|
|
214
|
-
if (
|
|
215
|
-
state[key] = await resolveFunction(def
|
|
248
|
+
if (isFunctionDef(def)) {
|
|
249
|
+
state[key] = await resolveFunction(def, state, key, base);
|
|
216
250
|
}
|
|
217
251
|
}
|
|
218
252
|
|
|
219
253
|
// Fourth pass: other $prototype entries (Request, Set, Map, etc.)
|
|
220
254
|
for (const [key, def] of Object.entries(defs)) {
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
(def as Record<string, unknown>)?.$prototype &&
|
|
224
|
-
(def as Record<string, unknown>).$prototype !== "Function"
|
|
225
|
-
) {
|
|
226
|
-
state[key] = await resolvePrototype(def as Record<string, any>, state, key, base);
|
|
255
|
+
if (isPrototypeDef(def)) {
|
|
256
|
+
state[key] = await resolvePrototype(def, state, key, base);
|
|
227
257
|
}
|
|
228
258
|
}
|
|
229
259
|
|
|
230
260
|
// Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
|
|
231
261
|
if (!_serverFnConfig.skip) {
|
|
232
262
|
for (const [key, def] of Object.entries(defs)) {
|
|
233
|
-
if (
|
|
234
|
-
def
|
|
235
|
-
typeof def === "object" &&
|
|
236
|
-
(def as Record<string, unknown>).timing === "server" &&
|
|
237
|
-
(def as Record<string, unknown>).$src &&
|
|
238
|
-
(def as Record<string, unknown>).$export &&
|
|
239
|
-
!(def as Record<string, unknown>).$prototype
|
|
240
|
-
) {
|
|
241
|
-
state[key] = await resolveServerFunction(def as Record<string, any>, state, key, base);
|
|
263
|
+
if (isServerFnDef(def)) {
|
|
264
|
+
state[key] = await resolveServerFunction(def, state, key, base);
|
|
242
265
|
}
|
|
243
266
|
}
|
|
244
267
|
}
|
|
@@ -256,12 +279,14 @@ export async function buildScope(
|
|
|
256
279
|
* Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
|
|
257
280
|
* type definition) from Shape 1 (naked object).
|
|
258
281
|
*
|
|
259
|
-
* @param {
|
|
282
|
+
* @param {JxScope} obj
|
|
260
283
|
* @returns {boolean}
|
|
261
284
|
*/
|
|
262
|
-
function hasSchemaKeywords(obj:
|
|
285
|
+
function hasSchemaKeywords(obj: object) {
|
|
263
286
|
for (const k of Object.keys(obj)) {
|
|
264
|
-
if (SCHEMA_KEYWORDS.has(k))
|
|
287
|
+
if (SCHEMA_KEYWORDS.has(k)) {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
265
290
|
}
|
|
266
291
|
return false;
|
|
267
292
|
}
|
|
@@ -272,11 +297,11 @@ export { hasSchemaKeywords };
|
|
|
272
297
|
* `state.varName` and `$map.item` syntax.
|
|
273
298
|
*
|
|
274
299
|
* @param {string} str
|
|
275
|
-
* @param {
|
|
300
|
+
* @param {JxScope} state
|
|
276
301
|
* @returns {string}
|
|
277
302
|
*/
|
|
278
|
-
function evaluateTemplate(str: string, state:
|
|
279
|
-
const $map = state?.$map;
|
|
303
|
+
function evaluateTemplate(str: string, state: JxScope) {
|
|
304
|
+
const $map = state?.$map as { item?: unknown; index?: number } | undefined;
|
|
280
305
|
const fn = new Function("state", "$map", "item", "index", `return \`${str}\``);
|
|
281
306
|
return fn(state, $map, $map?.item, $map?.index);
|
|
282
307
|
}
|
|
@@ -292,18 +317,13 @@ const _moduleCache = new Map();
|
|
|
292
317
|
* Functions receive state as their first parameter at call time. Functions with a return statement
|
|
293
318
|
* in their body are wrapped in computed() for reactive evaluation.
|
|
294
319
|
*
|
|
295
|
-
* @param {
|
|
296
|
-
* @param {
|
|
320
|
+
* @param {JxFunctionDef} def - State entry with $prototype: "Function"
|
|
321
|
+
* @param {JxScope} state - Reactive scope proxy
|
|
297
322
|
* @param {string} key - Def key name
|
|
298
323
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
299
324
|
* @returns {Promise<unknown>}
|
|
300
325
|
*/
|
|
301
|
-
async function resolveFunction(
|
|
302
|
-
def: Record<string, any>,
|
|
303
|
-
state: Record<string, any>,
|
|
304
|
-
key: string,
|
|
305
|
-
base?: string,
|
|
306
|
-
) {
|
|
326
|
+
async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
|
|
307
327
|
if (def.body && def.$src) {
|
|
308
328
|
throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
|
|
309
329
|
}
|
|
@@ -311,8 +331,8 @@ async function resolveFunction(
|
|
|
311
331
|
const params = resolveParamNames(def);
|
|
312
332
|
const noop = new Function(...params, "");
|
|
313
333
|
Object.defineProperty(noop, "name", {
|
|
314
|
-
value: def.name ?? key,
|
|
315
334
|
configurable: true,
|
|
335
|
+
value: def.name ?? key,
|
|
316
336
|
});
|
|
317
337
|
return noop;
|
|
318
338
|
}
|
|
@@ -323,12 +343,15 @@ async function resolveFunction(
|
|
|
323
343
|
const params = resolveParamNames(def);
|
|
324
344
|
fn = new Function(...params, def.body);
|
|
325
345
|
Object.defineProperty(fn, "name", {
|
|
326
|
-
value: def.name ?? key,
|
|
327
346
|
configurable: true,
|
|
347
|
+
value: def.name ?? key,
|
|
328
348
|
});
|
|
329
349
|
} else {
|
|
330
|
-
// $src: dynamic import
|
|
350
|
+
// $src: dynamic import (the body/$src dichotomy was validated above)
|
|
331
351
|
const src = def.$src;
|
|
352
|
+
if (!src) {
|
|
353
|
+
throw new Error(`Jx: '${key}' has neither body nor $src`);
|
|
354
|
+
}
|
|
332
355
|
const exportName = def.$export ?? key;
|
|
333
356
|
let mod;
|
|
334
357
|
if (_moduleCache.has(src)) {
|
|
@@ -348,7 +371,7 @@ async function resolveFunction(
|
|
|
348
371
|
}
|
|
349
372
|
fn = mod[exportName] ?? mod.default?.[exportName];
|
|
350
373
|
if (typeof fn !== "function") {
|
|
351
|
-
throw new
|
|
374
|
+
throw new TypeError(`Jx: export "${exportName}" not found or not a function in "${src}"`);
|
|
352
375
|
}
|
|
353
376
|
}
|
|
354
377
|
|
|
@@ -377,19 +400,11 @@ async function resolveFunction(
|
|
|
377
400
|
* array) and CEM-compatible "parameters" (object array). Always ensures "state" is the first
|
|
378
401
|
* parameter.
|
|
379
402
|
*
|
|
380
|
-
* @param {
|
|
403
|
+
* @param {JxFunctionDef | JxPrototypeDef} def
|
|
381
404
|
* @returns {string[]}
|
|
382
405
|
*/
|
|
383
|
-
function resolveParamNames(def:
|
|
384
|
-
const
|
|
385
|
-
let names;
|
|
386
|
-
if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === "object") {
|
|
387
|
-
// CEM-style: [{name: "event", type: {...}}, ...]
|
|
388
|
-
names = raw.map((p) => p.name ?? p.identifier ?? "arg");
|
|
389
|
-
} else {
|
|
390
|
-
// Legacy string array: ["state", "event"] or ["event"]
|
|
391
|
-
names = raw;
|
|
392
|
-
}
|
|
406
|
+
function resolveParamNames(def: JxFunctionDef | JxPrototypeDef) {
|
|
407
|
+
const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
393
408
|
return names.length > 0 && names[0] === "state" ? names : ["state", ...names];
|
|
394
409
|
}
|
|
395
410
|
|
|
@@ -436,16 +451,16 @@ export const RESERVED_KEYS = new Set([
|
|
|
436
451
|
/**
|
|
437
452
|
* Recursively render a Jx element definition into a DOM element.
|
|
438
453
|
*
|
|
439
|
-
* @param {
|
|
440
|
-
* @param {
|
|
454
|
+
* @param {JxElement | string | number | boolean} def
|
|
455
|
+
* @param {JxScope} state - Reactive scope proxy (or child scope via Object.create)
|
|
441
456
|
* @param {JxRenderOptions} [options]
|
|
442
457
|
* @returns {HTMLElement | Text}
|
|
443
458
|
*/
|
|
444
459
|
export function renderNode(
|
|
445
|
-
def:
|
|
446
|
-
state:
|
|
460
|
+
def: JxElement | string | number | boolean,
|
|
461
|
+
state: JxScope,
|
|
447
462
|
options?: JxRenderOptions,
|
|
448
|
-
) {
|
|
463
|
+
): HTMLElement | Text {
|
|
449
464
|
const path = options?._path ?? [];
|
|
450
465
|
|
|
451
466
|
// Text node children: bare strings/numbers/booleans produce DOM Text nodes
|
|
@@ -463,7 +478,9 @@ export function renderNode(
|
|
|
463
478
|
let localState = state;
|
|
464
479
|
for (const [key, val] of Object.entries(def)) {
|
|
465
480
|
if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
|
|
466
|
-
if (localState === state)
|
|
481
|
+
if (localState === state) {
|
|
482
|
+
localState = Object.create(state);
|
|
483
|
+
}
|
|
467
484
|
localState[key] = isRefObj(val) ? resolveRef(val.$ref, state) : val;
|
|
468
485
|
}
|
|
469
486
|
}
|
|
@@ -480,21 +497,32 @@ export function renderNode(
|
|
|
480
497
|
const { $props: _$props, ...rest } = def;
|
|
481
498
|
return renderNode(rest, mergeProps(def, localState), options);
|
|
482
499
|
}
|
|
483
|
-
if (def.$switch)
|
|
484
|
-
|
|
500
|
+
if (def.$switch) {
|
|
501
|
+
return renderSwitch(def, localState, options);
|
|
502
|
+
}
|
|
503
|
+
if (isMappedArray(def.children)) {
|
|
504
|
+
return renderMappedArray(def, def.children, localState, options);
|
|
505
|
+
}
|
|
485
506
|
|
|
486
507
|
const el = document.createElement(tagName);
|
|
487
508
|
|
|
488
|
-
if (options?.onNodeCreated)
|
|
509
|
+
if (options?.onNodeCreated) {
|
|
510
|
+
options.onNodeCreated(el, path, def);
|
|
511
|
+
}
|
|
489
512
|
|
|
490
513
|
applyProperties(el, def, localState);
|
|
491
|
-
applyStyle(
|
|
514
|
+
applyStyle(
|
|
515
|
+
el,
|
|
516
|
+
def.style ?? {},
|
|
517
|
+
(localState["$media"] as Record<string, string>) ?? {},
|
|
518
|
+
localState,
|
|
519
|
+
);
|
|
492
520
|
applyAttributes(el, def.attributes ?? {}, localState);
|
|
493
521
|
|
|
494
522
|
const children = Array.isArray(def.children) ? def.children : [];
|
|
495
523
|
for (let i = 0; i < children.length; i++) {
|
|
496
524
|
const childOpts = options ? { ...options, _path: [...path, "children", i] } : undefined;
|
|
497
|
-
el.
|
|
525
|
+
el.append(renderNode(children[i], localState, childOpts));
|
|
498
526
|
}
|
|
499
527
|
|
|
500
528
|
return el;
|
|
@@ -508,7 +536,7 @@ export function renderNode(
|
|
|
508
536
|
* @param {unknown} val
|
|
509
537
|
* @returns {boolean}
|
|
510
538
|
*/
|
|
511
|
-
function isTemplateString(val: unknown) {
|
|
539
|
+
function isTemplateString(val: unknown): val is string {
|
|
512
540
|
return typeof val === "string" && val.includes("${");
|
|
513
541
|
}
|
|
514
542
|
|
|
@@ -516,13 +544,17 @@ function isTemplateString(val: unknown) {
|
|
|
516
544
|
|
|
517
545
|
/**
|
|
518
546
|
* @param {HTMLElement} el
|
|
519
|
-
* @param {
|
|
520
|
-
* @param {
|
|
547
|
+
* @param {JxElement} def
|
|
548
|
+
* @param {JxScope} state
|
|
521
549
|
*/
|
|
522
|
-
function applyProperties(el: HTMLElement, def:
|
|
550
|
+
function applyProperties(el: HTMLElement, def: JxElement, state: JxScope) {
|
|
523
551
|
for (const [key, val] of Object.entries(def)) {
|
|
524
|
-
if (RESERVED_KEYS.has(key))
|
|
525
|
-
|
|
552
|
+
if (RESERVED_KEYS.has(key)) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (key.startsWith("$")) {
|
|
556
|
+
continue;
|
|
557
|
+
} // Scope bindings — handled in renderNode
|
|
526
558
|
|
|
527
559
|
if (key.startsWith("on")) {
|
|
528
560
|
// Event handler: $ref to a function
|
|
@@ -535,7 +567,7 @@ function applyProperties(el: HTMLElement, def: Record<string, any>, state: Recor
|
|
|
535
567
|
continue;
|
|
536
568
|
}
|
|
537
569
|
// Event handler: inline $prototype: "Function"
|
|
538
|
-
if (val &&
|
|
570
|
+
if (isFunctionDef(val) && val.body) {
|
|
539
571
|
const params = resolveParamNames(val);
|
|
540
572
|
const fn = new Function(...params, val.body);
|
|
541
573
|
const scope = state;
|
|
@@ -543,12 +575,8 @@ function applyProperties(el: HTMLElement, def: Record<string, any>, state: Recor
|
|
|
543
575
|
continue;
|
|
544
576
|
}
|
|
545
577
|
// Event handler: inline $expression
|
|
546
|
-
if (val
|
|
547
|
-
const node =
|
|
548
|
-
operator: string;
|
|
549
|
-
target: unknown;
|
|
550
|
-
value?: unknown;
|
|
551
|
-
};
|
|
578
|
+
if (isExpressionDef(val)) {
|
|
579
|
+
const node = val.$expression;
|
|
552
580
|
const scope = state;
|
|
553
581
|
el.addEventListener(key.slice(2), (e) => evaluateExpression(node, scope, e));
|
|
554
582
|
continue;
|
|
@@ -563,9 +591,9 @@ function applyProperties(el: HTMLElement, def: Record<string, any>, state: Recor
|
|
|
563
591
|
* @param {HTMLElement} el
|
|
564
592
|
* @param {string} key
|
|
565
593
|
* @param {unknown} val
|
|
566
|
-
* @param {
|
|
594
|
+
* @param {JxScope} state
|
|
567
595
|
*/
|
|
568
|
-
function bindProperty(el: HTMLElement, key: string, val: unknown, state:
|
|
596
|
+
function bindProperty(el: HTMLElement, key: string, val: unknown, state: JxScope) {
|
|
569
597
|
const target = el as unknown as Record<string, unknown>;
|
|
570
598
|
if (isRefObj(val)) {
|
|
571
599
|
const refVal = val as { $ref: string };
|
|
@@ -595,23 +623,23 @@ function bindProperty(el: HTMLElement, key: string, val: unknown, state: Record<
|
|
|
595
623
|
* breakpoint rules.
|
|
596
624
|
*
|
|
597
625
|
* @param {HTMLElement} el
|
|
598
|
-
* @param {
|
|
599
|
-
* @param {Record<string,
|
|
600
|
-
* @param {Record<string, any>} [state] Component scope for template string evaluation. Default is
|
|
626
|
+
* @param {JxStyle} styleDef
|
|
627
|
+
* @param {Record<string, string>} [mediaQueries] Named breakpoints from root $media. Default is
|
|
601
628
|
* `{}`
|
|
629
|
+
* @param {JxScope} [state] Component scope for template string evaluation. Default is `{}`
|
|
602
630
|
*/
|
|
603
631
|
export function applyStyle(
|
|
604
632
|
el: HTMLElement,
|
|
605
|
-
styleDef:
|
|
606
|
-
mediaQueries: Record<string,
|
|
607
|
-
state:
|
|
633
|
+
styleDef: JxStyle,
|
|
634
|
+
mediaQueries: Record<string, string> = {},
|
|
635
|
+
state: JxScope = {},
|
|
608
636
|
) {
|
|
609
|
-
const nested: Record<string,
|
|
610
|
-
const media: Record<string,
|
|
637
|
+
const nested: Record<string, JxStyle> = {};
|
|
638
|
+
const media: Record<string, JxStyle> = {};
|
|
611
639
|
const baseDecls: Record<string, string> = {};
|
|
612
640
|
|
|
613
641
|
// Collect properties overridden by media queries so we can avoid inline styles for them
|
|
614
|
-
const mediaOverriddenProps
|
|
642
|
+
const mediaOverriddenProps = new Set<string>();
|
|
615
643
|
for (const [prop, val] of Object.entries(styleDef)) {
|
|
616
644
|
if (prop.startsWith("@") && val && typeof val === "object") {
|
|
617
645
|
for (const k of Object.keys(val)) {
|
|
@@ -629,41 +657,69 @@ export function applyStyle(
|
|
|
629
657
|
}
|
|
630
658
|
|
|
631
659
|
for (const [prop, val] of Object.entries(styleDef)) {
|
|
632
|
-
if (
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
660
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
661
|
+
if (prop.startsWith("@")) {
|
|
662
|
+
media[prop] = val;
|
|
663
|
+
} else {
|
|
664
|
+
nested[prop] = val;
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (prop.startsWith("@") || isNestedSelector(prop)) {
|
|
669
|
+
// Scalar under a selector/media key — invalid style shape, drop it.
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (val === undefined) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const scalar = String(val);
|
|
676
|
+
if (prop.startsWith("--")) {
|
|
677
|
+
if (isTemplateString(val)) {
|
|
637
678
|
effect(() => {
|
|
638
679
|
el.style.setProperty(prop, evaluateTemplate(val, state));
|
|
639
680
|
});
|
|
640
|
-
else
|
|
641
|
-
|
|
681
|
+
} else {
|
|
682
|
+
el.style.setProperty(prop, scalar);
|
|
683
|
+
}
|
|
684
|
+
} else if (isTemplateString(val)) {
|
|
642
685
|
effect(() => {
|
|
643
686
|
(el.style as unknown as Record<string, string>)[prop] = evaluateTemplate(val, state);
|
|
644
687
|
});
|
|
645
|
-
else if (mediaOverriddenProps.has(prop))
|
|
646
|
-
|
|
688
|
+
} else if (mediaOverriddenProps.has(prop)) {
|
|
689
|
+
baseDecls[prop] = scalar;
|
|
690
|
+
} else {
|
|
691
|
+
(el.style as unknown as Record<string, string>)[prop] = scalar;
|
|
692
|
+
}
|
|
647
693
|
}
|
|
648
694
|
|
|
649
695
|
const hasNested = Object.keys(nested).length > 0;
|
|
650
696
|
const hasMedia = Object.keys(media).length > 0;
|
|
651
697
|
const hasBaseDecls = Object.keys(baseDecls).length > 0;
|
|
652
|
-
if (!hasNested && !hasMedia && !hasBaseDecls)
|
|
698
|
+
if (!hasNested && !hasMedia && !hasBaseDecls) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
653
701
|
|
|
654
702
|
const uid = `jx-${Math.random().toString(36).slice(2, 7)}`;
|
|
655
703
|
el.dataset.jx = uid;
|
|
656
704
|
|
|
657
705
|
let css = "";
|
|
658
706
|
const baseCSS = toCSSText(baseDecls);
|
|
659
|
-
if (baseCSS)
|
|
707
|
+
if (baseCSS) {
|
|
708
|
+
css += `[data-jx="${uid}"] { ${baseCSS} }\n`;
|
|
709
|
+
}
|
|
660
710
|
|
|
661
|
-
function emitNested(scope: string, rules:
|
|
711
|
+
function emitNested(scope: string, rules: JxStyle) {
|
|
662
712
|
const props = toCSSText(rules);
|
|
663
|
-
if (props)
|
|
713
|
+
if (props) {
|
|
714
|
+
css += `${scope} { ${props} }\n`;
|
|
715
|
+
}
|
|
664
716
|
for (const [sel, sub] of Object.entries(rules)) {
|
|
665
|
-
if (sub === null || typeof sub !== "object" || Array.isArray(sub))
|
|
666
|
-
|
|
717
|
+
if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (sel.startsWith("@")) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
667
723
|
const resolved = sel.startsWith("&")
|
|
668
724
|
? sel.replace("&", scope)
|
|
669
725
|
: sel.startsWith("[")
|
|
@@ -686,8 +742,33 @@ export function applyStyle(
|
|
|
686
742
|
emitNested(resolved, rules);
|
|
687
743
|
}
|
|
688
744
|
|
|
745
|
+
function emitMediaNested(atRule: string, parentSel: string, obj: JxStyle) {
|
|
746
|
+
for (const [sel, sub] of Object.entries(obj)) {
|
|
747
|
+
if (sub === null || typeof sub !== "object" || Array.isArray(sub)) {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (sel.startsWith("@")) {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const resolved = sel.startsWith("&")
|
|
754
|
+
? sel.replace("&", parentSel)
|
|
755
|
+
: sel.startsWith("[")
|
|
756
|
+
? `${parentSel}${sel}`
|
|
757
|
+
: sel.startsWith(":") || sel.startsWith(".")
|
|
758
|
+
? `${parentSel}${sel}`
|
|
759
|
+
: `${parentSel} ${sel}`;
|
|
760
|
+
const props = toCSSText(sub);
|
|
761
|
+
if (props) {
|
|
762
|
+
css += `${atRule} { ${resolved} { ${props} } }\n`;
|
|
763
|
+
}
|
|
764
|
+
emitMediaNested(atRule, resolved, sub);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
689
768
|
for (const [key, rules] of Object.entries(media)) {
|
|
690
|
-
if (key === "@--")
|
|
769
|
+
if (key === "@--") {
|
|
770
|
+
continue;
|
|
771
|
+
} // Base canvas width, not a real media query
|
|
691
772
|
const atRule = key.startsWith("@--")
|
|
692
773
|
? `@media ${mediaQueries[key.slice(1)] ?? key.slice(1)}`
|
|
693
774
|
: key.startsWith("@(")
|
|
@@ -695,37 +776,20 @@ export function applyStyle(
|
|
|
695
776
|
: key;
|
|
696
777
|
const scope = `[data-jx="${uid}"]`;
|
|
697
778
|
css += `${atRule} { ${scope} { ${toCSSText(rules)} } }\n`;
|
|
698
|
-
|
|
699
|
-
function emitMediaNested(parentSel: string, obj: Record<string, any>) {
|
|
700
|
-
for (const [sel, sub] of Object.entries(obj)) {
|
|
701
|
-
if (sub === null || typeof sub !== "object" || Array.isArray(sub)) continue;
|
|
702
|
-
if (sel.startsWith("@")) continue;
|
|
703
|
-
const resolved = sel.startsWith("&")
|
|
704
|
-
? sel.replace("&", parentSel)
|
|
705
|
-
: sel.startsWith("[")
|
|
706
|
-
? `${parentSel}${sel}`
|
|
707
|
-
: sel.startsWith(":") || sel.startsWith(".")
|
|
708
|
-
? `${parentSel}${sel}`
|
|
709
|
-
: `${parentSel} ${sel}`;
|
|
710
|
-
const props = toCSSText(sub);
|
|
711
|
-
if (props) css += `${atRule} { ${resolved} { ${props} } }\n`;
|
|
712
|
-
emitMediaNested(resolved, sub);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
emitMediaNested(scope, rules);
|
|
779
|
+
emitMediaNested(atRule, scope, rules);
|
|
716
780
|
}
|
|
717
781
|
|
|
718
782
|
const tag = document.createElement("style");
|
|
719
783
|
tag.textContent = css;
|
|
720
|
-
document.head.
|
|
784
|
+
document.head.append(tag);
|
|
721
785
|
}
|
|
722
786
|
|
|
723
787
|
/**
|
|
724
788
|
* @param {HTMLElement} el
|
|
725
|
-
* @param {Record<string,
|
|
726
|
-
* @param {
|
|
789
|
+
* @param {Record<string, import("@jxsuite/schema/types").JxAttributeValue>} attrs
|
|
790
|
+
* @param {JxScope} state
|
|
727
791
|
*/
|
|
728
|
-
function applyAttributes(el: HTMLElement, attrs: Record<string,
|
|
792
|
+
function applyAttributes(el: HTMLElement, attrs: Record<string, JxAttributeValue>, state: JxScope) {
|
|
729
793
|
for (const [k, v] of Object.entries(attrs)) {
|
|
730
794
|
if (isRefObj(v)) {
|
|
731
795
|
effect(() => el.setAttribute(k, String(resolveRef(v.$ref, state) ?? "")));
|
|
@@ -740,55 +804,62 @@ function applyAttributes(el: HTMLElement, attrs: Record<string, any>, state: Rec
|
|
|
740
804
|
// ─── Array mapping ────────────────────────────────────────────────────────────
|
|
741
805
|
|
|
742
806
|
/**
|
|
743
|
-
* @param {
|
|
744
|
-
* @param {
|
|
807
|
+
* @param {JxElement} def
|
|
808
|
+
* @param {import("@jxsuite/schema/types").JxMappedArray} arrayDef
|
|
809
|
+
* @param {JxScope} state
|
|
745
810
|
* @param {JxRenderOptions} [options]
|
|
746
811
|
* @returns {HTMLElement}
|
|
747
812
|
*/
|
|
748
813
|
function renderMappedArray(
|
|
749
|
-
def:
|
|
750
|
-
|
|
814
|
+
def: JxElement,
|
|
815
|
+
arrayDef: JxMappedArray,
|
|
816
|
+
state: JxScope,
|
|
751
817
|
options?: JxRenderOptions,
|
|
752
818
|
) {
|
|
753
819
|
const path = options?._path ?? [];
|
|
754
820
|
const container = document.createElement(def.tagName ?? "div");
|
|
755
821
|
|
|
756
|
-
if (options?.onNodeCreated)
|
|
822
|
+
if (options?.onNodeCreated) {
|
|
823
|
+
options.onNodeCreated(container, path, def);
|
|
824
|
+
}
|
|
757
825
|
|
|
758
826
|
applyProperties(container, def, state);
|
|
759
|
-
applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
|
|
827
|
+
applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
|
|
760
828
|
applyAttributes(container, def.attributes ?? {}, state);
|
|
761
|
-
const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } =
|
|
829
|
+
const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = arrayDef;
|
|
762
830
|
|
|
763
831
|
effect(() => {
|
|
764
832
|
container.innerHTML = "";
|
|
765
|
-
let items;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
items = itemsSrc;
|
|
833
|
+
let items: unknown;
|
|
834
|
+
items = isRefObj(itemsSrc) ? resolveRef(itemsSrc.$ref, state) : itemsSrc;
|
|
835
|
+
if (!Array.isArray(items)) {
|
|
836
|
+
return;
|
|
770
837
|
}
|
|
771
|
-
if (
|
|
772
|
-
if (filterRef) {
|
|
838
|
+
if (isRefObj(filterRef)) {
|
|
773
839
|
const fn = resolveRef(filterRef.$ref, state);
|
|
774
|
-
if (typeof fn === "function")
|
|
840
|
+
if (typeof fn === "function") {
|
|
841
|
+
items = items.filter(fn as (v: unknown) => boolean);
|
|
842
|
+
}
|
|
775
843
|
}
|
|
776
|
-
if (sortRef) {
|
|
844
|
+
if (isRefObj(sortRef)) {
|
|
777
845
|
const fn = resolveRef(sortRef.$ref, state);
|
|
778
|
-
if (typeof fn === "function")
|
|
779
|
-
items = [...items].
|
|
846
|
+
if (typeof fn === "function") {
|
|
847
|
+
items = [...(items as unknown[])].toSorted(fn as (a: unknown, b: unknown) => number);
|
|
848
|
+
}
|
|
780
849
|
}
|
|
781
850
|
|
|
782
|
-
|
|
851
|
+
for (const [index, item] of (items as unknown[]).entries()) {
|
|
783
852
|
const child = Object.create(state);
|
|
784
|
-
child.$map = {
|
|
853
|
+
child.$map = { index, item };
|
|
785
854
|
child["$map/item"] = item;
|
|
786
855
|
child["$map/index"] = index;
|
|
787
856
|
const childOpts = options
|
|
788
857
|
? { ...options, _path: [...path, "children", "map", index] }
|
|
789
858
|
: undefined;
|
|
790
|
-
|
|
791
|
-
|
|
859
|
+
if (mapDef) {
|
|
860
|
+
container.append(renderNode(mapDef, child, childOpts));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
792
863
|
});
|
|
793
864
|
|
|
794
865
|
return container;
|
|
@@ -797,53 +868,60 @@ function renderMappedArray(
|
|
|
797
868
|
// ─── $switch ──────────────────────────────────────────────────────────────────
|
|
798
869
|
|
|
799
870
|
/**
|
|
800
|
-
* @param {
|
|
801
|
-
* @param {
|
|
871
|
+
* @param {JxElement} def
|
|
872
|
+
* @param {JxScope} state
|
|
802
873
|
* @param {JxRenderOptions} [options]
|
|
803
874
|
* @returns {HTMLElement}
|
|
804
875
|
*/
|
|
805
|
-
function renderSwitch(
|
|
806
|
-
def: Record<string, any>,
|
|
807
|
-
state: Record<string, any>,
|
|
808
|
-
options?: JxRenderOptions,
|
|
809
|
-
) {
|
|
876
|
+
function renderSwitch(def: JxElement, state: JxScope, options?: JxRenderOptions) {
|
|
810
877
|
const path = options?._path ?? [];
|
|
811
878
|
const container = document.createElement(def.tagName ?? "div");
|
|
812
879
|
|
|
813
|
-
if (options?.onNodeCreated)
|
|
880
|
+
if (options?.onNodeCreated) {
|
|
881
|
+
options.onNodeCreated(container, path, def);
|
|
882
|
+
}
|
|
814
883
|
|
|
815
884
|
applyProperties(container, def, state);
|
|
816
|
-
applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
|
|
885
|
+
applyStyle(container, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
|
|
817
886
|
applyAttributes(container, def.attributes ?? {}, state);
|
|
818
887
|
let generation = 0;
|
|
819
888
|
|
|
820
889
|
effect(() => {
|
|
821
890
|
container.innerHTML = "";
|
|
891
|
+
if (!isRefObj(def.$switch)) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
822
894
|
const key = resolveRef(def.$switch.$ref, state) as string;
|
|
823
895
|
const caseDef = def.cases?.[key];
|
|
824
|
-
if (!caseDef)
|
|
896
|
+
if (!caseDef) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
825
899
|
|
|
826
900
|
if (isRefObj(caseDef)) {
|
|
827
901
|
// External $ref — fetch and render asynchronously
|
|
828
|
-
const gen =
|
|
829
|
-
const href = new URL(caseDef.$ref, location.href)
|
|
902
|
+
const gen = (generation += 1);
|
|
903
|
+
const { href } = new URL(caseDef.$ref, location.href);
|
|
830
904
|
resolve(href)
|
|
831
905
|
.then(async (doc) => {
|
|
832
|
-
if (gen !== generation)
|
|
906
|
+
if (gen !== generation) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
833
909
|
const childScope = await buildScope(doc, {}, href);
|
|
834
|
-
if (gen !== generation)
|
|
910
|
+
if (gen !== generation) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
835
913
|
container.innerHTML = "";
|
|
836
914
|
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
837
|
-
container.
|
|
915
|
+
container.append(renderNode(doc, childScope, childOpts));
|
|
838
916
|
})
|
|
839
|
-
.catch((
|
|
840
|
-
console.error("Jx $switch: failed to load external case", caseDef.$ref,
|
|
917
|
+
.catch((error: unknown) =>
|
|
918
|
+
console.error("Jx $switch: failed to load external case", caseDef.$ref, error),
|
|
841
919
|
);
|
|
842
920
|
return;
|
|
843
921
|
}
|
|
844
922
|
|
|
845
923
|
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
846
|
-
container.
|
|
924
|
+
container.append(renderNode(caseDef, state, childOpts));
|
|
847
925
|
});
|
|
848
926
|
|
|
849
927
|
return container;
|
|
@@ -857,15 +935,15 @@ function renderSwitch(
|
|
|
857
935
|
* Returns a ref() for async/persistent entries (Request, Storage, Cookie, IndexedDB), or a plain
|
|
858
936
|
* value for simple entries (Set, Map, FormData, Blob).
|
|
859
937
|
*
|
|
860
|
-
* @param {
|
|
861
|
-
* @param {
|
|
938
|
+
* @param {JxPrototypeDef} def - State entry with $prototype
|
|
939
|
+
* @param {JxScope} state - Reactive scope proxy
|
|
862
940
|
* @param {string} key - Def key (for diagnostics)
|
|
863
941
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
864
942
|
* @returns {Promise<unknown>}
|
|
865
943
|
*/
|
|
866
944
|
export async function resolvePrototype(
|
|
867
|
-
def:
|
|
868
|
-
state:
|
|
945
|
+
def: JxPrototypeDef,
|
|
946
|
+
state: JxScope,
|
|
869
947
|
key: string,
|
|
870
948
|
base?: string,
|
|
871
949
|
) {
|
|
@@ -876,7 +954,7 @@ export async function resolvePrototype(
|
|
|
876
954
|
|
|
877
955
|
switch (def.$prototype) {
|
|
878
956
|
case "Request": {
|
|
879
|
-
const s:
|
|
957
|
+
const s: Ref<unknown> = ref(null);
|
|
880
958
|
const debounceMs = def.debounce ?? 0;
|
|
881
959
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
882
960
|
|
|
@@ -886,20 +964,24 @@ export async function resolvePrototype(
|
|
|
886
964
|
if (isTemplateString(def.url)) {
|
|
887
965
|
url = evaluateTemplate(def.url, state);
|
|
888
966
|
} else {
|
|
889
|
-
url = def
|
|
967
|
+
({ url } = def);
|
|
968
|
+
}
|
|
969
|
+
if (!url || url === "undefined" || url.includes("undefined")) {
|
|
970
|
+
return;
|
|
890
971
|
}
|
|
891
|
-
if (!url || url === "undefined" || url.includes("undefined")) return;
|
|
892
972
|
|
|
893
973
|
const controller = new AbortController();
|
|
894
974
|
onEffectCleanup(() => {
|
|
895
975
|
controller.abort();
|
|
896
|
-
if (debounceTimer !== null)
|
|
976
|
+
if (debounceTimer !== null) {
|
|
977
|
+
clearTimeout(debounceTimer);
|
|
978
|
+
}
|
|
897
979
|
});
|
|
898
980
|
|
|
899
981
|
const doFetch = () =>
|
|
900
982
|
fetch(url, {
|
|
901
|
-
signal: controller.signal,
|
|
902
983
|
method: def.method ?? "GET",
|
|
984
|
+
signal: controller.signal,
|
|
903
985
|
...(def.headers && { headers: def.headers }),
|
|
904
986
|
...(def.body && {
|
|
905
987
|
body: typeof def.body === "object" ? JSON.stringify(def.body) : def.body,
|
|
@@ -909,8 +991,10 @@ export async function resolvePrototype(
|
|
|
909
991
|
.then((d) => {
|
|
910
992
|
s.value = d;
|
|
911
993
|
})
|
|
912
|
-
.catch((
|
|
913
|
-
if ((
|
|
994
|
+
.catch((error: unknown) => {
|
|
995
|
+
if (!(error instanceof Error && error.name === "AbortError")) {
|
|
996
|
+
s.value = { error: String(error) };
|
|
997
|
+
}
|
|
914
998
|
});
|
|
915
999
|
|
|
916
1000
|
if (debounceMs > 0) {
|
|
@@ -924,7 +1008,7 @@ export async function resolvePrototype(
|
|
|
924
1008
|
return s;
|
|
925
1009
|
}
|
|
926
1010
|
|
|
927
|
-
case "URLSearchParams":
|
|
1011
|
+
case "URLSearchParams": {
|
|
928
1012
|
return computed(() => {
|
|
929
1013
|
const p: Record<string, string> = {};
|
|
930
1014
|
for (const [k, v] of Object.entries(def)) {
|
|
@@ -938,6 +1022,7 @@ export async function resolvePrototype(
|
|
|
938
1022
|
}
|
|
939
1023
|
return new URLSearchParams(p).toString();
|
|
940
1024
|
});
|
|
1025
|
+
}
|
|
941
1026
|
|
|
942
1027
|
case "LocalStorage":
|
|
943
1028
|
case "SessionStorage": {
|
|
@@ -950,7 +1035,7 @@ export async function resolvePrototype(
|
|
|
950
1035
|
} catch {
|
|
951
1036
|
init = def.default ?? null;
|
|
952
1037
|
}
|
|
953
|
-
const storageState:
|
|
1038
|
+
const storageState: Ref<unknown> = ref(init);
|
|
954
1039
|
// Persist on change
|
|
955
1040
|
effect(() => {
|
|
956
1041
|
const v = storageState.value;
|
|
@@ -970,31 +1055,44 @@ export async function resolvePrototype(
|
|
|
970
1055
|
case "Cookie": {
|
|
971
1056
|
const name = def.name ?? key;
|
|
972
1057
|
const read = () => {
|
|
973
|
-
const m = document.cookie.match(new RegExp(
|
|
974
|
-
if (!m)
|
|
1058
|
+
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
|
1059
|
+
if (!m) {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
975
1062
|
try {
|
|
976
1063
|
return JSON.parse(decodeURIComponent(m[1]));
|
|
977
1064
|
} catch {
|
|
978
1065
|
return m[1];
|
|
979
1066
|
}
|
|
980
1067
|
};
|
|
981
|
-
const cookieState:
|
|
1068
|
+
const cookieState: Ref<unknown> = ref(read() ?? def.default ?? null);
|
|
982
1069
|
// Persist on change
|
|
983
1070
|
effect(() => {
|
|
984
1071
|
const v = cookieState.value;
|
|
985
1072
|
let s = `${name}=${encodeURIComponent(JSON.stringify(v))}`;
|
|
986
|
-
if (def.maxAge !== undefined)
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
if (def.
|
|
990
|
-
|
|
1073
|
+
if (def.maxAge !== undefined) {
|
|
1074
|
+
s += `; Max-Age=${def.maxAge}`;
|
|
1075
|
+
}
|
|
1076
|
+
if (def.path) {
|
|
1077
|
+
s += `; Path=${def.path}`;
|
|
1078
|
+
}
|
|
1079
|
+
if (def.domain) {
|
|
1080
|
+
s += `; Domain=${def.domain}`;
|
|
1081
|
+
}
|
|
1082
|
+
if (def.secure) {
|
|
1083
|
+
s += `; Secure`;
|
|
1084
|
+
}
|
|
1085
|
+
if (def.sameSite) {
|
|
1086
|
+
s += `; SameSite=${def.sameSite}`;
|
|
1087
|
+
}
|
|
1088
|
+
// oxlint-disable-next-line unicorn/no-document-cookie -- the Cookie $prototype IS the cookie store binding
|
|
991
1089
|
document.cookie = s;
|
|
992
1090
|
});
|
|
993
1091
|
return cookieState;
|
|
994
1092
|
}
|
|
995
1093
|
|
|
996
1094
|
case "IndexedDB": {
|
|
997
|
-
const idbState:
|
|
1095
|
+
const idbState: Ref<unknown> = ref(null);
|
|
998
1096
|
const {
|
|
999
1097
|
database,
|
|
1000
1098
|
store,
|
|
@@ -1003,54 +1101,68 @@ export async function resolvePrototype(
|
|
|
1003
1101
|
autoIncrement = true,
|
|
1004
1102
|
indexes = [],
|
|
1005
1103
|
} = def;
|
|
1104
|
+
if (!database || !store) {
|
|
1105
|
+
throw new Error(`Jx: IndexedDB entry '${key}' requires database and store`);
|
|
1106
|
+
}
|
|
1006
1107
|
const req = indexedDB.open(database, version);
|
|
1007
|
-
req.
|
|
1108
|
+
req.addEventListener("upgradeneeded", (e) => {
|
|
1008
1109
|
const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
|
|
1009
1110
|
if (!db.objectStoreNames.contains(store)) {
|
|
1010
|
-
const os = db.createObjectStore(store, {
|
|
1011
|
-
for (const i of indexes)
|
|
1111
|
+
const os = db.createObjectStore(store, { autoIncrement, keyPath });
|
|
1112
|
+
for (const i of indexes) {
|
|
1113
|
+
os.createIndex(i.name, i.keyPath, { unique: i.unique ?? false });
|
|
1114
|
+
}
|
|
1012
1115
|
}
|
|
1013
|
-
};
|
|
1014
|
-
req.
|
|
1116
|
+
});
|
|
1117
|
+
req.addEventListener("success", (e) => {
|
|
1015
1118
|
const db: IDBDatabase = (e.target as IDBOpenDBRequest).result;
|
|
1016
1119
|
idbState.value = {
|
|
1017
1120
|
database,
|
|
1018
|
-
store,
|
|
1019
|
-
version,
|
|
1020
|
-
isReady: true,
|
|
1021
1121
|
getStore: (mode: IDBTransactionMode = "readwrite") =>
|
|
1022
1122
|
Promise.resolve(db.transaction(store, mode).objectStore(store)),
|
|
1123
|
+
isReady: true,
|
|
1124
|
+
store,
|
|
1125
|
+
version,
|
|
1023
1126
|
};
|
|
1024
|
-
};
|
|
1025
|
-
req.
|
|
1127
|
+
});
|
|
1128
|
+
req.addEventListener("error", () => {
|
|
1026
1129
|
idbState.value = { error: req.error?.message };
|
|
1027
|
-
};
|
|
1130
|
+
});
|
|
1028
1131
|
return idbState;
|
|
1029
1132
|
}
|
|
1030
1133
|
|
|
1031
|
-
case "Set":
|
|
1032
|
-
return new Set(def.default
|
|
1134
|
+
case "Set": {
|
|
1135
|
+
return new Set(Array.isArray(def.default) ? def.default : []);
|
|
1136
|
+
}
|
|
1033
1137
|
|
|
1034
|
-
case "Map":
|
|
1035
|
-
return new Map(Object.entries(def.default
|
|
1138
|
+
case "Map": {
|
|
1139
|
+
return new Map(Object.entries(isJsonObject(def.default) ? def.default : {}));
|
|
1140
|
+
}
|
|
1036
1141
|
|
|
1037
1142
|
case "FormData": {
|
|
1038
1143
|
const fd = new FormData();
|
|
1039
|
-
for (const [k, v] of Object.entries(def.fields ?? {}))
|
|
1144
|
+
for (const [k, v] of Object.entries(def.fields ?? {})) {
|
|
1145
|
+
fd.append(k, v as string);
|
|
1146
|
+
}
|
|
1040
1147
|
return fd;
|
|
1041
1148
|
}
|
|
1042
1149
|
|
|
1043
|
-
case "Blob":
|
|
1044
|
-
return new Blob(def.parts ?? [], {
|
|
1150
|
+
case "Blob": {
|
|
1151
|
+
return new Blob((def.parts as BlobPart[]) ?? [], {
|
|
1152
|
+
type: typeof def.type === "string" ? def.type : "text/plain",
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1045
1155
|
|
|
1046
|
-
case "ReadableStream":
|
|
1156
|
+
case "ReadableStream": {
|
|
1047
1157
|
return null;
|
|
1158
|
+
}
|
|
1048
1159
|
|
|
1049
|
-
default:
|
|
1160
|
+
default: {
|
|
1050
1161
|
console.warn(
|
|
1051
1162
|
`Jx: unknown $prototype "${def.$prototype}" for "${key}". Did you mean to add '$src'?`,
|
|
1052
1163
|
);
|
|
1053
1164
|
return ref(null);
|
|
1165
|
+
}
|
|
1054
1166
|
}
|
|
1055
1167
|
}
|
|
1056
1168
|
|
|
@@ -1073,22 +1185,22 @@ const EXTERNAL_RESERVED = new Set([
|
|
|
1073
1185
|
/**
|
|
1074
1186
|
* Resolve an external class prototype via $src.
|
|
1075
1187
|
*
|
|
1076
|
-
* @param {
|
|
1077
|
-
* @param {
|
|
1188
|
+
* @param {JxPrototypeDef} def
|
|
1189
|
+
* @param {JxScope} state
|
|
1078
1190
|
* @param {string} key
|
|
1079
1191
|
* @param {string} [base]
|
|
1080
1192
|
* @returns {Promise<unknown>}
|
|
1081
1193
|
*/
|
|
1082
1194
|
async function resolveExternalPrototype(
|
|
1083
|
-
def:
|
|
1084
|
-
state:
|
|
1195
|
+
def: JxPrototypeDef,
|
|
1196
|
+
state: JxScope,
|
|
1085
1197
|
key: string,
|
|
1086
1198
|
base?: string,
|
|
1087
1199
|
) {
|
|
1088
1200
|
const src = def.$src;
|
|
1089
1201
|
|
|
1090
1202
|
// Non-Function $prototype must use .class.json as entrypoint
|
|
1091
|
-
if (!src.endsWith(".class.json")) {
|
|
1203
|
+
if (!src || !src.endsWith(".class.json")) {
|
|
1092
1204
|
throw new Error(
|
|
1093
1205
|
`Jx: $prototype "${def.$prototype}" requires a .class.json $src, got "${src}". ` +
|
|
1094
1206
|
`Wrap the class in a .class.json schema with $implementation.`,
|
|
@@ -1102,18 +1214,13 @@ async function resolveExternalPrototype(
|
|
|
1102
1214
|
* Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
|
|
1103
1215
|
* $implementation.
|
|
1104
1216
|
*
|
|
1105
|
-
* @param {
|
|
1217
|
+
* @param {JxScope} def - Original state entry (for config extraction)
|
|
1106
1218
|
* @param {string} src - JS module URL to import
|
|
1107
1219
|
* @param {string} exportName - Export name to look up
|
|
1108
1220
|
* @param {string} [base] - Base URL for resolution
|
|
1109
1221
|
* @returns {Promise<unknown>}
|
|
1110
1222
|
*/
|
|
1111
|
-
async function importAndInstantiate(
|
|
1112
|
-
def: Record<string, any>,
|
|
1113
|
-
src: string,
|
|
1114
|
-
exportName: string,
|
|
1115
|
-
base?: string,
|
|
1116
|
-
) {
|
|
1223
|
+
async function importAndInstantiate(def: JxScope, src: string, exportName: string, base?: string) {
|
|
1117
1224
|
let mod;
|
|
1118
1225
|
if (_moduleCache.has(src)) {
|
|
1119
1226
|
mod = _moduleCache.get(src);
|
|
@@ -1136,12 +1243,14 @@ async function importAndInstantiate(
|
|
|
1136
1243
|
throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
|
|
1137
1244
|
}
|
|
1138
1245
|
if (typeof ExportedClass !== "function") {
|
|
1139
|
-
throw new
|
|
1246
|
+
throw new TypeError(`Jx: "${exportName}" from "${src}" is not a class`);
|
|
1140
1247
|
}
|
|
1141
1248
|
|
|
1142
|
-
const config:
|
|
1249
|
+
const config: JxScope = {};
|
|
1143
1250
|
for (const [k, v] of Object.entries(def)) {
|
|
1144
|
-
if (!EXTERNAL_RESERVED.has(k))
|
|
1251
|
+
if (!EXTERNAL_RESERVED.has(k)) {
|
|
1252
|
+
config[k] = v;
|
|
1253
|
+
}
|
|
1145
1254
|
}
|
|
1146
1255
|
|
|
1147
1256
|
const instance = new ExportedClass(config);
|
|
@@ -1150,13 +1259,13 @@ async function importAndInstantiate(
|
|
|
1150
1259
|
if (typeof instance.resolve === "function") {
|
|
1151
1260
|
value = await instance.resolve();
|
|
1152
1261
|
} else if ("value" in instance) {
|
|
1153
|
-
value = instance
|
|
1262
|
+
({ value } = instance);
|
|
1154
1263
|
} else {
|
|
1155
1264
|
value = instance;
|
|
1156
1265
|
}
|
|
1157
1266
|
|
|
1158
1267
|
// Always wrap in ref for reactivity with external classes
|
|
1159
|
-
const s:
|
|
1268
|
+
const s: Ref<unknown> = ref(value);
|
|
1160
1269
|
if (typeof instance.subscribe === "function") {
|
|
1161
1270
|
instance.subscribe((newVal: unknown) => {
|
|
1162
1271
|
s.value = newVal;
|
|
@@ -1169,23 +1278,21 @@ async function importAndInstantiate(
|
|
|
1169
1278
|
* Resolve a .class.json schema-defined class. Fetches the schema, follows $implementation if
|
|
1170
1279
|
* hybrid, or constructs dynamically if self-contained.
|
|
1171
1280
|
*
|
|
1172
|
-
* @param {
|
|
1173
|
-
* @param {
|
|
1281
|
+
* @param {JxPrototypeDef} def
|
|
1282
|
+
* @param {JxScope} state
|
|
1174
1283
|
* @param {string} key
|
|
1175
1284
|
* @param {string} [base]
|
|
1176
1285
|
* @returns {Promise<unknown>}
|
|
1177
1286
|
*/
|
|
1178
|
-
async function resolveClassJson(
|
|
1179
|
-
def: Record<string, any>,
|
|
1180
|
-
state: Record<string, any>,
|
|
1181
|
-
key: string,
|
|
1182
|
-
base?: string,
|
|
1183
|
-
) {
|
|
1287
|
+
async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
|
|
1184
1288
|
const src = def.$src;
|
|
1185
|
-
|
|
1289
|
+
if (!src) {
|
|
1290
|
+
throw new Error(`Jx: class entry '${key}' has no $src`);
|
|
1291
|
+
}
|
|
1292
|
+
let classDef: JxClassDef;
|
|
1186
1293
|
|
|
1187
1294
|
// Bare specifiers (package references like @scope/pkg/file) can't be fetched directly —
|
|
1188
|
-
//
|
|
1295
|
+
// Go straight to dev proxy which can resolve them via node_modules.
|
|
1189
1296
|
const isBareSpecifier =
|
|
1190
1297
|
!src.startsWith(".") &&
|
|
1191
1298
|
!src.startsWith("/") &&
|
|
@@ -1199,8 +1306,11 @@ async function resolveClassJson(
|
|
|
1199
1306
|
try {
|
|
1200
1307
|
const url = base ? new URL(src, base).href : src;
|
|
1201
1308
|
const res = await fetch(url);
|
|
1202
|
-
if (!res.ok)
|
|
1203
|
-
|
|
1309
|
+
if (!res.ok) {
|
|
1310
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1311
|
+
}
|
|
1312
|
+
// Trust boundary: fetched .class.json sources are class definitions by contract.
|
|
1313
|
+
classDef = (await res.json()) as JxClassDef;
|
|
1204
1314
|
} catch {
|
|
1205
1315
|
// Fall back to dev proxy (server will handle .class.json resolution)
|
|
1206
1316
|
return resolveViaDevProxy(def, state, key, base);
|
|
@@ -1209,7 +1319,7 @@ async function resolveClassJson(
|
|
|
1209
1319
|
// Hybrid mode: $implementation points to the real JS module
|
|
1210
1320
|
if (classDef.$implementation) {
|
|
1211
1321
|
// If the schema references $context (e.g. content types), the browser cannot provide
|
|
1212
|
-
//
|
|
1322
|
+
// The required server-side context — go directly to dev proxy.
|
|
1213
1323
|
const schemaStr = JSON.stringify(classDef);
|
|
1214
1324
|
if (schemaStr.includes('"#/$context/')) {
|
|
1215
1325
|
return resolveViaDevProxy(def, state, key, base);
|
|
@@ -1226,9 +1336,11 @@ async function resolveClassJson(
|
|
|
1226
1336
|
|
|
1227
1337
|
// Self-contained: construct class dynamically from schema
|
|
1228
1338
|
const DynClass = classFromSchema(classDef);
|
|
1229
|
-
const config:
|
|
1339
|
+
const config: JxScope = {};
|
|
1230
1340
|
for (const [k, v] of Object.entries(def)) {
|
|
1231
|
-
if (!EXTERNAL_RESERVED.has(k))
|
|
1341
|
+
if (!EXTERNAL_RESERVED.has(k)) {
|
|
1342
|
+
config[k] = v;
|
|
1343
|
+
}
|
|
1232
1344
|
}
|
|
1233
1345
|
const instance = new DynClass(config);
|
|
1234
1346
|
|
|
@@ -1236,13 +1348,13 @@ async function resolveClassJson(
|
|
|
1236
1348
|
if (typeof instance.resolve === "function") {
|
|
1237
1349
|
value = await instance.resolve();
|
|
1238
1350
|
} else if ("value" in instance) {
|
|
1239
|
-
value = instance
|
|
1351
|
+
({ value } = instance);
|
|
1240
1352
|
} else {
|
|
1241
1353
|
value = instance;
|
|
1242
1354
|
}
|
|
1243
1355
|
|
|
1244
1356
|
// Always wrap in ref for reactivity
|
|
1245
|
-
const s:
|
|
1357
|
+
const s: Ref<unknown> = ref(value);
|
|
1246
1358
|
if (typeof instance.subscribe === "function") {
|
|
1247
1359
|
instance.subscribe((newVal: unknown) => {
|
|
1248
1360
|
s.value = newVal;
|
|
@@ -1255,43 +1367,31 @@ async function resolveClassJson(
|
|
|
1255
1367
|
* Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
|
|
1256
1368
|
* fields to _-prefixed public fields.
|
|
1257
1369
|
*
|
|
1258
|
-
* @param {
|
|
1370
|
+
* @param {import("@jxsuite/schema/types").JxClassDef} classDef
|
|
1259
1371
|
* @returns {DynamicClass}
|
|
1260
1372
|
*/
|
|
1261
|
-
function classFromSchema(classDef:
|
|
1373
|
+
function classFromSchema(classDef: JxClassDef) {
|
|
1262
1374
|
const fields = classDef.$defs?.fields ?? {};
|
|
1263
|
-
|
|
1375
|
+
// JSON objects inherit Object.prototype.constructor — only an own object value counts.
|
|
1376
|
+
const rawCtor = classDef.$defs?.constructor;
|
|
1377
|
+
const ctor = typeof rawCtor === "object" ? rawCtor : undefined;
|
|
1264
1378
|
const methods = classDef.$defs?.methods ?? {};
|
|
1265
1379
|
|
|
1266
|
-
|
|
1267
|
-
identifier?: string;
|
|
1268
|
-
access?: string;
|
|
1269
|
-
initializer?: unknown;
|
|
1270
|
-
default?: unknown;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
interface MethodDef {
|
|
1274
|
-
identifier?: string;
|
|
1275
|
-
parameters?: Record<string, unknown>[];
|
|
1276
|
-
body?: string | string[];
|
|
1277
|
-
role?: string;
|
|
1278
|
-
scope?: string;
|
|
1279
|
-
getter?: { body: string };
|
|
1280
|
-
setter?: { body: string; parameters?: Record<string, unknown>[] };
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1380
|
+
// oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
|
|
1283
1381
|
class DynClass {
|
|
1284
1382
|
constructor(config: Record<string, unknown> = {}) {
|
|
1285
|
-
for (const [key,
|
|
1286
|
-
const typedField = field as FieldDef;
|
|
1383
|
+
for (const [key, typedField] of Object.entries(fields)) {
|
|
1287
1384
|
const id = typedField.identifier ?? key;
|
|
1288
1385
|
const propName = typedField.access === "private" ? `_${id}` : id;
|
|
1289
|
-
if (config[id] !== undefined)
|
|
1290
|
-
|
|
1386
|
+
if (config[id] !== undefined) {
|
|
1387
|
+
(this as Record<string, unknown>)[propName] = config[id];
|
|
1388
|
+
} else if (typedField.initializer !== undefined) {
|
|
1291
1389
|
(this as Record<string, unknown>)[propName] = typedField.initializer;
|
|
1292
|
-
else if (typedField.default !== undefined)
|
|
1390
|
+
} else if (typedField.default !== undefined) {
|
|
1293
1391
|
(this as Record<string, unknown>)[propName] = structuredClone(typedField.default);
|
|
1294
|
-
else
|
|
1392
|
+
} else {
|
|
1393
|
+
(this as Record<string, unknown>)[propName] = null;
|
|
1394
|
+
}
|
|
1295
1395
|
}
|
|
1296
1396
|
if (ctor?.body) {
|
|
1297
1397
|
const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
|
|
@@ -1300,26 +1400,29 @@ function classFromSchema(classDef: Record<string, any>) {
|
|
|
1300
1400
|
}
|
|
1301
1401
|
}
|
|
1302
1402
|
|
|
1303
|
-
for (const [key,
|
|
1304
|
-
const typedMethod = method as MethodDef;
|
|
1403
|
+
for (const [key, typedMethod] of Object.entries(methods)) {
|
|
1305
1404
|
const name = typedMethod.identifier ?? key;
|
|
1306
|
-
const params = (typedMethod.parameters ?? []).map((p
|
|
1307
|
-
if (p.$ref)
|
|
1308
|
-
|
|
1309
|
-
|
|
1405
|
+
const params = (typedMethod.parameters ?? []).map((p) => {
|
|
1406
|
+
if (p.$ref) {
|
|
1407
|
+
return p.$ref.split("/").pop() as string;
|
|
1408
|
+
}
|
|
1409
|
+
const n = p.identifier ?? p.name;
|
|
1410
|
+
return typeof n === "string" ? n : "arg";
|
|
1411
|
+
});
|
|
1310
1412
|
const bodyStr = Array.isArray(typedMethod.body)
|
|
1311
1413
|
? typedMethod.body.join("\n")
|
|
1312
1414
|
: (typedMethod.body ?? "");
|
|
1313
1415
|
|
|
1314
1416
|
if (typedMethod.role === "accessor") {
|
|
1315
1417
|
const descriptor: PropertyDescriptor = {};
|
|
1316
|
-
if (typedMethod.getter)
|
|
1317
|
-
descriptor.get = new Function(typedMethod.getter.body) as () => unknown;
|
|
1418
|
+
if (typedMethod.getter) {
|
|
1419
|
+
descriptor.get = new Function(typedMethod.getter.body ?? "") as () => unknown;
|
|
1420
|
+
}
|
|
1318
1421
|
if (typedMethod.setter) {
|
|
1319
1422
|
const sp = (typedMethod.setter.parameters ?? []).map(
|
|
1320
|
-
(p
|
|
1423
|
+
(p) => p.$ref?.split("/").pop() ?? "v",
|
|
1321
1424
|
);
|
|
1322
|
-
descriptor.set = new Function(...sp, typedMethod.setter.body) as (v: unknown) => void;
|
|
1425
|
+
descriptor.set = new Function(...sp, typedMethod.setter.body ?? "") as (v: unknown) => void;
|
|
1323
1426
|
}
|
|
1324
1427
|
Object.defineProperty(DynClass.prototype, name, {
|
|
1325
1428
|
...descriptor,
|
|
@@ -1333,8 +1436,8 @@ function classFromSchema(classDef: Record<string, any>) {
|
|
|
1333
1436
|
}
|
|
1334
1437
|
|
|
1335
1438
|
Object.defineProperty(DynClass, "name", {
|
|
1336
|
-
value: classDef.title,
|
|
1337
1439
|
configurable: true,
|
|
1440
|
+
value: classDef.title,
|
|
1338
1441
|
});
|
|
1339
1442
|
const dynCtor = DynClass as unknown as DynamicClass;
|
|
1340
1443
|
return dynCtor;
|
|
@@ -1345,47 +1448,46 @@ function classFromSchema(classDef: Record<string, any>) {
|
|
|
1345
1448
|
* through the Jx dev server (POST /**jx_resolve**). Supports reactive template strings in config
|
|
1346
1449
|
* values via Vue effect().
|
|
1347
1450
|
*
|
|
1348
|
-
* @param {
|
|
1349
|
-
* @param {
|
|
1451
|
+
* @param {JxPrototypeDef} def
|
|
1452
|
+
* @param {JxScope} state
|
|
1350
1453
|
* @param {string} key
|
|
1351
1454
|
* @param {string} [base]
|
|
1352
1455
|
* @returns {Promise<unknown>}
|
|
1353
1456
|
*/
|
|
1354
|
-
async function resolveViaDevProxy(
|
|
1355
|
-
|
|
1356
|
-
state: Record<string, any>,
|
|
1357
|
-
key: string,
|
|
1358
|
-
base?: string,
|
|
1359
|
-
) {
|
|
1360
|
-
const config: Record<string, any> = {};
|
|
1457
|
+
async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: string, base?: string) {
|
|
1458
|
+
const config: JxScope = {};
|
|
1361
1459
|
for (const [k, v] of Object.entries(def)) {
|
|
1362
|
-
if (!EXTERNAL_RESERVED.has(k))
|
|
1460
|
+
if (!EXTERNAL_RESERVED.has(k)) {
|
|
1461
|
+
config[k] = v;
|
|
1462
|
+
}
|
|
1363
1463
|
}
|
|
1364
1464
|
|
|
1365
1465
|
const hasTemplates = Object.values(config).some((v: unknown) => isTemplateString(v));
|
|
1366
1466
|
|
|
1367
|
-
/** @param {
|
|
1368
|
-
const doResolve = (resolvedConfig:
|
|
1467
|
+
/** @param {JxScope} resolvedConfig */
|
|
1468
|
+
const doResolve = (resolvedConfig: JxScope) =>
|
|
1369
1469
|
fetch("/__jx_resolve__", {
|
|
1370
|
-
method: "POST",
|
|
1371
|
-
headers: { "Content-Type": "application/json" },
|
|
1372
1470
|
body: JSON.stringify({
|
|
1373
|
-
$src: def.$src,
|
|
1374
|
-
$prototype: def.$prototype,
|
|
1375
|
-
$export: def.$export,
|
|
1376
1471
|
$base: base,
|
|
1472
|
+
$export: def.$export,
|
|
1473
|
+
$prototype: def.$prototype,
|
|
1474
|
+
$src: def.$src,
|
|
1377
1475
|
...resolvedConfig,
|
|
1378
1476
|
}),
|
|
1477
|
+
headers: { "Content-Type": "application/json" },
|
|
1478
|
+
method: "POST",
|
|
1379
1479
|
}).then((r) => {
|
|
1380
|
-
if (!r.ok)
|
|
1480
|
+
if (!r.ok) {
|
|
1481
|
+
throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
|
|
1482
|
+
}
|
|
1381
1483
|
return r.json();
|
|
1382
1484
|
});
|
|
1383
1485
|
|
|
1384
1486
|
// Always wrap in ref for reactivity
|
|
1385
|
-
const s:
|
|
1487
|
+
const s: Ref<unknown> = ref(null);
|
|
1386
1488
|
if (hasTemplates) {
|
|
1387
1489
|
effect(() => {
|
|
1388
|
-
const resolvedConfig:
|
|
1490
|
+
const resolvedConfig: JxScope = {};
|
|
1389
1491
|
for (const [k, v] of Object.entries(config)) {
|
|
1390
1492
|
resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
|
|
1391
1493
|
}
|
|
@@ -1393,14 +1495,14 @@ async function resolveViaDevProxy(
|
|
|
1393
1495
|
.then((value: unknown) => {
|
|
1394
1496
|
s.value = value;
|
|
1395
1497
|
})
|
|
1396
|
-
.catch((
|
|
1498
|
+
.catch((error: unknown) => console.error("Jx dev proxy:", error));
|
|
1397
1499
|
});
|
|
1398
1500
|
} else {
|
|
1399
1501
|
doResolve(config)
|
|
1400
1502
|
.then((value: unknown) => {
|
|
1401
1503
|
s.value = value;
|
|
1402
1504
|
})
|
|
1403
|
-
.catch((
|
|
1505
|
+
.catch((error: unknown) => console.error("Jx dev proxy:", error));
|
|
1404
1506
|
}
|
|
1405
1507
|
return s;
|
|
1406
1508
|
}
|
|
@@ -1411,15 +1513,15 @@ async function resolveViaDevProxy(
|
|
|
1411
1513
|
* Resolve a timing: "server" entry in dev mode by executing the function client-side. In
|
|
1412
1514
|
* production, the compiler replaces this with a fetch to the generated server handler.
|
|
1413
1515
|
*
|
|
1414
|
-
* @param {
|
|
1415
|
-
* @param {
|
|
1516
|
+
* @param {JxScope} def
|
|
1517
|
+
* @param {JxScope} state
|
|
1416
1518
|
* @param {string} key
|
|
1417
1519
|
* @param {string} [base]
|
|
1418
1520
|
* @returns {Promise<unknown>}
|
|
1419
1521
|
*/
|
|
1420
1522
|
async function resolveServerFunction(
|
|
1421
|
-
def:
|
|
1422
|
-
state:
|
|
1523
|
+
def: JxServerFnDef,
|
|
1524
|
+
state: JxScope,
|
|
1423
1525
|
key: string,
|
|
1424
1526
|
base?: string,
|
|
1425
1527
|
) {
|
|
@@ -1449,22 +1551,25 @@ async function resolveServerFunction(
|
|
|
1449
1551
|
}
|
|
1450
1552
|
|
|
1451
1553
|
const fn = mod[exportName] ?? mod.default?.[exportName];
|
|
1452
|
-
if (!fn)
|
|
1453
|
-
|
|
1454
|
-
|
|
1554
|
+
if (!fn) {
|
|
1555
|
+
throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
|
|
1556
|
+
}
|
|
1557
|
+
if (typeof fn !== "function") {
|
|
1558
|
+
throw new TypeError(`Jx: "${exportName}" from "${src}" is not a function`);
|
|
1559
|
+
}
|
|
1455
1560
|
|
|
1456
1561
|
const rawArgs = def.arguments ?? {};
|
|
1457
1562
|
const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
|
|
1458
1563
|
const resolveArgs = () => {
|
|
1459
|
-
const args:
|
|
1564
|
+
const args: JxScope = {};
|
|
1460
1565
|
for (const [k, v] of Object.entries(rawArgs)) {
|
|
1461
|
-
args[k] = isRefObj(v) ? resolveRef(
|
|
1566
|
+
args[k] = isRefObj(v) ? resolveRef(v.$ref, state) : v;
|
|
1462
1567
|
}
|
|
1463
1568
|
return args;
|
|
1464
1569
|
};
|
|
1465
1570
|
|
|
1466
1571
|
// Always wrap in ref for reactivity
|
|
1467
|
-
const s:
|
|
1572
|
+
const s: Ref<unknown> = ref(null);
|
|
1468
1573
|
if (hasReactiveArg) {
|
|
1469
1574
|
effect(() => {
|
|
1470
1575
|
const args = resolveArgs();
|
|
@@ -1486,15 +1591,15 @@ async function resolveServerFunction(
|
|
|
1486
1591
|
* call through the Jx dev server (POST /**jx_server**). Supports reactive $ref arguments via Vue
|
|
1487
1592
|
* effect().
|
|
1488
1593
|
*
|
|
1489
|
-
* @param {
|
|
1490
|
-
* @param {
|
|
1594
|
+
* @param {JxScope} def
|
|
1595
|
+
* @param {JxScope} state
|
|
1491
1596
|
* @param {string} key
|
|
1492
1597
|
* @param {string} [base]
|
|
1493
1598
|
* @returns {Promise<unknown>}
|
|
1494
1599
|
*/
|
|
1495
1600
|
async function resolveServerFunctionViaProxy(
|
|
1496
|
-
def:
|
|
1497
|
-
state:
|
|
1601
|
+
def: JxScope,
|
|
1602
|
+
state: JxScope,
|
|
1498
1603
|
key: string,
|
|
1499
1604
|
base?: string,
|
|
1500
1605
|
) {
|
|
@@ -1502,31 +1607,33 @@ async function resolveServerFunctionViaProxy(
|
|
|
1502
1607
|
const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
|
|
1503
1608
|
|
|
1504
1609
|
const resolveArgs = () => {
|
|
1505
|
-
const args:
|
|
1610
|
+
const args: JxScope = {};
|
|
1506
1611
|
for (const [k, v] of Object.entries(rawArgs)) {
|
|
1507
1612
|
args[k] = isRefObj(v) ? resolveRef((v as { $ref: string }).$ref, state) : v;
|
|
1508
1613
|
}
|
|
1509
1614
|
return args;
|
|
1510
1615
|
};
|
|
1511
1616
|
|
|
1512
|
-
/** @param {
|
|
1513
|
-
const doResolve = (args:
|
|
1617
|
+
/** @param {JxScope} args */
|
|
1618
|
+
const doResolve = (args: JxScope) =>
|
|
1514
1619
|
fetch("/__jx_server__", {
|
|
1515
|
-
method: "POST",
|
|
1516
|
-
headers: { "Content-Type": "application/json" },
|
|
1517
1620
|
body: JSON.stringify({
|
|
1518
|
-
$src: def.$src,
|
|
1519
|
-
$export: def.$export,
|
|
1520
1621
|
$base: base,
|
|
1622
|
+
$export: def.$export,
|
|
1623
|
+
$src: def.$src,
|
|
1521
1624
|
arguments: args,
|
|
1522
1625
|
}),
|
|
1626
|
+
headers: { "Content-Type": "application/json" },
|
|
1627
|
+
method: "POST",
|
|
1523
1628
|
}).then((r) => {
|
|
1524
|
-
if (!r.ok)
|
|
1629
|
+
if (!r.ok) {
|
|
1630
|
+
throw new Error(`Jx server proxy ${r.status} for "${key}"`);
|
|
1631
|
+
}
|
|
1525
1632
|
return r.json();
|
|
1526
1633
|
});
|
|
1527
1634
|
|
|
1528
1635
|
// Always wrap in ref for reactivity
|
|
1529
|
-
const s:
|
|
1636
|
+
const s: Ref<unknown> = ref(null);
|
|
1530
1637
|
if (hasReactiveArg) {
|
|
1531
1638
|
effect(() => {
|
|
1532
1639
|
const args = resolveArgs();
|
|
@@ -1535,14 +1642,14 @@ async function resolveServerFunctionViaProxy(
|
|
|
1535
1642
|
.then((result: unknown) => {
|
|
1536
1643
|
s.value = result;
|
|
1537
1644
|
})
|
|
1538
|
-
.catch((
|
|
1645
|
+
.catch((error: unknown) => console.error("Jx server proxy:", error));
|
|
1539
1646
|
});
|
|
1540
1647
|
} else {
|
|
1541
1648
|
doResolve(resolveArgs())
|
|
1542
1649
|
.then((result: unknown) => {
|
|
1543
1650
|
s.value = result;
|
|
1544
1651
|
})
|
|
1545
|
-
.catch((
|
|
1652
|
+
.catch((error: unknown) => console.error("Jx server proxy:", error));
|
|
1546
1653
|
}
|
|
1547
1654
|
return s;
|
|
1548
1655
|
}
|
|
@@ -1554,28 +1661,38 @@ async function resolveServerFunctionViaProxy(
|
|
|
1554
1661
|
* computed, the read is tracked.
|
|
1555
1662
|
*
|
|
1556
1663
|
* @param {string} ref
|
|
1557
|
-
* @param {
|
|
1664
|
+
* @param {JxScope} state - Reactive scope proxy (or child scope)
|
|
1558
1665
|
* @returns {unknown}
|
|
1559
1666
|
*/
|
|
1560
|
-
export function resolveRef(
|
|
1561
|
-
if (typeof
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
const
|
|
1667
|
+
export function resolveRef(refPath: string, state: JxScope) {
|
|
1668
|
+
if (typeof refPath !== "string") {
|
|
1669
|
+
return refPath;
|
|
1670
|
+
}
|
|
1671
|
+
if (refPath.startsWith("$map/")) {
|
|
1672
|
+
const parts = refPath.split("/");
|
|
1673
|
+
const [, key] = parts; // "item" or "index"
|
|
1674
|
+
const map = state.$map as Record<string, unknown> | undefined;
|
|
1675
|
+
const base = map?.[key] ?? state[`$map/${key}`];
|
|
1566
1676
|
return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
|
|
1567
1677
|
}
|
|
1568
|
-
if (
|
|
1569
|
-
const sub =
|
|
1678
|
+
if (refPath.startsWith("#/state/")) {
|
|
1679
|
+
const sub = refPath.slice("#/state/".length);
|
|
1570
1680
|
const slash = sub.indexOf("/");
|
|
1571
|
-
if (slash
|
|
1681
|
+
if (slash === -1) {
|
|
1682
|
+
return state[sub];
|
|
1683
|
+
}
|
|
1572
1684
|
return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
|
|
1573
1685
|
}
|
|
1574
|
-
if (
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1686
|
+
if (refPath.startsWith("parent#/")) {
|
|
1687
|
+
return state[refPath.slice("parent#/".length)];
|
|
1688
|
+
}
|
|
1689
|
+
if (refPath.startsWith("window#/")) {
|
|
1690
|
+
return getPath(globalThis.window, refPath.slice("window#/".length));
|
|
1691
|
+
}
|
|
1692
|
+
if (refPath.startsWith("document#/")) {
|
|
1693
|
+
return getPath(globalThis.document, refPath.slice("document#/".length));
|
|
1694
|
+
}
|
|
1695
|
+
return state[refPath] ?? null;
|
|
1579
1696
|
}
|
|
1580
1697
|
|
|
1581
1698
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
@@ -1592,12 +1709,10 @@ export function isSignal(v: unknown) {
|
|
|
1592
1709
|
|
|
1593
1710
|
/**
|
|
1594
1711
|
* @param {unknown} v
|
|
1595
|
-
* @returns {
|
|
1712
|
+
* @returns {v is import("@jxsuite/schema/types").JxRef}
|
|
1596
1713
|
*/
|
|
1597
|
-
function isRefObj(v: unknown) {
|
|
1598
|
-
return (
|
|
1599
|
-
v !== null && typeof v === "object" && typeof (v as Record<string, unknown>).$ref === "string"
|
|
1600
|
-
);
|
|
1714
|
+
function isRefObj(v: unknown): v is JxRef {
|
|
1715
|
+
return isRefValue(v);
|
|
1601
1716
|
}
|
|
1602
1717
|
|
|
1603
1718
|
/**
|
|
@@ -1614,18 +1729,22 @@ function isNestedSelector(k: string) {
|
|
|
1614
1729
|
* @returns {unknown}
|
|
1615
1730
|
*/
|
|
1616
1731
|
function getPath(obj: unknown, path: string) {
|
|
1617
|
-
|
|
1732
|
+
let current: unknown = obj;
|
|
1733
|
+
for (const k of path.split(/[./]/)) {
|
|
1734
|
+
current = (current as Record<string, unknown>)?.[k];
|
|
1735
|
+
}
|
|
1736
|
+
return current;
|
|
1618
1737
|
}
|
|
1619
1738
|
|
|
1620
1739
|
/**
|
|
1621
|
-
* @param {
|
|
1622
|
-
* @param {
|
|
1623
|
-
* @returns {
|
|
1740
|
+
* @param {JxElement} def
|
|
1741
|
+
* @param {JxScope} parentState
|
|
1742
|
+
* @returns {JxScope}
|
|
1624
1743
|
*/
|
|
1625
|
-
function mergeProps(def:
|
|
1744
|
+
function mergeProps(def: JxElement, parentState: JxScope): JxScope {
|
|
1626
1745
|
const child = Object.create(parentState);
|
|
1627
1746
|
for (const [k, v] of Object.entries(def.$props ?? {})) {
|
|
1628
|
-
child[k] = isRefObj(v) ? resolveRef(
|
|
1747
|
+
child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
|
|
1629
1748
|
}
|
|
1630
1749
|
return child;
|
|
1631
1750
|
}
|
|
@@ -1637,7 +1756,7 @@ function mergeProps(def: Record<string, any>, parentState: Record<string, any>)
|
|
|
1637
1756
|
* @returns {string}
|
|
1638
1757
|
*/
|
|
1639
1758
|
export function camelToKebab(s: string) {
|
|
1640
|
-
return s.
|
|
1759
|
+
return s.replaceAll(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
1641
1760
|
}
|
|
1642
1761
|
|
|
1643
1762
|
/**
|
|
@@ -1661,11 +1780,11 @@ const _elementDefs = new Map();
|
|
|
1661
1780
|
/**
|
|
1662
1781
|
* Resolve and register $elements entries (depth-first).
|
|
1663
1782
|
*
|
|
1664
|
-
* @param {
|
|
1783
|
+
* @param {JxDocument["$elements"]} elements
|
|
1665
1784
|
* @param {string} base
|
|
1666
1785
|
* @returns {Promise<void>}
|
|
1667
1786
|
*/
|
|
1668
|
-
async function registerElements(elements:
|
|
1787
|
+
async function registerElements(elements: NonNullable<JxDocument["$elements"]>, base: string) {
|
|
1669
1788
|
for (const entry of elements) {
|
|
1670
1789
|
// Bare string: npm package side-effect import (registers custom elements)
|
|
1671
1790
|
if (typeof entry === "string") {
|
|
@@ -1675,16 +1794,22 @@ async function registerElements(elements: any[], base: string) {
|
|
|
1675
1794
|
const specifier =
|
|
1676
1795
|
entry.startsWith("/") || entry.startsWith(".") ? entry : `/node_modules/${entry}`;
|
|
1677
1796
|
await import(specifier);
|
|
1678
|
-
} catch (
|
|
1679
|
-
console.warn(`Jx: failed to import package "${entry}"`,
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
console.warn(`Jx: failed to import package "${entry}"`, error);
|
|
1680
1799
|
}
|
|
1681
1800
|
continue;
|
|
1682
1801
|
}
|
|
1683
|
-
if (!isRefObj(entry))
|
|
1684
|
-
|
|
1802
|
+
if (!isRefObj(entry)) {
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
const { href } = new URL(entry.$ref, base);
|
|
1685
1806
|
const doc = await resolve(href);
|
|
1686
|
-
if (!doc.tagName || !doc.tagName.includes("-"))
|
|
1687
|
-
|
|
1807
|
+
if (!doc.tagName || !doc.tagName.includes("-")) {
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
if (customElements.get(doc.tagName)) {
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1688
1813
|
|
|
1689
1814
|
// Depth-first: register sub-dependencies first
|
|
1690
1815
|
if (doc.$elements) {
|
|
@@ -1699,99 +1824,117 @@ async function registerElements(elements: any[], base: string) {
|
|
|
1699
1824
|
* Inject head elements from $head declarations. Each entry is { tagName, attributes } — bare npm
|
|
1700
1825
|
* specifiers in href/src are rewritten to /node_modules/ paths for the dev server.
|
|
1701
1826
|
*
|
|
1702
|
-
* @param {
|
|
1827
|
+
* @param {import("@jxsuite/schema/types").JxHeadEntry[]} entries
|
|
1703
1828
|
* @param {string} _base - Document base URL for resolving relative paths
|
|
1704
1829
|
*/
|
|
1705
|
-
function injectHead(entries:
|
|
1830
|
+
function injectHead(entries: JxHeadEntry[], _base: string) {
|
|
1706
1831
|
for (const entry of entries) {
|
|
1707
|
-
if (!entry || !entry.tagName)
|
|
1832
|
+
if (!entry || !entry.tagName) {
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1708
1835
|
const tag = entry.tagName.toLowerCase();
|
|
1709
1836
|
const attrs = { ...entry.attributes };
|
|
1710
1837
|
// Resolve href/src: bare npm specifiers -> /node_modules/ path
|
|
1711
|
-
for (const key of ["href", "src"]) {
|
|
1838
|
+
for (const key of ["href", "src"] as const) {
|
|
1839
|
+
const v = attrs[key];
|
|
1712
1840
|
if (
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
!
|
|
1716
|
-
!
|
|
1841
|
+
typeof v === "string" &&
|
|
1842
|
+
v &&
|
|
1843
|
+
!v.startsWith("/") &&
|
|
1844
|
+
!v.startsWith(".") &&
|
|
1845
|
+
!v.startsWith("http")
|
|
1717
1846
|
) {
|
|
1718
|
-
attrs[key] = `/node_modules/${
|
|
1847
|
+
attrs[key] = `/node_modules/${v}`;
|
|
1719
1848
|
}
|
|
1720
1849
|
}
|
|
1721
1850
|
|
|
1722
1851
|
// Deduplicate: skip if an identical element already exists
|
|
1723
1852
|
const selector = `${tag}${attrs.href ? `[href="${attrs.href}"]` : ""}${attrs.src ? `[src="${attrs.src}"]` : ""}`;
|
|
1724
|
-
if (selector !== tag && document.head.querySelector(selector))
|
|
1853
|
+
if (selector !== tag && document.head.querySelector(selector)) {
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1725
1856
|
|
|
1726
1857
|
const el = document.createElement(tag);
|
|
1727
1858
|
for (const [k, v] of Object.entries(attrs)) {
|
|
1728
|
-
el.setAttribute(k,
|
|
1859
|
+
el.setAttribute(k, String(v));
|
|
1729
1860
|
}
|
|
1730
|
-
if (entry.textContent)
|
|
1731
|
-
|
|
1861
|
+
if (entry.textContent) {
|
|
1862
|
+
el.textContent = entry.textContent;
|
|
1863
|
+
}
|
|
1864
|
+
document.head.append(el);
|
|
1732
1865
|
}
|
|
1733
1866
|
}
|
|
1734
1867
|
|
|
1735
1868
|
/**
|
|
1736
1869
|
* Register a custom element from a Jx document.
|
|
1737
1870
|
*
|
|
1738
|
-
* @param {string |
|
|
1871
|
+
* @param {string | JxDocument} source - URL to .json file, or raw document object
|
|
1739
1872
|
* @param {string} [base] - Base URL for resolving $src imports
|
|
1740
1873
|
* @returns {Promise<void>}
|
|
1741
1874
|
*/
|
|
1742
1875
|
const _definedSources = new Set<string>();
|
|
1743
1876
|
|
|
1744
|
-
export async function defineElement(source: string |
|
|
1877
|
+
export async function defineElement(source: string | JxDocument, baseUrl?: string) {
|
|
1878
|
+
let base = baseUrl;
|
|
1879
|
+
let doc = source;
|
|
1745
1880
|
if (typeof source === "string") {
|
|
1746
1881
|
base = new URL(source, base ?? location.href).href;
|
|
1747
|
-
if (_definedSources.has(base))
|
|
1882
|
+
if (_definedSources.has(base)) {
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1748
1885
|
_definedSources.add(base);
|
|
1749
|
-
|
|
1886
|
+
doc = await resolve(source);
|
|
1750
1887
|
}
|
|
1751
|
-
base
|
|
1888
|
+
base ??= location.href;
|
|
1752
1889
|
|
|
1753
|
-
const source_:
|
|
1890
|
+
const source_: JxDocument = doc as JxDocument;
|
|
1754
1891
|
|
|
1755
|
-
const tagName = source_
|
|
1892
|
+
const { tagName } = source_;
|
|
1756
1893
|
if (!tagName || !tagName.includes("-")) {
|
|
1757
1894
|
throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
|
|
1758
1895
|
}
|
|
1759
|
-
if (customElements.get(tagName))
|
|
1896
|
+
if (customElements.get(tagName)) {
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1760
1899
|
|
|
1761
1900
|
// Register sub-dependencies first
|
|
1762
1901
|
if (source_.$elements) {
|
|
1763
1902
|
await registerElements(source_.$elements, base);
|
|
1764
1903
|
}
|
|
1765
1904
|
|
|
1766
|
-
_elementDefs.set(tagName, { doc: source_
|
|
1905
|
+
_elementDefs.set(tagName, { base, doc: source_ });
|
|
1767
1906
|
|
|
1768
1907
|
const def = source_;
|
|
1769
1908
|
const observedAttrs = def.observedAttributes ?? [];
|
|
1770
1909
|
|
|
1771
1910
|
const ElementClass = class extends HTMLElement {
|
|
1772
1911
|
_jxInitialized = false;
|
|
1773
|
-
_state:
|
|
1912
|
+
_state: JxScope | null = null;
|
|
1774
1913
|
|
|
1775
1914
|
static get observedAttributes() {
|
|
1776
1915
|
return observedAttrs;
|
|
1777
1916
|
}
|
|
1778
1917
|
|
|
1779
1918
|
async connectedCallback() {
|
|
1780
|
-
if (this._jxInitialized)
|
|
1919
|
+
if (this._jxInitialized) {
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1781
1922
|
this._jxInitialized = true;
|
|
1782
1923
|
|
|
1783
1924
|
const state = await buildScope(def, {}, base);
|
|
1784
1925
|
|
|
1785
1926
|
// Read properties from directive encoding (markdown → data-jx-props)
|
|
1786
|
-
const propsAttr = this.
|
|
1927
|
+
const propsAttr = this.dataset.jxProps;
|
|
1787
1928
|
if (propsAttr) {
|
|
1788
1929
|
try {
|
|
1789
1930
|
const props = JSON.parse(propsAttr);
|
|
1790
1931
|
for (const [key, val] of Object.entries(props)) {
|
|
1791
|
-
if (key in (def.state ?? {}))
|
|
1932
|
+
if (key in (def.state ?? {})) {
|
|
1933
|
+
state[key] = val;
|
|
1934
|
+
}
|
|
1792
1935
|
}
|
|
1793
1936
|
} catch {}
|
|
1794
|
-
this.
|
|
1937
|
+
delete this.dataset.jxProps;
|
|
1795
1938
|
}
|
|
1796
1939
|
|
|
1797
1940
|
// Merge $props set as JS properties by parent before connection
|
|
@@ -1804,11 +1947,11 @@ export async function defineElement(source: string | Record<string, any>, base?:
|
|
|
1804
1947
|
for (const key of Object.keys(def.state ?? {})) {
|
|
1805
1948
|
if (!(key in HTMLElement.prototype)) {
|
|
1806
1949
|
Object.defineProperty(this, key, {
|
|
1950
|
+
configurable: true,
|
|
1807
1951
|
get: () => state[key],
|
|
1808
1952
|
set: (v: unknown) => {
|
|
1809
1953
|
state[key] = v;
|
|
1810
1954
|
},
|
|
1811
|
-
configurable: true,
|
|
1812
1955
|
});
|
|
1813
1956
|
}
|
|
1814
1957
|
}
|
|
@@ -1816,29 +1959,32 @@ export async function defineElement(source: string | Record<string, any>, base?:
|
|
|
1816
1959
|
this._state = state;
|
|
1817
1960
|
|
|
1818
1961
|
// Capture light DOM children (for slot distribution) before rendering
|
|
1819
|
-
const slottedChildren =
|
|
1962
|
+
const slottedChildren = [...this.childNodes];
|
|
1820
1963
|
this.innerHTML = "";
|
|
1821
1964
|
|
|
1822
1965
|
// Custom elements default to display:inline — use block so they behave as
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
1825
|
-
if (!this.style.display)
|
|
1966
|
+
// Containers (matching <div> semantics). The component's own style can
|
|
1967
|
+
// Override this if needed.
|
|
1968
|
+
if (!this.style.display) {
|
|
1969
|
+
this.style.display = "block";
|
|
1970
|
+
}
|
|
1826
1971
|
|
|
1827
1972
|
// Render template into light DOM (once, not in effect — inner effects handle reactivity)
|
|
1828
|
-
applyStyle(this, def.style ?? {}, state["$media"] ?? {}, state);
|
|
1973
|
+
applyStyle(this, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
|
|
1829
1974
|
applyAttributes(this, def.attributes ?? {}, state);
|
|
1830
1975
|
|
|
1831
1976
|
const children = Array.isArray(def.children) ? def.children : [];
|
|
1832
1977
|
for (const childDef of children) {
|
|
1833
|
-
this.
|
|
1978
|
+
this.append(renderNode(childDef, state));
|
|
1834
1979
|
}
|
|
1835
1980
|
|
|
1836
1981
|
// Slot distribution (light DOM)
|
|
1837
1982
|
distributeSlots(this, slottedChildren);
|
|
1838
1983
|
|
|
1839
1984
|
// Lifecycle: onMount
|
|
1840
|
-
|
|
1841
|
-
|
|
1985
|
+
const { onMount } = state;
|
|
1986
|
+
if (typeof onMount === "function") {
|
|
1987
|
+
queueMicrotask(() => onMount(state));
|
|
1842
1988
|
}
|
|
1843
1989
|
}
|
|
1844
1990
|
|
|
@@ -1855,13 +2001,18 @@ export async function defineElement(source: string | Record<string, any>, base?:
|
|
|
1855
2001
|
}
|
|
1856
2002
|
|
|
1857
2003
|
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
|
|
1858
|
-
if (!this._state || oldVal === newVal)
|
|
1859
|
-
|
|
2004
|
+
if (!this._state || oldVal === newVal) {
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
const camelKey = name.replaceAll(/-([a-z])/g, (_: string, c: string) => c.toUpperCase());
|
|
1860
2008
|
const current = this._state[camelKey];
|
|
1861
|
-
if (typeof current === "number")
|
|
1862
|
-
|
|
2009
|
+
if (typeof current === "number") {
|
|
2010
|
+
this._state[camelKey] = Number(newVal);
|
|
2011
|
+
} else if (typeof current === "boolean") {
|
|
1863
2012
|
this._state[camelKey] = newVal !== null && newVal !== "false";
|
|
1864
|
-
else
|
|
2013
|
+
} else {
|
|
2014
|
+
this._state[camelKey] = newVal;
|
|
2015
|
+
}
|
|
1865
2016
|
}
|
|
1866
2017
|
};
|
|
1867
2018
|
|
|
@@ -1871,50 +2022,52 @@ export async function defineElement(source: string | Record<string, any>, base?:
|
|
|
1871
2022
|
/**
|
|
1872
2023
|
* Render a registered custom element with $props (property-first interface).
|
|
1873
2024
|
*
|
|
1874
|
-
* @param {
|
|
1875
|
-
* @param {
|
|
2025
|
+
* @param {JxElement} def
|
|
2026
|
+
* @param {JxScope} state
|
|
1876
2027
|
* @param {JxRenderOptions} [options]
|
|
1877
2028
|
* @param {JxPath} [path]
|
|
1878
2029
|
* @returns {HTMLElement}
|
|
1879
2030
|
*/
|
|
1880
2031
|
function renderCustomElementWithProps(
|
|
1881
|
-
def:
|
|
1882
|
-
state:
|
|
2032
|
+
def: JxElement,
|
|
2033
|
+
state: JxScope,
|
|
1883
2034
|
options?: JxRenderOptions,
|
|
1884
2035
|
path?: JxPath,
|
|
1885
2036
|
) {
|
|
1886
|
-
const el = document.createElement(def.tagName);
|
|
2037
|
+
const el = document.createElement(def.tagName ?? "div");
|
|
1887
2038
|
|
|
1888
|
-
if (options?.onNodeCreated)
|
|
2039
|
+
if (options?.onNodeCreated) {
|
|
2040
|
+
options.onNodeCreated(el, path ?? [], def);
|
|
2041
|
+
}
|
|
1889
2042
|
|
|
1890
2043
|
// Set JS properties from $props (before connection)
|
|
1891
2044
|
for (const [key, val] of Object.entries(def.$props ?? {})) {
|
|
1892
2045
|
if (isRefObj(val)) {
|
|
1893
|
-
const refVal = val
|
|
2046
|
+
const refVal = val;
|
|
1894
2047
|
const resolved = resolveRef(refVal.$ref, state);
|
|
1895
|
-
(el as Record<string, unknown>)[key] = resolved;
|
|
2048
|
+
(el as unknown as Record<string, unknown>)[key] = resolved;
|
|
1896
2049
|
// Reactive forwarding: re-set the property when the source changes
|
|
1897
2050
|
effect(() => {
|
|
1898
|
-
(el as Record<string, unknown>)[key] = resolveRef(refVal.$ref, state);
|
|
2051
|
+
(el as unknown as Record<string, unknown>)[key] = resolveRef(refVal.$ref, state);
|
|
1899
2052
|
});
|
|
1900
2053
|
} else if (isTemplateString(val)) {
|
|
1901
2054
|
effect(() => {
|
|
1902
|
-
(el as Record<string, unknown>)[key] = evaluateTemplate(val
|
|
2055
|
+
(el as unknown as Record<string, unknown>)[key] = evaluateTemplate(val, state);
|
|
1903
2056
|
});
|
|
1904
2057
|
} else {
|
|
1905
|
-
(el as Record<string, unknown>)[key] = val;
|
|
2058
|
+
(el as unknown as Record<string, unknown>)[key] = val;
|
|
1906
2059
|
}
|
|
1907
2060
|
}
|
|
1908
2061
|
|
|
1909
2062
|
// Apply host-level style and attributes from the usage site
|
|
1910
|
-
applyStyle(el, def.style ?? {}, state["$media"] ?? {}, state);
|
|
2063
|
+
applyStyle(el, def.style ?? {}, (state["$media"] as Record<string, string>) ?? {}, state);
|
|
1911
2064
|
applyAttributes(el, def.attributes ?? {}, state);
|
|
1912
2065
|
|
|
1913
2066
|
// Append slotted children
|
|
1914
2067
|
const children = Array.isArray(def.children) ? def.children : [];
|
|
1915
2068
|
for (let i = 0; i < children.length; i++) {
|
|
1916
2069
|
const childOpts = options && path ? { ...options, _path: [...path, "children", i] } : undefined;
|
|
1917
|
-
el.
|
|
2070
|
+
el.append(renderNode(children[i], state, childOpts));
|
|
1918
2071
|
}
|
|
1919
2072
|
|
|
1920
2073
|
return el;
|
|
@@ -1927,18 +2080,24 @@ function renderCustomElementWithProps(
|
|
|
1927
2080
|
* @param {ChildNode[]} slottedChildren
|
|
1928
2081
|
*/
|
|
1929
2082
|
function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
|
|
1930
|
-
if (slottedChildren.length === 0)
|
|
2083
|
+
if (slottedChildren.length === 0) {
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
1931
2086
|
|
|
1932
2087
|
const slots = host.querySelectorAll("slot");
|
|
1933
|
-
if (slots.length === 0)
|
|
2088
|
+
if (slots.length === 0) {
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
1934
2091
|
|
|
1935
|
-
const named
|
|
2092
|
+
const named = new Map<string | null, ChildNode[]>();
|
|
1936
2093
|
const unnamed: ChildNode[] = [];
|
|
1937
2094
|
|
|
1938
2095
|
for (const child of slottedChildren) {
|
|
1939
2096
|
if (child.nodeType === Node.ELEMENT_NODE && (child as Element).getAttribute("slot")) {
|
|
1940
2097
|
const name = (child as Element).getAttribute("slot");
|
|
1941
|
-
if (!named.has(name))
|
|
2098
|
+
if (!named.has(name)) {
|
|
2099
|
+
named.set(name, []);
|
|
2100
|
+
}
|
|
1942
2101
|
(named.get(name) as ChildNode[]).push(child);
|
|
1943
2102
|
} else {
|
|
1944
2103
|
unnamed.push(child);
|
|
@@ -1951,7 +2110,7 @@ function distributeSlots(host: HTMLElement, slottedChildren: ChildNode[]) {
|
|
|
1951
2110
|
if (matches.length > 0) {
|
|
1952
2111
|
slot.innerHTML = "";
|
|
1953
2112
|
for (const child of matches) {
|
|
1954
|
-
slot.
|
|
2113
|
+
slot.append(child);
|
|
1955
2114
|
}
|
|
1956
2115
|
}
|
|
1957
2116
|
}
|