@jxsuite/compiler 5.1.3 → 6.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "5.1.3",
3
+ "version": "6.0.2",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@apidevtools/json-schema-ref-parser": "^16.0.5",
49
- "@jxsuite/create": "^1.3.14",
50
- "@jxsuite/runtime": "^4.1.0",
51
- "@jxsuite/schema": "^3.0.0",
52
- "@jxsuite/site": "^2.0.4",
49
+ "@jxsuite/create": "^1.3.17",
50
+ "@jxsuite/runtime": "^5.1.0",
51
+ "@jxsuite/schema": "^4.0.0",
52
+ "@jxsuite/site": "^2.1.0",
53
53
  "@vue/reactivity": "3.5.44",
54
54
  "esbuild": "^0.28.2",
55
55
  "lit-html": "^3.3.3",
@@ -57,8 +57,9 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "@happy-dom/global-registrator": "^21.0.3",
60
- "@jxsuite/connector": "^0.6.2",
61
- "@jxsuite/parser": "^2.0.1",
60
+ "@jxsuite/connector": "^0.7.0",
61
+ "@jxsuite/parser": "^4.0.1",
62
+ "@jxsuite/test-support": "^0.0.0",
62
63
  "@types/mdast": "^4.0.4"
63
64
  }
64
65
  }
