@jxsuite/compiler 5.1.2 → 6.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.
Files changed (40) hide show
  1. package/dist/cli.js +218 -346
  2. package/dist/compiler.js +130 -258
  3. package/package.json +11 -10
  4. package/src/cli.ts +12 -14
  5. package/src/compiler.ts +45 -23
  6. package/src/shadow.ts +9 -8
  7. package/src/shared.ts +693 -329
  8. package/src/site/asset-mounts.ts +22 -12
  9. package/src/site/bundler.ts +38 -12
  10. package/src/site/client-runtime.ts +31 -17
  11. package/src/site/csp.ts +20 -11
  12. package/src/site/db-push.ts +38 -24
  13. package/src/site/dev-command.ts +3 -3
  14. package/src/site/format-host.ts +56 -45
  15. package/src/site/headers-emitter.ts +7 -4
  16. package/src/site/i18n.ts +20 -19
  17. package/src/site/image-cache.ts +16 -3
  18. package/src/site/image-optimizer.ts +19 -5
  19. package/src/site/image-transform.ts +56 -60
  20. package/src/site/img-loading.ts +11 -5
  21. package/src/site/layout-resolver.ts +7 -6
  22. package/src/site/link-relations.ts +10 -6
  23. package/src/site/locale-negotiation.ts +1 -1
  24. package/src/site/pages-discovery.ts +65 -47
  25. package/src/site/preview-server.ts +22 -19
  26. package/src/site/project-sections.ts +16 -3
  27. package/src/site/prototype-resolver.ts +94 -42
  28. package/src/site/schema-command.ts +26 -13
  29. package/src/site/service-worker.ts +2 -2
  30. package/src/site/site-build.ts +421 -279
  31. package/src/site/site-loader.ts +6 -3
  32. package/src/site/static-host.ts +3 -3
  33. package/src/site/validate-command.ts +64 -34
  34. package/src/site/well-known.ts +57 -12
  35. package/src/targets/compile-class.ts +17 -5
  36. package/src/targets/compile-client.ts +156 -111
  37. package/src/targets/compile-element.ts +227 -198
  38. package/src/targets/compile-server.ts +11 -5
  39. package/src/targets/compile-static.ts +57 -63
  40. package/src/types.ts +5 -6
