@jxsuite/runtime 0.0.1
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 +2409 -0
- package/dist/runtime.js.map +12 -0
- package/package.json +25 -0
- package/src/runtime.js +1728 -0
package/src/runtime.js
ADDED
|
@@ -0,0 +1,1728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jx — JSON-native reactive web component runtime
|
|
3
|
+
* @version 3.0.0
|
|
4
|
+
* @license MIT
|
|
5
|
+
*
|
|
6
|
+
* Four-step pipeline:
|
|
7
|
+
* 1. resolve — fetch JSON source (or accept raw object)
|
|
8
|
+
* 2. buildScope — state detection + reactive proxy construction
|
|
9
|
+
* 3. render — walk resolved tree, build DOM, wire reactive effects
|
|
10
|
+
* 4. output — append to target
|
|
11
|
+
*
|
|
12
|
+
* @module jx
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { reactive, ref, computed, effect, isRef, onEffectCleanup } from "@vue/reactivity";
|
|
16
|
+
|
|
17
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mount a Jx document into a DOM container.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* import { Jx } from "@jxplatform/runtime";
|
|
24
|
+
* const state = await Jx("./counter.json", document.getElementById("app"));
|
|
25
|
+
*
|
|
26
|
+
* @param {string | Record<string, any>} source - Path to .json file, URL, or raw document object
|
|
27
|
+
* @param {HTMLElement} [target] Default is `document.body`
|
|
28
|
+
* @param {any} [options]
|
|
29
|
+
* @returns {Promise<Record<string, any>>} Resolves with the live component scope (state reactive
|
|
30
|
+
* proxy)
|
|
31
|
+
*/
|
|
32
|
+
export async function Jx(source, target = document.body, options) {
|
|
33
|
+
const base = typeof source === "string" ? new URL(source, location.href).href : location.href;
|
|
34
|
+
const doc = await resolve(source);
|
|
35
|
+
|
|
36
|
+
// Register custom elements declared in $elements (depth-first)
|
|
37
|
+
if (doc.$elements) {
|
|
38
|
+
await registerElements(doc.$elements, base);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Inject <head> elements declared in $head (link, meta, script, etc.)
|
|
42
|
+
if (doc.$head) {
|
|
43
|
+
injectHead(doc.$head, base);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const state = await buildScope(doc, {}, base);
|
|
47
|
+
target.appendChild(renderNode(doc, state, options));
|
|
48
|
+
if (typeof state.onMount === "function") state.onMount(state);
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Step 1: Resolve ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch and parse a Jx JSON source. Accepts a URL string, absolute URL, or a pre-parsed object.
|
|
56
|
+
*
|
|
57
|
+
* @param {string | Record<string, any>} source
|
|
58
|
+
* @returns {Promise<any>}
|
|
59
|
+
*/
|
|
60
|
+
export async function resolve(source) {
|
|
61
|
+
if (typeof source !== "string") return source;
|
|
62
|
+
const res = await fetch(source);
|
|
63
|
+
if (!res.ok) throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
|
|
64
|
+
return res.json();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Step 2: Build scope ──────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** JSON Schema keywords used to identify pure type definitions (Shape 2b). */
|
|
70
|
+
const SCHEMA_KEYWORDS = new Set([
|
|
71
|
+
"type",
|
|
72
|
+
"properties",
|
|
73
|
+
"items",
|
|
74
|
+
"enum",
|
|
75
|
+
"minimum",
|
|
76
|
+
"maximum",
|
|
77
|
+
"minLength",
|
|
78
|
+
"maxLength",
|
|
79
|
+
"pattern",
|
|
80
|
+
"required",
|
|
81
|
+
"examples",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the reactive scope (state) from the document using the five-shape detection algorithm.
|
|
86
|
+
*
|
|
87
|
+
* @param {Record<string, any>} doc
|
|
88
|
+
* @param {Record<string, any>} [parentScope] Default is `{}`
|
|
89
|
+
* @param {string} [base] Base URL for resolving $src imports. Default is `location.href`
|
|
90
|
+
* @returns {Promise<Record<string, any>>} Reactive proxy (state)
|
|
91
|
+
*/
|
|
92
|
+
export async function buildScope(doc, parentScope = {}, base = location.href) {
|
|
93
|
+
/** @type {Record<string, any>} */
|
|
94
|
+
const raw = {};
|
|
95
|
+
|
|
96
|
+
// Merge parent scope properties
|
|
97
|
+
for (const [key, val] of Object.entries(parentScope)) {
|
|
98
|
+
raw[key] = val;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const defs = doc.state ?? {};
|
|
102
|
+
|
|
103
|
+
// Pass 0: resolve bare $prototype names via import map
|
|
104
|
+
const imports = doc.imports ?? {};
|
|
105
|
+
for (const [, def] of Object.entries(defs)) {
|
|
106
|
+
if (
|
|
107
|
+
def &&
|
|
108
|
+
typeof def === "object" &&
|
|
109
|
+
!Array.isArray(def) &&
|
|
110
|
+
def.$prototype &&
|
|
111
|
+
def.$prototype !== "Function" &&
|
|
112
|
+
!def.$src
|
|
113
|
+
) {
|
|
114
|
+
const mapped = imports[def.$prototype];
|
|
115
|
+
if (mapped) {
|
|
116
|
+
if (typeof mapped !== "string" || !mapped.endsWith(".class.json")) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`Jx: import "${def.$prototype}" must map to a .class.json path, got "${mapped}"`,
|
|
119
|
+
);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
def.$src = mapped;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// First pass: collect naked values, expanded defaults, plain objects
|
|
128
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
129
|
+
// 1. String value
|
|
130
|
+
if (typeof def === "string") {
|
|
131
|
+
if (!def.includes("${")) raw[key] = def; // Shape 1: naked string
|
|
132
|
+
continue; // template strings handled in second pass
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 2. Number, boolean, null
|
|
136
|
+
if (typeof def === "number" || typeof def === "boolean" || def === null) {
|
|
137
|
+
raw[key] = def;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Array
|
|
142
|
+
if (Array.isArray(def)) {
|
|
143
|
+
raw[key] = def;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 4. Object
|
|
148
|
+
if (typeof def === "object") {
|
|
149
|
+
if (def.$prototype) continue; // handled in later passes
|
|
150
|
+
if (def.timing === "server" && def.$src && def.$export) continue; // handled in fifth pass
|
|
151
|
+
if ("default" in def) {
|
|
152
|
+
raw[key] = def.default;
|
|
153
|
+
continue;
|
|
154
|
+
} // Shape 2: expanded signal
|
|
155
|
+
if (hasSchemaKeywords(def)) continue; // Shape 2b: pure type def
|
|
156
|
+
raw[key] = def; // Shape 1: plain object
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Wrap in Vue reactive proxy — deep reactivity from this point on
|
|
161
|
+
const state = reactive(raw);
|
|
162
|
+
|
|
163
|
+
// Second pass: template strings → computed
|
|
164
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
165
|
+
if (typeof def === "string" && def.includes("${")) {
|
|
166
|
+
state[key] = computed(() => evaluateTemplate(def, state));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Third pass: $prototype: "Function" entries
|
|
171
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
172
|
+
if (typeof def === "object" && def?.$prototype === "Function") {
|
|
173
|
+
state[key] = await resolveFunction(def, state, key, base);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fourth pass: other $prototype entries (Request, Set, Map, etc.)
|
|
178
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
179
|
+
if (typeof def === "object" && def?.$prototype && def.$prototype !== "Function") {
|
|
180
|
+
state[key] = await resolvePrototype(def, state, key, base);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
|
|
185
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
186
|
+
if (
|
|
187
|
+
def != null &&
|
|
188
|
+
typeof def === "object" &&
|
|
189
|
+
def.timing === "server" &&
|
|
190
|
+
def.$src &&
|
|
191
|
+
def.$export &&
|
|
192
|
+
!def.$prototype
|
|
193
|
+
) {
|
|
194
|
+
state[key] = await resolveServerFunction(def, state, key, base);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (doc.$media) {
|
|
199
|
+
state["$media"] = doc.$media;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return state;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check whether an object contains any JSON Schema keywords. Used to discriminate Shape 2b (pure
|
|
207
|
+
* type definition) from Shape 1 (naked object).
|
|
208
|
+
*
|
|
209
|
+
* @param {Record<string, any>} obj
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
function hasSchemaKeywords(obj) {
|
|
213
|
+
for (const k of Object.keys(obj)) {
|
|
214
|
+
if (SCHEMA_KEYWORDS.has(k)) return true;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
export { hasSchemaKeywords };
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Evaluate a template string in the context of state and optional $map. Templates use
|
|
222
|
+
* `state.varName` and `$map.item` syntax.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} str
|
|
225
|
+
* @param {Record<string, any>} state
|
|
226
|
+
* @returns {any}
|
|
227
|
+
*/
|
|
228
|
+
function evaluateTemplate(str, state) {
|
|
229
|
+
const fn = new Function("state", "$map", `return \`${str}\``);
|
|
230
|
+
return fn(state, state?.$map);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Step 2b: Function resolution (Shape 4) ─────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** Module cache for $src imports (shared with external class resolution). */
|
|
236
|
+
const _moduleCache = new Map();
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a $prototype: "Function" entry into a function or computed.
|
|
240
|
+
*
|
|
241
|
+
* Functions receive state as their first parameter at call time. Functions with a return statement
|
|
242
|
+
* in their body are wrapped in computed() for reactive evaluation.
|
|
243
|
+
*
|
|
244
|
+
* @param {Record<string, any>} def - State entry with $prototype: "Function"
|
|
245
|
+
* @param {Record<string, any>} state - Reactive scope proxy
|
|
246
|
+
* @param {string} key - Def key name
|
|
247
|
+
* @param {string} [base] - Base URL for resolving $src imports
|
|
248
|
+
* @returns {Promise<any>}
|
|
249
|
+
*/
|
|
250
|
+
async function resolveFunction(def, state, key, base) {
|
|
251
|
+
if (def.body && def.$src) {
|
|
252
|
+
throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
|
|
253
|
+
}
|
|
254
|
+
if (!def.body && !def.$src) {
|
|
255
|
+
throw new Error(`Jx: '${key}' is a Function prototype with no body or $src`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let fn;
|
|
259
|
+
|
|
260
|
+
if (def.body) {
|
|
261
|
+
const params = resolveParamNames(def);
|
|
262
|
+
fn = new Function(...params, def.body);
|
|
263
|
+
Object.defineProperty(fn, "name", { value: def.name ?? key, configurable: true });
|
|
264
|
+
} else {
|
|
265
|
+
// $src: dynamic import
|
|
266
|
+
const src = def.$src;
|
|
267
|
+
const exportName = def.$export ?? key;
|
|
268
|
+
let mod;
|
|
269
|
+
if (_moduleCache.has(src)) {
|
|
270
|
+
mod = _moduleCache.get(src);
|
|
271
|
+
} else {
|
|
272
|
+
try {
|
|
273
|
+
mod = await import(src);
|
|
274
|
+
} catch {
|
|
275
|
+
if (base) {
|
|
276
|
+
const resolvedSrc = new URL(src, base).href;
|
|
277
|
+
mod = await import(resolvedSrc);
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error(`Jx: failed to import '$src' "${src}" for "${key}"`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
_moduleCache.set(src, mod);
|
|
283
|
+
}
|
|
284
|
+
fn = mod[exportName] ?? mod.default?.[exportName];
|
|
285
|
+
if (typeof fn !== "function") {
|
|
286
|
+
throw new Error(`Jx: export "${exportName}" not found or not a function in "${src}"`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Detect computed: body contains a return statement
|
|
291
|
+
if (def.body && /\breturn\b/.test(def.body)) {
|
|
292
|
+
return computed(() => fn(state));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return fn;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Step 3: Render ───────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Extract parameter names from a function definition. Supports both legacy "arguments" (string
|
|
302
|
+
* array) and CEM-compatible "parameters" (object array). Always ensures "state" is the first
|
|
303
|
+
* parameter.
|
|
304
|
+
*
|
|
305
|
+
* @param {Record<string, any>} def
|
|
306
|
+
* @returns {string[]}
|
|
307
|
+
*/
|
|
308
|
+
function resolveParamNames(def) {
|
|
309
|
+
const raw = def.parameters ?? def.arguments ?? [];
|
|
310
|
+
let names;
|
|
311
|
+
if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === "object") {
|
|
312
|
+
// CEM-style: [{name: "event", type: {...}}, ...]
|
|
313
|
+
names = raw.map((p) => p.name ?? p.identifier ?? "arg");
|
|
314
|
+
} else {
|
|
315
|
+
// Legacy string array: ["state", "event"] or ["event"]
|
|
316
|
+
names = raw;
|
|
317
|
+
}
|
|
318
|
+
return names.length > 0 && names[0] === "state" ? names : ["state", ...names];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Reserved Jx keys — never set as DOM properties.
|
|
323
|
+
*
|
|
324
|
+
* @type {Set<string>}
|
|
325
|
+
*/
|
|
326
|
+
export const RESERVED_KEYS = new Set([
|
|
327
|
+
"$schema",
|
|
328
|
+
"$id",
|
|
329
|
+
"$defs",
|
|
330
|
+
"state",
|
|
331
|
+
"$ref",
|
|
332
|
+
"$props",
|
|
333
|
+
"$elements",
|
|
334
|
+
"$switch",
|
|
335
|
+
"$prototype",
|
|
336
|
+
"$src",
|
|
337
|
+
"$export",
|
|
338
|
+
"$media",
|
|
339
|
+
"$map",
|
|
340
|
+
"timing",
|
|
341
|
+
"default",
|
|
342
|
+
"description",
|
|
343
|
+
"body",
|
|
344
|
+
"parameters",
|
|
345
|
+
"arguments",
|
|
346
|
+
"name",
|
|
347
|
+
"tagName",
|
|
348
|
+
"children",
|
|
349
|
+
"style",
|
|
350
|
+
"attributes",
|
|
351
|
+
"items",
|
|
352
|
+
"map",
|
|
353
|
+
"filter",
|
|
354
|
+
"sort",
|
|
355
|
+
"cases",
|
|
356
|
+
"observedAttributes",
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Recursively render a Jx element definition into a DOM element.
|
|
361
|
+
*
|
|
362
|
+
* @param {Record<string, any>} def
|
|
363
|
+
* @param {Record<string, any>} state - Reactive scope proxy (or child scope via Object.create)
|
|
364
|
+
* @param {any} [options]
|
|
365
|
+
* @returns {HTMLElement | Text}
|
|
366
|
+
*/
|
|
367
|
+
export function renderNode(def, state, options) {
|
|
368
|
+
const path = options?._path ?? [];
|
|
369
|
+
|
|
370
|
+
// Text node children: bare strings/numbers/booleans produce DOM Text nodes
|
|
371
|
+
if (typeof def === "string" || typeof def === "number" || typeof def === "boolean") {
|
|
372
|
+
const textNode = document.createTextNode(String(def));
|
|
373
|
+
if (typeof def === "string" && isTemplateString(def)) {
|
|
374
|
+
effect(() => {
|
|
375
|
+
textNode.textContent = evaluateTemplate(def, state);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return textNode;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Extend scope with any $-prefixed local bindings declared on this node
|
|
382
|
+
let localState = state;
|
|
383
|
+
for (const [key, val] of Object.entries(def)) {
|
|
384
|
+
if (key.startsWith("$") && !RESERVED_KEYS.has(key)) {
|
|
385
|
+
if (localState === state) localState = Object.create(state);
|
|
386
|
+
localState[key] = isRefObj(val) ? resolveRef(val.$ref, state) : val;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Custom element with $props: set JS properties on the element instance
|
|
391
|
+
const tagName = def.tagName ?? "div";
|
|
392
|
+
const isCustomEl = tagName.includes("-") && customElements.get(tagName);
|
|
393
|
+
|
|
394
|
+
if (def.$props && isCustomEl) {
|
|
395
|
+
return renderCustomElementWithProps(def, localState, options, path);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (def.$props) {
|
|
399
|
+
const { $props: _$props, ...rest } = def;
|
|
400
|
+
return renderNode(rest, mergeProps(def, localState), options);
|
|
401
|
+
}
|
|
402
|
+
if (def.$switch) return renderSwitch(def, localState, options);
|
|
403
|
+
if (def.children?.$prototype === "Array") return renderMappedArray(def, localState, options);
|
|
404
|
+
|
|
405
|
+
const el = document.createElement(tagName);
|
|
406
|
+
|
|
407
|
+
if (options?.onNodeCreated) options.onNodeCreated(el, path, def);
|
|
408
|
+
|
|
409
|
+
applyProperties(el, def, localState);
|
|
410
|
+
applyStyle(el, def.style ?? {}, localState["$media"] ?? {}, localState);
|
|
411
|
+
applyAttributes(el, def.attributes ?? {}, localState);
|
|
412
|
+
|
|
413
|
+
const children = Array.isArray(def.children) ? def.children : [];
|
|
414
|
+
for (let i = 0; i < children.length; i++) {
|
|
415
|
+
const childOpts = options ? { ...options, _path: [...path, "children", i] } : undefined;
|
|
416
|
+
el.appendChild(renderNode(children[i], localState, childOpts));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return el;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Template string utilities ────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Check if a value is a template string (contains ${}).
|
|
426
|
+
*
|
|
427
|
+
* @param {any} val
|
|
428
|
+
* @returns {boolean}
|
|
429
|
+
*/
|
|
430
|
+
function isTemplateString(val) {
|
|
431
|
+
return typeof val === "string" && val.includes("${");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─── Property / style / attribute application ─────────────────────────────────
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @param {HTMLElement} el
|
|
438
|
+
* @param {Record<string, any>} def
|
|
439
|
+
* @param {Record<string, any>} state
|
|
440
|
+
*/
|
|
441
|
+
function applyProperties(el, def, state) {
|
|
442
|
+
for (const [key, val] of Object.entries(def)) {
|
|
443
|
+
if (RESERVED_KEYS.has(key)) continue;
|
|
444
|
+
if (key.startsWith("$")) continue; // scope bindings — handled in renderNode
|
|
445
|
+
|
|
446
|
+
if (key.startsWith("on")) {
|
|
447
|
+
// Event handler: $ref to a function
|
|
448
|
+
if (isRefObj(val)) {
|
|
449
|
+
const handler = resolveRef(val.$ref, state);
|
|
450
|
+
if (typeof handler === "function") {
|
|
451
|
+
const scope = state;
|
|
452
|
+
el.addEventListener(key.slice(2), (e) => handler(scope, e));
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
// Event handler: inline $prototype: "Function"
|
|
457
|
+
if (val && typeof val === "object" && val.$prototype === "Function" && val.body) {
|
|
458
|
+
const params = resolveParamNames(val);
|
|
459
|
+
const fn = new Function(...params, val.body);
|
|
460
|
+
const scope = state;
|
|
461
|
+
el.addEventListener(key.slice(2), (e) => fn(scope, e));
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
bindProperty(el, key, val, state);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {any} el
|
|
472
|
+
* @param {string} key
|
|
473
|
+
* @param {any} val
|
|
474
|
+
* @param {Record<string, any>} state
|
|
475
|
+
*/
|
|
476
|
+
function bindProperty(el, key, val, state) {
|
|
477
|
+
if (isRefObj(val)) {
|
|
478
|
+
if (key === "id") {
|
|
479
|
+
el[key] = resolveRef(val.$ref, state);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
effect(() => {
|
|
483
|
+
el[key] = resolveRef(val.$ref, state);
|
|
484
|
+
});
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Universal ${} reactivity — template strings in element properties
|
|
489
|
+
if (isTemplateString(val)) {
|
|
490
|
+
effect(() => {
|
|
491
|
+
el[key] = evaluateTemplate(val, state);
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
el[key] = val;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Apply inline styles and emit a scoped <style> block for nested CSS selectors and @custom-media
|
|
501
|
+
* breakpoint rules.
|
|
502
|
+
*
|
|
503
|
+
* @param {HTMLElement} el
|
|
504
|
+
* @param {Record<string, any>} styleDef
|
|
505
|
+
* @param {Record<string, any>} [mediaQueries] Named breakpoints from root $media. Default is `{}`
|
|
506
|
+
* @param {Record<string, any>} [state] Component scope for template string evaluation. Default is
|
|
507
|
+
* `{}`
|
|
508
|
+
*/
|
|
509
|
+
export function applyStyle(el, styleDef, mediaQueries = {}, state = {}) {
|
|
510
|
+
/** @type {Record<string, any>} */
|
|
511
|
+
const nested = {};
|
|
512
|
+
/** @type {Record<string, any>} */
|
|
513
|
+
const media = {};
|
|
514
|
+
|
|
515
|
+
for (const [prop, val] of Object.entries(styleDef)) {
|
|
516
|
+
if (prop.startsWith("@")) media[prop] = val;
|
|
517
|
+
else if (isNestedSelector(prop)) nested[prop] = val;
|
|
518
|
+
else if (prop.startsWith("--")) {
|
|
519
|
+
if (isTemplateString(val))
|
|
520
|
+
effect(() => {
|
|
521
|
+
el.style.setProperty(prop, evaluateTemplate(val, state));
|
|
522
|
+
});
|
|
523
|
+
else el.style.setProperty(prop, val);
|
|
524
|
+
} else if (isTemplateString(val))
|
|
525
|
+
effect(() => {
|
|
526
|
+
/** @type {any} */ (el.style)[prop] = evaluateTemplate(val, state);
|
|
527
|
+
});
|
|
528
|
+
else /** @type {any} */ (el.style)[prop] = val;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const hasNested = Object.keys(nested).length > 0;
|
|
532
|
+
const hasMedia = Object.keys(media).length > 0;
|
|
533
|
+
if (!hasNested && !hasMedia) return;
|
|
534
|
+
|
|
535
|
+
const uid = `jx-${Math.random().toString(36).slice(2, 7)}`;
|
|
536
|
+
el.dataset.jx = uid;
|
|
537
|
+
|
|
538
|
+
let css = "";
|
|
539
|
+
|
|
540
|
+
for (const [sel, rules] of Object.entries(nested)) {
|
|
541
|
+
const resolved = sel.startsWith("&")
|
|
542
|
+
? sel.replace("&", `[data-jx="${uid}"]`)
|
|
543
|
+
: sel.startsWith("[")
|
|
544
|
+
? `[data-jx="${uid}"]${sel}`
|
|
545
|
+
: `[data-jx="${uid}"] ${sel}`;
|
|
546
|
+
css += `${resolved} { ${toCSSText(rules)} }\n`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const [key, rules] of Object.entries(media)) {
|
|
550
|
+
if (key === "@--") continue; // base canvas width, not a real media query
|
|
551
|
+
const query = key.startsWith("@--")
|
|
552
|
+
? (mediaQueries[key.slice(1)] ?? key.slice(1))
|
|
553
|
+
: key.slice(1);
|
|
554
|
+
const scope = `[data-jx="${uid}"]`;
|
|
555
|
+
css += `@media ${query} { ${scope} { ${toCSSText(rules)} } }\n`;
|
|
556
|
+
for (const [sel, nestedRules] of Object.entries(rules)) {
|
|
557
|
+
if (!isNestedSelector(sel)) continue;
|
|
558
|
+
const resolved = sel.startsWith("&")
|
|
559
|
+
? sel.replace("&", scope)
|
|
560
|
+
: sel.startsWith("[")
|
|
561
|
+
? `${scope}${sel}`
|
|
562
|
+
: `${scope} ${sel}`;
|
|
563
|
+
css += `@media ${query} { ${resolved} { ${toCSSText(nestedRules)} } }\n`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const tag = document.createElement("style");
|
|
568
|
+
tag.textContent = css;
|
|
569
|
+
document.head.appendChild(tag);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* @param {HTMLElement} el
|
|
574
|
+
* @param {Record<string, any>} attrs
|
|
575
|
+
* @param {Record<string, any>} state
|
|
576
|
+
*/
|
|
577
|
+
function applyAttributes(el, attrs, state) {
|
|
578
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
579
|
+
if (isRefObj(v)) {
|
|
580
|
+
effect(() => el.setAttribute(k, String(resolveRef(v.$ref, state) ?? "")));
|
|
581
|
+
} else if (isTemplateString(v)) {
|
|
582
|
+
effect(() => el.setAttribute(k, String(evaluateTemplate(v, state))));
|
|
583
|
+
} else {
|
|
584
|
+
el.setAttribute(k, String(v));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ─── Array mapping ────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* @param {Record<string, any>} def
|
|
593
|
+
* @param {Record<string, any>} state
|
|
594
|
+
* @param {any} [options]
|
|
595
|
+
* @returns {HTMLElement}
|
|
596
|
+
*/
|
|
597
|
+
function renderMappedArray(def, state, options) {
|
|
598
|
+
const path = options?._path ?? [];
|
|
599
|
+
const container = document.createElement(def.tagName ?? "div");
|
|
600
|
+
|
|
601
|
+
if (options?.onNodeCreated) options.onNodeCreated(container, path, def);
|
|
602
|
+
|
|
603
|
+
applyProperties(container, def, state);
|
|
604
|
+
applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
|
|
605
|
+
applyAttributes(container, def.attributes ?? {}, state);
|
|
606
|
+
const { items: itemsSrc, map: mapDef, filter: filterRef, sort: sortRef } = def.children;
|
|
607
|
+
|
|
608
|
+
effect(() => {
|
|
609
|
+
container.innerHTML = "";
|
|
610
|
+
let items;
|
|
611
|
+
if (isRefObj(itemsSrc)) {
|
|
612
|
+
items = resolveRef(itemsSrc.$ref, state);
|
|
613
|
+
} else {
|
|
614
|
+
items = itemsSrc;
|
|
615
|
+
}
|
|
616
|
+
if (!Array.isArray(items)) return;
|
|
617
|
+
if (filterRef) {
|
|
618
|
+
const fn = resolveRef(filterRef.$ref, state);
|
|
619
|
+
if (typeof fn === "function") items = items.filter(fn);
|
|
620
|
+
}
|
|
621
|
+
if (sortRef) {
|
|
622
|
+
const fn = resolveRef(sortRef.$ref, state);
|
|
623
|
+
if (typeof fn === "function") items = [...items].sort(fn);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
items.forEach((item, index) => {
|
|
627
|
+
const child = Object.create(state);
|
|
628
|
+
child.$map = { item, index };
|
|
629
|
+
child["$map/item"] = item;
|
|
630
|
+
child["$map/index"] = index;
|
|
631
|
+
const childOpts = options
|
|
632
|
+
? { ...options, _path: [...path, "children", "map", index] }
|
|
633
|
+
: undefined;
|
|
634
|
+
container.appendChild(renderNode(mapDef, child, childOpts));
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
return container;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ─── $switch ──────────────────────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* @param {Record<string, any>} def
|
|
645
|
+
* @param {Record<string, any>} state
|
|
646
|
+
* @param {any} [options]
|
|
647
|
+
* @returns {HTMLElement}
|
|
648
|
+
*/
|
|
649
|
+
function renderSwitch(def, state, options) {
|
|
650
|
+
const path = options?._path ?? [];
|
|
651
|
+
const container = document.createElement(def.tagName ?? "div");
|
|
652
|
+
|
|
653
|
+
if (options?.onNodeCreated) options.onNodeCreated(container, path, def);
|
|
654
|
+
|
|
655
|
+
applyProperties(container, def, state);
|
|
656
|
+
applyStyle(container, def.style ?? {}, state["$media"] ?? {}, state);
|
|
657
|
+
applyAttributes(container, def.attributes ?? {}, state);
|
|
658
|
+
let generation = 0;
|
|
659
|
+
|
|
660
|
+
effect(() => {
|
|
661
|
+
container.innerHTML = "";
|
|
662
|
+
const key = resolveRef(def.$switch.$ref, state);
|
|
663
|
+
const caseDef = def.cases?.[key];
|
|
664
|
+
if (!caseDef) return;
|
|
665
|
+
|
|
666
|
+
if (isRefObj(caseDef)) {
|
|
667
|
+
// External $ref — fetch and render asynchronously
|
|
668
|
+
const gen = ++generation;
|
|
669
|
+
const href = new URL(caseDef.$ref, location.href).href;
|
|
670
|
+
resolve(href)
|
|
671
|
+
.then(async (doc) => {
|
|
672
|
+
if (gen !== generation) return;
|
|
673
|
+
const childScope = await buildScope(doc, {}, href);
|
|
674
|
+
if (gen !== generation) return;
|
|
675
|
+
container.innerHTML = "";
|
|
676
|
+
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
677
|
+
container.appendChild(renderNode(doc, childScope, childOpts));
|
|
678
|
+
})
|
|
679
|
+
.catch((/** @type {any} */ e) =>
|
|
680
|
+
console.error("Jx $switch: failed to load external case", caseDef.$ref, e),
|
|
681
|
+
);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const childOpts = options ? { ...options, _path: [...path, "cases", key] } : undefined;
|
|
686
|
+
container.appendChild(renderNode(caseDef, state, childOpts));
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
return container;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ─── Prototype namespaces (Shape 5) ──────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Resolve a $prototype definition into a value for the reactive scope.
|
|
696
|
+
*
|
|
697
|
+
* Returns a ref() for async/persistent entries (Request, Storage, Cookie, IndexedDB), or a plain
|
|
698
|
+
* value for simple entries (Set, Map, FormData, Blob).
|
|
699
|
+
*
|
|
700
|
+
* @param {Record<string, any>} def - State entry with $prototype
|
|
701
|
+
* @param {Record<string, any>} state - Reactive scope proxy
|
|
702
|
+
* @param {string} key - Def key (for diagnostics)
|
|
703
|
+
* @param {string} [base] - Base URL for resolving $src imports
|
|
704
|
+
* @returns {Promise<any>}
|
|
705
|
+
*/
|
|
706
|
+
export async function resolvePrototype(def, state, key, base) {
|
|
707
|
+
// ── External class via $src ─────────────────────────────────────────────────
|
|
708
|
+
if (def.$src) {
|
|
709
|
+
return resolveExternalPrototype(def, state, key, base);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
switch (def.$prototype) {
|
|
713
|
+
case "Request": {
|
|
714
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
715
|
+
const s = ref(null);
|
|
716
|
+
const debounceMs = def.debounce ?? 0;
|
|
717
|
+
/** @type {any} */
|
|
718
|
+
let debounceTimer = null;
|
|
719
|
+
|
|
720
|
+
if (!def.manual) {
|
|
721
|
+
effect(() => {
|
|
722
|
+
let url;
|
|
723
|
+
if (isTemplateString(def.url)) {
|
|
724
|
+
url = evaluateTemplate(def.url, state);
|
|
725
|
+
} else {
|
|
726
|
+
url = def.url;
|
|
727
|
+
}
|
|
728
|
+
if (!url || url === "undefined" || url.includes("undefined")) return;
|
|
729
|
+
|
|
730
|
+
const controller = new AbortController();
|
|
731
|
+
onEffectCleanup(() => {
|
|
732
|
+
controller.abort();
|
|
733
|
+
clearTimeout(debounceTimer);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const doFetch = () =>
|
|
737
|
+
fetch(url, {
|
|
738
|
+
signal: controller.signal,
|
|
739
|
+
method: def.method ?? "GET",
|
|
740
|
+
...(def.headers && { headers: def.headers }),
|
|
741
|
+
...(def.body && {
|
|
742
|
+
body: typeof def.body === "object" ? JSON.stringify(def.body) : def.body,
|
|
743
|
+
}),
|
|
744
|
+
})
|
|
745
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
|
|
746
|
+
.then((d) => {
|
|
747
|
+
s.value = d;
|
|
748
|
+
})
|
|
749
|
+
.catch((/** @type {any} */ e) => {
|
|
750
|
+
if (e.name !== "AbortError") s.value = { error: String(e) };
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
if (debounceMs > 0) {
|
|
754
|
+
debounceTimer = setTimeout(doFetch, debounceMs);
|
|
755
|
+
} else {
|
|
756
|
+
doFetch();
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return s;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
case "URLSearchParams":
|
|
765
|
+
return computed(() => {
|
|
766
|
+
/** @type {Record<string, string>} */
|
|
767
|
+
const p = {};
|
|
768
|
+
for (const [k, v] of Object.entries(def)) {
|
|
769
|
+
if (k !== "$prototype") {
|
|
770
|
+
p[k] = isRefObj(v)
|
|
771
|
+
? resolveRef(v.$ref, state)
|
|
772
|
+
: isTemplateString(v)
|
|
773
|
+
? evaluateTemplate(v, state)
|
|
774
|
+
: v;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return new URLSearchParams(p).toString();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
case "LocalStorage":
|
|
781
|
+
case "SessionStorage": {
|
|
782
|
+
const store = def.$prototype === "LocalStorage" ? localStorage : sessionStorage;
|
|
783
|
+
const k = def.key ?? key;
|
|
784
|
+
let init;
|
|
785
|
+
try {
|
|
786
|
+
const s = store.getItem(k);
|
|
787
|
+
init = s !== null ? JSON.parse(s) : (def.default ?? null);
|
|
788
|
+
} catch {
|
|
789
|
+
init = def.default ?? null;
|
|
790
|
+
}
|
|
791
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
792
|
+
const storageState = ref(init);
|
|
793
|
+
// Persist on change
|
|
794
|
+
effect(() => {
|
|
795
|
+
const v = storageState.value;
|
|
796
|
+
if (v === null) {
|
|
797
|
+
try {
|
|
798
|
+
store.removeItem(k);
|
|
799
|
+
} catch {}
|
|
800
|
+
} else {
|
|
801
|
+
try {
|
|
802
|
+
store.setItem(k, JSON.stringify(v));
|
|
803
|
+
} catch {}
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
return storageState;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
case "Cookie": {
|
|
810
|
+
const name = def.name ?? key;
|
|
811
|
+
const read = () => {
|
|
812
|
+
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
|
813
|
+
if (!m) return null;
|
|
814
|
+
try {
|
|
815
|
+
return JSON.parse(decodeURIComponent(m[1]));
|
|
816
|
+
} catch {
|
|
817
|
+
return m[1];
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
821
|
+
const cookieState = ref(read() ?? def.default ?? null);
|
|
822
|
+
// Persist on change
|
|
823
|
+
effect(() => {
|
|
824
|
+
const v = cookieState.value;
|
|
825
|
+
let s = `${name}=${encodeURIComponent(JSON.stringify(v))}`;
|
|
826
|
+
if (def.maxAge !== undefined) s += `; Max-Age=${def.maxAge}`;
|
|
827
|
+
if (def.path) s += `; Path=${def.path}`;
|
|
828
|
+
if (def.domain) s += `; Domain=${def.domain}`;
|
|
829
|
+
if (def.secure) s += `; Secure`;
|
|
830
|
+
if (def.sameSite) s += `; SameSite=${def.sameSite}`;
|
|
831
|
+
document.cookie = s;
|
|
832
|
+
});
|
|
833
|
+
return cookieState;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
case "IndexedDB": {
|
|
837
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
838
|
+
const idbState = ref(null);
|
|
839
|
+
const {
|
|
840
|
+
database,
|
|
841
|
+
store,
|
|
842
|
+
version = 1,
|
|
843
|
+
keyPath = "id",
|
|
844
|
+
autoIncrement = true,
|
|
845
|
+
indexes = [],
|
|
846
|
+
} = def;
|
|
847
|
+
const req = indexedDB.open(database, version);
|
|
848
|
+
req.onupgradeneeded = (e) => {
|
|
849
|
+
/** @type {any} */
|
|
850
|
+
const db = /** @type {any} */ (e.target)?.result;
|
|
851
|
+
if (!db.objectStoreNames.contains(store)) {
|
|
852
|
+
const os = db.createObjectStore(store, { keyPath, autoIncrement });
|
|
853
|
+
for (const i of indexes) os.createIndex(i.name, i.keyPath, { unique: i.unique ?? false });
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
req.onsuccess = (e) => {
|
|
857
|
+
/** @type {any} */
|
|
858
|
+
const db = /** @type {any} */ (e.target)?.result;
|
|
859
|
+
idbState.value = {
|
|
860
|
+
database,
|
|
861
|
+
store,
|
|
862
|
+
version,
|
|
863
|
+
isReady: true,
|
|
864
|
+
getStore: (/** @type {string} */ mode = "readwrite") =>
|
|
865
|
+
Promise.resolve(db.transaction(store, mode).objectStore(store)),
|
|
866
|
+
};
|
|
867
|
+
};
|
|
868
|
+
req.onerror = () => {
|
|
869
|
+
idbState.value = { error: req.error?.message };
|
|
870
|
+
};
|
|
871
|
+
return idbState;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
case "Set":
|
|
875
|
+
return new Set(def.default ?? []);
|
|
876
|
+
|
|
877
|
+
case "Map":
|
|
878
|
+
return new Map(Object.entries(def.default ?? {}));
|
|
879
|
+
|
|
880
|
+
case "FormData": {
|
|
881
|
+
const fd = new FormData();
|
|
882
|
+
for (const [k, v] of Object.entries(def.fields ?? {})) fd.append(k, v);
|
|
883
|
+
return fd;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
case "Blob":
|
|
887
|
+
return new Blob(def.parts ?? [], { type: def.type ?? "text/plain" });
|
|
888
|
+
|
|
889
|
+
case "ReadableStream":
|
|
890
|
+
return null;
|
|
891
|
+
|
|
892
|
+
default:
|
|
893
|
+
console.warn(
|
|
894
|
+
`Jx: unknown $prototype "${def.$prototype}" for "${key}". Did you mean to add '$src'?`,
|
|
895
|
+
);
|
|
896
|
+
return ref(null);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ─── External class resolution ────────────────────────────────────────────────
|
|
901
|
+
|
|
902
|
+
/** Reserved keys stripped from the config object passed to external class constructors. */
|
|
903
|
+
const EXTERNAL_RESERVED = new Set([
|
|
904
|
+
"$prototype",
|
|
905
|
+
"$src",
|
|
906
|
+
"$export",
|
|
907
|
+
"timing",
|
|
908
|
+
"default",
|
|
909
|
+
"description",
|
|
910
|
+
"body",
|
|
911
|
+
"parameters",
|
|
912
|
+
"arguments",
|
|
913
|
+
"name",
|
|
914
|
+
]);
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Resolve an external class prototype via $src.
|
|
918
|
+
*
|
|
919
|
+
* @param {Record<string, any>} def
|
|
920
|
+
* @param {Record<string, any>} state
|
|
921
|
+
* @param {string} key
|
|
922
|
+
* @param {string} [base]
|
|
923
|
+
* @returns {Promise<any>}
|
|
924
|
+
*/
|
|
925
|
+
async function resolveExternalPrototype(def, state, key, base) {
|
|
926
|
+
const src = def.$src;
|
|
927
|
+
|
|
928
|
+
// Non-Function $prototype must use .class.json as entrypoint
|
|
929
|
+
if (!src.endsWith(".class.json")) {
|
|
930
|
+
throw new Error(
|
|
931
|
+
`Jx: $prototype "${def.$prototype}" requires a .class.json $src, got "${src}". ` +
|
|
932
|
+
`Wrap the class in a .class.json schema with $implementation.`,
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return resolveClassJson(def, state, key, base);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Import a JS module and instantiate a class from it. Internal helper used by resolveClassJson for
|
|
941
|
+
* $implementation.
|
|
942
|
+
*
|
|
943
|
+
* @param {Record<string, any>} def - Original state entry (for config extraction)
|
|
944
|
+
* @param {string} src - JS module URL to import
|
|
945
|
+
* @param {string} exportName - Export name to look up
|
|
946
|
+
* @param {string} [base] - Base URL for resolution
|
|
947
|
+
* @returns {Promise<any>}
|
|
948
|
+
*/
|
|
949
|
+
async function importAndInstantiate(def, src, exportName, base) {
|
|
950
|
+
let mod;
|
|
951
|
+
if (_moduleCache.has(src)) {
|
|
952
|
+
mod = _moduleCache.get(src);
|
|
953
|
+
} else {
|
|
954
|
+
try {
|
|
955
|
+
mod = await import(src);
|
|
956
|
+
} catch {
|
|
957
|
+
if (base) {
|
|
958
|
+
const resolvedSrc = new URL(src, base).href;
|
|
959
|
+
mod = await import(resolvedSrc);
|
|
960
|
+
} else {
|
|
961
|
+
throw new Error(`Failed to import "${src}"`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
_moduleCache.set(src, mod);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const ExportedClass = mod[exportName] ?? mod.default?.[exportName];
|
|
968
|
+
if (!ExportedClass) {
|
|
969
|
+
throw new Error(`Jx: export "${exportName}" not found in "${src}"`);
|
|
970
|
+
}
|
|
971
|
+
if (typeof ExportedClass !== "function") {
|
|
972
|
+
throw new Error(`Jx: "${exportName}" from "${src}" is not a class`);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** @type {Record<string, any>} */
|
|
976
|
+
const config = {};
|
|
977
|
+
for (const [k, v] of Object.entries(def)) {
|
|
978
|
+
if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const instance = new ExportedClass(config);
|
|
982
|
+
|
|
983
|
+
let value;
|
|
984
|
+
if (typeof instance.resolve === "function") {
|
|
985
|
+
value = await instance.resolve();
|
|
986
|
+
} else if ("value" in instance) {
|
|
987
|
+
value = instance.value;
|
|
988
|
+
} else {
|
|
989
|
+
value = instance;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Always wrap in ref for reactivity with external classes
|
|
993
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
994
|
+
const s = ref(value);
|
|
995
|
+
if (typeof instance.subscribe === "function") {
|
|
996
|
+
instance.subscribe((/** @type {any} */ newVal) => {
|
|
997
|
+
s.value = newVal;
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return s;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Resolve a .class.json schema-defined class. Fetches the schema, follows $implementation if
|
|
1005
|
+
* hybrid, or constructs dynamically if self-contained.
|
|
1006
|
+
*
|
|
1007
|
+
* @param {Record<string, any>} def
|
|
1008
|
+
* @param {Record<string, any>} state
|
|
1009
|
+
* @param {string} key
|
|
1010
|
+
* @param {string} [base]
|
|
1011
|
+
* @returns {Promise<any>}
|
|
1012
|
+
*/
|
|
1013
|
+
async function resolveClassJson(def, state, key, base) {
|
|
1014
|
+
const src = def.$src;
|
|
1015
|
+
let classDef;
|
|
1016
|
+
|
|
1017
|
+
// Try fetching the .class.json file directly
|
|
1018
|
+
try {
|
|
1019
|
+
const url = base ? new URL(src, base).href : src;
|
|
1020
|
+
const res = await fetch(url);
|
|
1021
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1022
|
+
classDef = await res.json();
|
|
1023
|
+
} catch {
|
|
1024
|
+
// Fall back to dev proxy (server will handle .class.json resolution)
|
|
1025
|
+
return resolveViaDevProxy(def, state, key, base);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Hybrid mode: $implementation points to the real JS module
|
|
1029
|
+
if (classDef.$implementation) {
|
|
1030
|
+
const schemaUrl = base ? new URL(src, base).href : new URL(src, location.href).href;
|
|
1031
|
+
const implSrc = new URL(classDef.$implementation, schemaUrl).href;
|
|
1032
|
+
const exportName = def.$export ?? classDef.title ?? def.$prototype;
|
|
1033
|
+
try {
|
|
1034
|
+
return await importAndInstantiate(def, implSrc, exportName, base);
|
|
1035
|
+
} catch {
|
|
1036
|
+
// Browser can't import the JS module — fall back to dev proxy with original .class.json def
|
|
1037
|
+
return resolveViaDevProxy(def, state, key, base);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Self-contained: construct class dynamically from schema
|
|
1042
|
+
const DynClass = classFromSchema(classDef);
|
|
1043
|
+
/** @type {Record<string, any>} */
|
|
1044
|
+
const config = {};
|
|
1045
|
+
for (const [k, v] of Object.entries(def)) {
|
|
1046
|
+
if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
|
|
1047
|
+
}
|
|
1048
|
+
const instance = new DynClass(config);
|
|
1049
|
+
|
|
1050
|
+
let value;
|
|
1051
|
+
if (typeof instance.resolve === "function") {
|
|
1052
|
+
value = await instance.resolve();
|
|
1053
|
+
} else if ("value" in instance) {
|
|
1054
|
+
value = instance.value;
|
|
1055
|
+
} else {
|
|
1056
|
+
value = instance;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Always wrap in ref for reactivity
|
|
1060
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
1061
|
+
const s = ref(value);
|
|
1062
|
+
if (typeof instance.subscribe === "function") {
|
|
1063
|
+
instance.subscribe((/** @type {any} */ newVal) => {
|
|
1064
|
+
s.value = newVal;
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
return s;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Dynamically construct a class from a .class.json schema definition. Browser-side: maps private
|
|
1072
|
+
* fields to _-prefixed public fields.
|
|
1073
|
+
*
|
|
1074
|
+
* @param {Record<string, any>} classDef
|
|
1075
|
+
* @returns {any}
|
|
1076
|
+
*/
|
|
1077
|
+
function classFromSchema(classDef) {
|
|
1078
|
+
const fields = classDef.$defs?.fields ?? {};
|
|
1079
|
+
const ctor = classDef.$defs?.constructor;
|
|
1080
|
+
const methods = classDef.$defs?.methods ?? {};
|
|
1081
|
+
|
|
1082
|
+
class DynClass {
|
|
1083
|
+
constructor(/** @type {Record<string, any>} */ config = {}) {
|
|
1084
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
1085
|
+
/** @type {any} */
|
|
1086
|
+
const typedField = field;
|
|
1087
|
+
const id = typedField.identifier ?? key;
|
|
1088
|
+
const propName = typedField.access === "private" ? `_${id}` : id;
|
|
1089
|
+
if (config[id] !== undefined) /** @type {any} */ (this)[propName] = config[id];
|
|
1090
|
+
else if (typedField.initializer !== undefined)
|
|
1091
|
+
/** @type {any} */ (this)[propName] = typedField.initializer;
|
|
1092
|
+
else if (typedField.default !== undefined)
|
|
1093
|
+
/** @type {any} */ (this)[propName] = structuredClone(typedField.default);
|
|
1094
|
+
else /** @type {any} */ (this)[propName] = null;
|
|
1095
|
+
}
|
|
1096
|
+
if (ctor?.body) {
|
|
1097
|
+
const bodyStr = Array.isArray(ctor.body) ? ctor.body.join("\n") : ctor.body;
|
|
1098
|
+
new Function("config", bodyStr).call(this, config);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
for (const [key, method] of Object.entries(methods)) {
|
|
1104
|
+
/** @type {any} */
|
|
1105
|
+
const typedMethod = method;
|
|
1106
|
+
const name = typedMethod.identifier ?? key;
|
|
1107
|
+
const params = (typedMethod.parameters ?? []).map((/** @type {any} */ p) => {
|
|
1108
|
+
if (p.$ref) return p.$ref.split("/").pop();
|
|
1109
|
+
return p.identifier ?? p.name ?? "arg";
|
|
1110
|
+
});
|
|
1111
|
+
const bodyStr = Array.isArray(typedMethod.body)
|
|
1112
|
+
? typedMethod.body.join("\n")
|
|
1113
|
+
: (typedMethod.body ?? "");
|
|
1114
|
+
|
|
1115
|
+
if (typedMethod.role === "accessor") {
|
|
1116
|
+
/** @type {PropertyDescriptor} */
|
|
1117
|
+
const descriptor = {};
|
|
1118
|
+
if (typedMethod.getter)
|
|
1119
|
+
descriptor.get = /** @type {any} */ (new Function(typedMethod.getter.body));
|
|
1120
|
+
if (typedMethod.setter) {
|
|
1121
|
+
const sp = (typedMethod.setter.parameters ?? []).map(
|
|
1122
|
+
(/** @type {any} */ p) => p.$ref?.split("/").pop() ?? "v",
|
|
1123
|
+
);
|
|
1124
|
+
descriptor.set = /** @type {any} */ (new Function(...sp, typedMethod.setter.body));
|
|
1125
|
+
}
|
|
1126
|
+
Object.defineProperty(DynClass.prototype, name, { ...descriptor, configurable: true });
|
|
1127
|
+
} else if (typedMethod.scope === "static") {
|
|
1128
|
+
/** @type {any} */ (DynClass)[name] = new Function(...params, bodyStr);
|
|
1129
|
+
} else {
|
|
1130
|
+
/** @type {any} */ (DynClass.prototype)[name] = new Function(...params, bodyStr);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
Object.defineProperty(DynClass, "name", { value: classDef.title, configurable: true });
|
|
1135
|
+
return DynClass;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Dev-mode fallback: when an $src module cannot run in the browser, proxy the resolve() call
|
|
1140
|
+
* through the Jx dev server (POST /**jx_resolve**). Supports reactive template strings in config
|
|
1141
|
+
* values via Vue effect().
|
|
1142
|
+
*
|
|
1143
|
+
* @param {Record<string, any>} def
|
|
1144
|
+
* @param {Record<string, any>} state
|
|
1145
|
+
* @param {string} key
|
|
1146
|
+
* @param {string} [base]
|
|
1147
|
+
* @returns {Promise<any>}
|
|
1148
|
+
*/
|
|
1149
|
+
async function resolveViaDevProxy(def, state, key, base) {
|
|
1150
|
+
/** @type {Record<string, any>} */
|
|
1151
|
+
const config = {};
|
|
1152
|
+
for (const [k, v] of Object.entries(def)) {
|
|
1153
|
+
if (!EXTERNAL_RESERVED.has(k)) config[k] = v;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const hasTemplates = Object.values(config).some((/** @type {any} */ v) => isTemplateString(v));
|
|
1157
|
+
|
|
1158
|
+
/** @param {Record<string, any>} resolvedConfig */
|
|
1159
|
+
const doResolve = (resolvedConfig) =>
|
|
1160
|
+
fetch("/__jx_resolve__", {
|
|
1161
|
+
method: "POST",
|
|
1162
|
+
headers: { "Content-Type": "application/json" },
|
|
1163
|
+
body: JSON.stringify({
|
|
1164
|
+
$src: def.$src,
|
|
1165
|
+
$prototype: def.$prototype,
|
|
1166
|
+
$export: def.$export,
|
|
1167
|
+
$base: base,
|
|
1168
|
+
...resolvedConfig,
|
|
1169
|
+
}),
|
|
1170
|
+
}).then((r) => {
|
|
1171
|
+
if (!r.ok) throw new Error(`Jx dev proxy ${r.status} for "${key}"`);
|
|
1172
|
+
return r.json();
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
// Always wrap in ref for reactivity
|
|
1176
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
1177
|
+
const s = ref(null);
|
|
1178
|
+
if (hasTemplates) {
|
|
1179
|
+
effect(() => {
|
|
1180
|
+
/** @type {Record<string, any>} */
|
|
1181
|
+
const resolvedConfig = {};
|
|
1182
|
+
for (const [k, v] of Object.entries(config)) {
|
|
1183
|
+
resolvedConfig[k] = isTemplateString(v) ? evaluateTemplate(v, state) : v;
|
|
1184
|
+
}
|
|
1185
|
+
doResolve(resolvedConfig)
|
|
1186
|
+
.then((/** @type {any} */ value) => {
|
|
1187
|
+
s.value = value;
|
|
1188
|
+
})
|
|
1189
|
+
.catch((/** @type {any} */ e) => console.error("Jx dev proxy:", e));
|
|
1190
|
+
});
|
|
1191
|
+
} else {
|
|
1192
|
+
doResolve(config)
|
|
1193
|
+
.then((/** @type {any} */ value) => {
|
|
1194
|
+
s.value = value;
|
|
1195
|
+
})
|
|
1196
|
+
.catch((/** @type {any} */ e) => console.error("Jx dev proxy:", e));
|
|
1197
|
+
}
|
|
1198
|
+
return s;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// ─── Server function resolution (dev mode) ────────────────────────────────────
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Resolve a timing: "server" entry in dev mode by executing the function client-side. In
|
|
1205
|
+
* production, the compiler replaces this with a fetch to the generated server handler.
|
|
1206
|
+
*
|
|
1207
|
+
* @param {Record<string, any>} def
|
|
1208
|
+
* @param {Record<string, any>} state
|
|
1209
|
+
* @param {string} key
|
|
1210
|
+
* @param {string} [base]
|
|
1211
|
+
* @returns {Promise<any>}
|
|
1212
|
+
*/
|
|
1213
|
+
async function resolveServerFunction(def, state, key, base) {
|
|
1214
|
+
const src = def.$src;
|
|
1215
|
+
const exportName = def.$export;
|
|
1216
|
+
|
|
1217
|
+
let mod;
|
|
1218
|
+
if (_moduleCache.has(src)) {
|
|
1219
|
+
mod = _moduleCache.get(src);
|
|
1220
|
+
} else {
|
|
1221
|
+
try {
|
|
1222
|
+
mod = await import(src);
|
|
1223
|
+
} catch {
|
|
1224
|
+
if (base) {
|
|
1225
|
+
try {
|
|
1226
|
+
const resolvedSrc = new URL(src, base).href;
|
|
1227
|
+
mod = await import(resolvedSrc);
|
|
1228
|
+
} catch {
|
|
1229
|
+
// Module cannot run in the browser — fall back to dev server proxy
|
|
1230
|
+
return resolveServerFunctionViaProxy(def, state, key, base);
|
|
1231
|
+
}
|
|
1232
|
+
} else {
|
|
1233
|
+
return resolveServerFunctionViaProxy(def, state, key, base);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
_moduleCache.set(src, mod);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const fn = mod[exportName] ?? mod.default?.[exportName];
|
|
1240
|
+
if (!fn) throw new Error(`Jx: export "${exportName}" not found in "${src}" for "${key}"`);
|
|
1241
|
+
if (typeof fn !== "function")
|
|
1242
|
+
throw new Error(`Jx: "${exportName}" from "${src}" is not a function`);
|
|
1243
|
+
|
|
1244
|
+
const rawArgs = def.arguments ?? {};
|
|
1245
|
+
const hasReactiveArg = Object.values(rawArgs).some((/** @type {any} */ v) => isRefObj(v));
|
|
1246
|
+
const resolveArgs = () => {
|
|
1247
|
+
/** @type {Record<string, any>} */
|
|
1248
|
+
const args = {};
|
|
1249
|
+
for (const [k, v] of Object.entries(rawArgs)) {
|
|
1250
|
+
args[k] = isRefObj(v) ? resolveRef(/** @type {any} */ (v).$ref, state) : v;
|
|
1251
|
+
}
|
|
1252
|
+
return args;
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
// Always wrap in ref for reactivity
|
|
1256
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
1257
|
+
const s = ref(null);
|
|
1258
|
+
if (hasReactiveArg) {
|
|
1259
|
+
effect(() => {
|
|
1260
|
+
const args = resolveArgs();
|
|
1261
|
+
onEffectCleanup(() => {});
|
|
1262
|
+
fn(args)
|
|
1263
|
+
.then((/** @type {any} */ result) => {
|
|
1264
|
+
s.value = result;
|
|
1265
|
+
})
|
|
1266
|
+
.catch(() => {});
|
|
1267
|
+
});
|
|
1268
|
+
} else {
|
|
1269
|
+
s.value = await fn(resolveArgs());
|
|
1270
|
+
}
|
|
1271
|
+
return s;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Dev-mode fallback: when a timing: "server" module cannot run in the browser, proxy the function
|
|
1276
|
+
* call through the Jx dev server (POST /**jx_server**). Supports reactive $ref arguments via Vue
|
|
1277
|
+
* effect().
|
|
1278
|
+
*
|
|
1279
|
+
* @param {Record<string, any>} def
|
|
1280
|
+
* @param {Record<string, any>} state
|
|
1281
|
+
* @param {string} key
|
|
1282
|
+
* @param {string} [base]
|
|
1283
|
+
* @returns {Promise<any>}
|
|
1284
|
+
*/
|
|
1285
|
+
async function resolveServerFunctionViaProxy(def, state, key, base) {
|
|
1286
|
+
const rawArgs = def.arguments ?? {};
|
|
1287
|
+
const hasReactiveArg = Object.values(rawArgs).some((/** @type {any} */ v) => isRefObj(v));
|
|
1288
|
+
|
|
1289
|
+
const resolveArgs = () => {
|
|
1290
|
+
/** @type {Record<string, any>} */
|
|
1291
|
+
const args = {};
|
|
1292
|
+
for (const [k, v] of Object.entries(rawArgs)) {
|
|
1293
|
+
args[k] = isRefObj(v) ? resolveRef(/** @type {any} */ (v).$ref, state) : v;
|
|
1294
|
+
}
|
|
1295
|
+
return args;
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
/** @param {Record<string, any>} args */
|
|
1299
|
+
const doResolve = (args) =>
|
|
1300
|
+
fetch("/__jx_server__", {
|
|
1301
|
+
method: "POST",
|
|
1302
|
+
headers: { "Content-Type": "application/json" },
|
|
1303
|
+
body: JSON.stringify({
|
|
1304
|
+
$src: def.$src,
|
|
1305
|
+
$export: def.$export,
|
|
1306
|
+
$base: base,
|
|
1307
|
+
arguments: args,
|
|
1308
|
+
}),
|
|
1309
|
+
}).then((r) => {
|
|
1310
|
+
if (!r.ok) throw new Error(`Jx server proxy ${r.status} for "${key}"`);
|
|
1311
|
+
return r.json();
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
// Always wrap in ref for reactivity
|
|
1315
|
+
/** @type {import("@vue/reactivity").Ref<any>} */
|
|
1316
|
+
const s = ref(null);
|
|
1317
|
+
if (hasReactiveArg) {
|
|
1318
|
+
effect(() => {
|
|
1319
|
+
const args = resolveArgs();
|
|
1320
|
+
onEffectCleanup(() => {});
|
|
1321
|
+
doResolve(args)
|
|
1322
|
+
.then((/** @type {any} */ result) => {
|
|
1323
|
+
s.value = result;
|
|
1324
|
+
})
|
|
1325
|
+
.catch((/** @type {any} */ e) => console.error("Jx server proxy:", e));
|
|
1326
|
+
});
|
|
1327
|
+
} else {
|
|
1328
|
+
doResolve(resolveArgs())
|
|
1329
|
+
.then((/** @type {any} */ result) => {
|
|
1330
|
+
s.value = result;
|
|
1331
|
+
})
|
|
1332
|
+
.catch((/** @type {any} */ e) => console.error("Jx server proxy:", e));
|
|
1333
|
+
}
|
|
1334
|
+
return s;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Resolve a $ref string to a value in scope.
|
|
1339
|
+
*
|
|
1340
|
+
* With Vue reactivity, this reads directly from the reactive proxy. When called inside a effect or
|
|
1341
|
+
* computed, the read is tracked.
|
|
1342
|
+
*
|
|
1343
|
+
* @param {string} ref
|
|
1344
|
+
* @param {Record<string, any>} state - Reactive scope proxy (or child scope)
|
|
1345
|
+
* @returns {any}
|
|
1346
|
+
*/
|
|
1347
|
+
export function resolveRef(ref, state) {
|
|
1348
|
+
if (typeof ref !== "string") return ref;
|
|
1349
|
+
if (ref.startsWith("$map/")) {
|
|
1350
|
+
const parts = ref.split("/");
|
|
1351
|
+
const key = parts[1]; // 'item' or 'index'
|
|
1352
|
+
const base = state.$map?.[key] ?? state["$map/" + key];
|
|
1353
|
+
return parts.length > 2 ? getPath(base, parts.slice(2).join("/")) : base;
|
|
1354
|
+
}
|
|
1355
|
+
if (ref.startsWith("#/state/")) {
|
|
1356
|
+
const sub = ref.slice("#/state/".length);
|
|
1357
|
+
const slash = sub.indexOf("/");
|
|
1358
|
+
if (slash < 0) return state[sub];
|
|
1359
|
+
return getPath(state[sub.slice(0, slash)], sub.slice(slash + 1));
|
|
1360
|
+
}
|
|
1361
|
+
if (ref.startsWith("parent#/")) return state[ref.slice("parent#/".length)];
|
|
1362
|
+
if (ref.startsWith("window#/")) return getPath(globalThis.window, ref.slice("window#/".length));
|
|
1363
|
+
if (ref.startsWith("document#/"))
|
|
1364
|
+
return getPath(globalThis.document, ref.slice("document#/".length));
|
|
1365
|
+
return state[ref] ?? null;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Check if v is a Vue ref (including computed).
|
|
1372
|
+
*
|
|
1373
|
+
* @param {any} v
|
|
1374
|
+
* @returns {boolean}
|
|
1375
|
+
*/
|
|
1376
|
+
export function isSignal(v) {
|
|
1377
|
+
return isRef(v);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* @param {any} v
|
|
1382
|
+
* @returns {boolean}
|
|
1383
|
+
*/
|
|
1384
|
+
function isRefObj(v) {
|
|
1385
|
+
return v !== null && typeof v === "object" && typeof v.$ref === "string";
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* @param {string} k
|
|
1390
|
+
* @returns {boolean}
|
|
1391
|
+
*/
|
|
1392
|
+
function isNestedSelector(k) {
|
|
1393
|
+
return k.startsWith(":") || k.startsWith(".") || k.startsWith("&") || k.startsWith("[");
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* @param {any} obj
|
|
1398
|
+
* @param {string} path
|
|
1399
|
+
* @returns {any}
|
|
1400
|
+
*/
|
|
1401
|
+
function getPath(obj, path) {
|
|
1402
|
+
return path.split(/[./]/).reduce((o, k) => o?.[k], obj);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* @param {Record<string, any>} def
|
|
1407
|
+
* @param {Record<string, any>} parentState
|
|
1408
|
+
* @returns {Record<string, any>}
|
|
1409
|
+
*/
|
|
1410
|
+
function mergeProps(def, parentState) {
|
|
1411
|
+
const child = Object.create(parentState);
|
|
1412
|
+
for (const [k, v] of Object.entries(def.$props ?? {})) {
|
|
1413
|
+
child[k] = isRefObj(v) ? resolveRef(v.$ref, parentState) : v;
|
|
1414
|
+
}
|
|
1415
|
+
return child;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Convert camelCase to kebab-case.
|
|
1420
|
+
*
|
|
1421
|
+
* @param {string} s
|
|
1422
|
+
* @returns {string}
|
|
1423
|
+
*/
|
|
1424
|
+
export function camelToKebab(s) {
|
|
1425
|
+
return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Convert a style rules object to a CSS text string (skipping nested selectors).
|
|
1430
|
+
*
|
|
1431
|
+
* @param {Record<string, any>} rules
|
|
1432
|
+
* @returns {string}
|
|
1433
|
+
*/
|
|
1434
|
+
export function toCSSText(rules) {
|
|
1435
|
+
return Object.entries(rules)
|
|
1436
|
+
.filter(([k]) => !isNestedSelector(k))
|
|
1437
|
+
.map(([p, v]) => `${camelToKebab(p)}: ${v}`)
|
|
1438
|
+
.join("; ");
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// ─── Custom Element Registration ──────────────────────────────────────────────
|
|
1442
|
+
|
|
1443
|
+
const _elementDefs = new Map();
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Resolve and register $elements entries (depth-first).
|
|
1447
|
+
*
|
|
1448
|
+
* @param {any[]} elements
|
|
1449
|
+
* @param {string} base
|
|
1450
|
+
* @returns {Promise<void>}
|
|
1451
|
+
*/
|
|
1452
|
+
async function registerElements(elements, base) {
|
|
1453
|
+
for (const entry of elements) {
|
|
1454
|
+
// Bare string: npm package side-effect import (registers custom elements)
|
|
1455
|
+
if (typeof entry === "string") {
|
|
1456
|
+
try {
|
|
1457
|
+
// Bare specifiers need a URL path for the browser; the dev server resolves
|
|
1458
|
+
// /node_modules/<pkg> to the package entry point via exports/module/main.
|
|
1459
|
+
const specifier =
|
|
1460
|
+
entry.startsWith("/") || entry.startsWith(".") ? entry : `/node_modules/${entry}`;
|
|
1461
|
+
await import(specifier);
|
|
1462
|
+
} catch (e) {
|
|
1463
|
+
console.warn(`Jx: failed to import package "${entry}"`, e);
|
|
1464
|
+
}
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
if (!isRefObj(entry)) continue;
|
|
1468
|
+
const href = new URL(entry.$ref, base).href;
|
|
1469
|
+
const doc = await resolve(href);
|
|
1470
|
+
if (!doc.tagName || !doc.tagName.includes("-")) continue;
|
|
1471
|
+
if (customElements.get(doc.tagName)) continue;
|
|
1472
|
+
|
|
1473
|
+
// Depth-first: register sub-dependencies first
|
|
1474
|
+
if (doc.$elements) {
|
|
1475
|
+
await registerElements(doc.$elements, href);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
await defineElement(doc, href);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Inject head elements from $head declarations. Each entry is { tagName, attributes } — bare npm
|
|
1484
|
+
* specifiers in href/src are rewritten to /node_modules/ paths for the dev server.
|
|
1485
|
+
*
|
|
1486
|
+
* @param {any[]} entries
|
|
1487
|
+
* @param {string} _base - Document base URL for resolving relative paths
|
|
1488
|
+
*/
|
|
1489
|
+
function injectHead(entries, _base) {
|
|
1490
|
+
for (const entry of entries) {
|
|
1491
|
+
if (!entry || !entry.tagName) continue;
|
|
1492
|
+
const tag = entry.tagName.toLowerCase();
|
|
1493
|
+
const attrs = { ...entry.attributes };
|
|
1494
|
+
// Resolve href/src: bare npm specifiers -> /node_modules/ path
|
|
1495
|
+
for (const key of ["href", "src"]) {
|
|
1496
|
+
if (
|
|
1497
|
+
attrs[key] &&
|
|
1498
|
+
!attrs[key].startsWith("/") &&
|
|
1499
|
+
!attrs[key].startsWith(".") &&
|
|
1500
|
+
!attrs[key].startsWith("http")
|
|
1501
|
+
) {
|
|
1502
|
+
attrs[key] = `/node_modules/${attrs[key]}`;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Deduplicate: skip if an identical element already exists
|
|
1507
|
+
const selector = `${tag}${attrs.href ? `[href="${attrs.href}"]` : ""}${attrs.src ? `[src="${attrs.src}"]` : ""}`;
|
|
1508
|
+
if (selector !== tag && document.head.querySelector(selector)) continue;
|
|
1509
|
+
|
|
1510
|
+
const el = document.createElement(tag);
|
|
1511
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
1512
|
+
el.setAttribute(k, /** @type {string} */ (v));
|
|
1513
|
+
}
|
|
1514
|
+
if (entry.textContent) el.textContent = entry.textContent;
|
|
1515
|
+
document.head.appendChild(el);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Register a custom element from a Jx document.
|
|
1521
|
+
*
|
|
1522
|
+
* @param {string | Record<string, any>} source - URL to .json file, or raw document object
|
|
1523
|
+
* @param {string} [base] - Base URL for resolving $src imports
|
|
1524
|
+
* @returns {Promise<void>}
|
|
1525
|
+
*/
|
|
1526
|
+
export async function defineElement(source, base) {
|
|
1527
|
+
if (typeof source === "string") {
|
|
1528
|
+
base = new URL(source, base ?? location.href).href;
|
|
1529
|
+
source = await resolve(source);
|
|
1530
|
+
}
|
|
1531
|
+
base = base ?? location.href;
|
|
1532
|
+
|
|
1533
|
+
/** @type {Record<string, any>} */
|
|
1534
|
+
const source_ = /** @type {Record<string, any>} */ (source);
|
|
1535
|
+
|
|
1536
|
+
const tagName = source_.tagName;
|
|
1537
|
+
if (!tagName || !tagName.includes("-")) {
|
|
1538
|
+
throw new Error(`Jx defineElement: tagName "${tagName}" must contain a hyphen`);
|
|
1539
|
+
}
|
|
1540
|
+
if (customElements.get(tagName)) return;
|
|
1541
|
+
|
|
1542
|
+
// Register sub-dependencies first
|
|
1543
|
+
if (source_.$elements) {
|
|
1544
|
+
await registerElements(source_.$elements, base);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
_elementDefs.set(tagName, { doc: source_, base });
|
|
1548
|
+
|
|
1549
|
+
const def = source_;
|
|
1550
|
+
const observedAttrs = def.observedAttributes ?? [];
|
|
1551
|
+
|
|
1552
|
+
const ElementClass = class extends HTMLElement {
|
|
1553
|
+
static get observedAttributes() {
|
|
1554
|
+
return observedAttrs;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async connectedCallback() {
|
|
1558
|
+
const state = await buildScope(def, {}, base);
|
|
1559
|
+
|
|
1560
|
+
// Merge $props set as JS properties by parent before connection
|
|
1561
|
+
for (const key of Object.keys(def.state ?? {})) {
|
|
1562
|
+
if (key in this && /** @type {any} */ (this)[key] !== undefined) {
|
|
1563
|
+
state[key] = /** @type {any} */ (this)[key];
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
// Set up property getters/setters that forward into reactive state
|
|
1567
|
+
for (const key of Object.keys(def.state ?? {})) {
|
|
1568
|
+
if (!(key in HTMLElement.prototype)) {
|
|
1569
|
+
Object.defineProperty(this, key, {
|
|
1570
|
+
get: () => state[key],
|
|
1571
|
+
set: (/** @type {any} */ v) => {
|
|
1572
|
+
state[key] = v;
|
|
1573
|
+
},
|
|
1574
|
+
configurable: true,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** @type {any} */ (this)._state = state;
|
|
1580
|
+
|
|
1581
|
+
// Capture light DOM children (for slot distribution) before rendering
|
|
1582
|
+
const slottedChildren = Array.from(this.childNodes);
|
|
1583
|
+
this.innerHTML = "";
|
|
1584
|
+
|
|
1585
|
+
// Render template into light DOM (once, not in effect — inner effects handle reactivity)
|
|
1586
|
+
applyStyle(this, def.style ?? {}, state["$media"] ?? {}, state);
|
|
1587
|
+
applyAttributes(this, def.attributes ?? {}, state);
|
|
1588
|
+
|
|
1589
|
+
const children = Array.isArray(def.children) ? def.children : [];
|
|
1590
|
+
for (const childDef of children) {
|
|
1591
|
+
this.appendChild(renderNode(childDef, state));
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Slot distribution (light DOM)
|
|
1595
|
+
distributeSlots(this, slottedChildren);
|
|
1596
|
+
|
|
1597
|
+
// Lifecycle: onMount
|
|
1598
|
+
if (typeof state.onMount === "function") {
|
|
1599
|
+
queueMicrotask(() => state.onMount(state));
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
disconnectedCallback() {
|
|
1604
|
+
/** @type {any} */
|
|
1605
|
+
const self = this;
|
|
1606
|
+
if (typeof self._state?.onUnmount === "function") {
|
|
1607
|
+
self._state.onUnmount(self._state);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
adoptedCallback() {
|
|
1612
|
+
/** @type {any} */
|
|
1613
|
+
const self = this;
|
|
1614
|
+
if (typeof self._state?.onAdopted === "function") {
|
|
1615
|
+
self._state.onAdopted(self._state);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
attributeChangedCallback(
|
|
1620
|
+
/** @type {string} */ name,
|
|
1621
|
+
/** @type {string | null} */ oldVal,
|
|
1622
|
+
/** @type {string | null} */ newVal,
|
|
1623
|
+
) {
|
|
1624
|
+
/** @type {any} */
|
|
1625
|
+
const self = this;
|
|
1626
|
+
if (!self._state || oldVal === newVal) return;
|
|
1627
|
+
const camelKey = name.replace(
|
|
1628
|
+
/-([a-z])/g,
|
|
1629
|
+
(/** @type {string} */ _, /** @type {string} */ c) => c.toUpperCase(),
|
|
1630
|
+
);
|
|
1631
|
+
const current = self._state[camelKey];
|
|
1632
|
+
if (typeof current === "number") self._state[camelKey] = Number(newVal);
|
|
1633
|
+
else if (typeof current === "boolean")
|
|
1634
|
+
self._state[camelKey] = newVal !== null && newVal !== "false";
|
|
1635
|
+
else self._state[camelKey] = newVal;
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
customElements.define(tagName, ElementClass);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Render a registered custom element with $props (property-first interface).
|
|
1644
|
+
*
|
|
1645
|
+
* @param {Record<string, any>} def
|
|
1646
|
+
* @param {Record<string, any>} state
|
|
1647
|
+
* @param {any} [options]
|
|
1648
|
+
* @param {any[]} [path]
|
|
1649
|
+
* @returns {HTMLElement}
|
|
1650
|
+
*/
|
|
1651
|
+
function renderCustomElementWithProps(def, state, options, path) {
|
|
1652
|
+
/** @type {any} */
|
|
1653
|
+
const el = document.createElement(def.tagName);
|
|
1654
|
+
|
|
1655
|
+
if (options?.onNodeCreated) options.onNodeCreated(el, path, def);
|
|
1656
|
+
|
|
1657
|
+
// Set JS properties from $props (before connection)
|
|
1658
|
+
for (const [key, val] of Object.entries(def.$props ?? {})) {
|
|
1659
|
+
if (isRefObj(val)) {
|
|
1660
|
+
const resolved = resolveRef(val.$ref, state);
|
|
1661
|
+
el[key] = resolved;
|
|
1662
|
+
// Reactive forwarding: re-set the property when the source changes
|
|
1663
|
+
effect(() => {
|
|
1664
|
+
el[key] = resolveRef(val.$ref, state);
|
|
1665
|
+
});
|
|
1666
|
+
} else if (isTemplateString(val)) {
|
|
1667
|
+
effect(() => {
|
|
1668
|
+
el[key] = evaluateTemplate(val, state);
|
|
1669
|
+
});
|
|
1670
|
+
} else {
|
|
1671
|
+
el[key] = val;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// Apply host-level style and attributes from the usage site
|
|
1676
|
+
applyStyle(el, def.style ?? {}, state["$media"] ?? {}, state);
|
|
1677
|
+
applyAttributes(el, def.attributes ?? {}, state);
|
|
1678
|
+
|
|
1679
|
+
// Append slotted children
|
|
1680
|
+
const children = Array.isArray(def.children) ? def.children : [];
|
|
1681
|
+
for (let i = 0; i < children.length; i++) {
|
|
1682
|
+
el.appendChild(renderNode(children[i], state, options));
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
return el;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Light DOM slot distribution.
|
|
1690
|
+
*
|
|
1691
|
+
* @param {HTMLElement} host
|
|
1692
|
+
* @param {ChildNode[]} slottedChildren
|
|
1693
|
+
*/
|
|
1694
|
+
function distributeSlots(host, slottedChildren) {
|
|
1695
|
+
if (slottedChildren.length === 0) return;
|
|
1696
|
+
|
|
1697
|
+
const slots = host.querySelectorAll("slot");
|
|
1698
|
+
if (slots.length === 0) return;
|
|
1699
|
+
|
|
1700
|
+
/** @type {Map<string | null, ChildNode[]>} */
|
|
1701
|
+
const named = new Map();
|
|
1702
|
+
/** @type {ChildNode[]} */
|
|
1703
|
+
const unnamed = [];
|
|
1704
|
+
|
|
1705
|
+
for (const child of slottedChildren) {
|
|
1706
|
+
if (
|
|
1707
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
1708
|
+
/** @type {Element} */ (child).getAttribute("slot")
|
|
1709
|
+
) {
|
|
1710
|
+
const name = /** @type {Element} */ (child).getAttribute("slot");
|
|
1711
|
+
if (!named.has(name)) named.set(name, []);
|
|
1712
|
+
/** @type {ChildNode[]} */ (named.get(name)).push(child);
|
|
1713
|
+
} else {
|
|
1714
|
+
unnamed.push(child);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
for (const slot of slots) {
|
|
1719
|
+
const name = slot.getAttribute("name");
|
|
1720
|
+
const matches = name ? (named.get(name) ?? []) : unnamed;
|
|
1721
|
+
if (matches.length > 0) {
|
|
1722
|
+
slot.innerHTML = "";
|
|
1723
|
+
for (const child of matches) {
|
|
1724
|
+
slot.appendChild(child);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|