package/src/shared.ts CHANGED
@@ -11,6 +11,10 @@ import {
11
11
  COLOR_SCHEME_STORAGE_KEY,
12
12
  MAX_COMPONENT_NESTING,
13
13
  RESERVED_KEYS,
14
+ SLOT_ATTR,
15
+ SLOT_MARK,
16
+ SLOT_RECORD_KEY,
17
+ SLOT_WHITESPACE,
14
18
  booleanAttrValue,
15
19
  buildStyleRules,
16
20
  enumeratedAttrNames,
@@ -32,6 +36,7 @@ import {
32
36
  isFunctionDef,
33
37
  isMappedArray,
34
38
  isNamedFormulaDef,
39
+ isNodeObject,
35
40
  isNonNullObject,
36
41
  isNumber,
37
42
  isPrototypeDef,
@@ -54,6 +59,7 @@ import type {
54
59
  JsonValue,
55
60
  JxDocumentValue,
56
61
  JxElement,
62
+ JxMappedArray,
57
63
  JxMutableNode,
58
64
  JxPrototypeDef,
59
65
  JxRef,
@@ -867,6 +873,20 @@ function readsRuntimeOnlyState(str: string, scope: JxScope | null) {
867
873
  return false;
868
874
  }
869
875
 
876
+ /**
877
+ * The JavaScript expression a Shape 3 template string compiles to (spec.md §5.3): the expression
878
+ * itself, parenthesized, when the template is ONE expression, so it keeps its own type, and a
879
+ * template literal otherwise, which is a string by construction. The same line
880
+ * {@link evaluateStaticTemplate} and the runtime draw, so a prerendered page, a hydrated component
881
+ * and Studio's canvas read `"${state.a === state.b}"` as the same boolean.
882
+ *
883
+ * @param {string} template - Template source, already rewritten to the target's state prefix
884
+ * @returns {string} A JavaScript expression
885
+ */
886
+ export function templateValueSource(template: string): string {
887
+ return isSingleExpression(template) ? `(${template.slice(2, -1)})` : `\`${template}\``;
888
+ }
889
+
870
890
  /**
871
891
  * @param {string} str
872
892
  * @param {JxScope | null} scope
@@ -1262,6 +1282,154 @@ export function attrHelperSource(): string {
1262
1282
  );
1263
1283
  }
1264
1284
 
1285
+ /** The capture helper a light-DOM component module calls before its first render. */
1286
+ export const SLOT_CAPTURE_HELPER = "__jxCaptureSlotted";
1287
+
1288
+ /** The distribution helper a light-DOM component module calls after its first render. */
1289
+ export const SLOT_DISTRIBUTE_HELPER = "__jxDistributeSlots";
1290
+
1291
+ /**
1292
+ * The slot rule (spec.md §8.5) as source for a generated module: `captureSlotted` and
1293
+ * `distributeSlots` from `@jxsuite/runtime`, inlined for the reason `attrHelperSource` gives, with
1294
+ * the whitespace pattern, the bracket word, the attribute and the record key SERIALIZED from the
1295
+ * runtime's exports rather than retyped. The record lives on `globalThis` under the same registered
1296
+ * symbol, so a group one module (or the interpreter) records is read by the element that captures
1297
+ * next. `slot-distribution-agreement.test.ts` runs this source and the runtime's functions over one
1298
+ * fixture table, which is what keeps the two from drifting.
1299
+ *
1300
+ * @returns {string} Two `function` declarations and their two constants, for a module's preamble.
1301
+ */
1302
+ export function slotHelperSource(): string {
1303
+ const ws = `new RegExp(${JSON.stringify(SLOT_WHITESPACE.source)}, ${JSON.stringify(SLOT_WHITESPACE.flags)})`;
1304
+ const key = `Symbol.for(${JSON.stringify(SLOT_RECORD_KEY)})`;
1305
+ const attr = JSON.stringify(SLOT_ATTR);
1306
+ const mark = JSON.stringify(SLOT_MARK);
1307
+ return [
1308
+ `const __jxSlotWs = ${ws};`,
1309
+ `const __jxSlotRec = globalThis[${key}] || (globalThis[${key}] = new WeakMap());`,
1310
+ `function ${SLOT_CAPTURE_HELPER}(host) {`,
1311
+ ` const id = host.getAttribute(${attr});`,
1312
+ ` if (id === null) return Array.from(host.childNodes);`,
1313
+ ` host.removeAttribute(${attr});`,
1314
+ ` const open = ${mark} + ' ' + id + ' ';`,
1315
+ ` const close = '/' + ${mark} + ' ' + id;`,
1316
+ ` const openers = [];`,
1317
+ ` const w = host.ownerDocument.createTreeWalker(host, 128);`,
1318
+ ` for (let n = w.nextNode(); n; n = w.nextNode()) if (n.data.startsWith(open)) openers.push(n);`,
1319
+ ` const out = [];`,
1320
+ ` for (const o of openers) {`,
1321
+ ` const name = decodeURIComponent(o.data.slice(open.length));`,
1322
+ ` for (let n = o.nextSibling; n; n = n.nextSibling) {`,
1323
+ ` if (n.nodeType === 8 && n.data === close) break;`,
1324
+ ` __jxSlotRec.set(n, name);`,
1325
+ ` out.push(n);`,
1326
+ ` }`,
1327
+ ` }`,
1328
+ ` return out;`,
1329
+ `}`,
1330
+ `function ${SLOT_DISTRIBUTE_HELPER}(host, captured) {`,
1331
+ ` const slots = Array.from(host.querySelectorAll('slot'));`,
1332
+ ` if (slots.length === 0) return;`,
1333
+ ` const groups = new Map();`,
1334
+ ` for (const n of captured) {`,
1335
+ ` if (n.nodeType === 3 && __jxSlotWs.test(n.data)) continue;`,
1336
+ ` const r = __jxSlotRec.get(n);`,
1337
+ ` __jxSlotRec.delete(n);`,
1338
+ ` const name = r !== undefined ? r : n.nodeType === 1 ? n.getAttribute('slot') || '' : '';`,
1339
+ ` let g = groups.get(name);`,
1340
+ ` if (!g) { g = []; groups.set(name, g); }`,
1341
+ ` g.push(n);`,
1342
+ ` }`,
1343
+ ` const taken = new Set();`,
1344
+ ` for (const slot of slots) {`,
1345
+ ` const name = slot.getAttribute('name') || '';`,
1346
+ ` let g = [];`,
1347
+ ` if (!taken.has(name)) { taken.add(name); g = groups.get(name) || []; }`,
1348
+ ` const content = g.some((n) => n.nodeType === 1 || (n.nodeType === 3 && n.data !== ''));`,
1349
+ ` const placed = content ? g : g.concat(Array.from(slot.childNodes));`,
1350
+ ` const p = slot.parentElement;`,
1351
+ ` if (p && p !== host && p.localName.includes('-')) {`,
1352
+ ` const r = __jxSlotRec.get(slot);`,
1353
+ ` const f = r !== undefined ? r : slot.getAttribute('slot') || '';`,
1354
+ ` for (const n of placed) __jxSlotRec.set(n, f);`,
1355
+ ` }`,
1356
+ ` slot.replaceWith(...placed);`,
1357
+ ` }`,
1358
+ `}`,
1359
+ ].join("\n");
1360
+ }
1361
+
1362
+ /**
1363
+ * An empty comment written where a lit child part would otherwise end at a node distribution moves.
1364
+ *
1365
+ * Lit ends a child part at the node that followed its marker when the template was cloned. A `$map`
1366
+ * or `$switch` written directly before a `<slot>` ends at that slot, and once the slot is replaced
1367
+ * the part's next update clears every later sibling of its new parent and throws; a binding among
1368
+ * an instance's children ends at a named sibling, which moves to another group, in the same way. A
1369
+ * comment ends the part instead: it travels with its group, and it affects neither `:empty` nor a
1370
+ * child combinator, so a slot still "leaves no node" in the selector tree §16.6 is about.
1371
+ */
1372
+ export const LIT_PART_GUARD = "<!---->";
1373
+
1374
+ /**
1375
+ * Whether a definition's tree draws a `<slot>` anywhere a render can reach: `children`, a
1376
+ * `$switch`'s `cases`, or a repeater's `map` (a whole-children repeater included).
1377
+ *
1378
+ * @param {JxMutableNode["children"]} children
1379
+ * @returns {boolean}
1380
+ */
1381
+ export function definitionHasSlot(children: SlotChildren): boolean {
1382
+ for (const child of slotChildList(children)) {
1383
+ if (isMappedArray(child)) {
1384
+ if (child.map && definitionHasSlot([child.map])) {
1385
+ return true;
1386
+ }
1387
+ continue;
1388
+ }
1389
+ if (!isNodeObject(child)) {
1390
+ continue;
1391
+ }
1392
+ if (child.tagName === "slot") {
1393
+ return true;
1394
+ }
1395
+ const cases = Object.values(child.cases ?? {}).filter(
1396
+ (c) => isNodeObject(c) && !isRefObject(c),
1397
+ );
1398
+ if (definitionHasSlot(cases) || definitionHasSlot(child.children)) {
1399
+ return true;
1400
+ }
1401
+ }
1402
+ return false;
1403
+ }
1404
+
1405
+ /** A node's `children` as either document shape writes it. */
1406
+ export type SlotChildren =
1407
+ | JxElement["children"]
1408
+ | JxMutableNode["children"]
1409
+ | readonly StaticChild[]
1410
+ | null
1411
+ | undefined;
1412
+
1413
+ /**
1414
+ * `children` as a list: an array as it is, a whole-children repeater as its one member, nothing as
1415
+ * nothing.
1416
+ *
1417
+ * @param {SlotChildren} children
1418
+ * @returns {readonly (StaticChild | JxMappedArray)[]}
1419
+ */
1420
+ function slotChildList(children: SlotChildren): readonly (StaticChild | JxMappedArray)[] {
1421
+ if (isMappedArray(children)) {
1422
+ return [children];
1423
+ }
1424
+ // Document JSON can hold anything here; what is neither a list nor a repeater holds no child.
1425
+ if (!Array.isArray(children)) {
1426
+ return [];
1427
+ }
1428
+ /* SAFETY: every array member of `SlotChildren` is a list of children; `Array.isArray` narrows the
1429
+ union to `any[]`, so the cast only restores the element type it already had. */
1430
+ return children as readonly StaticChild[];
1431
+ }
1432
+
1265
1433
  /**
1266
1434
  * Walk the entire document tree and collect all static nested CSS rules.
1267
1435
  *
@@ -1561,6 +1729,120 @@ export interface ComponentPrerenderContext {
1561
1729
  defaults?: ProjectConfig["defaults"] | undefined;
1562
1730
  /** The instances being expanded, outermost first; empty at a page-level instance. */
1563
1731
  path?: readonly PrerenderFrame[] | undefined;
1732
+ /**
1733
+ * The page's counter for `data-jx-slots` ids. Nested contexts are spread copies, so they share
1734
+ * it; without one (a unit test, a definition preview) no instance is stamped or bracketed.
1735
+ */
1736
+ slotIds?: { n: number } | undefined;
1737
+ }
1738
+
1739
+ /**
1740
+ * An instance's children, grouped by the slot they name, as the static renderer places them
1741
+ * (spec.md §8.5). One render consumes a fill: `taken` records which names a slot already took.
1742
+ */
1743
+ export interface SlotFill {
1744
+ /** Group name (`""` is the unnamed group) → its rendered HTML and whether it holds content. */
1745
+ groups: Map<string, { html: string; content: boolean }>;
1746
+ taken: Set<string>;
1747
+ /** The instance's `data-jx-slots` id, whose brackets wrap each placed group; null for none. */
1748
+ id: number | null;
1749
+ }
1750
+
1751
+ /**
1752
+ * The next `data-jx-slots` id of the page being rendered, or null without a counter.
1753
+ *
1754
+ * @param {ComponentPrerenderContext | null} context
1755
+ * @returns {number | null}
1756
+ */
1757
+ export function nextSlotId(context: ComponentPrerenderContext | null): number | null {
1758
+ if (!context?.slotIds) {
1759
+ return null;
1760
+ }
1761
+ context.slotIds.n += 1;
1762
+ return context.slotIds.n;
1763
+ }
1764
+
1765
+ /**
1766
+ * Group an instance's children by slot name, as definitions, applying the rule the interpreter's
1767
+ * `distributeSlots` applies to nodes: a string or number whose rendered text is only HTML
1768
+ * whitespace is dropped; an element names its group by its resolved `attributes.slot`, else its
1769
+ * resolved node-level `slot` (which is then rendered as the attribute, so a recovered child still
1770
+ * names its group); everything else joins the unnamed group. An element, `$switch` or repeater
1771
+ * child is content; a string or number is when its text is not empty.
1772
+ *
1773
+ * @param {StaticChild} children
1774
+ * @param {JxScope | null} scope - The scope the children resolve in (the parent's)
1775
+ * @param {(child: StaticChild) => string} render - Renders one child
1776
+ * @param {number | null} [id]
1777
+ * @returns {SlotFill}
1778
+ */
1779
+ export function createSlotFill(
1780
+ children: SlotChildren,
1781
+ scope: JxScope | null,
1782
+ render: (child: StaticChild) => string,
1783
+ id: number | null = null,
1784
+ ): SlotFill {
1785
+ const groups: SlotFill["groups"] = new Map();
1786
+ for (const child of slotChildList(children)) {
1787
+ let name = "";
1788
+ let content = true;
1789
+ let html: string;
1790
+ if (isString(child) || isNumber(child) || isBoolean(child)) {
1791
+ html = render(child);
1792
+ if (SLOT_WHITESPACE.test(html)) {
1793
+ continue;
1794
+ }
1795
+ content = html !== "";
1796
+ } else if (isMappedArray(child)) {
1797
+ // A repeater renders nothing statically; it is content all the same, as its rows will be.
1798
+ html = "";
1799
+ } else if (!isNodeObject(child)) {
1800
+ html = render(child);
1801
+ content = html !== "";
1802
+ } else {
1803
+ const fromAttr = resolveStaticValue(child.attributes?.slot, scope);
1804
+ const fromNode = isString(child.slot) ? resolveStaticValue(child.slot, scope) : null;
1805
+ if (isString(fromAttr) && fromAttr !== "") {
1806
+ name = fromAttr;
1807
+ html = render(child);
1808
+ } else if (isString(fromNode) && fromNode !== "") {
1809
+ name = fromNode;
1810
+ const { slot: _slot, ...rest } = child;
1811
+ html = render({ ...rest, attributes: { ...rest.attributes, slot: fromNode } });
1812
+ } else {
1813
+ html = render(child);
1814
+ }
1815
+ }
1816
+ const group = groups.get(name);
1817
+ if (group) {
1818
+ group.html = group.html === "" ? html : `${group.html}\n${html}`;
1819
+ group.content ||= content;
1820
+ } else {
1821
+ groups.set(name, { content, html });
1822
+ }
1823
+ }
1824
+ return { groups, id, taken: new Set() };
1825
+ }
1826
+
1827
+ /**
1828
+ * Mark every slot name inside a fallback that is not rendered as taken, in tree order, so the
1829
+ * prerender's answer matches the DOM tiers', where such a slot is still met by `querySelectorAll`.
1830
+ *
1831
+ * @param {StaticChild} node
1832
+ * @param {JxScope | null} scope
1833
+ * @param {SlotFill} slots
1834
+ */
1835
+ function takeSlotNames(children: SlotChildren, scope: JxScope | null, slots: SlotFill): void {
1836
+ for (const node of slotChildList(children)) {
1837
+ if (isMappedArray(node) || !isNodeObject(node)) {
1838
+ continue;
1839
+ }
1840
+ if (node.tagName === "slot") {
1841
+ const name = resolveStaticValue(node.attributes?.name, scope);
1842
+ slots.taken.add(isString(name) ? name : "");
1843
+ }
1844
+ takeSlotNames(node.children, scope, slots);
1845
+ }
1564
1846
  }
1565
1847
 
1566
1848
  /** One instance on the expansion path: its tag, and its resolved props as an identity. */
@@ -1682,14 +1964,14 @@ export function resolveHostStyle(
1682
1964
  *
1683
1965
  * @param {JxElement | JxMutableNode} node
1684
1966
  * @param {JxScope | null} scope
1685
- * @param {string | null} slotContent
1967
+ * @param {SlotFill | null} slots
1686
1968
  * @param {ComponentPrerenderContext | null} context
1687
1969
  * @returns {string}
1688
1970
  */
1689
1971
  function renderInner(
1690
1972
  node: JxElement | JxMutableNode,
1691
1973
  scope: JxScope | null,
1692
- slotContent: string | null,
1974
+ slots: SlotFill | null,
1693
1975
  context: ComponentPrerenderContext | null,
1694
1976
  ): string {
1695
1977
  if (node.textContent !== undefined) {
@@ -1702,9 +1984,7 @@ function renderInner(
1702
1984
  }
1703
1985
  if (Array.isArray(node.children)) {
1704
1986
  return node.children
1705
- .map((c: JxElement | JxMutableNode | string) =>
1706
- renderStaticNode(c, scope, slotContent, context),
1707
- )
1987
+ .map((c: JxElement | JxMutableNode | string) => renderStaticNode(c, scope, slots, context))
1708
1988
  .join("\n");
1709
1989
  }
1710
1990
  return "";
@@ -1729,7 +2009,8 @@ export type StaticChild =
1729
2009
  *
1730
2010
  * @param {StaticChild} node
1731
2011
  * @param {JxScope | null} scope
1732
- * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
2012
+ * @param {SlotFill | null} [slots] - The instance's children by slot name; `null` leaves a `<slot>`
2013
+ * literal (a shadow root, or a definition rendered with no instance)
1733
2014
  * @param {ComponentPrerenderContext | null} [context] - The component registry, when a custom
1734
2015
  * element tag met here should be expanded rather than emitted bare
1735
2016
  * @returns {string}
@@ -1737,7 +2018,7 @@ export type StaticChild =
1737
2018
  export function renderStaticNode(
1738
2019
  node: StaticChild,
1739
2020
  scope: JxScope | null,
1740
- slotContent: string | null = null,
2021
+ slots: SlotFill | null = null,
1741
2022
  context: ComponentPrerenderContext | null = null,
1742
2023
  ): string {
1743
2024
  if (isString(node)) {
@@ -1751,7 +2032,7 @@ export function renderStaticNode(
1751
2032
  return escapeHtml(String(node));
1752
2033
  }
1753
2034
  if (Array.isArray(node)) {
1754
- return node.map((c): string => renderStaticNode(c, scope, slotContent, context)).join("\n");
2035
+ return node.map((c): string => renderStaticNode(c, scope, slots, context)).join("\n");
1755
2036
  }
1756
2037
  if (!isNonNullObject(node)) {
1757
2038
  return "";
@@ -1772,7 +2053,7 @@ export function renderStaticNode(
1772
2053
  const caseDef = key == null ? undefined : node.cases?.[String(key)];
1773
2054
  const inner =
1774
2055
  caseDef !== undefined && !isRefObject(caseDef)
1775
- ? renderStaticNode(caseDef, scope, slotContent, context)
2056
+ ? renderStaticNode(caseDef, scope, slots, context)
1776
2057
  : "";
1777
2058
  return `<${switchTag}${attrs}>${inner}</${switchTag}>`;
1778
2059
  }
@@ -1783,15 +2064,33 @@ export function renderStaticNode(
1783
2064
  state does not exist, so SSR silently emitted the fallback branch's tag. */
1784
2065
  const tag = resolveStaticTagName(node.tagName, scope);
1785
2066
 
1786
- // Replace <slot> with provided slot content
1787
- if (tag === "slot" && slotContent != null) {
1788
- return slotContent;
2067
+ /* A slot is REPLACED by its group (spec.md §8.5), and leaves no node: the first slot of a name
2068
+ takes the group, and the fallback follows only when the group holds no content. A slot inside
2069
+ a fallback that is not rendered still takes its name, as it does in the DOM tiers. */
2070
+ if (tag === "slot" && slots) {
2071
+ const resolved = resolveStaticValue(node.attributes?.name, scope);
2072
+ const name = isString(resolved) ? resolved : "";
2073
+ let group: { html: string; content: boolean } | undefined;
2074
+ if (!slots.taken.has(name)) {
2075
+ slots.taken.add(name);
2076
+ group = slots.groups.get(name);
2077
+ }
2078
+ let out = group?.html ?? "";
2079
+ if (out !== "" && slots.id !== null) {
2080
+ out = `<!--${SLOT_MARK} ${slots.id} ${encodeURIComponent(name)}-->${out}<!--/${SLOT_MARK} ${slots.id}-->`;
2081
+ }
2082
+ if (group?.content) {
2083
+ takeSlotNames(node.children, scope, slots);
2084
+ return out;
2085
+ }
2086
+ const fallback = renderInner(node, scope, slots, context);
2087
+ return out === "" ? fallback : fallback === "" ? out : `${out}\n${fallback}`;
1789
2088
  }
1790
2089
 
1791
2090
  // A registered component: expanded exactly as the page walk expands one, not emitted bare.
1792
2091
  const def = context?.componentDefs.get(tag);
1793
2092
  if (def && context) {
1794
- return renderComponentInstance(node, def, scope, slotContent, context);
2093
+ return renderComponentInstance(node, def, scope, slots, context);
1795
2094
  }
1796
2095
 
1797
2096
  const attrs = buildAttrs(node, scope);
@@ -1800,7 +2099,7 @@ export function renderStaticNode(
1800
2099
  return `<${tag}${attrs}>`;
1801
2100
  }
1802
2101
 
1803
- return `<${tag}${attrs}>${renderInner(node, scope, slotContent, context)}</${tag}>`;
2102
+ return `<${tag}${attrs}>${renderInner(node, scope, slots, context)}</${tag}>`;
1804
2103
  }
1805
2104
 
1806
2105
  /**
@@ -1811,7 +2110,10 @@ export function renderStaticNode(
1811
2110
  * scope, which is what `resolveDocTemplates` does for a page-level instance before the page walk
1812
2111
  * reaches it.
1813
2112
  * - Slot content: the instance's own children, rendered in the parent's scope and with the parent's
1814
- * slot content, so a `<slot>` written among them passes the grandparent's through.
2113
+ * fill, so a `<slot>` written among them passes the grandparent's group of that name through, and
2114
+ * placed in the definition's slots by name (`createSlotFill`, spec.md §8.5). A live instance
2115
+ * whose definition has a slot carries `data-jx-slots` and brackets each group, so its element
2116
+ * recovers what it was given when it upgrades (`captureSlotted` in the runtime).
1815
2117
  * - Shadow mode: a declarative shadow root with the stylesheet link inside it (spec.md §16.6).
1816
2118
  * - Host style: the definition's template-string entries resolved against the INSTANCE, written on
1817
2119
  * the element — the page walk's `resolvedStyle` in inline form, since there is no page stylesheet
@@ -1826,7 +2128,7 @@ export function renderStaticNode(
1826
2128
  * @param {JxElement | JxMutableNode} node - The instance as written in the parent's tree
1827
2129
  * @param {JxElement} def - The registered definition for its tag
1828
2130
  * @param {JxScope | null} scope - The parent's scope
1829
- * @param {string | null} slotContent - The parent's slot content
2131
+ * @param {SlotFill | null} parentSlots - The parent's fill
1830
2132
  * @param {ComponentPrerenderContext} context
1831
2133
  * @returns {string}
1832
2134
  * @docs framework/build
@@ -1835,7 +2137,7 @@ function renderComponentInstance(
1835
2137
  node: JxElement | JxMutableNode,
1836
2138
  def: JxElement,
1837
2139
  scope: JxScope | null,
1838
- slotContent: string | null,
2140
+ parentSlots: SlotFill | null,
1839
2141
  context: ComponentPrerenderContext,
1840
2142
  ): string {
1841
2143
  /* SAFETY: the caller looked `def` up in `componentDefs` by the node's resolved string tag, and
@@ -1869,25 +2171,29 @@ function renderComponentInstance(
1869
2171
  }
1870
2172
  const nested: ComponentPrerenderContext = { ...context, path: [...path, frame] };
1871
2173
 
1872
- // The instance's children are the parent's nodes: parent scope, parent slot, parent path.
1873
- const slot =
1874
- Array.isArray(node.children) && node.children.length > 0
1875
- ? node.children
1876
- .map((c: JxElement | JxMutableNode | string) =>
1877
- renderStaticNode(c, scope, slotContent, context),
1878
- )
1879
- .join("\n")
1880
- : null;
2174
+ // The instance's children are the parent's nodes: parent scope, parent fill, parent path.
2175
+ const { children } = node;
2176
+ const renderChild = (c: StaticChild) => renderStaticNode(c, scope, parentSlots, context);
1881
2177
 
1882
2178
  const shadow = resolveShadowMode(def, context.defaults);
1883
2179
  const isStatic = isComponentFullyStatic(def);
1884
2180
  const instanceScope = buildInstanceScope(def, hasProps ? props : null);
1885
- const inner = renderInner(def, instanceScope, shadow ? null : slot, nested);
1886
- const innerHTML = shadow
1887
- ? `<template shadowrootmode="${shadow}">` +
2181
+ let innerHTML: string;
2182
+ let slotId: number | null = null;
2183
+ if (shadow) {
2184
+ // The light children stay siblings of the template, where a real `<slot>` distributes them.
2185
+ const light = Array.isArray(children)
2186
+ ? children.map((c: StaticChild) => renderChild(c)).join("\n")
2187
+ : "";
2188
+ innerHTML =
2189
+ `<template shadowrootmode="${shadow}">` +
1888
2190
  `<link rel="stylesheet" href="/components/${tag}.css">` +
1889
- `${inner}</template>${slot ?? ""}`
1890
- : inner;
2191
+ `${renderInner(def, instanceScope, null, nested)}</template>${light}`;
2192
+ } else {
2193
+ slotId = !isStatic && definitionHasSlot(def.children) ? nextSlotId(context) : null;
2194
+ const fill = createSlotFill(children, scope, renderChild, slotId);
2195
+ innerHTML = renderInner(def, instanceScope, fill, nested);
2196
+ }
1891
2197
 
1892
2198
  const hostStyle = Object.entries(resolveHostStyle(def.style, instanceScope))
1893
2199
  .map(([prop, value]) => `${camelToKebab(prop)}: ${value}`)
@@ -1900,6 +2206,9 @@ function renderComponentInstance(
1900
2206
  delete instance.children;
1901
2207
  instance.attributes =
1902
2208
  !isStatic && hasProps ? { ...attributes, "data-jx-props": JSON.stringify(props) } : attributes;
2209
+ if (slotId !== null) {
2210
+ instance.attributes = { ...instance.attributes, [SLOT_ATTR]: String(slotId) };
2211
+ }
1903
2212
  if (isStatic) {
1904
2213
  instance.$static = true;
1905
2214
  } else {
@@ -1914,7 +2223,8 @@ function renderComponentInstance(
1914
2223
  * @param {JxElement} doc - Component JSON definition
1915
2224
  * @param {Record<string, JsonValue> | null} [propsOverride] - Instance-specific prop values to
1916
2225
  * merge into state
1917
- * @param {string | null} [slotContent] - HTML to substitute for `<slot>` elements
2226
+ * @param {SlotFill | null} [slots] - The instance's children by slot name (`createSlotFill`);
2227
+ * `null` leaves every `<slot>` literal
1918
2228
  * @param {ComponentPrerenderContext | null} [context] - The component registry; with one, an
1919
2229
  * instance of a registered component inside `doc` is expanded rather than emitted bare
1920
2230
  * @returns {string} The pre-rendered innerHTML
@@ -1922,10 +2232,10 @@ function renderComponentInstance(
1922
2232
  export function preRenderComponentHtml(
1923
2233
  doc: JxElement,
1924
2234
  propsOverride: Record<string, JsonValue> | null = null,
1925
- slotContent: string | null = null,
2235
+ slots: SlotFill | null = null,
1926
2236
  context: ComponentPrerenderContext | null = null,
1927
2237
  ) {
1928
- return renderInner(doc, buildInstanceScope(doc, propsOverride), slotContent, context);
2238
+ return renderInner(doc, buildInstanceScope(doc, propsOverride), slots, context);
1929
2239
  }
1930
2240
 
1931
2241
  /**
@@ -29,12 +29,15 @@ export type ProjectSections = Record<string, ProjectSectionData>;
29
29
  * @param {string} projectRoot - Absolute path to the project root
30
30
  * @param {ProjectConfig | undefined} projectConfig - Already-loaded project config
31
31
  * @param {ExtensionRegistry} registry - The project's extension registry
32
+ * @param {{ lazy?: boolean }} [options] - `lazy`: the host serves one page at a time, so an owner
33
+ * may load its section on demand (specs/extensions.md §8). A build leaves it off.
32
34
  * @returns {Promise<ProjectSections>} Section key → projectData result
33
35
  */
34
36
  export async function loadProjectSections(
35
37
  projectRoot: string,
36
38
  projectConfig: ProjectConfig | undefined,
37
39
  registry: ExtensionRegistry,
40
+ options: { lazy?: boolean } = {},
38
41
  ): Promise<ProjectSections> {
39
42
  const sections: ProjectSections = {};
40
43
  const io = createNodeFormatIO(projectRoot);
@@ -51,6 +54,7 @@ export async function loadProjectSections(
51
54
  projectConfig,
52
55
  registry,
53
56
  root: projectRoot,
57
+ ...(options.lazy === true && { lazy: true }),
54
58
  });
55
59
  }
56
60
  return sections;