@jxsuite/compiler 5.1.1 → 5.1.3
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/cli.js +224 -355
- package/dist/compiler.js +126 -257
- package/package.json +8 -8
- package/src/cli.ts +12 -14
- package/src/compiler.ts +45 -23
- package/src/shadow.ts +9 -8
- package/src/shared.ts +350 -296
- package/src/site/asset-mounts.ts +22 -12
- package/src/site/bundler.ts +38 -12
- package/src/site/client-runtime.ts +31 -17
- package/src/site/csp.ts +20 -11
- package/src/site/db-push.ts +38 -24
- package/src/site/dev-command.ts +3 -3
- package/src/site/format-host.ts +56 -45
- package/src/site/headers-emitter.ts +7 -4
- package/src/site/i18n.ts +20 -19
- package/src/site/image-cache.ts +16 -3
- package/src/site/image-optimizer.ts +19 -5
- package/src/site/image-transform.ts +56 -60
- package/src/site/img-loading.ts +11 -5
- package/src/site/layout-resolver.ts +7 -6
- package/src/site/link-relations.ts +10 -6
- package/src/site/locale-negotiation.ts +1 -1
- package/src/site/pages-discovery.ts +65 -47
- package/src/site/preview-server.ts +22 -19
- package/src/site/project-sections.ts +12 -3
- package/src/site/prototype-resolver.ts +94 -42
- package/src/site/schema-command.ts +26 -13
- package/src/site/service-worker.ts +2 -2
- package/src/site/site-build.ts +379 -262
- package/src/site/site-loader.ts +6 -3
- package/src/site/static-host.ts +3 -3
- package/src/site/validate-command.ts +64 -34
- package/src/site/well-known.ts +57 -12
- package/src/targets/compile-class.ts +17 -5
- package/src/targets/compile-client.ts +141 -106
- package/src/targets/compile-element.ts +157 -140
- package/src/targets/compile-server.ts +11 -5
- package/src/targets/compile-static.ts +57 -63
- package/src/types.ts +5 -6
|
@@ -28,30 +28,46 @@ import {
|
|
|
28
28
|
bodyReturnsValue,
|
|
29
29
|
hasStructuredBody,
|
|
30
30
|
isExpressionDef,
|
|
31
|
+
isBoolean,
|
|
31
32
|
isFunctionDef,
|
|
32
33
|
isMappedArray,
|
|
33
34
|
isNamedFormulaDef,
|
|
35
|
+
isNonNullObject,
|
|
36
|
+
isNumber,
|
|
37
|
+
isPrototypeDef,
|
|
34
38
|
isRef,
|
|
39
|
+
isString,
|
|
35
40
|
isTagExpression,
|
|
36
41
|
paramNames,
|
|
37
42
|
} from "@jxsuite/schema/guards";
|
|
38
43
|
import { parseJxDocument } from "@jxsuite/schema/parse";
|
|
39
|
-
import type { ExpressionNode } from "../shared.ts";
|
|
40
44
|
import { resolveShadowMode } from "../shadow.ts";
|
|
41
45
|
import type { ShadowMode } from "../shadow.ts";
|
|
42
46
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
43
47
|
import { escapeToken, objectKey, refAccessor, refSegments } from "@jxsuite/runtime/pointer";
|
|
44
48
|
import type {
|
|
49
|
+
JsonPrimitive,
|
|
45
50
|
JxDocument,
|
|
51
|
+
JxDocumentValue,
|
|
46
52
|
JxExpressionDef,
|
|
53
|
+
JxExpressionNode,
|
|
47
54
|
JxFunctionDef,
|
|
48
55
|
JxMappedArray,
|
|
49
56
|
JxMutableNode,
|
|
50
57
|
JxPrototypeDef,
|
|
58
|
+
JxRef,
|
|
59
|
+
JxStateDefinition,
|
|
51
60
|
JxStyle,
|
|
52
61
|
ProjectConfig,
|
|
53
62
|
} from "@jxsuite/schema/types";
|
|
54
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A member of a node's `children` (or a `$switch` case) as a parsed document holds it: an element,
|
|
66
|
+
* a text string, or — since the document is JSON — a number, boolean or `null`, rendered as text or
|
|
67
|
+
* skipped.
|
|
68
|
+
*/
|
|
69
|
+
type LitChild = JxMutableNode | JsonPrimitive;
|
|
70
|
+
|
|
55
71
|
/** Options accepted by compileElement. */
|
|
56
72
|
export interface CompileElementOptions {
|
|
57
73
|
/** Resolve an `$elements` ref path to its emitted JS module path. */
|
|
@@ -67,7 +83,6 @@ export interface CompileElementOptions {
|
|
|
67
83
|
basePath?: string | null;
|
|
68
84
|
/** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
|
|
69
85
|
defaults?: ProjectConfig["defaults"];
|
|
70
|
-
[key: string]: unknown;
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
@@ -93,7 +108,7 @@ export async function compileElement(
|
|
|
93
108
|
async function processElement(srcPath: string | JxDocument, parentDir: string | null) {
|
|
94
109
|
let doc: JxDocument;
|
|
95
110
|
let filePath: string | null;
|
|
96
|
-
if (
|
|
111
|
+
if (isString(srcPath)) {
|
|
97
112
|
const { readFileSync } = await import("node:fs");
|
|
98
113
|
const { resolve } = await import("node:path");
|
|
99
114
|
filePath = parentDir ? resolve(parentDir, srcPath) : resolve(srcPath);
|
|
@@ -106,13 +121,12 @@ export async function compileElement(
|
|
|
106
121
|
} else {
|
|
107
122
|
const { extname } = await import("node:path");
|
|
108
123
|
const ext = extname(filePath).toLowerCase();
|
|
109
|
-
const entry = formats?.byExtension
|
|
124
|
+
const entry = formats?.byExtension(ext, "parse");
|
|
110
125
|
if (!entry) {
|
|
111
126
|
const { unknownFormatError } = await import("../site/format-host.ts");
|
|
112
127
|
throw unknownFormatError(filePath, ext);
|
|
113
128
|
}
|
|
114
|
-
|
|
115
|
-
doc = (await entry.call("parse", readFileSync(filePath, "utf8"))) as JxDocument;
|
|
129
|
+
doc = await entry.parseDocument(readFileSync(filePath, "utf8"));
|
|
116
130
|
}
|
|
117
131
|
} else {
|
|
118
132
|
doc = srcPath;
|
|
@@ -138,7 +152,7 @@ export async function compileElement(
|
|
|
138
152
|
if (Array.isArray(doc.$elements)) {
|
|
139
153
|
for (const elRef of doc.$elements) {
|
|
140
154
|
const refPath = isRef(elRef) ? elRef.$ref : elRef;
|
|
141
|
-
if (
|
|
155
|
+
if (!isString(refPath)) {
|
|
142
156
|
continue;
|
|
143
157
|
}
|
|
144
158
|
|
|
@@ -174,12 +188,19 @@ export async function compileElement(
|
|
|
174
188
|
return { files };
|
|
175
189
|
}
|
|
176
190
|
|
|
191
|
+
/** Options accepted by compileElementPage: the page shell's, plus everything compileElement takes. */
|
|
192
|
+
export interface CompileElementPageOptions extends CompileElementOptions {
|
|
193
|
+
title?: string;
|
|
194
|
+
reactivitySrc?: string;
|
|
195
|
+
litHtmlSrc?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
177
198
|
/**
|
|
178
199
|
* Compile a Jx custom element document to a complete HTML page with an import map for CDN
|
|
179
200
|
* dependencies.
|
|
180
201
|
*
|
|
181
|
-
* @param {string |
|
|
182
|
-
* @param {
|
|
202
|
+
* @param {string | JxDocument} sourcePath
|
|
203
|
+
* @param {CompileElementPageOptions} [opts]
|
|
183
204
|
* @returns {Promise<{
|
|
184
205
|
* html: string;
|
|
185
206
|
* files: { path: string; content: string; tagName: string }[];
|
|
@@ -187,12 +208,7 @@ export async function compileElement(
|
|
|
187
208
|
*/
|
|
188
209
|
export async function compileElementPage(
|
|
189
210
|
sourcePath: string | JxDocument,
|
|
190
|
-
opts: {
|
|
191
|
-
title?: string;
|
|
192
|
-
reactivitySrc?: string;
|
|
193
|
-
litHtmlSrc?: string;
|
|
194
|
-
[key: string]: unknown;
|
|
195
|
-
} = {},
|
|
211
|
+
opts: CompileElementPageOptions = {},
|
|
196
212
|
) {
|
|
197
213
|
const {
|
|
198
214
|
title = "Jx App",
|
|
@@ -240,11 +256,11 @@ export async function compileElementPage(
|
|
|
240
256
|
* like { type, default, description } now correctly extract the `default` value instead of dumping
|
|
241
257
|
* the whole object.
|
|
242
258
|
*
|
|
243
|
-
* @param {
|
|
259
|
+
* @param {JxStateDefinition} def
|
|
244
260
|
* @returns {string | undefined}
|
|
245
261
|
*/
|
|
246
|
-
function extractInitialValue(def:
|
|
247
|
-
if (def
|
|
262
|
+
function extractInitialValue(def: JxStateDefinition) {
|
|
263
|
+
if (!isNonNullObject(def) || Array.isArray(def)) {
|
|
248
264
|
return JSON.stringify(def);
|
|
249
265
|
}
|
|
250
266
|
// Expanded signal with explicit default
|
|
@@ -286,21 +302,25 @@ function collectCallableRefs(doc: JxDocument) {
|
|
|
286
302
|
const keys = new Set<string>();
|
|
287
303
|
const seen = new Set<object>();
|
|
288
304
|
|
|
289
|
-
const addStateRef = (ref:
|
|
290
|
-
if (
|
|
305
|
+
const addStateRef = (ref: string) => {
|
|
306
|
+
if (ref.startsWith("#/state/")) {
|
|
307
|
+
/* SAFETY: `String.prototype.split` always returns at least one element (the whole string
|
|
308
|
+
when the separator is absent), so index 0 is present. */
|
|
291
309
|
keys.add(ref.slice("#/state/".length).split("/")[0] as string);
|
|
292
310
|
}
|
|
293
311
|
};
|
|
294
312
|
|
|
295
|
-
const visit = (node:
|
|
296
|
-
if (
|
|
313
|
+
const visit = (node: JxDocumentValue | undefined) => {
|
|
314
|
+
if (isString(node)) {
|
|
297
315
|
// `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
|
|
298
316
|
for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
317
|
+
/* SAFETY: the pattern's one capture group is not optional, so every match fills
|
|
318
|
+
index 1 with the called member's name. */
|
|
299
319
|
keys.add(call[1] as string);
|
|
300
320
|
}
|
|
301
321
|
return;
|
|
302
322
|
}
|
|
303
|
-
if (!node ||
|
|
323
|
+
if (!isNonNullObject(node) || seen.has(node)) {
|
|
304
324
|
return;
|
|
305
325
|
}
|
|
306
326
|
seen.add(node);
|
|
@@ -310,10 +330,13 @@ function collectCallableRefs(doc: JxDocument) {
|
|
|
310
330
|
}
|
|
311
331
|
return;
|
|
312
332
|
}
|
|
313
|
-
|
|
333
|
+
/* SAFETY: `node` is a non-null, non-array object reached from `doc.children` or `doc.state`,
|
|
334
|
+
and every such object in a parsed Jx document is a JSON object whose members are document
|
|
335
|
+
values; the view only reads members, and each is checked before use. */
|
|
336
|
+
const record = node as Readonly<Record<string, JxDocumentValue | undefined>>;
|
|
314
337
|
// An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
|
|
315
338
|
if (record.operator === "call" && isRef(record.target)) {
|
|
316
|
-
addStateRef(
|
|
339
|
+
addStateRef(record.target.$ref);
|
|
317
340
|
}
|
|
318
341
|
for (const [key, val] of Object.entries(record)) {
|
|
319
342
|
if (key.startsWith("on") && key !== "observedAttributes" && isRef(val)) {
|
|
@@ -360,10 +383,9 @@ export function emitElementModule(
|
|
|
360
383
|
const defs = doc.state ?? {};
|
|
361
384
|
const callableRefs = collectCallableRefs(doc);
|
|
362
385
|
for (const [key, def] of Object.entries(defs)) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (d.$lazy === true) {
|
|
386
|
+
if (isFunctionDef(def) && def.$src) {
|
|
387
|
+
const srcPath = def.$src;
|
|
388
|
+
if (def.$lazy === true) {
|
|
367
389
|
/*
|
|
368
390
|
* A computed reads its value synchronously, and a lazy binding can only ever hand back a
|
|
369
391
|
* promise. Silently producing `computed(() => Promise)` would render "[object Promise]" in
|
|
@@ -376,19 +398,20 @@ export function emitElementModule(
|
|
|
376
398
|
`Remove "$lazy", or call ${key}() from a handler instead of binding its value.`,
|
|
377
399
|
);
|
|
378
400
|
}
|
|
379
|
-
|
|
380
|
-
|
|
401
|
+
let lazyBindings = lazySrcMap.get(srcPath);
|
|
402
|
+
if (!lazyBindings) {
|
|
403
|
+
lazyBindings = [];
|
|
404
|
+
lazySrcMap.set(srcPath, lazyBindings);
|
|
381
405
|
}
|
|
382
|
-
|
|
383
|
-
binding: key,
|
|
384
|
-
exportName: srcExportName(key, d),
|
|
385
|
-
});
|
|
406
|
+
lazyBindings.push({ binding: key, exportName: srcExportName(key, def) });
|
|
386
407
|
continue;
|
|
387
408
|
}
|
|
388
|
-
|
|
389
|
-
|
|
409
|
+
let bindings = srcImportMap.get(srcPath);
|
|
410
|
+
if (!bindings) {
|
|
411
|
+
bindings = [];
|
|
412
|
+
srcImportMap.set(srcPath, bindings);
|
|
390
413
|
}
|
|
391
|
-
|
|
414
|
+
bindings.push(srcImportBinding(key, def));
|
|
392
415
|
}
|
|
393
416
|
}
|
|
394
417
|
for (const [srcPath, names] of srcImportMap) {
|
|
@@ -401,10 +424,9 @@ export function emitElementModule(
|
|
|
401
424
|
// `$prototype: "Request"` entries each get an auto-fetch effect in connectedCallback, plus the
|
|
402
425
|
// Machinery to stop those effects again on disconnect. Both are emitted only when one exists, so
|
|
403
426
|
// Documents without a Request produce exactly the same module as before.
|
|
404
|
-
const requestEntries = Object.entries(defs).
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
});
|
|
427
|
+
const requestEntries = Object.entries(defs).flatMap(([key, def]): [string, JxPrototypeDef][] =>
|
|
428
|
+
isPrototypeDef(def) && def.$prototype === "Request" ? [[key, def]] : [],
|
|
429
|
+
);
|
|
408
430
|
|
|
409
431
|
lines.push(
|
|
410
432
|
`import { reactive, computed, effect, stop } from '@vue/reactivity';`,
|
|
@@ -460,32 +482,30 @@ export function emitElementModule(
|
|
|
460
482
|
/** `"${…}"` state entries — computed, exactly as the runtime treats them. */
|
|
461
483
|
const templateEntries: [string, string][] = [];
|
|
462
484
|
for (const [key, def] of Object.entries(defs)) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (
|
|
467
|
-
|
|
468
|
-
} else if (isMutating(node.operator)) {
|
|
469
|
-
functionEntries.push([key, d]);
|
|
485
|
+
if (isExpressionDef(def)) {
|
|
486
|
+
if (isNamedFormulaDef(def)) {
|
|
487
|
+
formulaEntries.push([key, def]);
|
|
488
|
+
} else if (isMutating(def.$expression.operator)) {
|
|
489
|
+
functionEntries.push([key, def]);
|
|
470
490
|
} else {
|
|
471
|
-
computedEntries.push([key,
|
|
491
|
+
computedEntries.push([key, def]);
|
|
472
492
|
}
|
|
473
|
-
} else if (isFunctionDef(
|
|
474
|
-
if (
|
|
493
|
+
} else if (isFunctionDef(def)) {
|
|
494
|
+
if (def.$src && !isString(def.body)) {
|
|
475
495
|
// Bodyless `$src` entry — classify by document usage, not by an absent body. `body` and
|
|
476
496
|
// `$src` are mutually exclusive (spec.md §5.3 4d), so this is the only shape a
|
|
477
497
|
// Spec-conformant external Function takes.
|
|
478
498
|
if (callableRefs.has(key) || LIFECYCLE_KEYS.has(key)) {
|
|
479
|
-
functionEntries.push([key,
|
|
499
|
+
functionEntries.push([key, def]);
|
|
480
500
|
} else {
|
|
481
|
-
computedEntries.push([key,
|
|
501
|
+
computedEntries.push([key, def]);
|
|
482
502
|
}
|
|
483
|
-
} else if (
|
|
484
|
-
computedEntries.push([key,
|
|
503
|
+
} else if (isString(def.body) && bodyReturnsValue(def.body)) {
|
|
504
|
+
computedEntries.push([key, def]);
|
|
485
505
|
} else {
|
|
486
|
-
functionEntries.push([key,
|
|
506
|
+
functionEntries.push([key, def]);
|
|
487
507
|
}
|
|
488
|
-
} else if (
|
|
508
|
+
} else if (isString(def) && def.includes("${")) {
|
|
489
509
|
/* A TEMPLATE STRING IS A COMPUTED, and the runtime has always said so — `runtime.ts`'s
|
|
490
510
|
second state pass is `if (typeof def === "string" && def.includes("${")) state[key] =
|
|
491
511
|
computed(() => evaluateTemplate(def, state))`, and `StateEntry`'s own schema description
|
|
@@ -501,7 +521,7 @@ export function emitElementModule(
|
|
|
501
521
|
templateEntries.push([key, def]);
|
|
502
522
|
} else {
|
|
503
523
|
// Use extractInitialValue to get the correct initial value
|
|
504
|
-
const initVal = extractInitialValue(
|
|
524
|
+
const initVal = extractInitialValue(def);
|
|
505
525
|
if (initVal !== undefined) {
|
|
506
526
|
stateEntries.push([key, initVal]);
|
|
507
527
|
}
|
|
@@ -523,7 +543,7 @@ export function emitElementModule(
|
|
|
523
543
|
for (const [key, def] of functionEntries) {
|
|
524
544
|
lines.push("");
|
|
525
545
|
if (isExpressionDef(def)) {
|
|
526
|
-
const compiled = compileExpression(def.$expression
|
|
546
|
+
const compiled = compileExpression(def.$expression, {
|
|
527
547
|
eventParam: "e",
|
|
528
548
|
statePrefix: "s",
|
|
529
549
|
});
|
|
@@ -547,7 +567,7 @@ export function emitElementModule(
|
|
|
547
567
|
} else {
|
|
548
568
|
const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
|
|
549
569
|
const paramList = args.join(", ");
|
|
550
|
-
const body =
|
|
570
|
+
const body = isString(def.body) ? def.body : "";
|
|
551
571
|
// Every call site invokes a state function as `(state, event)` — the template's
|
|
552
572
|
// `s.fn(s, e)`, `onMount`, `onUnmount`. Declared names that already start with `state` line
|
|
553
573
|
// Up positionally, so they are emitted directly. Any other declaration — including the
|
|
@@ -591,7 +611,7 @@ export function emitElementModule(
|
|
|
591
611
|
// Emit named formulas — scope callables mapping positional args onto declared parameters
|
|
592
612
|
for (const [key, def] of formulaEntries) {
|
|
593
613
|
lines.push("");
|
|
594
|
-
const compiled = compileExpression(def.$expression
|
|
614
|
+
const compiled = compileExpression(def.$expression, {
|
|
595
615
|
eventParam: "e",
|
|
596
616
|
statePrefix: "this.state",
|
|
597
617
|
});
|
|
@@ -614,7 +634,7 @@ export function emitElementModule(
|
|
|
614
634
|
for (const [key, def] of computedEntries) {
|
|
615
635
|
lines.push("");
|
|
616
636
|
if (isExpressionDef(def)) {
|
|
617
|
-
const compiled = compileExpression(def.$expression
|
|
637
|
+
const compiled = compileExpression(def.$expression, {
|
|
618
638
|
eventParam: "e",
|
|
619
639
|
statePrefix: "this.state",
|
|
620
640
|
});
|
|
@@ -627,10 +647,7 @@ export function emitElementModule(
|
|
|
627
647
|
);
|
|
628
648
|
} else {
|
|
629
649
|
lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
|
|
630
|
-
const body = (
|
|
631
|
-
"state.",
|
|
632
|
-
"this.state.",
|
|
633
|
-
);
|
|
650
|
+
const body = (isString(def.body) ? def.body : "").replaceAll("state.", "this.state.");
|
|
634
651
|
lines.push(` ${body}`, " });");
|
|
635
652
|
}
|
|
636
653
|
}
|
|
@@ -742,14 +759,14 @@ export function emitElementModule(
|
|
|
742
759
|
// Keep fetching after removal, and every re-insertion would stack another effect on top.
|
|
743
760
|
for (const [key, def] of requestEntries) {
|
|
744
761
|
lines.push(
|
|
745
|
-
emitRequestFetch(key, def
|
|
762
|
+
emitRequestFetch(key, def, {
|
|
746
763
|
collect: "this.#effects",
|
|
747
764
|
indent: " ",
|
|
748
765
|
statePrefix: "this.state",
|
|
749
766
|
}),
|
|
750
767
|
);
|
|
751
768
|
}
|
|
752
|
-
if (doc.style
|
|
769
|
+
if (isNonNullObject(doc.style)) {
|
|
753
770
|
const dynamicStyles: [string, string][] = [];
|
|
754
771
|
for (const [prop, value] of Object.entries(doc.style)) {
|
|
755
772
|
if (
|
|
@@ -761,12 +778,9 @@ export function emitElementModule(
|
|
|
761
778
|
) {
|
|
762
779
|
continue;
|
|
763
780
|
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const cssProp = camelToKebab(prop);
|
|
768
|
-
if (typeof value === "string" && value.includes("${")) {
|
|
769
|
-
dynamicStyles.push([cssProp, value]);
|
|
781
|
+
// Only a template declaration is dynamic; nested blocks and literal values are static CSS.
|
|
782
|
+
if (isString(value) && value.includes("${")) {
|
|
783
|
+
dynamicStyles.push([camelToKebab(prop), value]);
|
|
770
784
|
}
|
|
771
785
|
}
|
|
772
786
|
if (dynamicStyles.length > 0) {
|
|
@@ -938,7 +952,7 @@ function emitLitChildren(
|
|
|
938
952
|
|
|
939
953
|
// Mixed children: elements/text plus array pseudo-elements expanded inline among siblings.
|
|
940
954
|
return children
|
|
941
|
-
.map((child
|
|
955
|
+
.map((child) =>
|
|
942
956
|
isMappedArray(child)
|
|
943
957
|
? emitMappedArray(child, indent)
|
|
944
958
|
: emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
|
|
@@ -947,13 +961,13 @@ function emitLitChildren(
|
|
|
947
961
|
}
|
|
948
962
|
|
|
949
963
|
/**
|
|
950
|
-
* @param {
|
|
964
|
+
* @param {LitChild} def
|
|
951
965
|
* @param {string} indent
|
|
952
966
|
* @param {boolean} [preformatted]
|
|
953
967
|
* @returns {string}
|
|
954
968
|
*/
|
|
955
969
|
function emitLitNode(
|
|
956
|
-
def:
|
|
970
|
+
def: LitChild,
|
|
957
971
|
indent: string,
|
|
958
972
|
preformatted = false,
|
|
959
973
|
inMap = false,
|
|
@@ -962,16 +976,16 @@ function emitLitNode(
|
|
|
962
976
|
innerOverride: string | null = null,
|
|
963
977
|
): string {
|
|
964
978
|
// String children are text nodes
|
|
965
|
-
if (
|
|
979
|
+
if (isString(def)) {
|
|
966
980
|
if (def.includes("${")) {
|
|
967
981
|
return `${indent}${toLitTextContent(def)}`;
|
|
968
982
|
}
|
|
969
983
|
return `${indent}${escapeHtml(def)}`;
|
|
970
984
|
}
|
|
971
|
-
if (
|
|
985
|
+
if (isNumber(def) || isBoolean(def)) {
|
|
972
986
|
return `${indent}${escapeHtml(String(def))}`;
|
|
973
987
|
}
|
|
974
|
-
if (!def
|
|
988
|
+
if (!isNonNullObject(def)) {
|
|
975
989
|
return "";
|
|
976
990
|
}
|
|
977
991
|
|
|
@@ -992,7 +1006,7 @@ function emitLitNode(
|
|
|
992
1006
|
eventParam: "e",
|
|
993
1007
|
statePrefix: "s",
|
|
994
1008
|
});
|
|
995
|
-
const branches =
|
|
1009
|
+
const branches: [string | boolean, string][] =
|
|
996
1010
|
expression.operator === "?:"
|
|
997
1011
|
? [
|
|
998
1012
|
[true, expression.value],
|
|
@@ -1002,7 +1016,7 @@ function emitLitNode(
|
|
|
1002
1016
|
// The inner content is identical across candidates — only the tag differs — so it is emitted
|
|
1003
1017
|
// Once and every branch references it. Skipped when the candidates disagree about being
|
|
1004
1018
|
// Preformatted, because `white-space` decides how the subtree itself is indented.
|
|
1005
|
-
const candidateTags = branches.map(([, candidate]) =>
|
|
1019
|
+
const candidateTags = branches.map(([, candidate]) => candidate);
|
|
1006
1020
|
const sharedPre = candidateTags.map((t) => preformatted || PREFORMATTED_TAGS.has(t));
|
|
1007
1021
|
let innerRef: string | null = null;
|
|
1008
1022
|
if (scope && sharedPre.every((p) => p === sharedPre[0])) {
|
|
@@ -1011,8 +1025,8 @@ function emitLitNode(
|
|
|
1011
1025
|
innerRef = hoistSubtree(scope, sharedInner);
|
|
1012
1026
|
}
|
|
1013
1027
|
}
|
|
1014
|
-
const rendered: [string | boolean
|
|
1015
|
-
const asLiteral = { ...def, tagName: candidate
|
|
1028
|
+
const rendered: [string | boolean, string][] = branches.map(([key, candidate]) => {
|
|
1029
|
+
const asLiteral = { ...def, tagName: candidate };
|
|
1016
1030
|
return [key, emitLitNode(asLiteral, `${indent} `, preformatted, inMap, scope, innerRef)];
|
|
1017
1031
|
});
|
|
1018
1032
|
if (expression.operator === "?:") {
|
|
@@ -1026,9 +1040,9 @@ ${yes}
|
|
|
1026
1040
|
${no}
|
|
1027
1041
|
\`}`;
|
|
1028
1042
|
}
|
|
1029
|
-
const entries = rendered
|
|
1030
|
-
|
|
1031
|
-
|
|
1043
|
+
const entries = rendered.flatMap(([key, text]) =>
|
|
1044
|
+
key === "__default__" ? [] : [` ${JSON.stringify(key)}: html\`\n${text}\n \``],
|
|
1045
|
+
);
|
|
1032
1046
|
const [, fallback] = rendered.at(-1)!;
|
|
1033
1047
|
return (
|
|
1034
1048
|
`${indent}${"$"}{{\n${entries.join(",\n")}\n}` +
|
|
@@ -1049,17 +1063,17 @@ ${no}
|
|
|
1049
1063
|
§8.3). A LITERAL boolean is decided now; a BOUND one cannot be, because its resolved type
|
|
1050
1064
|
is not known until the effect runs — so it goes through the emitted helper, whose `null`
|
|
1051
1065
|
becomes lit's `nothing` and takes the attribute off. */
|
|
1052
|
-
if (
|
|
1066
|
+
if (isRef(val)) {
|
|
1053
1067
|
parts.push(`${key}="\${${ATTR_HELPER}('${key}', ${refToExpr(val.$ref)}) ?? nothing}"`);
|
|
1054
|
-
} else if (
|
|
1068
|
+
} else if (isString(val) && isWholeTemplate(val)) {
|
|
1055
1069
|
parts.push(
|
|
1056
1070
|
`${key}="\${${ATTR_HELPER}('${key}', ${toLitExpr(val).slice(2, -1)}) ?? nothing}"`,
|
|
1057
1071
|
);
|
|
1058
|
-
} else if (
|
|
1072
|
+
} else if (isString(val) && val.includes("${")) {
|
|
1059
1073
|
/* A template with literal text around it — `"page ${n}"` — is a string by construction, so
|
|
1060
1074
|
there is no boolean to decide and wrapping it would only add a call. */
|
|
1061
1075
|
parts.push(`${key}="${toLitExpr(val)}"`);
|
|
1062
|
-
} else if (
|
|
1076
|
+
} else if (isBoolean(val)) {
|
|
1063
1077
|
const text = booleanAttrValue(key, val);
|
|
1064
1078
|
if (text !== null) {
|
|
1065
1079
|
parts.push(text === "" ? key : `${key}="${text}"`);
|
|
@@ -1097,9 +1111,9 @@ ${no}
|
|
|
1097
1111
|
continue;
|
|
1098
1112
|
}
|
|
1099
1113
|
|
|
1100
|
-
if (
|
|
1101
|
-
parts.push(`.${key}="\${${refToExpr(
|
|
1102
|
-
} else if (
|
|
1114
|
+
if (isBoundRef(val)) {
|
|
1115
|
+
parts.push(`.${key}="\${${refToExpr(val.$ref)}}"`);
|
|
1116
|
+
} else if (isString(val) && val.includes("${")) {
|
|
1103
1117
|
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
1104
1118
|
} else {
|
|
1105
1119
|
// A literal IDL value (`value: "a"`, `checked: true`) is still a property the author asked
|
|
@@ -1112,9 +1126,9 @@ ${no}
|
|
|
1112
1126
|
|
|
1113
1127
|
if (def.$props) {
|
|
1114
1128
|
for (const [key, val] of Object.entries(def.$props)) {
|
|
1115
|
-
if (
|
|
1116
|
-
parts.push(`.${key}="\${${refToExpr(
|
|
1117
|
-
} else if (
|
|
1129
|
+
if (isBoundRef(val)) {
|
|
1130
|
+
parts.push(`.${key}="\${${refToExpr(val.$ref)}}"`);
|
|
1131
|
+
} else if (isString(val) && val.includes("${")) {
|
|
1118
1132
|
// A template value is a binding, not text. JSON-quoting it emitted the template's own source
|
|
1119
1133
|
// As the prop value (`.label="${"${state.x}"}"`), so the component received the literal
|
|
1120
1134
|
// String `${state.x}` forever.
|
|
@@ -1134,20 +1148,20 @@ ${no}
|
|
|
1134
1148
|
continue;
|
|
1135
1149
|
}
|
|
1136
1150
|
const eventName = key.slice(2).toLowerCase();
|
|
1137
|
-
if (
|
|
1151
|
+
if (isBoundRef(val)) {
|
|
1138
1152
|
parts.push(
|
|
1139
1153
|
mapCtx
|
|
1140
|
-
? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr(
|
|
1141
|
-
: `@${eventName}="\${(e) => ${refToExpr(
|
|
1142
|
-
);
|
|
1143
|
-
} else if (val && typeof val === "object" && "$expression" in /** @type {any} */ val) {
|
|
1144
|
-
const compiled = compileExpression(
|
|
1145
|
-
(val as Record<string, unknown>).$expression as ExpressionNode,
|
|
1146
|
-
{
|
|
1147
|
-
eventParam: "e",
|
|
1148
|
-
statePrefix: "s",
|
|
1149
|
-
},
|
|
1154
|
+
? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr(val.$ref)}(s, e); }}"`
|
|
1155
|
+
: `@${eventName}="\${(e) => ${refToExpr(val.$ref)}(s, e)}"`,
|
|
1150
1156
|
);
|
|
1157
|
+
} else if (isNonNullObject(val) && "$expression" in val) {
|
|
1158
|
+
/* SAFETY: an `on*` binding object carrying `$expression` is a declarative handler, and the
|
|
1159
|
+
document schema types that member as an expression node (spec.md §19). It is passed on
|
|
1160
|
+
unvalidated, exactly as authored, so a malformed node fails inside compileExpression. */
|
|
1161
|
+
const compiled = compileExpression(val.$expression as JxExpressionNode, {
|
|
1162
|
+
eventParam: "e",
|
|
1163
|
+
statePrefix: "s",
|
|
1164
|
+
});
|
|
1151
1165
|
parts.push(`@${eventName}="\${(e) => { ${mapCtx}${compiled}; }}"`);
|
|
1152
1166
|
} else if (isFunctionDef(val)) {
|
|
1153
1167
|
parts.push(`@${eventName}="\${(e) => { ${mapCtx}${inlineHandlerBody(val)} }}"`);
|
|
@@ -1195,11 +1209,11 @@ function emitNodeInner(
|
|
|
1195
1209
|
scope: HoistScope | null,
|
|
1196
1210
|
): string {
|
|
1197
1211
|
if (def.$switch) {
|
|
1198
|
-
const switchRef = isRef(def.$switch) ? def.$switch.$ref :
|
|
1212
|
+
const switchRef = isRef(def.$switch) ? def.$switch.$ref : def.$switch;
|
|
1199
1213
|
const switchExpr = refToExpr(switchRef);
|
|
1200
|
-
const cases = (def.cases ?? {}) as Record<string, JxMutableNode>;
|
|
1201
1214
|
const rendered: [string, string][] = [];
|
|
1202
|
-
|
|
1215
|
+
// A case value is document data, so a `null` or empty one can reach here and is skipped.
|
|
1216
|
+
for (const [key, caseDef] of Object.entries<LitChild>(def.cases ?? {})) {
|
|
1203
1217
|
if (!caseDef || isRef(caseDef)) {
|
|
1204
1218
|
continue;
|
|
1205
1219
|
}
|
|
@@ -1207,10 +1221,9 @@ function emitNodeInner(
|
|
|
1207
1221
|
}
|
|
1208
1222
|
// Cases that emit byte-identical subtrees share one hoisted `const` rather than repeating it
|
|
1209
1223
|
// Once per case — the authored document names the subtree once (#126).
|
|
1224
|
+
const texts = rendered.map(([, text]) => text);
|
|
1210
1225
|
const repeated = new Set(
|
|
1211
|
-
|
|
1212
|
-
.map(([, text]) => text)
|
|
1213
|
-
.filter((text, i, all) => all.indexOf(text) !== i && text.trim() !== ""),
|
|
1226
|
+
texts.filter((text, i) => texts.indexOf(text) !== i && text.trim() !== ""),
|
|
1214
1227
|
);
|
|
1215
1228
|
const caseEntries = rendered.map(([key, text]) => {
|
|
1216
1229
|
const body = scope && repeated.has(text) ? hoistSubtree(scope, text) : text;
|
|
@@ -1252,9 +1265,9 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
1252
1265
|
// `${item…}`/`${index…}` templates resolve against the map callback's own parameters.
|
|
1253
1266
|
if (mapDef.attributes) {
|
|
1254
1267
|
for (const [key, val] of Object.entries(mapDef.attributes)) {
|
|
1255
|
-
if (
|
|
1268
|
+
if (isRef(val)) {
|
|
1256
1269
|
parts.push(`${key}="\${${mapRefToExpr(val.$ref)}}"`);
|
|
1257
|
-
} else if (
|
|
1270
|
+
} else if (isString(val) && val.includes("${")) {
|
|
1258
1271
|
parts.push(`${key}="${toLitExpr(val)}"`);
|
|
1259
1272
|
} else {
|
|
1260
1273
|
parts.push(`${key}="${val}"`);
|
|
@@ -1290,9 +1303,9 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
1290
1303
|
continue;
|
|
1291
1304
|
}
|
|
1292
1305
|
|
|
1293
|
-
if (
|
|
1294
|
-
parts.push(`.${key}="\${${mapRefToExpr(
|
|
1295
|
-
} else if (
|
|
1306
|
+
if (isBoundRef(val)) {
|
|
1307
|
+
parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
|
|
1308
|
+
} else if (isString(val) && val.includes("${")) {
|
|
1296
1309
|
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
1297
1310
|
} else {
|
|
1298
1311
|
parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
|
|
@@ -1303,7 +1316,7 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
1303
1316
|
for (const [key, val] of Object.entries(mapDef.$props)) {
|
|
1304
1317
|
if (isRef(val)) {
|
|
1305
1318
|
parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
|
|
1306
|
-
} else if (
|
|
1319
|
+
} else if (isString(val) && val.includes("${")) {
|
|
1307
1320
|
// `$props: { label: "${$map.item.name}" }` is the ordinary way to pass per-item data to a
|
|
1308
1321
|
// Component in a list; JSON-quoting it handed over the template source instead.
|
|
1309
1322
|
parts.push(`.${key}="${toLitExpr(val)}"`);
|
|
@@ -1326,7 +1339,7 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
1326
1339
|
if (isRef(val)) {
|
|
1327
1340
|
parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${refToExpr(val.$ref)}(s, e); }}"`);
|
|
1328
1341
|
} else if (isExpressionDef(val)) {
|
|
1329
|
-
const compiled = compileExpression(val.$expression
|
|
1342
|
+
const compiled = compileExpression(val.$expression, {
|
|
1330
1343
|
eventParam: "e",
|
|
1331
1344
|
statePrefix: "s",
|
|
1332
1345
|
});
|
|
@@ -1366,6 +1379,17 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
|
|
|
1366
1379
|
return `${indent}\${${itemsExpr}.map(${callback})}`;
|
|
1367
1380
|
}
|
|
1368
1381
|
|
|
1382
|
+
/**
|
|
1383
|
+
* Whether a document value binds through a non-empty `$ref` pointer. An empty pointer names no
|
|
1384
|
+
* state, so the emitters treat that object as a literal value rather than a binding.
|
|
1385
|
+
*
|
|
1386
|
+
* @param {JxDocumentValue | undefined} value
|
|
1387
|
+
* @returns {boolean}
|
|
1388
|
+
*/
|
|
1389
|
+
function isBoundRef(value: JxDocumentValue | undefined): value is JxRef {
|
|
1390
|
+
return isRef(value) && value.$ref !== "";
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1369
1393
|
/**
|
|
1370
1394
|
* Convert a $ref string to a JS expression using `s` (this.state alias).
|
|
1371
1395
|
*
|
|
@@ -1445,19 +1469,15 @@ function isWholeTemplate(str: string): boolean {
|
|
|
1445
1469
|
* Convert textContent value to lit-html text content. Bug fix: handles $ref objects, which
|
|
1446
1470
|
* previously produced [object Object].
|
|
1447
1471
|
*
|
|
1448
|
-
* @param {
|
|
1472
|
+
* @param {string | null | JxRef} value
|
|
1449
1473
|
* @returns {string}
|
|
1450
1474
|
*/
|
|
1451
|
-
function toLitTextContent(value:
|
|
1475
|
+
function toLitTextContent(value: string | null | JxRef) {
|
|
1452
1476
|
// Handle $ref objects → emit as lit expression
|
|
1453
|
-
if (
|
|
1454
|
-
value
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
) {
|
|
1458
|
-
return `\${${refToExpr((value as JxMutableNode).$ref as string)}}`;
|
|
1459
|
-
}
|
|
1460
|
-
if (typeof value === "string" && value.includes("${")) {
|
|
1477
|
+
if (isRef(value)) {
|
|
1478
|
+
return `\${${refToExpr(value.$ref)}}`;
|
|
1479
|
+
}
|
|
1480
|
+
if (isString(value) && value.includes("${")) {
|
|
1461
1481
|
return toLitExpr(value);
|
|
1462
1482
|
}
|
|
1463
1483
|
return String(value);
|
|
@@ -1475,7 +1495,7 @@ function inlineHandlerBody(def: JxFunctionDef) {
|
|
|
1475
1495
|
statePrefix: "s",
|
|
1476
1496
|
});
|
|
1477
1497
|
}
|
|
1478
|
-
const body =
|
|
1498
|
+
const body = isString(def.body) ? def.body : "";
|
|
1479
1499
|
const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
|
|
1480
1500
|
if (declared.length > 0) {
|
|
1481
1501
|
// The call site supplies only `(e)`, so map the declared names onto the arguments the handler
|
|
@@ -1493,7 +1513,7 @@ function inlineHandlerBody(def: JxFunctionDef) {
|
|
|
1493
1513
|
* @returns {string}
|
|
1494
1514
|
*/
|
|
1495
1515
|
function emitStyleString(styleDef: JxStyle | null | undefined) {
|
|
1496
|
-
if (!styleDef
|
|
1516
|
+
if (!isNonNullObject(styleDef)) {
|
|
1497
1517
|
return "";
|
|
1498
1518
|
}
|
|
1499
1519
|
|
|
@@ -1509,11 +1529,8 @@ function emitStyleString(styleDef: JxStyle | null | undefined) {
|
|
|
1509
1529
|
continue;
|
|
1510
1530
|
}
|
|
1511
1531
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
if (typeof value === "string" && value.includes("${")) {
|
|
1532
|
+
// Only a template declaration is dynamic; nested blocks and literal values are static CSS.
|
|
1533
|
+
if (isString(value) && value.includes("${")) {
|
|
1517
1534
|
const cssProp = camelToKebab(prop);
|
|
1518
1535
|
parts.push(`${cssProp}: ${toLitExpr(value)}`);
|
|
1519
1536
|
}
|
|
@@ -1533,7 +1550,7 @@ function treeHasSlot(children: JxMutableNode["children"]): boolean {
|
|
|
1533
1550
|
return false;
|
|
1534
1551
|
}
|
|
1535
1552
|
for (const child of children) {
|
|
1536
|
-
if (!child
|
|
1553
|
+
if (!isNonNullObject(child)) {
|
|
1537
1554
|
continue;
|
|
1538
1555
|
}
|
|
1539
1556
|
if (child.tagName === "slot") {
|