@jxsuite/compiler 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/compiler.js +165 -0
- package/package.json +38 -0
- package/src/cli.js +59 -0
- package/src/compiler.js +148 -0
- package/src/shared.js +690 -0
- package/src/site/content-loader.js +452 -0
- package/src/site/context-injection.js +152 -0
- package/src/site/head-merger.js +161 -0
- package/src/site/layout-resolver.js +182 -0
- package/src/site/pages-discovery.js +272 -0
- package/src/site/prototype-resolver.js +161 -0
- package/src/site/site-build.js +600 -0
- package/src/site/site-loader.js +85 -0
- package/src/targets/compile-class.js +194 -0
- package/src/targets/compile-client.js +806 -0
- package/src/targets/compile-element.js +619 -0
- package/src/targets/compile-server.js +57 -0
- package/src/targets/compile-static.js +155 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-client.js — Pre-rendered HTML with reactive bindings
|
|
3
|
+
*
|
|
4
|
+
* Produces clean HTML with `data-bind` marker attributes and a small JS bootstrapper using
|
|
5
|
+
* vue/reactivity's `effect` + `computed`.
|
|
6
|
+
*
|
|
7
|
+
* Functions whose body contains `return` become computed() on state. Mapped arrays ($prototype:
|
|
8
|
+
* "Array") use lit-html for efficient rendering.
|
|
9
|
+
*
|
|
10
|
+
* Output pattern: HTML: pre-rendered with data-bind, :prop="key", @event="key" JS: state (reactive
|
|
11
|
+
* state + computed signals), bind (DOM getters), on (event handlers), hydrate()
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { camelToKebab, RESERVED_KEYS } from "@jxsuite/runtime";
|
|
15
|
+
import {
|
|
16
|
+
isSchemaOnly,
|
|
17
|
+
isTemplateString,
|
|
18
|
+
isRefObject,
|
|
19
|
+
resolveStaticValue,
|
|
20
|
+
createCompileContext,
|
|
21
|
+
buildAttrs,
|
|
22
|
+
compileStyles,
|
|
23
|
+
escapeHtml,
|
|
24
|
+
DEFAULT_REACTIVITY_SRC,
|
|
25
|
+
DEFAULT_LIT_HTML_SRC,
|
|
26
|
+
} from "../shared.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compile a Jx document to pre-rendered HTML + reactive JS module.
|
|
30
|
+
*
|
|
31
|
+
* @param {any} raw
|
|
32
|
+
* @param {any} opts
|
|
33
|
+
* @returns {{ html: string; files: { path: string; content: string }[] }}
|
|
34
|
+
*/
|
|
35
|
+
export function compileClient(raw, opts) {
|
|
36
|
+
const {
|
|
37
|
+
title,
|
|
38
|
+
reactivitySrc = DEFAULT_REACTIVITY_SRC,
|
|
39
|
+
litHtmlSrc = DEFAULT_LIT_HTML_SRC,
|
|
40
|
+
modulePath = "app.js",
|
|
41
|
+
} = opts;
|
|
42
|
+
|
|
43
|
+
const context = createCompileContext(raw, null, raw.state ?? {}, raw.$media ?? {});
|
|
44
|
+
const styleBlock = compileStyles(raw, raw.$media ?? {});
|
|
45
|
+
|
|
46
|
+
// Collectors for bindings and handlers
|
|
47
|
+
const counter = { t: 0, s: 0, h: 0, m: 0, sw: 0, l: 0, needsLit: false };
|
|
48
|
+
/** @type {Map<string, string>} */
|
|
49
|
+
const bindings = new Map(); // key → expression string
|
|
50
|
+
/** @type {Map<string, any>} */
|
|
51
|
+
const handlers = new Map(); // key → { body, args }
|
|
52
|
+
|
|
53
|
+
// Classify state entries into reactive state, computed, bind, on, and init blocks
|
|
54
|
+
/** @type {[string, any][]} */
|
|
55
|
+
const stateEntries = []; // [key, initValue] → reactive({...})
|
|
56
|
+
/** @type {[string, string][]} */
|
|
57
|
+
const computedEntries = []; // [key, bodyExpr] → state.key = computed(...)
|
|
58
|
+
/** @type {[string, string][]} */
|
|
59
|
+
const bindEntries = []; // [key, bodyExpr] → bind = {...}
|
|
60
|
+
/** @type {[string, any][]} */
|
|
61
|
+
const onEntries = []; // [key, { args, body }] → on = {...}
|
|
62
|
+
/** @type {string[]} */
|
|
63
|
+
const initBlocks = []; // lines emitted after state for prototype init
|
|
64
|
+
|
|
65
|
+
// Map $src path → Set of function names to import
|
|
66
|
+
/** @type {Map<string, Set<string>>} */
|
|
67
|
+
const srcImportMap = new Map();
|
|
68
|
+
|
|
69
|
+
const defs = raw.state ?? {};
|
|
70
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
71
|
+
if (def === null || typeof def !== "object" || Array.isArray(def)) {
|
|
72
|
+
// Naked primitive or array → reactive state
|
|
73
|
+
if (typeof def === "string" && isTemplateString(def)) {
|
|
74
|
+
// Template string → computed on state so other computeds can ref it
|
|
75
|
+
computedEntries.push([key, "() => `" + def + "`"]);
|
|
76
|
+
} else {
|
|
77
|
+
stateEntries.push([key, def]);
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const d = /** @type {any} */ (def);
|
|
83
|
+
|
|
84
|
+
// $prototype: "Function"
|
|
85
|
+
if (d.$prototype === "Function") {
|
|
86
|
+
const args = d.parameters ?? d.arguments;
|
|
87
|
+
if (d.$src) {
|
|
88
|
+
if (!srcImportMap.has(d.$src)) srcImportMap.set(d.$src, new Set());
|
|
89
|
+
/** @type {Set<string>} */ (srcImportMap.get(d.$src)).add(key);
|
|
90
|
+
|
|
91
|
+
// $src functions always produce computed entries (they return values)
|
|
92
|
+
computedEntries.push([key, "() => { return " + key + "(state); }"]);
|
|
93
|
+
} else if (d.body && d.body.includes("return")) {
|
|
94
|
+
// Body contains return → computed
|
|
95
|
+
computedEntries.push([key, "() => { " + d.body + " }"]);
|
|
96
|
+
} else {
|
|
97
|
+
// No return → event handler
|
|
98
|
+
onEntries.push([key, { args: args ?? ["state"], body: d.body }]);
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pure schema-only type def → skip
|
|
104
|
+
if (isSchemaOnly(d)) continue;
|
|
105
|
+
|
|
106
|
+
// Expanded signal with default
|
|
107
|
+
if ("default" in d && !d.$prototype) {
|
|
108
|
+
stateEntries.push([key, d.default]);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// $prototype: "LocalStorage" / "SessionStorage"
|
|
113
|
+
if (d.$prototype === "LocalStorage" || d.$prototype === "SessionStorage") {
|
|
114
|
+
const storeName = d.$prototype === "LocalStorage" ? "localStorage" : "sessionStorage";
|
|
115
|
+
const storageKey = d.key ?? key;
|
|
116
|
+
const defaultVal = d.default ?? null;
|
|
117
|
+
stateEntries.push([key, null]);
|
|
118
|
+
initBlocks.push(emitStorageInit(key, storeName, storageKey, defaultVal));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// $prototype: "Request"
|
|
123
|
+
if (d.$prototype === "Request") {
|
|
124
|
+
stateEntries.push([key, null]);
|
|
125
|
+
initBlocks.push(emitRequestInit(key, d));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// $prototype: "Cookie"
|
|
130
|
+
if (d.$prototype === "Cookie") {
|
|
131
|
+
stateEntries.push([key, null]);
|
|
132
|
+
initBlocks.push(emitCookieInit(key, d.name ?? key, d.default ?? null));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Plain object → reactive state
|
|
137
|
+
stateEntries.push([key, d]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build HTML tree with data-bind markers
|
|
141
|
+
const bodyContent = buildClientNode(raw, raw, context, bindings, handlers, counter);
|
|
142
|
+
|
|
143
|
+
// Merge inline-discovered bindings/handlers
|
|
144
|
+
for (const [key, expr] of bindings) {
|
|
145
|
+
if (!bindEntries.some(([k]) => k === key)) {
|
|
146
|
+
bindEntries.push([key, expr]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const [key, def] of handlers) {
|
|
150
|
+
if (!onEntries.some(([k]) => k === key)) {
|
|
151
|
+
onEntries.push([key, def]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Generate the JS module
|
|
156
|
+
const moduleContent = emitClientModule(
|
|
157
|
+
stateEntries,
|
|
158
|
+
computedEntries,
|
|
159
|
+
bindEntries,
|
|
160
|
+
onEntries,
|
|
161
|
+
initBlocks,
|
|
162
|
+
srcImportMap,
|
|
163
|
+
counter,
|
|
164
|
+
reactivitySrc,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Build importmap entries
|
|
168
|
+
const importmapEntries = [` "@vue/reactivity": "${reactivitySrc}"`];
|
|
169
|
+
if (counter.needsLit) {
|
|
170
|
+
importmapEntries.push(` "lit-html": "${litHtmlSrc}"`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const html = `<!DOCTYPE html>
|
|
174
|
+
<html lang="en">
|
|
175
|
+
<head>
|
|
176
|
+
<meta charset="utf-8">
|
|
177
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
178
|
+
<title>${escapeHtml(title)}</title>
|
|
179
|
+
<script type="importmap">
|
|
180
|
+
{
|
|
181
|
+
"imports": {
|
|
182
|
+
${importmapEntries.join(",\n")}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
</script>
|
|
186
|
+
${styleBlock}
|
|
187
|
+
<script type="module" src="./${modulePath}"></script>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
${bodyContent}
|
|
191
|
+
</body>
|
|
192
|
+
</html>`;
|
|
193
|
+
|
|
194
|
+
return { html, files: [{ path: modulePath, content: moduleContent }] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── HTML tree walker ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {any} def
|
|
201
|
+
* @param {any} raw
|
|
202
|
+
* @param {any} context
|
|
203
|
+
* @param {Map<string, string>} bindings
|
|
204
|
+
* @param {Map<string, any>} handlers
|
|
205
|
+
* @param {any} counter
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function buildClientNode(def, raw, context, bindings, handlers, counter) {
|
|
209
|
+
// String children are text nodes
|
|
210
|
+
if (typeof def === "string") {
|
|
211
|
+
return escapeHtml(def);
|
|
212
|
+
}
|
|
213
|
+
if (typeof def === "number" || typeof def === "boolean") {
|
|
214
|
+
return escapeHtml(String(def));
|
|
215
|
+
}
|
|
216
|
+
if (!def || typeof def !== "object") return "";
|
|
217
|
+
|
|
218
|
+
const nextContext = createCompileContext(
|
|
219
|
+
raw,
|
|
220
|
+
context.scope,
|
|
221
|
+
raw?.state ?? context.scopeDefs,
|
|
222
|
+
raw?.$media ?? context.media,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const tag = def.tagName ?? "div";
|
|
226
|
+
const bindAttrs = [];
|
|
227
|
+
let needsBind = false;
|
|
228
|
+
|
|
229
|
+
// textContent bindings
|
|
230
|
+
if (def.textContent !== undefined) {
|
|
231
|
+
const tc = raw?.textContent ?? def.textContent;
|
|
232
|
+
if (isRefObject(tc)) {
|
|
233
|
+
const key = refToBindingKey(tc.$ref);
|
|
234
|
+
bindAttrs.push(`:text-content="${key}"`);
|
|
235
|
+
addRefBinding(bindings, key, tc.$ref);
|
|
236
|
+
needsBind = true;
|
|
237
|
+
} else if (isTemplateString(tc)) {
|
|
238
|
+
const key = `_t${counter.t++}`;
|
|
239
|
+
bindAttrs.push(`:text-content="${key}"`);
|
|
240
|
+
bindings.set(key, "() => `" + tc + "`");
|
|
241
|
+
needsBind = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Event handlers (onclick, oninput, etc.)
|
|
246
|
+
for (const [prop, val] of Object.entries(def)) {
|
|
247
|
+
if (!prop.startsWith("on") || prop === "observedAttributes") continue;
|
|
248
|
+
const eventName = prop.slice(2).toLowerCase();
|
|
249
|
+
if (isRefObject(val)) {
|
|
250
|
+
const key = refToBindingKey(/** @type {any} */ (val).$ref);
|
|
251
|
+
bindAttrs.push(`@${eventName}="${key}"`);
|
|
252
|
+
needsBind = true;
|
|
253
|
+
} else if (
|
|
254
|
+
val &&
|
|
255
|
+
typeof val === "object" &&
|
|
256
|
+
/** @type {any} */ (val).$prototype === "Function"
|
|
257
|
+
) {
|
|
258
|
+
const v = /** @type {any} */ (val);
|
|
259
|
+
const key = `_h${counter.h++}`;
|
|
260
|
+
bindAttrs.push(`@${eventName}="${key}"`);
|
|
261
|
+
handlers.set(key, { args: v.parameters ?? v.arguments ?? ["state", "event"], body: v.body });
|
|
262
|
+
needsBind = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Dynamic style properties
|
|
267
|
+
if (def.style && typeof def.style === "object") {
|
|
268
|
+
for (const [prop, val] of Object.entries(def.style)) {
|
|
269
|
+
if (
|
|
270
|
+
prop.startsWith(":") ||
|
|
271
|
+
prop.startsWith(".") ||
|
|
272
|
+
prop.startsWith("&") ||
|
|
273
|
+
prop.startsWith("[") ||
|
|
274
|
+
prop.startsWith("@")
|
|
275
|
+
)
|
|
276
|
+
continue;
|
|
277
|
+
if (val === null || typeof val === "object") continue;
|
|
278
|
+
if (isTemplateString(val)) {
|
|
279
|
+
const key = `_s${counter.s++}`;
|
|
280
|
+
bindAttrs.push(`:style.${camelToKebab(prop)}="${key}"`);
|
|
281
|
+
bindings.set(key, "() => `" + val + "`");
|
|
282
|
+
needsBind = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Dynamic attributes
|
|
288
|
+
if (def.attributes && typeof def.attributes === "object") {
|
|
289
|
+
for (const [attr, val] of Object.entries(def.attributes)) {
|
|
290
|
+
if (isRefObject(val)) {
|
|
291
|
+
const key = refToBindingKey(/** @type {any} */ (val).$ref);
|
|
292
|
+
bindAttrs.push(`:attr.${attr}="${key}"`);
|
|
293
|
+
addRefBinding(bindings, key, /** @type {any} */ (val).$ref);
|
|
294
|
+
needsBind = true;
|
|
295
|
+
} else if (isTemplateString(val)) {
|
|
296
|
+
const key = `_t${counter.t++}`;
|
|
297
|
+
bindAttrs.push(`:attr.${attr}="${key}"`);
|
|
298
|
+
bindings.set(key, "() => `" + val + "`");
|
|
299
|
+
needsBind = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Dynamic non-reserved properties (hidden, value, etc.)
|
|
305
|
+
for (const [prop, val] of Object.entries(def)) {
|
|
306
|
+
if (
|
|
307
|
+
RESERVED_KEYS.has(prop) ||
|
|
308
|
+
prop.startsWith("on") ||
|
|
309
|
+
prop.startsWith("$") ||
|
|
310
|
+
prop === "tagName" ||
|
|
311
|
+
prop === "id" ||
|
|
312
|
+
prop === "className" ||
|
|
313
|
+
prop === "style" ||
|
|
314
|
+
prop === "children" ||
|
|
315
|
+
prop === "textContent" ||
|
|
316
|
+
prop === "innerHTML" ||
|
|
317
|
+
prop === "attributes"
|
|
318
|
+
)
|
|
319
|
+
continue;
|
|
320
|
+
if (isRefObject(val)) {
|
|
321
|
+
const key = refToBindingKey(/** @type {any} */ (val).$ref);
|
|
322
|
+
bindAttrs.push(`:${camelToKebab(prop)}="${key}"`);
|
|
323
|
+
addRefBinding(bindings, key, /** @type {any} */ (val).$ref);
|
|
324
|
+
needsBind = true;
|
|
325
|
+
} else if (isTemplateString(val)) {
|
|
326
|
+
const key = `_t${counter.t++}`;
|
|
327
|
+
bindAttrs.push(`:${camelToKebab(prop)}="${key}"`);
|
|
328
|
+
bindings.set(key, "() => `" + val + "`");
|
|
329
|
+
needsBind = true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Build static attrs
|
|
334
|
+
const staticAttrs = buildAttrs(def, nextContext.scope);
|
|
335
|
+
const dataBindAttr = needsBind ? " data-bind" : "";
|
|
336
|
+
const bindAttrStr = bindAttrs.length > 0 ? " " + bindAttrs.join(" ") : "";
|
|
337
|
+
|
|
338
|
+
// Inner content
|
|
339
|
+
let inner = "";
|
|
340
|
+
const source = raw ?? def;
|
|
341
|
+
if (source.textContent !== undefined && !needsBind) {
|
|
342
|
+
const value = resolveStaticValue(source.textContent, nextContext.scope);
|
|
343
|
+
inner = value == null ? "" : escapeHtml(String(value));
|
|
344
|
+
} else if (source.textContent !== undefined && needsBind) {
|
|
345
|
+
try {
|
|
346
|
+
const value = resolveStaticValue(source.textContent, nextContext.scope);
|
|
347
|
+
inner = value == null ? "" : escapeHtml(String(value));
|
|
348
|
+
} catch {
|
|
349
|
+
inner = "";
|
|
350
|
+
}
|
|
351
|
+
} else if (source.innerHTML) {
|
|
352
|
+
inner = resolveStaticValue(source.innerHTML, nextContext.scope) ?? "";
|
|
353
|
+
} else if (
|
|
354
|
+
source.children &&
|
|
355
|
+
typeof source.children === "object" &&
|
|
356
|
+
!Array.isArray(source.children) &&
|
|
357
|
+
source.children.$prototype === "Array"
|
|
358
|
+
) {
|
|
359
|
+
// ─── Mapped array → lit-html render binding ───
|
|
360
|
+
counter.needsLit = true;
|
|
361
|
+
const listKey = `_list${counter.l++}`;
|
|
362
|
+
const arrayDef = source.children;
|
|
363
|
+
|
|
364
|
+
// Resolve items source expression
|
|
365
|
+
let itemsExpr;
|
|
366
|
+
if (isRefObject(arrayDef.items)) {
|
|
367
|
+
const path = refToBindingKey(arrayDef.items.$ref);
|
|
368
|
+
itemsExpr = "state." + path;
|
|
369
|
+
} else {
|
|
370
|
+
itemsExpr = JSON.stringify(arrayDef.items);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Compile the map template to a lit-html template string
|
|
374
|
+
const litTemplate = emitLitMapTemplate(arrayDef.map);
|
|
375
|
+
bindings.set(
|
|
376
|
+
listKey,
|
|
377
|
+
"() => (" + itemsExpr + " ?? []).map((item, index) => html`" + litTemplate + "`)",
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
bindAttrs.push(`:render="${listKey}"`);
|
|
381
|
+
needsBind = true;
|
|
382
|
+
// Re-derive the data-bind/attr strings since we added to bindAttrs
|
|
383
|
+
const dataBindAttr2 = " data-bind";
|
|
384
|
+
const bindAttrStr2 = " " + bindAttrs.join(" ");
|
|
385
|
+
const selfClosing = new Set(["input", "br", "hr", "img", "meta", "link"]);
|
|
386
|
+
if (selfClosing.has(tag)) {
|
|
387
|
+
return `<${tag}${staticAttrs}${dataBindAttr2}${bindAttrStr2}>`;
|
|
388
|
+
}
|
|
389
|
+
return `<${tag}${staticAttrs}${dataBindAttr2}${bindAttrStr2}></${tag}>`;
|
|
390
|
+
} else if (Array.isArray(source.children)) {
|
|
391
|
+
const rawChildren = raw?.children;
|
|
392
|
+
inner = source.children
|
|
393
|
+
.map((/** @type {any} */ c, /** @type {number} */ i) => {
|
|
394
|
+
const childRaw = rawChildren?.[i] ?? c;
|
|
395
|
+
return buildClientNode(c, childRaw, nextContext, bindings, handlers, counter);
|
|
396
|
+
})
|
|
397
|
+
.join("\n ");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Self-closing tags
|
|
401
|
+
const selfClosing = new Set(["input", "br", "hr", "img", "meta", "link"]);
|
|
402
|
+
if (selfClosing.has(tag)) {
|
|
403
|
+
return `<${tag}${staticAttrs}${dataBindAttr}${bindAttrStr}>`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return `<${tag}${staticAttrs}${dataBindAttr}${bindAttrStr}>${inner}</${tag}>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─── Lit-html map template generation ─────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Compile a map definition to a lit-html template string. Converts $map.item → item, $map.index →
|
|
413
|
+
* index.
|
|
414
|
+
*
|
|
415
|
+
* @param {any} def
|
|
416
|
+
* @returns {string}
|
|
417
|
+
*/
|
|
418
|
+
function emitLitMapTemplate(def) {
|
|
419
|
+
if (!def) return "";
|
|
420
|
+
const tag = def.tagName ?? "div";
|
|
421
|
+
let attrs = "";
|
|
422
|
+
|
|
423
|
+
if (def.id) attrs += ' id="' + def.id + '"';
|
|
424
|
+
if (def.className) attrs += ' class="' + mapRefsToLit(def.className) + '"';
|
|
425
|
+
|
|
426
|
+
// attributes object
|
|
427
|
+
if (def.attributes && typeof def.attributes === "object") {
|
|
428
|
+
for (const [k, v] of Object.entries(def.attributes)) {
|
|
429
|
+
if (typeof v === "string" && isTemplateString(v)) {
|
|
430
|
+
attrs += " " + k + '="' + mapRefsToLit(v) + '"';
|
|
431
|
+
} else {
|
|
432
|
+
attrs += " " + k + '="' + escapeHtml(String(v)) + '"';
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// style → inline CSS
|
|
438
|
+
if (def.style && typeof def.style === "object") {
|
|
439
|
+
/** @type {string[]} */
|
|
440
|
+
const parts = [];
|
|
441
|
+
for (const [k, v] of Object.entries(def.style)) {
|
|
442
|
+
if (
|
|
443
|
+
k.startsWith(":") ||
|
|
444
|
+
k.startsWith(".") ||
|
|
445
|
+
k.startsWith("&") ||
|
|
446
|
+
k.startsWith("[") ||
|
|
447
|
+
k.startsWith("@")
|
|
448
|
+
)
|
|
449
|
+
continue;
|
|
450
|
+
if (v === null || typeof v === "object") continue;
|
|
451
|
+
const cssProp = camelToKebab(k);
|
|
452
|
+
if (isTemplateString(String(v))) {
|
|
453
|
+
parts.push(cssProp + ": " + mapRefsToLit(String(v)));
|
|
454
|
+
} else {
|
|
455
|
+
parts.push(cssProp + ": " + v);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (parts.length > 0) {
|
|
459
|
+
attrs += ' style="' + parts.join("; ") + '"';
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Event handlers in map template
|
|
464
|
+
for (const [prop, val] of Object.entries(def)) {
|
|
465
|
+
if (!prop.startsWith("on") || prop === "observedAttributes") continue;
|
|
466
|
+
const eventName = prop.slice(2).toLowerCase();
|
|
467
|
+
if (isRefObject(val)) {
|
|
468
|
+
const key = refToBindingKey(/** @type {any} */ (val).$ref);
|
|
469
|
+
attrs += " @" + eventName + "=${(e) => { state.$map = { item, index }; on." + key + "(e); }}";
|
|
470
|
+
} else if (
|
|
471
|
+
val &&
|
|
472
|
+
typeof val === "object" &&
|
|
473
|
+
/** @type {any} */ (val).$prototype === "Function"
|
|
474
|
+
) {
|
|
475
|
+
const body = mapRefsToLit(/** @type {any} */ (val).body);
|
|
476
|
+
attrs += " @" + eventName + "=${(e) => { " + body + " }}";
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Non-reserved properties that render as attributes
|
|
481
|
+
if (def.contentEditable) {
|
|
482
|
+
attrs += ' contenteditable="' + def.contentEditable + '"';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Inner content
|
|
486
|
+
let inner = "";
|
|
487
|
+
if (def.textContent !== undefined) {
|
|
488
|
+
const tc = String(def.textContent);
|
|
489
|
+
if (isTemplateString(tc)) {
|
|
490
|
+
inner = mapRefsToLit(tc);
|
|
491
|
+
} else if (isRefObject(def.textContent)) {
|
|
492
|
+
const path = refToBindingKey(def.textContent.$ref);
|
|
493
|
+
inner = "${state." + path + "}";
|
|
494
|
+
} else {
|
|
495
|
+
inner = escapeHtml(tc);
|
|
496
|
+
}
|
|
497
|
+
} else if (def.innerHTML) {
|
|
498
|
+
inner = mapRefsToLit(String(def.innerHTML));
|
|
499
|
+
} else if (Array.isArray(def.children)) {
|
|
500
|
+
inner =
|
|
501
|
+
"\n " +
|
|
502
|
+
def.children.map((/** @type {any} */ c) => emitLitMapTemplate(c)).join("\n ") +
|
|
503
|
+
"\n ";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const voidTags = new Set(["input", "br", "hr", "img", "meta", "link"]);
|
|
507
|
+
if (voidTags.has(tag)) return "<" + tag + attrs + ">";
|
|
508
|
+
return "<" + tag + attrs + ">" + inner + "</" + tag + ">";
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Replace $map references: $map.item → item, $map.index → index
|
|
513
|
+
*
|
|
514
|
+
* @param {string} str
|
|
515
|
+
* @returns {string}
|
|
516
|
+
*/
|
|
517
|
+
function mapRefsToLit(str) {
|
|
518
|
+
return str.replace(/\$map\./g, "");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── JS module generation ─────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @param {[string, any][]} stateEntries
|
|
525
|
+
* @param {[string, string][]} computedEntries
|
|
526
|
+
* @param {[string, string][]} bindEntries
|
|
527
|
+
* @param {[string, any][]} onEntries
|
|
528
|
+
* @param {string[]} initBlocks
|
|
529
|
+
* @param {Map<string, Set<string>>} srcImportMap
|
|
530
|
+
* @param {any} counter
|
|
531
|
+
* @param {string} _reactivitySrc
|
|
532
|
+
* @returns {string}
|
|
533
|
+
*/
|
|
534
|
+
function emitClientModule(
|
|
535
|
+
stateEntries,
|
|
536
|
+
computedEntries,
|
|
537
|
+
bindEntries,
|
|
538
|
+
onEntries,
|
|
539
|
+
initBlocks,
|
|
540
|
+
srcImportMap,
|
|
541
|
+
counter,
|
|
542
|
+
_reactivitySrc,
|
|
543
|
+
) {
|
|
544
|
+
/** @type {string[]} */
|
|
545
|
+
const lines = [];
|
|
546
|
+
const needsLit = counter.needsLit;
|
|
547
|
+
const needsComputed = computedEntries.length > 0;
|
|
548
|
+
|
|
549
|
+
lines.push("// Generated by @jxsuite/compiler — do not edit manually");
|
|
550
|
+
|
|
551
|
+
// Reactivity imports
|
|
552
|
+
const reactivityImports = ["reactive", "effect"];
|
|
553
|
+
if (needsComputed) reactivityImports.push("computed");
|
|
554
|
+
lines.push("import { " + reactivityImports.join(", ") + " } from '@vue/reactivity';");
|
|
555
|
+
|
|
556
|
+
// lit-html imports (only when arrays are present)
|
|
557
|
+
if (needsLit) {
|
|
558
|
+
lines.push("import { html, render } from 'lit-html';");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// $src imports
|
|
562
|
+
for (const [src, names] of srcImportMap) {
|
|
563
|
+
lines.push("import { " + [...names].join(", ") + " } from '" + src + "';");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
lines.push("");
|
|
567
|
+
|
|
568
|
+
// state — reactive state
|
|
569
|
+
lines.push("const state = reactive({");
|
|
570
|
+
for (const [key, val] of stateEntries) {
|
|
571
|
+
lines.push(" " + key + ": " + JSON.stringify(val) + ",");
|
|
572
|
+
}
|
|
573
|
+
lines.push("});");
|
|
574
|
+
lines.push("");
|
|
575
|
+
|
|
576
|
+
// Prototype init blocks (Request fetch, LocalStorage read, etc.)
|
|
577
|
+
if (initBlocks.length > 0) {
|
|
578
|
+
for (const block of initBlocks) {
|
|
579
|
+
lines.push(block);
|
|
580
|
+
}
|
|
581
|
+
lines.push("");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Computed signals on state
|
|
585
|
+
if (computedEntries.length > 0) {
|
|
586
|
+
for (const [key, expr] of computedEntries) {
|
|
587
|
+
lines.push("state." + key + " = computed(" + expr + ");");
|
|
588
|
+
}
|
|
589
|
+
lines.push("");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// bind — DOM getters
|
|
593
|
+
if (bindEntries.length > 0) {
|
|
594
|
+
lines.push("const bind = {");
|
|
595
|
+
for (const [key, expr] of bindEntries) {
|
|
596
|
+
lines.push(" " + key + ": " + expr + ",");
|
|
597
|
+
}
|
|
598
|
+
lines.push("};");
|
|
599
|
+
} else {
|
|
600
|
+
lines.push("const bind = {};");
|
|
601
|
+
}
|
|
602
|
+
lines.push("");
|
|
603
|
+
|
|
604
|
+
// on — event handlers
|
|
605
|
+
if (onEntries.length > 0) {
|
|
606
|
+
lines.push("const on = {");
|
|
607
|
+
for (const [key, def] of onEntries) {
|
|
608
|
+
if (def.imported) {
|
|
609
|
+
const argNames = def.args ?? ["state"];
|
|
610
|
+
const callArgs = argNames
|
|
611
|
+
.map((/** @type {string} */ a) => (a === "state" ? "state" : "e"))
|
|
612
|
+
.join(", ");
|
|
613
|
+
lines.push(" " + key + ": (e) => { " + key + "(" + callArgs + "); },");
|
|
614
|
+
} else {
|
|
615
|
+
const argNames = def.args ?? ["state"];
|
|
616
|
+
const callArgs = argNames
|
|
617
|
+
.map((/** @type {string} */ a) => (a === "state" ? "state" : "e"))
|
|
618
|
+
.join(", ");
|
|
619
|
+
lines.push(
|
|
620
|
+
" " +
|
|
621
|
+
key +
|
|
622
|
+
": (e) => { const fn = (" +
|
|
623
|
+
argNames.join(", ") +
|
|
624
|
+
") => { " +
|
|
625
|
+
def.body +
|
|
626
|
+
" }; fn(" +
|
|
627
|
+
callArgs +
|
|
628
|
+
"); },",
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
lines.push("};");
|
|
633
|
+
} else {
|
|
634
|
+
lines.push("const on = {};");
|
|
635
|
+
}
|
|
636
|
+
lines.push("");
|
|
637
|
+
|
|
638
|
+
// Hydration function
|
|
639
|
+
lines.push("function hydrate(root) {");
|
|
640
|
+
lines.push(" root.querySelectorAll('[data-bind]').forEach(el => {");
|
|
641
|
+
lines.push(" [...el.attributes].forEach(a => {");
|
|
642
|
+
lines.push(" if (a.name.startsWith(':')) {");
|
|
643
|
+
lines.push(" const parts = a.name.slice(1).split('.');");
|
|
644
|
+
lines.push(" const key = a.value;");
|
|
645
|
+
if (needsLit) {
|
|
646
|
+
lines.push(" if (parts[0] === 'render') {");
|
|
647
|
+
lines.push(" effect(() => { render(bind[key](), el); });");
|
|
648
|
+
lines.push(" } else if (parts[0] === 'style' && parts.length > 1) {");
|
|
649
|
+
} else {
|
|
650
|
+
lines.push(" if (parts[0] === 'style' && parts.length > 1) {");
|
|
651
|
+
}
|
|
652
|
+
lines.push(" effect(() => { el.style[parts[1]] = bind[key](); });");
|
|
653
|
+
lines.push(" } else if (parts[0] === 'attr' && parts.length > 1) {");
|
|
654
|
+
lines.push(" effect(() => { el.setAttribute(parts[1], bind[key]()); });");
|
|
655
|
+
lines.push(" } else {");
|
|
656
|
+
lines.push(" const prop = parts[0].replace(/-([a-z])/g, (_, c) => c.toUpperCase());");
|
|
657
|
+
lines.push(" effect(() => { el[prop] = bind[key](); });");
|
|
658
|
+
lines.push(" }");
|
|
659
|
+
lines.push(" } else if (a.name.startsWith('@')) {");
|
|
660
|
+
lines.push(" el.addEventListener(a.name.slice(1), on[a.value]);");
|
|
661
|
+
lines.push(" }");
|
|
662
|
+
lines.push(" });");
|
|
663
|
+
lines.push(" });");
|
|
664
|
+
lines.push("}");
|
|
665
|
+
lines.push("");
|
|
666
|
+
lines.push("hydrate(document);");
|
|
667
|
+
lines.push("");
|
|
668
|
+
|
|
669
|
+
return lines.join("\n");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ─── Prototype init emitters ─────────────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* @param {string} key
|
|
676
|
+
* @param {any} def
|
|
677
|
+
* @returns {string}
|
|
678
|
+
*/
|
|
679
|
+
function emitRequestInit(key, def) {
|
|
680
|
+
const url = def.url;
|
|
681
|
+
const method = def.method ?? "GET";
|
|
682
|
+
const isTemplateUrl = isTemplateString(url);
|
|
683
|
+
|
|
684
|
+
if (def.manual) {
|
|
685
|
+
return "// " + key + ": manual Request — fetch triggered by user action";
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** @type {string[]} */
|
|
689
|
+
const lines = [];
|
|
690
|
+
lines.push("// " + key + ": auto-fetch from " + (isTemplateUrl ? "(dynamic URL)" : url));
|
|
691
|
+
lines.push("effect(() => {");
|
|
692
|
+
|
|
693
|
+
if (isTemplateUrl) {
|
|
694
|
+
lines.push(" const url = `" + url + "`;");
|
|
695
|
+
lines.push(' if (!url || url === "undefined" || url.includes("undefined")) return;');
|
|
696
|
+
} else {
|
|
697
|
+
lines.push(" const url = " + JSON.stringify(url) + ";");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** @type {string[]} */
|
|
701
|
+
const fetchOpts = [];
|
|
702
|
+
if (method !== "GET") fetchOpts.push("method: " + JSON.stringify(method));
|
|
703
|
+
if (def.headers) fetchOpts.push("headers: " + JSON.stringify(def.headers));
|
|
704
|
+
if (def.body) {
|
|
705
|
+
const bodyStr =
|
|
706
|
+
typeof def.body === "object"
|
|
707
|
+
? JSON.stringify(JSON.stringify(def.body))
|
|
708
|
+
: JSON.stringify(def.body);
|
|
709
|
+
fetchOpts.push("body: " + bodyStr);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const optsStr = fetchOpts.length > 0 ? ", { " + fetchOpts.join(", ") + " }" : "";
|
|
713
|
+
lines.push(" fetch(url" + optsStr + ")");
|
|
714
|
+
lines.push(" .then(r => r.ok ? r.json() : Promise.reject(r.statusText))");
|
|
715
|
+
lines.push(" .then(d => { state." + key + " = d; })");
|
|
716
|
+
lines.push(" .catch(e => { state." + key + " = { error: String(e) }; });");
|
|
717
|
+
lines.push("});");
|
|
718
|
+
|
|
719
|
+
return lines.join("\n");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* @param {string} key
|
|
724
|
+
* @param {string} storeName
|
|
725
|
+
* @param {string} storageKey
|
|
726
|
+
* @param {any} defaultVal
|
|
727
|
+
* @returns {string}
|
|
728
|
+
*/
|
|
729
|
+
function emitStorageInit(key, storeName, storageKey, defaultVal) {
|
|
730
|
+
/** @type {string[]} */
|
|
731
|
+
const lines = [];
|
|
732
|
+
lines.push("// " + key + ": " + storeName + ' (key: "' + storageKey + '")');
|
|
733
|
+
lines.push("try {");
|
|
734
|
+
lines.push(" const _s = " + storeName + ".getItem(" + JSON.stringify(storageKey) + ");");
|
|
735
|
+
lines.push(
|
|
736
|
+
" state." + key + " = _s !== null ? JSON.parse(_s) : " + JSON.stringify(defaultVal) + ";",
|
|
737
|
+
);
|
|
738
|
+
lines.push("} catch { state." + key + " = " + JSON.stringify(defaultVal) + "; }");
|
|
739
|
+
lines.push("effect(() => {");
|
|
740
|
+
lines.push(" const v = state." + key + ";");
|
|
741
|
+
lines.push(" try {");
|
|
742
|
+
lines.push(
|
|
743
|
+
" if (v === null) " + storeName + ".removeItem(" + JSON.stringify(storageKey) + ");",
|
|
744
|
+
);
|
|
745
|
+
lines.push(
|
|
746
|
+
" else " + storeName + ".setItem(" + JSON.stringify(storageKey) + ", JSON.stringify(v));",
|
|
747
|
+
);
|
|
748
|
+
lines.push(" } catch {}");
|
|
749
|
+
lines.push("});");
|
|
750
|
+
return lines.join("\n");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* @param {string} key
|
|
755
|
+
* @param {string} cookieName
|
|
756
|
+
* @param {any} defaultVal
|
|
757
|
+
* @returns {string}
|
|
758
|
+
*/
|
|
759
|
+
function emitCookieInit(key, cookieName, defaultVal) {
|
|
760
|
+
/** @type {string[]} */
|
|
761
|
+
const lines = [];
|
|
762
|
+
lines.push("// " + key + ': Cookie (name: "' + cookieName + '")');
|
|
763
|
+
lines.push("{");
|
|
764
|
+
lines.push(
|
|
765
|
+
' const _m = document.cookie.match(new RegExp("(?:^|; )' + cookieName + '=([^;]*)"));',
|
|
766
|
+
);
|
|
767
|
+
lines.push(
|
|
768
|
+
" try { state." +
|
|
769
|
+
key +
|
|
770
|
+
" = _m ? JSON.parse(decodeURIComponent(_m[1])) : " +
|
|
771
|
+
JSON.stringify(defaultVal) +
|
|
772
|
+
"; }",
|
|
773
|
+
);
|
|
774
|
+
lines.push(" catch { state." + key + " = _m ? _m[1] : " + JSON.stringify(defaultVal) + "; }");
|
|
775
|
+
lines.push("}");
|
|
776
|
+
return lines.join("\n");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* @param {string} ref
|
|
783
|
+
* @returns {string}
|
|
784
|
+
*/
|
|
785
|
+
function refToBindingKey(ref) {
|
|
786
|
+
if (ref.startsWith("#/state/")) {
|
|
787
|
+
return ref.slice("#/state/".length).replace(/\//g, "_");
|
|
788
|
+
}
|
|
789
|
+
return ref.replace(/\//g, "_");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* @param {Map<string, string>} bindings
|
|
794
|
+
* @param {string} key
|
|
795
|
+
* @param {string} ref
|
|
796
|
+
*/
|
|
797
|
+
function addRefBinding(bindings, key, ref) {
|
|
798
|
+
if (bindings.has(key)) return;
|
|
799
|
+
if (ref.startsWith("#/state/")) {
|
|
800
|
+
const path = ref.slice("#/state/".length);
|
|
801
|
+
const parts = path.split("/");
|
|
802
|
+
bindings.set(key, "() => state." + parts.join("."));
|
|
803
|
+
} else {
|
|
804
|
+
bindings.set(key, "() => state." + ref);
|
|
805
|
+
}
|
|
806
|
+
}
|