@jxsuite/compiler 5.1.3 → 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.
@@ -69,7 +69,7 @@ import type { AssetMount } from "@jxsuite/schema/asset-paths";
69
69
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
70
70
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
71
71
  import { resolveLayout } from "./layout-resolver.ts";
72
- import { mergeHead, renderHead } from "@jxsuite/site/head-merger";
72
+ import { mergeHead, renderHead, withPageDescription } from "@jxsuite/site/head-merger";
73
73
  import { unregisteredHeadRelations } from "./link-relations.ts";
74
74
  import { injectContext } from "./context-injection.ts";
75
75
  import { compile, compileServer, compileSiteServer } from "../compiler.ts";
@@ -84,18 +84,22 @@ import {
84
84
  collectServerEntries,
85
85
  collectStyles,
86
86
  colorSchemePrePaintScript,
87
+ createSlotFill,
88
+ definitionHasSlot,
87
89
  escapeStyleText,
88
90
  evaluateStaticTemplate,
89
91
  isComponentFullyStatic,
90
92
  isSingleExpression,
91
93
  liftPropsAttributes,
94
+ nextSlotId,
92
95
  preRenderComponentHtml,
93
96
  pureSchemeOf,
94
97
  renderStaticNode,
95
98
  resolveHostStyle,
96
99
  resolveRefValue,
97
100
  } from "../shared.ts";
98
- import type { ComponentPrerenderContext } from "../shared.ts";
101
+ import type { ComponentPrerenderContext, StaticChild } from "../shared.ts";
102
+ import { SLOT_ATTR } from "@jxsuite/runtime";
99
103
  import { resolvePrototypes } from "./prototype-resolver.ts";
100
104
  import { transformImageNodes } from "./image-transform.ts";
101
105
  import { collectCspSources, emptyCspSources } from "./csp.ts";