@@ -10,7 +10,12 @@ import {
10
10
  ATTR_HELPER,
11
11
  attrHelperSource,
12
12
  booleanAttrValue,
13
+ definitionHasSlot,
14
+ LIT_PART_GUARD,
13
15
  PREFORMATTED_TAGS,
16
+ SLOT_CAPTURE_HELPER,
17
+ SLOT_DISTRIBUTE_HELPER,
18
+ slotHelperSource,
14
19
  collectStyles,
15
20
  compileExpression,
16
21
  compileOperandSource,
@@ -23,35 +28,52 @@ import {
23
28
  srcExportName,
24
29
  srcImportBinding,
25
30
  tagNameToClassName,
31
+ templateValueSource,
26
32
  } from "../shared.ts";
27
33
  import {
28
34
  bodyReturnsValue,
29
35
  hasStructuredBody,
30
36
  isExpressionDef,
37
+ isBoolean,
31
38
  isFunctionDef,
32
39
  isMappedArray,
33
40
  isNamedFormulaDef,
41
+ isNonNullObject,
42
+ isNumber,
43
+ isPrototypeDef,
34
44
  isRef,
45
+ isString,
35
46
  isTagExpression,
36
47
  paramNames,
37
48
  } from "@jxsuite/schema/guards";
38
49
  import { parseJxDocument } from "@jxsuite/schema/parse";
39
- import type { ExpressionNode } from "../shared.ts";
40
50
  import { resolveShadowMode } from "../shadow.ts";
41
51
  import type { ShadowMode } from "../shadow.ts";
42
52
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
43
53
  import { escapeToken, objectKey, refAccessor, refSegments } from "@jxsuite/runtime/pointer";
44
54
  import type {
55
+ JsonPrimitive,
45
56
  JxDocument,
57
+ JxDocumentValue,
46
58
  JxExpressionDef,
59
+ JxExpressionNode,
47
60
  JxFunctionDef,
48
61
  JxMappedArray,
49
62
  JxMutableNode,
50
63
  JxPrototypeDef,
64
+ JxRef,
65
+ JxStateDefinition,
51
66
  JxStyle,
52
67
  ProjectConfig,
53
68
  } from "@jxsuite/schema/types";
54
69
 
70
+ /**
71
+ * A member of a node's `children` (or a `$switch` case) as a parsed document holds it: an element,
72
+ * a text string, or — since the document is JSON — a number, boolean or `null`, rendered as text or
73
+ * skipped.
74
+ */
75
+ type LitChild = JxMutableNode | JsonPrimitive;
76
+
55
77
  /** Options accepted by compileElement. */
56
78
  export interface CompileElementOptions {
57
79
  /** Resolve an `$elements` ref path to its emitted JS module path. */
@@ -67,7 +89,6 @@ export interface CompileElementOptions {
67
89
  basePath?: string | null;
68
90
  /** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
69
91
  defaults?: ProjectConfig["defaults"];
70
- [key: string]: unknown;
71
92
  }
72
93
 
73
94
  /**
@@ -93,7 +114,7 @@ export async function compileElement(
93
114
  async function processElement(srcPath: string | JxDocument, parentDir: string | null) {
94
115
  let doc: JxDocument;
95
116
  let filePath: string | null;
96
- if (typeof srcPath === "string") {
117
+ if (isString(srcPath)) {
97
118
  const { readFileSync } = await import("node:fs");
98
119
  const { resolve } = await import("node:path");
99
120
  filePath = parentDir ? resolve(parentDir, srcPath) : resolve(srcPath);
@@ -106,13 +127,12 @@ export async function compileElement(
106
127
  } else {
107
128
  const { extname } = await import("node:path");
108
129
  const ext = extname(filePath).toLowerCase();
109
- const entry = formats?.byExtension?.(ext, "parse");
130
+ const entry = formats?.byExtension(ext, "parse");
110
131
  if (!entry) {
111
132
  const { unknownFormatError } = await import("../site/format-host.ts");
112
133
  throw unknownFormatError(filePath, ext);
113
134
  }
114
- // Format plugins contractually parse source text into a Jx document.
115
- doc = (await entry.call("parse", readFileSync(filePath, "utf8"))) as JxDocument;
135
+ doc = await entry.parseDocument(readFileSync(filePath, "utf8"));
116
136
  }
117
137
  } else {
118
138
  doc = srcPath;
@@ -138,7 +158,7 @@ export async function compileElement(
138
158
  if (Array.isArray(doc.$elements)) {
139
159
  for (const elRef of doc.$elements) {
140
160
  const refPath = isRef(elRef) ? elRef.$ref : elRef;
141
- if (typeof refPath !== "string") {
161
+ if (!isString(refPath)) {
142
162
  continue;
143
163
  }
144
164
 
@@ -174,12 +194,19 @@ export async function compileElement(
174
194
  return { files };
175
195
  }
176
196
 
197
+ /** Options accepted by compileElementPage: the page shell's, plus everything compileElement takes. */
198
+ export interface CompileElementPageOptions extends CompileElementOptions {
199
+ title?: string;
200
+ reactivitySrc?: string;
201
+ litHtmlSrc?: string;
202
+ }
203
+
177
204
  /**
178
205
  * Compile a Jx custom element document to a complete HTML page with an import map for CDN
179
206
  * dependencies.
180
207
  *
181
- * @param {string | any} sourcePath
182
- * @param {Record<string, unknown>} [opts]
208
+ * @param {string | JxDocument} sourcePath
209
+ * @param {CompileElementPageOptions} [opts]
183
210
  * @returns {Promise<{
184
211
  * html: string;
185
212
  * files: { path: string; content: string; tagName: string }[];
@@ -187,12 +214,7 @@ export async function compileElement(
187
214
  */
188
215
  export async function compileElementPage(
189
216
  sourcePath: string | JxDocument,
190
- opts: {
191
- title?: string;
192
- reactivitySrc?: string;
193
- litHtmlSrc?: string;
194
- [key: string]: unknown;
195
- } = {},
217
+ opts: CompileElementPageOptions = {},
196
218
  ) {
197
219
  const {
198
220
  title = "Jx App",
@@ -240,11 +262,11 @@ export async function compileElementPage(
240
262
  * like { type, default, description } now correctly extract the `default` value instead of dumping
241
263
  * the whole object.
242
264
  *
243
- * @param {JxMutableNode} def
265
+ * @param {JxStateDefinition} def
244
266
  * @returns {string | undefined}
245
267
  */
246
- function extractInitialValue(def: JxMutableNode) {
247
- if (def === null || typeof def !== "object" || Array.isArray(def)) {
268
+ function extractInitialValue(def: JxStateDefinition) {
269
+ if (!isNonNullObject(def) || Array.isArray(def)) {
248
270
  return JSON.stringify(def);
249
271
  }
250
272
  // Expanded signal with explicit default
@@ -286,21 +308,25 @@ function collectCallableRefs(doc: JxDocument) {
286
308
  const keys = new Set<string>();
287
309
  const seen = new Set<object>();
288
310
 
289
- const addStateRef = (ref: unknown) => {
290
- if (typeof ref === "string" && ref.startsWith("#/state/")) {
311
+ const addStateRef = (ref: string) => {
312
+ if (ref.startsWith("#/state/")) {
313
+ /* SAFETY: `String.prototype.split` always returns at least one element (the whole string
314
+ when the separator is absent), so index 0 is present. */
291
315
  keys.add(ref.slice("#/state/".length).split("/")[0] as string);
292
316
  }
293
317
  };
294
318
 
295
- const visit = (node: unknown) => {
296
- if (typeof node === "string") {
319
+ const visit = (node: JxDocumentValue | undefined) => {
320
+ if (isString(node)) {
297
321
  // `${state.formatDate(d)}` in a template, or `state.helper(x)` in another function's body.
298
322
  for (const call of node.matchAll(/\bstate\.([A-Za-z_$][\w$]*)\s*\(/g)) {
323
+ /* SAFETY: the pattern's one capture group is not optional, so every match fills
324
+ index 1 with the called member's name. */
299
325
  keys.add(call[1] as string);
300
326
  }
301
327
  return;
302
328
  }
303
- if (!node || typeof node !== "object" || seen.has(node)) {
329
+ if (!isNonNullObject(node) || seen.has(node)) {
304
330
  return;
305
331
  }
306
332
  seen.add(node);
@@ -310,10 +336,13 @@ function collectCallableRefs(doc: JxDocument) {
310
336
  }
311
337
  return;
312
338
  }
313
- const record = node as Record<string, unknown>;
339
+ /* SAFETY: `node` is a non-null, non-array object reached from `doc.children` or `doc.state`,
340
+ and every such object in a parsed Jx document is a JSON object whose members are document
341
+ values; the view only reads members, and each is checked before use. */
342
+ const record = node as Readonly<Record<string, JxDocumentValue | undefined>>;
314
343
  // An `$expression` `call` node names its callee in `target` (spec.md §19.4c).
315
344
  if (record.operator === "call" && isRef(record.target)) {
316
- addStateRef((record.target as { $ref: string }).$ref);
345
+ addStateRef(record.target.$ref);
317
346
  }
318
347
  for (const [key, val] of Object.entries(record)) {
319
348
  if (key.startsWith("on") && key !== "observedAttributes" && isRef(val)) {
@@ -360,10 +389,9 @@ export function emitElementModule(
360
389
  const defs = doc.state ?? {};
361
390
  const callableRefs = collectCallableRefs(doc);
362
391
  for (const [key, def] of Object.entries(defs)) {
363
- const d = def as JxMutableNode;
364
- if (d && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Function" && d.$src) {
365
- const srcPath = d.$src;
366
- if (d.$lazy === true) {
392
+ if (isFunctionDef(def) && def.$src) {
393
+ const srcPath = def.$src;
394
+ if (def.$lazy === true) {
367
395
  /*
368
396
  * A computed reads its value synchronously, and a lazy binding can only ever hand back a
369
397
  * promise. Silently producing `computed(() => Promise)` would render "[object Promise]" in
@@ -376,19 +404,20 @@ export function emitElementModule(
376
404
  `Remove "$lazy", or call ${key}() from a handler instead of binding its value.`,
377
405
  );
378
406
  }
379
- if (!lazySrcMap.has(srcPath)) {
380
- lazySrcMap.set(srcPath, []);
407
+ let lazyBindings = lazySrcMap.get(srcPath);
408
+ if (!lazyBindings) {
409
+ lazyBindings = [];
410
+ lazySrcMap.set(srcPath, lazyBindings);
381
411
  }
382
- (lazySrcMap.get(srcPath) as { binding: string; exportName: string }[]).push({
383
- binding: key,
384
- exportName: srcExportName(key, d),
385
- });
412
+ lazyBindings.push({ binding: key, exportName: srcExportName(key, def) });
386
413
  continue;
387
414
  }
388
- if (!srcImportMap.has(srcPath)) {
389
- srcImportMap.set(srcPath, []);
415
+ let bindings = srcImportMap.get(srcPath);
416
+ if (!bindings) {
417
+ bindings = [];
418
+ srcImportMap.set(srcPath, bindings);
390
419
  }
391
- (srcImportMap.get(srcPath) as string[]).push(srcImportBinding(key, d));
420
+ bindings.push(srcImportBinding(key, def));
392
421
  }
393
422
  }
394
423
  for (const [srcPath, names] of srcImportMap) {
@@ -401,10 +430,9 @@ export function emitElementModule(
401
430
  // `$prototype: "Request"` entries each get an auto-fetch effect in connectedCallback, plus the
402
431
  // Machinery to stop those effects again on disconnect. Both are emitted only when one exists, so
403
432
  // Documents without a Request produce exactly the same module as before.
404
- const requestEntries = Object.entries(defs).filter(([, def]) => {
405
- const d = def as JxMutableNode;
406
- return Boolean(d) && typeof d === "object" && !Array.isArray(d) && d.$prototype === "Request";
407
- });
433
+ const requestEntries = Object.entries(defs).flatMap(([key, def]): [string, JxPrototypeDef][] =>
434
+ isPrototypeDef(def) && def.$prototype === "Request" ? [[key, def]] : [],
435
+ );
408
436
 
409
437
  lines.push(
410
438
  `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
@@ -414,6 +442,13 @@ export function emitElementModule(
414
442
  `import { render, html, nothing } from 'lit-html';`,
415
443
  attrHelperSource(),
416
444
  );
445
+ /* Light-DOM slot distribution, by the rule the interpreter applies (spec.md §8.5): only a module
446
+ whose template draws a slot carries it, and a shadow component never does — its light children
447
+ ARE the slotted content, and a real `<slot>` distributes them. */
448
+ const hasSlot = shadow === null && definitionHasSlot(doc.children);
449
+ if (hasSlot) {
450
+ lines.push(slotHelperSource());
451
+ }
417
452
 
418
453
  /*
419
454
  * `$lazy` swaps the static import for a memoized dynamic one. A static import is a download, a
@@ -460,32 +495,30 @@ export function emitElementModule(
460
495
  /** `"${…}"` state entries — computed, exactly as the runtime treats them. */
461
496
  const templateEntries: [string, string][] = [];
462
497
  for (const [key, def] of Object.entries(defs)) {
463
- const d = def as JxMutableNode;
464
- if (isExpressionDef(d)) {
465
- const node = d.$expression as ExpressionNode;
466
- if (isNamedFormulaDef(d)) {
467
- formulaEntries.push([key, d]);
468
- } else if (isMutating(node.operator)) {
469
- functionEntries.push([key, d]);
498
+ if (isExpressionDef(def)) {
499
+ if (isNamedFormulaDef(def)) {
500
+ formulaEntries.push([key, def]);
501
+ } else if (isMutating(def.$expression.operator)) {
502
+ functionEntries.push([key, def]);
470
503
  } else {
471
- computedEntries.push([key, d]);
504
+ computedEntries.push([key, def]);
472
505
  }
473
- } else if (isFunctionDef(d)) {
474
- if (d.$src && typeof d.body !== "string") {
506
+ } else if (isFunctionDef(def)) {
507
+ if (def.$src && !isString(def.body)) {
475
508
  // Bodyless `$src` entry — classify by document usage, not by an absent body. `body` and
476
509
  // `$src` are mutually exclusive (spec.md §5.3 4d), so this is the only shape a
477
510
  // Spec-conformant external Function takes.
478
511
  if (callableRefs.has(key) || LIFECYCLE_KEYS.has(key)) {
479
- functionEntries.push([key, d]);
512
+ functionEntries.push([key, def]);
480
513
  } else {
481
- computedEntries.push([key, d]);
514
+ computedEntries.push([key, def]);
482
515
  }
483
- } else if (typeof d.body === "string" && bodyReturnsValue(d.body)) {
484
- computedEntries.push([key, d]);
516
+ } else if (isString(def.body) && bodyReturnsValue(def.body)) {
517
+ computedEntries.push([key, def]);
485
518
  } else {
486
- functionEntries.push([key, d]);
519
+ functionEntries.push([key, def]);
487
520
  }
488
- } else if (typeof def === "string" && def.includes("${")) {
521
+ } else if (isString(def) && def.includes("${")) {
489
522
  /* A TEMPLATE STRING IS A COMPUTED, and the runtime has always said so — `runtime.ts`'s
490
523
  second state pass is `if (typeof def === "string" && def.includes("${")) state[key] =
491
524
  computed(() => evaluateTemplate(def, state))`, and `StateEntry`'s own schema description
@@ -501,7 +534,7 @@ export function emitElementModule(
501
534
  templateEntries.push([key, def]);
502
535
  } else {
503
536
  // Use extractInitialValue to get the correct initial value
504
- const initVal = extractInitialValue(d);
537
+ const initVal = extractInitialValue(def);
505
538
  if (initVal !== undefined) {
506
539
  stateEntries.push([key, initVal]);
507
540
  }
@@ -523,7 +556,7 @@ export function emitElementModule(
523
556
  for (const [key, def] of functionEntries) {
524
557
  lines.push("");
525
558
  if (isExpressionDef(def)) {
526
- const compiled = compileExpression(def.$expression as ExpressionNode, {
559
+ const compiled = compileExpression(def.$expression, {
527
560
  eventParam: "e",
528
561
  statePrefix: "s",
529
562
  });
@@ -547,7 +580,7 @@ export function emitElementModule(
547
580
  } else {
548
581
  const args = def.parameters ? paramNames(def.parameters) : (def.arguments ?? ["state"]);
549
582
  const paramList = args.join(", ");
550
- const body = typeof def.body === "string" ? def.body : "";
583
+ const body = isString(def.body) ? def.body : "";
551
584
  // Every call site invokes a state function as `(state, event)` — the template's
552
585
  // `s.fn(s, e)`, `onMount`, `onUnmount`. Declared names that already start with `state` line
553
586
  // Up positionally, so they are emitted directly. Any other declaration — including the
@@ -591,7 +624,7 @@ export function emitElementModule(
591
624
  // Emit named formulas — scope callables mapping positional args onto declared parameters
592
625
  for (const [key, def] of formulaEntries) {
593
626
  lines.push("");
594
- const compiled = compileExpression(def.$expression as ExpressionNode, {
627
+ const compiled = compileExpression(def.$expression, {
595
628
  eventParam: "e",
596
629
  statePrefix: "this.state",
597
630
  });
@@ -600,13 +633,14 @@ export function emitElementModule(
600
633
  );
601
634
  }
602
635
 
603
- /* Emitted with the same `state.` → `this.state.` rewrite the inline-body path below uses, into a
604
- template literal — which is what `evaluateTemplate` is, so the result is a string on both
605
- sides rather than a string here and a raw value there. */
636
+ /* Emitted with the same `state.` → `this.state.` rewrite the inline-body path below uses. A
637
+ single expression keeps its own type and anything else is a template literal, exactly as the
638
+ runtime and the build-time scope evaluate it (spec.md §5.3), so a component reads the same
639
+ value hydrated as it did prerendered. */
606
640
  for (const [key, template] of templateEntries) {
607
641
  lines.push(
608
642
  "",
609
- ` ${refAccessor("this.state", escapeToken(key))} = computed(() => \`${template.replaceAll("state.", "this.state.")}\`);`,
643
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${templateValueSource(template.replaceAll("state.", "this.state."))});`,
610
644
  );
611
645
  }
612
646
 
@@ -614,7 +648,7 @@ export function emitElementModule(
614
648
  for (const [key, def] of computedEntries) {
615
649
  lines.push("");
616
650
  if (isExpressionDef(def)) {
617
- const compiled = compileExpression(def.$expression as ExpressionNode, {
651
+ const compiled = compileExpression(def.$expression, {
618
652
  eventParam: "e",
619
653
  statePrefix: "this.state",
620
654
  });
@@ -627,10 +661,7 @@ export function emitElementModule(
627
661
  );
628
662
  } else {
629
663
  lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
630
- const body = (typeof def.body === "string" ? def.body : "").replaceAll(
631
- "state.",
632
- "this.state.",
633
- );
664
+ const body = (isString(def.body) ? def.body : "").replaceAll("state.", "this.state.");
634
665
  lines.push(` ${body}`, " });");
635
666
  }
636
667
  }
@@ -742,14 +773,14 @@ export function emitElementModule(
742
773
  // Keep fetching after removal, and every re-insertion would stack another effect on top.
743
774
  for (const [key, def] of requestEntries) {
744
775
  lines.push(
745
- emitRequestFetch(key, def as unknown as JxPrototypeDef, {
776
+ emitRequestFetch(key, def, {
746
777
  collect: "this.#effects",
747
778
  indent: " ",
748
779
  statePrefix: "this.state",
749
780
  }),
750
781
  );
751
782
  }
752
- if (doc.style && typeof doc.style === "object") {
783
+ if (isNonNullObject(doc.style)) {
753
784
  const dynamicStyles: [string, string][] = [];
754
785
  for (const [prop, value] of Object.entries(doc.style)) {
755
786
  if (
@@ -761,12 +792,9 @@ export function emitElementModule(
761
792
  ) {
762
793
  continue;
763
794
  }
764
- if (value === null || typeof value === "object") {
765
- continue;
766
- }
767
- const cssProp = camelToKebab(prop);
768
- if (typeof value === "string" && value.includes("${")) {
769
- dynamicStyles.push([cssProp, value]);
795
+ // Only a template declaration is dynamic; nested blocks and literal values are static CSS.
796
+ if (isString(value) && value.includes("${")) {
797
+ dynamicStyles.push([camelToKebab(prop), value]);
770
798
  }
771
799
  }
772
800
  if (dynamicStyles.length > 0) {
@@ -781,19 +809,17 @@ export function emitElementModule(
781
809
  lines.push(" }));");
782
810
  }
783
811
  }
784
- const hasSlot = treeHasSlot(doc.children);
785
812
  /*
786
- * The slot emulation is a light-DOM construction: save the host's children, render over them,
787
- * then splice them back where the literal `<slot>` element landed. Inside a shadow root that
813
+ * The slot emulation is a light-DOM construction: capture the host's children, render over them,
814
+ * then replace each literal `<slot>` with its group (spec.md §8.5). Inside a shadow root that
788
815
  * would be actively wrong — the light children ARE the slotted content and must stay in the
789
816
  * light tree, where a real `<slot>` distributes them. This is the single largest behavioural
790
- * difference between the two modes, and the reason shadow cannot be the default.
817
+ * difference between the two modes, and the reason shadow cannot be the default. Captured before
818
+ * `data-jx-prerendered` goes and before the clear: a prerendered instance's children are its
819
+ * markup, and capture recovers what the prerender placed from its brackets instead.
791
820
  */
792
- if (hasSlot && shadow === null) {
793
- // Save light DOM children (slotted content) before clearing
794
- lines.push(
795
- " const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
796
- );
821
+ if (hasSlot) {
822
+ lines.push(` const _slotted = ${SLOT_CAPTURE_HELPER}(this);`);
797
823
  }
798
824
  lines.push(
799
825
  " if (this.hasAttribute('data-jx-prerendered')) {",
@@ -834,15 +860,9 @@ export function emitElementModule(
834
860
  " this.#effects.push(effect(() => render(this.template(), _root)));",
835
861
  );
836
862
  }
837
- if (hasSlot && shadow === null) {
838
- // Replace <slot> placeholder with saved slotted content
839
- lines.push(
840
- " const _slot = this.querySelector('slot');",
841
- " if (_slot && _slotted.length > 0) {",
842
- " for (const n of _slotted) _slot.before(n);",
843
- " _slot.remove();",
844
- " }",
845
- );
863
+ if (hasSlot) {
864
+ // Once, after the first render: a slot a later render draws stays literal (spec.md §8.5).
865
+ lines.push(` ${SLOT_DISTRIBUTE_HELPER}(this, _slotted);`);
846
866
  }
847
867
  lines.push(
848
868
  // Lifecycle: onMount (spec §16.4) — same microtask contract as the runtime
@@ -922,38 +942,50 @@ function emitLitChildren(
922
942
  preformatted = false,
923
943
  inMap = false,
924
944
  scope: HoistScope | null = null,
945
+ /** The children are an instance's, so each child part ends at a guard rather than a sibling. */
946
+ guardParts = false,
925
947
  ) {
926
948
  if (!children) {
927
949
  return "";
928
950
  }
929
951
 
952
+ const guard = guardParts ? LIT_PART_GUARD : "";
953
+
930
954
  // Legacy whole-children repeater: the array IS the children slot.
931
955
  if (isMappedArray(children)) {
932
- return emitMappedArray(children, indent);
956
+ return `${emitMappedArray(children, indent)}${guard}`;
933
957
  }
934
958
 
935
959
  if (!Array.isArray(children)) {
936
960
  return "";
937
961
  }
938
962
 
939
- // Mixed children: elements/text plus array pseudo-elements expanded inline among siblings.
963
+ /* Mixed children: elements/text plus array pseudo-elements expanded inline among siblings. Among
964
+ an instance's children, a child that emits a lit child part — a binding, a repeater, a chosen
965
+ tag — is followed by a guard: distribution moves its neighbours, and a part that ended at one
966
+ would clear its new parent on the next update (`LIT_PART_GUARD`). */
940
967
  return children
941
- .map((child: JxMutableNode | string) =>
942
- isMappedArray(child)
943
- ? emitMappedArray(child, indent)
944
- : emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
945
- )
968
+ .map((child) => {
969
+ if (isMappedArray(child)) {
970
+ return `${emitMappedArray(child, indent)}${guard}`;
971
+ }
972
+ const text = emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope);
973
+ const isPart =
974
+ (isString(child) && child.includes("${")) ||
975
+ (isNonNullObject(child) && isTagExpression(child.tagName));
976
+ return isPart ? `${text}${guard}` : text;
977
+ })
946
978
  .join(preformatted ? "" : "\n");
947
979
  }
948
980
 
949
981
  /**
950
- * @param {JxMutableNode | string} def
982
+ * @param {LitChild} def
951
983
  * @param {string} indent
952
984
  * @param {boolean} [preformatted]
953
985
  * @returns {string}
954
986
  */
955
987
  function emitLitNode(
956
- def: JxMutableNode | string,
988
+ def: LitChild,
957
989
  indent: string,
958
990
  preformatted = false,
959
991
  inMap = false,
@@ -962,16 +994,16 @@ function emitLitNode(
962
994
  innerOverride: string | null = null,
963
995
  ): string {
964
996
  // String children are text nodes
965
- if (typeof def === "string") {
997
+ if (isString(def)) {
966
998
  if (def.includes("${")) {
967
999
  return `${indent}${toLitTextContent(def)}`;
968
1000
  }
969
1001
  return `${indent}${escapeHtml(def)}`;
970
1002
  }
971
- if (typeof def === "number" || typeof def === "boolean") {
1003
+ if (isNumber(def) || isBoolean(def)) {
972
1004
  return `${indent}${escapeHtml(String(def))}`;
973
1005
  }
974
- if (!def || typeof def !== "object") {
1006
+ if (!isNonNullObject(def)) {
975
1007
  return "";
976
1008
  }
977
1009
 
@@ -992,7 +1024,7 @@ function emitLitNode(
992
1024
  eventParam: "e",
993
1025
  statePrefix: "s",
994
1026
  });
995
- const branches =
1027
+ const branches: [string | boolean, string][] =
996
1028
  expression.operator === "?:"
997
1029
  ? [
998
1030
  [true, expression.value],
@@ -1002,17 +1034,24 @@ function emitLitNode(
1002
1034
  // The inner content is identical across candidates — only the tag differs — so it is emitted
1003
1035
  // Once and every branch references it. Skipped when the candidates disagree about being
1004
1036
  // Preformatted, because `white-space` decides how the subtree itself is indented.
1005
- const candidateTags = branches.map(([, candidate]) => String(candidate));
1037
+ const candidateTags = branches.map(([, candidate]) => candidate);
1006
1038
  const sharedPre = candidateTags.map((t) => preformatted || PREFORMATTED_TAGS.has(t));
1007
1039
  let innerRef: string | null = null;
1008
1040
  if (scope && sharedPre.every((p) => p === sharedPre[0])) {
1009
- const sharedInner = emitNodeInner(def, `${indent} `, sharedPre[0] === true, inMap, scope);
1041
+ const sharedInner = emitNodeInner(
1042
+ def,
1043
+ `${indent} `,
1044
+ sharedPre[0] === true,
1045
+ inMap,
1046
+ scope,
1047
+ candidateTags.some((t) => t.includes("-")),
1048
+ );
1010
1049
  if (sharedInner.trim() !== "") {
1011
1050
  innerRef = hoistSubtree(scope, sharedInner);
1012
1051
  }
1013
1052
  }
1014
- const rendered: [string | boolean | undefined, string][] = branches.map(([key, candidate]) => {
1015
- const asLiteral = { ...def, tagName: candidate as string };
1053
+ const rendered: [string | boolean, string][] = branches.map(([key, candidate]) => {
1054
+ const asLiteral = { ...def, tagName: candidate };
1016
1055
  return [key, emitLitNode(asLiteral, `${indent} `, preformatted, inMap, scope, innerRef)];
1017
1056
  });
1018
1057
  if (expression.operator === "?:") {
@@ -1026,9 +1065,9 @@ ${yes}
1026
1065
  ${no}
1027
1066
  \`}`;
1028
1067
  }
1029
- const entries = rendered
1030
- .filter(([key]) => key !== "__default__")
1031
- .map(([key, text]) => ` ${JSON.stringify(key)}: html\`\n${text}\n \``);
1068
+ const entries = rendered.flatMap(([key, text]) =>
1069
+ key === "__default__" ? [] : [` ${JSON.stringify(key)}: html\`\n${text}\n \``],
1070
+ );
1032
1071
  const [, fallback] = rendered.at(-1)!;
1033
1072
  return (
1034
1073
  `${indent}${"$"}{{\n${entries.join(",\n")}\n}` +
@@ -1049,17 +1088,17 @@ ${no}
1049
1088
  §8.3). A LITERAL boolean is decided now; a BOUND one cannot be, because its resolved type
1050
1089
  is not known until the effect runs — so it goes through the emitted helper, whose `null`
1051
1090
  becomes lit's `nothing` and takes the attribute off. */
1052
- if (val && typeof val === "object" && isRef(val)) {
1091
+ if (isRef(val)) {
1053
1092
  parts.push(`${key}="\${${ATTR_HELPER}('${key}', ${refToExpr(val.$ref)}) ?? nothing}"`);
1054
- } else if (typeof val === "string" && isWholeTemplate(val)) {
1093
+ } else if (isString(val) && isWholeTemplate(val)) {
1055
1094
  parts.push(
1056
1095
  `${key}="\${${ATTR_HELPER}('${key}', ${toLitExpr(val).slice(2, -1)}) ?? nothing}"`,
1057
1096
  );
1058
- } else if (typeof val === "string" && val.includes("${")) {
1097
+ } else if (isString(val) && val.includes("${")) {
1059
1098
  /* A template with literal text around it — `"page ${n}"` — is a string by construction, so
1060
1099
  there is no boolean to decide and wrapping it would only add a call. */
1061
1100
  parts.push(`${key}="${toLitExpr(val)}"`);
1062
- } else if (typeof val === "boolean") {
1101
+ } else if (isBoolean(val)) {
1063
1102
  const text = booleanAttrValue(key, val);
1064
1103
  if (text !== null) {
1065
1104
  parts.push(text === "" ? key : `${key}="${text}"`);
@@ -1097,9 +1136,9 @@ ${no}
1097
1136
  continue;
1098
1137
  }
1099
1138
 
1100
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1101
- parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
1102
- } else if (typeof val === "string" && val.includes("${")) {
1139
+ if (isBoundRef(val)) {
1140
+ parts.push(`.${key}="\${${refToExpr(val.$ref)}}"`);
1141
+ } else if (isString(val) && val.includes("${")) {
1103
1142
  parts.push(`.${key}="${toLitExpr(val)}"`);
1104
1143
  } else {
1105
1144
  // A literal IDL value (`value: "a"`, `checked: true`) is still a property the author asked
@@ -1112,9 +1151,9 @@ ${no}
1112
1151
 
1113
1152
  if (def.$props) {
1114
1153
  for (const [key, val] of Object.entries(def.$props)) {
1115
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1116
- parts.push(`.${key}="\${${refToExpr((val as JxMutableNode).$ref as string)}}"`);
1117
- } else if (typeof val === "string" && val.includes("${")) {
1154
+ if (isBoundRef(val)) {
1155
+ parts.push(`.${key}="\${${refToExpr(val.$ref)}}"`);
1156
+ } else if (isString(val) && val.includes("${")) {
1118
1157
  // A template value is a binding, not text. JSON-quoting it emitted the template's own source
1119
1158
  // As the prop value (`.label="${"${state.x}"}"`), so the component received the literal
1120
1159
  // String `${state.x}` forever.
@@ -1134,20 +1173,20 @@ ${no}
1134
1173
  continue;
1135
1174
  }
1136
1175
  const eventName = key.slice(2).toLowerCase();
1137
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1176
+ if (isBoundRef(val)) {
1138
1177
  parts.push(
1139
1178
  mapCtx
1140
- ? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr((val as JxMutableNode).$ref as string)}(s, e); }}"`
1141
- : `@${eventName}="\${(e) => ${refToExpr((val as JxMutableNode).$ref as string)}(s, e)}"`,
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
- },
1179
+ ? `@${eventName}="\${(e) => { ${mapCtx}${refToExpr(val.$ref)}(s, e); }}"`
1180
+ : `@${eventName}="\${(e) => ${refToExpr(val.$ref)}(s, e)}"`,
1150
1181
  );
1182
+ } else if (isNonNullObject(val) && "$expression" in val) {
1183
+ /* SAFETY: an `on*` binding object carrying `$expression` is a declarative handler, and the
1184
+ document schema types that member as an expression node (spec.md §19). It is passed on
1185
+ unvalidated, exactly as authored, so a malformed node fails inside compileExpression. */
1186
+ const compiled = compileExpression(val.$expression as JxExpressionNode, {
1187
+ eventParam: "e",
1188
+ statePrefix: "s",
1189
+ });
1151
1190
  parts.push(`@${eventName}="\${(e) => { ${mapCtx}${compiled}; }}"`);
1152
1191
  } else if (isFunctionDef(val)) {
1153
1192
  parts.push(`@${eventName}="\${(e) => { ${mapCtx}${inlineHandlerBody(val)} }}"`);
@@ -1166,12 +1205,18 @@ ${no}
1166
1205
  return `${indent}<${tag}${attrsStr}\n${indent}>`;
1167
1206
  }
1168
1207
 
1169
- const inner = innerOverride ?? emitNodeInner(def, indent, inPre, inMap, scope);
1208
+ // A custom element's children are distributed into its slots, so their parts need guards.
1209
+ const inner = innerOverride ?? emitNodeInner(def, indent, inPre, inMap, scope, tag.includes("-"));
1210
+
1211
+ /* A `<slot>` is replaced after the first render, so no part may end at it or at its last child:
1212
+ one guard before the slot (where an adjacent part ends) and one before `</slot>` (where a last
1213
+ fallback binding ends, and which travels with the fallback when it is shown). */
1214
+ const guard = tag === "slot" ? LIT_PART_GUARD : "";
1170
1215
 
1171
1216
  // Inside a <pre>, the newline before `>` would land in the element's own text.
1172
1217
  return inPre
1173
- ? `<${tag}${attrsStr}>${inner}</${tag}>`
1174
- : `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
1218
+ ? `${guard}<${tag}${attrsStr}>${inner}${guard}</${tag}>`
1219
+ : `${indent}${guard}<${tag}${attrsStr}\n${indent}>${inner}${guard}</${tag}>`;
1175
1220
  }
1176
1221
 
1177
1222
  /**
@@ -1193,13 +1238,15 @@ function emitNodeInner(
1193
1238
  inPre: boolean,
1194
1239
  inMap: boolean,
1195
1240
  scope: HoistScope | null,
1241
+ /** `def` is a custom element, whose children are distributed: see `emitLitChildren`. */
1242
+ guardParts = false,
1196
1243
  ): string {
1197
1244
  if (def.$switch) {
1198
- const switchRef = isRef(def.$switch) ? def.$switch.$ref : (def.$switch as string);
1245
+ const switchRef = isRef(def.$switch) ? def.$switch.$ref : def.$switch;
1199
1246
  const switchExpr = refToExpr(switchRef);
1200
- const cases = (def.cases ?? {}) as Record<string, JxMutableNode>;
1201
1247
  const rendered: [string, string][] = [];
1202
- for (const [key, caseDef] of Object.entries(cases)) {
1248
+ // A case value is document data, so a `null` or empty one can reach here and is skipped.
1249
+ for (const [key, caseDef] of Object.entries<LitChild>(def.cases ?? {})) {
1203
1250
  if (!caseDef || isRef(caseDef)) {
1204
1251
  continue;
1205
1252
  }
@@ -1207,10 +1254,9 @@ function emitNodeInner(
1207
1254
  }
1208
1255
  // Cases that emit byte-identical subtrees share one hoisted `const` rather than repeating it
1209
1256
  // Once per case — the authored document names the subtree once (#126).
1257
+ const texts = rendered.map(([, text]) => text);
1210
1258
  const repeated = new Set(
1211
- rendered
1212
- .map(([, text]) => text)
1213
- .filter((text, i, all) => all.indexOf(text) !== i && text.trim() !== ""),
1259
+ texts.filter((text, i) => texts.indexOf(text) !== i && text.trim() !== ""),
1214
1260
  );
1215
1261
  const caseEntries = rendered.map(([key, text]) => {
1216
1262
  const body = scope && repeated.has(text) ? hoistSubtree(scope, text) : text;
@@ -1219,15 +1265,18 @@ function emitNodeInner(
1219
1265
  return `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
1220
1266
  }
1221
1267
  if (def.textContent !== undefined) {
1222
- return toLitTextContent(def.textContent);
1268
+ const text = toLitTextContent(def.textContent);
1269
+ const bound =
1270
+ isRef(def.textContent) || (isString(def.textContent) && def.textContent.includes("${"));
1271
+ return guardParts && bound ? `${text}${LIT_PART_GUARD}` : text;
1223
1272
  }
1224
1273
  if (def.innerHTML !== undefined) {
1225
1274
  return def.innerHTML;
1226
1275
  }
1227
1276
  if (def.children) {
1228
1277
  return inPre
1229
- ? emitLitChildren(def.children, def.style, "", true, inMap, scope)
1230
- : `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap, scope)}\n${indent}`;
1278
+ ? emitLitChildren(def.children, def.style, "", true, inMap, scope, guardParts)
1279
+ : `\n${emitLitChildren(def.children, def.style, `${indent} `, false, inMap, scope, guardParts)}\n${indent}`;
1231
1280
  }
1232
1281
  return "";
1233
1282
  }
@@ -1252,9 +1301,9 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1252
1301
  // `${item…}`/`${index…}` templates resolve against the map callback's own parameters.
1253
1302
  if (mapDef.attributes) {
1254
1303
  for (const [key, val] of Object.entries(mapDef.attributes)) {
1255
- if (val && typeof val === "object" && isRef(val)) {
1304
+ if (isRef(val)) {
1256
1305
  parts.push(`${key}="\${${mapRefToExpr(val.$ref)}}"`);
1257
- } else if (typeof val === "string" && val.includes("${")) {
1306
+ } else if (isString(val) && val.includes("${")) {
1258
1307
  parts.push(`${key}="${toLitExpr(val)}"`);
1259
1308
  } else {
1260
1309
  parts.push(`${key}="${val}"`);
@@ -1290,9 +1339,9 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1290
1339
  continue;
1291
1340
  }
1292
1341
 
1293
- if (val && typeof val === "object" && (val as JxMutableNode).$ref) {
1294
- parts.push(`.${key}="\${${mapRefToExpr((val as JxMutableNode).$ref as string)}}"`);
1295
- } else if (typeof val === "string" && val.includes("${")) {
1342
+ if (isBoundRef(val)) {
1343
+ parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
1344
+ } else if (isString(val) && val.includes("${")) {
1296
1345
  parts.push(`.${key}="${toLitExpr(val)}"`);
1297
1346
  } else {
1298
1347
  parts.push(`.${key}="\${${JSON.stringify(val)}}"`);
@@ -1303,7 +1352,7 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1303
1352
  for (const [key, val] of Object.entries(mapDef.$props)) {
1304
1353
  if (isRef(val)) {
1305
1354
  parts.push(`.${key}="\${${mapRefToExpr(val.$ref)}}"`);
1306
- } else if (typeof val === "string" && val.includes("${")) {
1355
+ } else if (isString(val) && val.includes("${")) {
1307
1356
  // `$props: { label: "${$map.item.name}" }` is the ordinary way to pass per-item data to a
1308
1357
  // Component in a list; JSON-quoting it handed over the template source instead.
1309
1358
  parts.push(`.${key}="${toLitExpr(val)}"`);
@@ -1326,7 +1375,7 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1326
1375
  if (isRef(val)) {
1327
1376
  parts.push(`@${eventName}="\${(e) => { s.$map = $map; ${refToExpr(val.$ref)}(s, e); }}"`);
1328
1377
  } else if (isExpressionDef(val)) {
1329
- const compiled = compileExpression(val.$expression as ExpressionNode, {
1378
+ const compiled = compileExpression(val.$expression, {
1330
1379
  eventParam: "e",
1331
1380
  statePrefix: "s",
1332
1381
  });
@@ -1366,6 +1415,17 @@ function emitMappedArray(arrayDef: JxMappedArray, indent: string) {
1366
1415
  return `${indent}\${${itemsExpr}.map(${callback})}`;
1367
1416
  }
1368
1417
 
1418
+ /**
1419
+ * Whether a document value binds through a non-empty `$ref` pointer. An empty pointer names no
1420
+ * state, so the emitters treat that object as a literal value rather than a binding.
1421
+ *
1422
+ * @param {JxDocumentValue | undefined} value
1423
+ * @returns {boolean}
1424
+ */
1425
+ function isBoundRef(value: JxDocumentValue | undefined): value is JxRef {
1426
+ return isRef(value) && value.$ref !== "";
1427
+ }
1428
+
1369
1429
  /**
1370
1430
  * Convert a $ref string to a JS expression using `s` (this.state alias).
1371
1431
  *
@@ -1445,19 +1505,15 @@ function isWholeTemplate(str: string): boolean {
1445
1505
  * Convert textContent value to lit-html text content. Bug fix: handles $ref objects, which
1446
1506
  * previously produced [object Object].
1447
1507
  *
1448
- * @param {unknown} value
1508
+ * @param {string | null | JxRef} value
1449
1509
  * @returns {string}
1450
1510
  */
1451
- function toLitTextContent(value: unknown) {
1511
+ function toLitTextContent(value: string | null | JxRef) {
1452
1512
  // Handle $ref objects → emit as lit expression
1453
- if (
1454
- value !== null &&
1455
- typeof value === "object" &&
1456
- typeof (value as JxMutableNode).$ref === "string"
1457
- ) {
1458
- return `\${${refToExpr((value as JxMutableNode).$ref as string)}}`;
1459
- }
1460
- if (typeof value === "string" && value.includes("${")) {
1513
+ if (isRef(value)) {
1514
+ return `\${${refToExpr(value.$ref)}}`;
1515
+ }
1516
+ if (isString(value) && value.includes("${")) {
1461
1517
  return toLitExpr(value);
1462
1518
  }
1463
1519
  return String(value);
@@ -1475,7 +1531,7 @@ function inlineHandlerBody(def: JxFunctionDef) {
1475
1531
  statePrefix: "s",
1476
1532
  });
1477
1533
  }
1478
- const body = typeof def.body === "string" ? def.body : "";
1534
+ const body = isString(def.body) ? def.body : "";
1479
1535
  const declared = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
1480
1536
  if (declared.length > 0) {
1481
1537
  // The call site supplies only `(e)`, so map the declared names onto the arguments the handler
@@ -1493,7 +1549,7 @@ function inlineHandlerBody(def: JxFunctionDef) {
1493
1549
  * @returns {string}
1494
1550
  */
1495
1551
  function emitStyleString(styleDef: JxStyle | null | undefined) {
1496
- if (!styleDef || typeof styleDef !== "object") {
1552
+ if (!isNonNullObject(styleDef)) {
1497
1553
  return "";
1498
1554
  }
1499
1555
 
@@ -1509,11 +1565,8 @@ function emitStyleString(styleDef: JxStyle | null | undefined) {
1509
1565
  continue;
1510
1566
  }
1511
1567
 
1512
- if (value === null || typeof value === "object") {
1513
- continue;
1514
- }
1515
-
1516
- if (typeof value === "string" && value.includes("${")) {
1568
+ // Only a template declaration is dynamic; nested blocks and literal values are static CSS.
1569
+ if (isString(value) && value.includes("${")) {
1517
1570
  const cssProp = camelToKebab(prop);
1518
1571
  parts.push(`${cssProp}: ${toLitExpr(value)}`);
1519
1572
  }
@@ -1521,27 +1574,3 @@ function emitStyleString(styleDef: JxStyle | null | undefined) {
1521
1574
 
1522
1575
  return parts.join("; ");
1523
1576
  }
1524
-
1525
- /**
1526
- * Check if a children tree contains a `<slot>` element.
1527
- *
1528
- * @param {JxMutableNode["children"]} children
1529
- * @returns {boolean}
1530
- */
1531
- function treeHasSlot(children: JxMutableNode["children"]): boolean {
1532
- if (!Array.isArray(children)) {
1533
- return false;
1534
- }
1535
- for (const child of children) {
1536
- if (!child || typeof child !== "object") {
1537
- continue;
1538
- }
1539
- if (child.tagName === "slot") {
1540
- return true;
1541
- }
1542
- if (treeHasSlot(child.children)) {
1543
- return true;
1544
- }
1545
- }
1546
- return false;
1547
- }