@@ -1343,7 +1347,10 @@ async function compilePage(
1343
1347
  // Extract head arrays before they get lost in the merge
1344
1348
  /* SAFETY: `_pageHead` is written only by `resolveLayout`, which copies the page's `$head` (a
1345
1349
  JxHeadEntry[]) into it when the page has one. */
1346
- const pageHead = (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[];
1350
+ const pageHead = withPageDescription(
1351
+ (pageDoc.$head ?? layoutDoc._pageHead ?? []) as JxHeadEntry[],
1352
+ pageDoc.description,
1353
+ );
1347
1354
  const layoutHead = layoutDoc.$head ?? [];
1348
1355
  /* SAFETY: `_pageTitle` is written only by `resolveLayout`, which copies the page's `title` (a
1349
1356
  string) into it when the page has one. */
@@ -1423,7 +1430,9 @@ async function compilePage(
1423
1430
  media: { ...projectConfig.$media, ...layoutDoc.$media },
1424
1431
  rules: [],
1425
1432
  };
1426
- expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults);
1433
+ // One counter per page, so every `data-jx-slots` id on it is unique (spec.md §8.5).
1434
+ const slotIds = { n: 0 };
1435
+ expandComponents(layoutDoc, componentDefs, slotCss, projectConfig.defaults, slotIds);
1427
1436
 
1428
1437
  // Strip resolved timing: "compiler" state entries — they're now baked into the tree
1429
1438
  // And keeping them would cause isDynamic() to misclassify the page as dynamic.
@@ -2208,19 +2217,21 @@ interface SlotCssCollector {
2208
2217
  * @param {Map<string, JxElement>} componentDefs
2209
2218
  * @param {SlotCssCollector} [slotCss]
2210
2219
  * @param {ProjectConfig["defaults"]} [defaults] - Read for `defaults.shadow` (spec.md §16.6)
2220
+ * @param {{ n: number }} [slotIds] - The page's `data-jx-slots` counter
2211
2221
  */
2212
2222
  function expandComponents(
2213
2223
  node: JxElement | string | (JxElement | string)[],
2214
2224
  componentDefs: Map<string, JxElement>,
2215
2225
  slotCss?: SlotCssCollector,
2216
2226
  defaults?: ProjectConfig["defaults"],
2227
+ slotIds?: { n: number },
2217
2228
  ) {
2218
2229
  if (!isNonNullObject(node)) {
2219
2230
  return;
2220
2231
  }
2221
2232
  if (Array.isArray(node)) {
2222
2233
  for (const n of node) {
2223
- expandComponents(n, componentDefs, slotCss, defaults);
2234
+ expandComponents(n, componentDefs, slotCss, defaults, slotIds);
2224
2235
  }
2225
2236
  return;
2226
2237
  }
@@ -2228,7 +2239,7 @@ function expandComponents(
2228
2239
  // Recurse into children first (bottom-up expansion)
2229
2240
  if (Array.isArray(node.children)) {
2230
2241
  for (const child of node.children) {
2231
- expandComponents(child, componentDefs, slotCss, defaults);
2242
+ expandComponents(child, componentDefs, slotCss, defaults, slotIds);
2232
2243
  }
2233
2244
  }
2234
2245
 
@@ -2256,17 +2267,12 @@ function expandComponents(
2256
2267
  * was given. Instances written inside `def` itself are another matter — those are reached
2257
2268
  * below, through `preRenderComponentHtml`, with `node` as the first frame of the path.
2258
2269
  */
2259
- const slotContent =
2260
- Array.isArray(node.children) && node.children.length > 0
2261
- ? node.children
2262
- .map((c: JxElement | string) => {
2263
- if (slotCss && isNonNullObject(c)) {
2264
- collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
2265
- }
2266
- return renderStaticNode(c, {}, null);
2267
- })
2268
- .join("\n")
2269
- : null;
2270
+ const renderSlotted = (c: StaticChild) => {
2271
+ if (slotCss && isNonNullObject(c) && !Array.isArray(c)) {
2272
+ collectStyles(c, slotCss.rules, slotCss.media, "", slotCss.counter, "jxs");
2273
+ }
2274
+ return renderStaticNode(c, {}, null);
2275
+ };
2270
2276
 
2271
2277
  /*
2272
2278
  * In shadow mode the slotted children are NOT substituted into the prerendered markup: they
@@ -2282,9 +2288,28 @@ function expandComponents(
2282
2288
  componentDefs,
2283
2289
  defaults,
2284
2290
  path: [{ key: JSON.stringify(props ?? {}), tag: tagName }],
2291
+ slotIds,
2285
2292
  };
2286
- const innerHTML = preRenderComponentHtml(def, props, shadow ? null : slotContent, context);
2287
2293
  const isStatic = isComponentFullyStatic(def);
2294
+ const { children } = node;
2295
+ /* Light mode places the children by slot name (spec.md §8.5). A live instance whose definition
2296
+ has a slot is stamped and its groups bracketed, so its element recovers them on upgrade. */
2297
+ const slotId =
2298
+ !shadow && !isStatic && definitionHasSlot(def.children) ? nextSlotId(context) : null;
2299
+ const slotContent = shadow
2300
+ ? (Array.isArray(children) ? children : [])
2301
+ .map((c: StaticChild) => renderSlotted(c))
2302
+ .join("\n")
2303
+ : null;
2304
+ const innerHTML = preRenderComponentHtml(
2305
+ def,
2306
+ props,
2307
+ shadow ? null : createSlotFill(children, {}, renderSlotted, slotId),
2308
+ context,
2309
+ );
2310
+ if (slotId !== null) {
2311
+ node.attributes = { ...node.attributes, [SLOT_ATTR]: String(slotId) };
2312
+ }
2288
2313
 
2289
2314
  /*
2290
2315
  * A declarative shadow root: markup the parser materializes before any script runs, which the
@@ -32,10 +32,12 @@ import {
32
32
  isRefObject,
33
33
  isSchemaOnly,
34
34
  isTemplateString,
35
+ LIT_PART_GUARD,
35
36
  PREFORMATTED_TAGS,
36
37
  pureSchemeOf,
37
38
  resolveStaticValue,
38
39
  srcImportBinding,
40
+ templateValueSource,
39
41
  } from "../shared.ts";
40
42
  import {
41
43
  bodyReturnsValue,
@@ -141,7 +143,7 @@ export function compileClient(
141
143
  // Naked primitive or array → reactive state
142
144
  if (isTemplateString(def)) {
143
145
  // Template string → computed on state so other computeds can ref it
144
- computedEntries.push([key, `() => \`${def}\``]);
146
+ computedEntries.push([key, `() => ${templateValueSource(def)}`]);
145
147
  } else {
146
148
  stateEntries.push([key, def]);
147
149
  }
@@ -776,15 +778,19 @@ function emitLitMapTemplate(def: JxMutableNode | null | undefined, preformatted
776
778
  attrs += ` contenteditable="${def.contentEditable}"`;
777
779
  }
778
780
 
781
+ /* A row can hold a component instance, whose children its slots redistribute; a child part
782
+ there ends at a guard rather than at a sibling that may move (`LIT_PART_GUARD`). */
783
+ const guard = tag.includes("-") ? LIT_PART_GUARD : "";
784
+
779
785
  // Inner content
780
786
  let inner = "";
781
787
  if (def.textContent !== undefined) {
782
788
  const tc = String(def.textContent);
783
789
  if (isTemplateString(tc)) {
784
- inner = mapRefsToLit(tc);
790
+ inner = `${mapRefsToLit(tc)}${guard}`;
785
791
  } else if (isRefObject(def.textContent)) {
786
792
  const path = refToBindingKey(def.textContent.$ref);
787
- inner = `\${state.${path}}`;
793
+ inner = `\${state.${path}}${guard}`;
788
794
  } else {
789
795
  inner = escapeHtml(tc);
790
796
  }
@@ -792,9 +798,13 @@ function emitLitMapTemplate(def: JxMutableNode | null | undefined, preformatted
792
798
  inner = mapRefsToLit(String(def.innerHTML));
793
799
  } else if (isMappedArray(def.children)) {
794
800
  // Legacy whole-children repeater nested inside a map template.
795
- inner = `\n ${emitArrayHole(def.children)}\n `;
801
+ inner = `\n ${emitArrayHole(def.children)}${guard}\n `;
796
802
  } else if (Array.isArray(def.children)) {
797
- const rendered = def.children.map((c) => emitChildLit(c, inPre));
803
+ const rendered = def.children.map((c) => {
804
+ const text = emitChildLit(c, inPre);
805
+ const isPart = isMappedArray(c) || (isString(c) && isTemplateString(c));
806
+ return isPart ? `${text}${guard}` : text;
807
+ });
798
808
  inner = inPre ? rendered.join("") : `\n ${rendered.join("\n ")}\n `;
799
809
  }
800
810
 
@@ -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,6 +28,7 @@ import {
23
28
  srcExportName,
24
29
  srcImportBinding,
25
30
  tagNameToClassName,
31
+ templateValueSource,
26
32
  } from "../shared.ts";
27
33
  import {
28
34
  bodyReturnsValue,
@@ -436,6 +442,13 @@ export function emitElementModule(
436
442
  `import { render, html, nothing } from 'lit-html';`,
437
443
  attrHelperSource(),
438
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
+ }
439
452
 
440
453
  /*
441
454
  * `$lazy` swaps the static import for a memoized dynamic one. A static import is a download, a
@@ -620,13 +633,14 @@ export function emitElementModule(
620
633
  );
621
634
  }
622
635
 
623
- /* Emitted with the same `state.` → `this.state.` rewrite the inline-body path below uses, into a
624
- template literal — which is what `evaluateTemplate` is, so the result is a string on both
625
- 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. */
626
640
  for (const [key, template] of templateEntries) {
627
641
  lines.push(
628
642
  "",
629
- ` ${refAccessor("this.state", escapeToken(key))} = computed(() => \`${template.replaceAll("state.", "this.state.")}\`);`,
643
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${templateValueSource(template.replaceAll("state.", "this.state."))});`,
630
644
  );
631
645
  }
632
646
 
@@ -795,19 +809,17 @@ export function emitElementModule(
795
809
  lines.push(" }));");
796
810
  }
797
811
  }
798
- const hasSlot = treeHasSlot(doc.children);
799
812
  /*
800
- * The slot emulation is a light-DOM construction: save the host's children, render over them,
801
- * 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
802
815
  * would be actively wrong — the light children ARE the slotted content and must stay in the
803
816
  * light tree, where a real `<slot>` distributes them. This is the single largest behavioural
804
- * 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.
805
820
  */
806
- if (hasSlot && shadow === null) {
807
- // Save light DOM children (slotted content) before clearing
808
- lines.push(
809
- " const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
810
- );
821
+ if (hasSlot) {
822
+ lines.push(` const _slotted = ${SLOT_CAPTURE_HELPER}(this);`);
811
823
  }
812
824
  lines.push(
813
825
  " if (this.hasAttribute('data-jx-prerendered')) {",
@@ -848,15 +860,9 @@ export function emitElementModule(
848
860
  " this.#effects.push(effect(() => render(this.template(), _root)));",
849
861
  );
850
862
  }
851
- if (hasSlot && shadow === null) {
852
- // Replace <slot> placeholder with saved slotted content
853
- lines.push(
854
- " const _slot = this.querySelector('slot');",
855
- " if (_slot && _slotted.length > 0) {",
856
- " for (const n of _slotted) _slot.before(n);",
857
- " _slot.remove();",
858
- " }",
859
- );
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);`);
860
866
  }
861
867
  lines.push(
862
868
  // Lifecycle: onMount (spec §16.4) — same microtask contract as the runtime
@@ -936,27 +942,39 @@ function emitLitChildren(
936
942
  preformatted = false,
937
943
  inMap = false,
938
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,
939
947
  ) {
940
948
  if (!children) {
941
949
  return "";
942
950
  }
943
951
 
952
+ const guard = guardParts ? LIT_PART_GUARD : "";
953
+
944
954
  // Legacy whole-children repeater: the array IS the children slot.
945
955
  if (isMappedArray(children)) {
946
- return emitMappedArray(children, indent);
956
+ return `${emitMappedArray(children, indent)}${guard}`;
947
957
  }
948
958
 
949
959
  if (!Array.isArray(children)) {
950
960
  return "";
951
961
  }
952
962
 
953
- // 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`). */
954
967
  return children
955
- .map((child) =>
956
- isMappedArray(child)
957
- ? emitMappedArray(child, indent)
958
- : emitLitNode(child, preformatted ? "" : indent, preformatted, inMap, scope),
959
- )
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
+ })
960
978
  .join(preformatted ? "" : "\n");
961
979
  }
962
980
 
@@ -1020,7 +1038,14 @@ function emitLitNode(
1020
1038
  const sharedPre = candidateTags.map((t) => preformatted || PREFORMATTED_TAGS.has(t));
1021
1039
  let innerRef: string | null = null;
1022
1040
  if (scope && sharedPre.every((p) => p === sharedPre[0])) {
1023
- 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
+ );
1024
1049
  if (sharedInner.trim() !== "") {
1025
1050
  innerRef = hoistSubtree(scope, sharedInner);
1026
1051
  }
@@ -1180,12 +1205,18 @@ ${no}
1180
1205
  return `${indent}<${tag}${attrsStr}\n${indent}>`;
1181
1206
  }
1182
1207
 
1183
- 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 : "";
1184
1215
 
1185
1216
  // Inside a <pre>, the newline before `>` would land in the element's own text.
1186
1217
  return inPre
1187
- ? `<${tag}${attrsStr}>${inner}</${tag}>`
1188
- : `${indent}<${tag}${attrsStr}\n${indent}>${inner}</${tag}>`;
1218
+ ? `${guard}<${tag}${attrsStr}>${inner}${guard}</${tag}>`
1219
+ : `${indent}${guard}<${tag}${attrsStr}\n${indent}>${inner}${guard}</${tag}>`;
1189
1220
  }
1190
1221
 
1191
1222
  /**
@@ -1207,6 +1238,8 @@ function emitNodeInner(
1207
1238
  inPre: boolean,
1208
1239
  inMap: boolean,
1209
1240
  scope: HoistScope | null,
1241
+ /** `def` is a custom element, whose children are distributed: see `emitLitChildren`. */
1242
+ guardParts = false,
1210
1243
  ): string {
1211
1244
  if (def.$switch) {
1212
1245
  const switchRef = isRef(def.$switch) ? def.$switch.$ref : def.$switch;
@@ -1232,15 +1265,18 @@ function emitNodeInner(
1232
1265
  return `\${{\n${caseEntries.join(",\n")}\n}[${switchExpr}]}`;
1233
1266
  }
1234
1267
  if (def.textContent !== undefined) {
1235
- 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;
1236
1272
  }
1237
1273
  if (def.innerHTML !== undefined) {
1238
1274
  return def.innerHTML;
1239
1275
  }
1240
1276
  if (def.children) {
1241
1277
  return inPre
1242
- ? emitLitChildren(def.children, def.style, "", true, inMap, scope)
1243
- : `\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}`;
1244
1280
  }
1245
1281
  return "";
1246
1282
  }
@@ -1538,27 +1574,3 @@ function emitStyleString(styleDef: JxStyle | null | undefined) {
1538
1574
 
1539
1575
  return parts.join("; ");
1540
1576
  }
1541
-
1542
- /**
1543
- * Check if a children tree contains a `<slot>` element.
1544
- *
1545
- * @param {JxMutableNode["children"]} children
1546
- * @returns {boolean}
1547
- */
1548
- function treeHasSlot(children: JxMutableNode["children"]): boolean {
1549
- if (!Array.isArray(children)) {
1550
- return false;
1551
- }
1552
- for (const child of children) {
1553
- if (!isNonNullObject(child)) {
1554
- continue;
1555
- }
1556
- if (child.tagName === "slot") {
1557
- return true;
1558
- }
1559
- if (treeHasSlot(child.children)) {
1560
- return true;
1561
- }
1562
- }
1563
- return false;
1564
- }