@abide/abide 0.32.1 → 0.33.0

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 (54) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +93 -63
  3. package/package.json +6 -2
  4. package/src/lib/server/runtime/buildCacheSnapshot.ts +5 -4
  5. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  6. package/src/lib/shared/cache.ts +43 -29
  7. package/src/lib/shared/types/CacheEntry.ts +12 -12
  8. package/src/lib/shared/types/CacheOptions.ts +17 -13
  9. package/src/lib/ui/README.md +3 -3
  10. package/src/lib/ui/compile/HTML_TAGS.ts +132 -0
  11. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +4 -1
  12. package/src/lib/ui/compile/componentWrapperTag.ts +13 -10
  13. package/src/lib/ui/compile/generateBuild.ts +265 -121
  14. package/src/lib/ui/compile/generateSSR.ts +78 -37
  15. package/src/lib/ui/compile/parseTemplate.ts +52 -0
  16. package/src/lib/ui/compile/skeletonable.ts +80 -0
  17. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +6 -0
  18. package/src/lib/ui/dom/SVG_NAMESPACE.ts +7 -0
  19. package/src/lib/ui/dom/anchorCursor.ts +24 -0
  20. package/src/lib/ui/dom/appendSnippet.ts +1 -1
  21. package/src/lib/ui/dom/appendTextAt.ts +70 -0
  22. package/src/lib/ui/dom/awaitBlock.ts +27 -7
  23. package/src/lib/ui/dom/cloneStatic.ts +15 -24
  24. package/src/lib/ui/dom/each.ts +44 -25
  25. package/src/lib/ui/dom/eachAsync.ts +6 -2
  26. package/src/lib/ui/dom/effectiveChildNamespace.ts +13 -0
  27. package/src/lib/ui/dom/enterNamespace.ts +20 -0
  28. package/src/lib/ui/dom/fillBefore.ts +20 -3
  29. package/src/lib/ui/dom/foreignWrapperTag.ts +22 -0
  30. package/src/lib/ui/dom/hydrate.ts +1 -1
  31. package/src/lib/ui/dom/inheritedNamespace.ts +19 -0
  32. package/src/lib/ui/dom/mountSlot.ts +32 -0
  33. package/src/lib/ui/dom/openMarker.ts +4 -2
  34. package/src/lib/ui/dom/skeleton.ts +202 -0
  35. package/src/lib/ui/dom/switchBlock.ts +10 -3
  36. package/src/lib/ui/dom/templateFor.ts +28 -0
  37. package/src/lib/ui/dom/tryBlock.ts +7 -5
  38. package/src/lib/ui/dom/types/SkeletonHoles.ts +8 -0
  39. package/src/lib/ui/dom/when.ts +6 -2
  40. package/src/lib/ui/installHotBridge.ts +8 -2
  41. package/src/lib/ui/runtime/HOLE_ATTRIBUTE.ts +9 -0
  42. package/src/lib/ui/runtime/RENDER.ts +7 -0
  43. package/src/lib/ui/runtime/createDoc.ts +11 -8
  44. package/src/lib/ui/runtime/types/PathWalk.ts +10 -0
  45. package/src/lib/ui/runtime/types/UiProps.ts +3 -4
  46. package/src/lib/ui/runtime/walkPath.ts +27 -0
  47. package/template/src/ui/pages/about/page.abide +4 -6
  48. package/template/src/ui/pages/layout.abide +21 -0
  49. package/template/src/ui/pages/page.abide +5 -8
  50. package/src/lib/ui/compile/partitionSlots.ts +0 -36
  51. package/src/lib/ui/dom/openChild.ts +0 -22
  52. package/src/lib/ui/runtime/pathExists.ts +0 -23
  53. package/src/lib/ui/runtime/valueAtPath.ts +0 -18
  54. package/template/src/ui/Layout.abide +0 -19
@@ -1,12 +1,11 @@
1
+ import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
1
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
3
  import { bindListenEvent } from './bindListenEvent.ts'
3
4
  import { componentWrapperTag } from './componentWrapperTag.ts'
4
5
  import { groupBindParts } from './groupBindParts.ts'
5
6
  import { lowerContext } from './lowerContext.ts'
6
- import { partitionSlots } from './partitionSlots.ts'
7
7
  import { scopeAttr } from './scopeAttr.ts'
8
8
  import { staticAttr } from './staticAttr.ts'
9
- import { staticAttrValue } from './staticAttrValue.ts'
10
9
  import { staticTextPart } from './staticTextPart.ts'
11
10
  import type { TemplateNode } from './types/TemplateNode.ts'
12
11
  import { VOID_TAGS } from './VOID_TAGS.ts'
@@ -37,69 +36,207 @@ export function generateBuild(
37
36
  withNestedScripts,
38
37
  } = lowerContext(stateNames, derivedNames)
39
38
 
40
- /* Builds an element and its children; returns the build code and its var.
41
- `varExpr` is how the element is obtained — `openChild(parent, tag)` for a
42
- child (create-or-claim), or `document.createElement(tag)` for a returned
43
- root (rows/branches, which are create-only). */
44
- function generateElement(
39
+ /* Emits the wiring for one non-static attribute against an already-obtained skeleton
40
+ element var reactive `attr`, `on` listener, `attach`, or a two-way `bind`. */
41
+ function dynamicAttr(
45
42
  node: Extract<TemplateNode, { kind: 'element' }>,
46
- varExpr: string,
47
- ): { code: string; varName: string } {
48
- const varName = nextVar('el')
49
- let code = `const ${varName} = ${varExpr};\n`
50
- /* Stamp the scope attribute of every `<style>` active at this element (its own
51
- sibling list plus every ancestor's), so the bundled CSS matches it. */
43
+ attr: Extract<
44
+ (typeof node.attrs)[number],
45
+ { kind: 'expression' | 'event' | 'attach' | 'bind' }
46
+ >,
47
+ varName: string,
48
+ ): string {
49
+ if (attr.kind === 'expression') {
50
+ return `attr(${varName}, ${JSON.stringify(attr.name)}, () => (${lowerExpression(attr.code)}));\n`
51
+ }
52
+ if (attr.kind === 'event') {
53
+ return `on(${varName}, ${JSON.stringify(attr.event)}, (${lowerExpression(attr.code)}));\n`
54
+ }
55
+ if (attr.kind === 'attach') {
56
+ return `attach(${varName}, (${lowerExpression(attr.code)}));\n`
57
+ }
58
+ if (attr.property === 'group') {
59
+ /* Grouped two-way: radio binds the path to the single checked `value`;
60
+ checkbox treats the path as an array, adding/removing `value` on toggle.
61
+ Membership reads the array via the lowered path and calls native
62
+ `.includes`/`.indexOf` (the doc API has no array search); mutations go
63
+ through `push`/`delete`, which lower to `add`/`remove` patches that the
64
+ doc reindexes. */
65
+ const { valueCode, isRadio } = groupBindParts(node)
66
+ const value = lowerExpression(valueCode)
67
+ if (isRadio) {
68
+ return (
69
+ `effect(() => { ${varName}.checked = (${lowerExpression(attr.code)}) === (${value}); });\n` +
70
+ `on(${varName}, "change", () => { if (${varName}.checked) { ${lowerStatement(`${attr.code} = ${valueCode}`)} } });\n`
71
+ )
72
+ }
73
+ return (
74
+ `effect(() => { ${varName}.checked = (${lowerExpression(attr.code)}).includes(${value}); });\n` +
75
+ `on(${varName}, "change", () => { const $groupValue = ${value}; if (${varName}.checked) { if (!(${lowerExpression(attr.code)}).includes($groupValue)) { ${lowerStatement(`${attr.code}.push($groupValue)`)} } } else { const $groupIndex = (${lowerExpression(attr.code)}).indexOf($groupValue); if ($groupIndex !== -1) { ${lowerStatement(`delete ${attr.code}[$groupIndex]`)} } } });\n`
76
+ )
77
+ }
78
+ /* Two-way: drive the property from the path, and write the path back on the
79
+ property's native event (`input` for most fields, but `toggle` for
80
+ `<details open>`, `change` for checked/select). The path is an lvalue, so
81
+ the write lowers to an assignment. */
82
+ const event = bindListenEvent(attr.property, node.tag)
83
+ return (
84
+ `effect(() => { ${varName}.${attr.property} = ${lowerExpression(attr.code)}; });\n` +
85
+ `on(${varName}, ${JSON.stringify(event)}, () => { ${lowerStatement(`${attr.code} = ${varName}.${attr.property}`)} });\n`
86
+ )
87
+ }
88
+
89
+ /* Renders a skeletonable node to its marker-stamped skeleton markup, appending each
90
+ hole's wiring to `binds`. Children are walked in document order, so the holes number
91
+ in the order the runtime produces them: element holes (reactive attr / text-leaf
92
+ text) by element-only path (`sk.el`, pre-order); anchor holes (interleaved reactive
93
+ text, control-flow blocks, slots) by document-order scan (`sk.an`). A control-flow
94
+ block or slot drops an `<!--a-->` anchor at its position and mounts there (see
95
+ `anchorCursor`), so it can sit ANYWHERE among static siblings. Static descendants are
96
+ plain markup. */
97
+ function skeletonMarkup(
98
+ node: TemplateNode,
99
+ skVar: string,
100
+ counter: { el: number; an: number },
101
+ binds: string[],
102
+ ): string {
103
+ if (node.kind === 'text') {
104
+ /* Reactive text reached here is INTERLEAVED with element siblings (a text-leaf
105
+ is bound via `generateChildren` instead). It can't be element-positioned, so
106
+ it gets an `<!--a-->` anchor — kept in both SSR and client (like a control-flow
107
+ range marker), located by document-order scan (`sk.an`). */
108
+ return node.parts
109
+ .map((part) => {
110
+ if (part.kind === 'static') {
111
+ return staticTextPart(part.value)
112
+ }
113
+ binds.push(
114
+ `appendTextAt(${skVar}.an[${counter.an++}], () => (${lowerExpression(part.code)}));\n`,
115
+ )
116
+ return '<!--a-->'
117
+ })
118
+ .join('')
119
+ }
120
+ if (isControlFlowNode(node)) {
121
+ /* A control-flow block at its position: an `<!--a-->` anchor in the clone, the
122
+ block mounted at it. `anchorCursor` parks the hydrate cursor past the anchor
123
+ and returns the create insertion reference; the block's parent is the located
124
+ element the anchor was cloned into (`anchor.parentNode`). */
125
+ const anchorVar = nextVar('an')
126
+ binds.push(`const ${anchorVar} = ${skVar}.an[${counter.an++}];\n`)
127
+ binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
128
+ return '<!--a-->'
129
+ }
130
+ if (node.kind === 'component') {
131
+ /* The wrapper element is a positioned hole in the skeleton; the child mounts
132
+ into the located node (idempotent display:contents for a transparent wrap,
133
+ static so it lives in the markup). */
134
+ const { tag, transparent } = componentWrapperTag(node.name)
135
+ const { code } = mountComponent(node, `${skVar}.el[${counter.el++}]`)
136
+ binds.push(code)
137
+ const style = transparent ? ' style="display:contents"' : ''
138
+ return `<${tag} ${HOLE_ATTRIBUTE}${style}></${tag}>`
139
+ }
140
+ if (node.kind === 'script') {
141
+ /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
142
+ runs as a bind, in document order, so its signals are declared before the later
143
+ siblings that deref them (the enclosing `withNestedScripts` puts those names in
144
+ scope). */
145
+ binds.push(`${lowerStatement(node.code)}\n`)
146
+ return ''
147
+ }
148
+ if (node.kind === 'snippet') {
149
+ /* A `<template name>` snippet declares a hoisted builder, appending nothing here —
150
+ `{name(args)}` mounts it. Emit the declaration as a bind. */
151
+ binds.push(generateSnippet(node))
152
+ return ''
153
+ }
154
+ if (node.kind !== 'element') {
155
+ return '' // <style> emits no markup
156
+ }
157
+ if (node.tag === 'slot') {
158
+ /* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
159
+ mounted as a marker-bounded range (`mountSlot`) so it positions like a block. */
160
+ const anchorVar = nextVar('an')
161
+ binds.push(`const ${anchorVar} = ${skVar}.an[${counter.an++}];\n`)
162
+ const hostVar = nextVar('host')
163
+ binds.push(
164
+ `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
165
+ )
166
+ return '<!--a-->'
167
+ }
168
+ const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
169
+ const hasReactiveText = node.children.some(
170
+ (child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
171
+ )
172
+ /* A text-leaf (only text/style children) with reactive text binds marker-free via
173
+ `generateChildren` on the located element; otherwise reactive text is interleaved
174
+ and uses `<!--a-->` anchors during the child recursion below. */
175
+ const isTextLeaf = node.children.every(
176
+ (child) => child.kind === 'text' || child.kind === 'style',
177
+ )
178
+ const textLeafBind = hasReactiveText && isTextLeaf
179
+ let openTag = `<${node.tag}`
180
+ let elVar = ''
181
+ if (hasReactiveAttr || textLeafBind) {
182
+ /* The element is a located hole (for attr binds or text-leaf text). Take its
183
+ index BEFORE recursing, so holes number in pre-order — the order the runtime's
184
+ path walk produces them. */
185
+ elVar = nextVar('el')
186
+ binds.push(`const ${elVar} = ${skVar}.el[${counter.el++}];\n`)
187
+ openTag += ` ${HOLE_ATTRIBUTE}`
188
+ for (const attr of node.attrs) {
189
+ if (attr.kind !== 'static') {
190
+ binds.push(dynamicAttr(node, attr, elVar))
191
+ }
192
+ }
193
+ }
52
194
  for (const scope of node.scopes ?? []) {
53
- code += `${varName}.setAttribute(${JSON.stringify(scope)}, "");\n`
195
+ openTag += scopeAttr(scope)
54
196
  }
55
197
  for (const attr of node.attrs) {
56
198
  if (attr.kind === 'static') {
57
- code += `${varName}.setAttribute(${JSON.stringify(attr.name)}, ${JSON.stringify(attr.value)});\n`
58
- } else if (attr.kind === 'expression') {
59
- code += `attr(${varName}, ${JSON.stringify(attr.name)}, () => (${lowerExpression(attr.code)}));\n`
60
- } else if (attr.kind === 'event') {
61
- code += `on(${varName}, ${JSON.stringify(attr.event)}, (${lowerExpression(attr.code)}));\n`
62
- } else if (attr.kind === 'attach') {
63
- code += `attach(${varName}, (${lowerExpression(attr.code)}));\n`
64
- } else if (attr.kind === 'bind' && attr.property === 'group') {
65
- /* Grouped two-way: radio binds the path to the single checked
66
- `value`; checkbox treats the path as an array, adding/removing
67
- `value` on toggle. Membership reads the array via the lowered
68
- path and calls native `.includes`/`.indexOf` (the doc API has no
69
- array search); mutations go through `push`/`delete`, which lower
70
- to `add`/`remove` patches that the doc reindexes. */
71
- const { valueCode, isRadio } = groupBindParts(node)
72
- const value = lowerExpression(valueCode)
73
- if (isRadio) {
74
- code += `effect(() => { ${varName}.checked = (${lowerExpression(attr.code)}) === (${value}); });\n`
75
- code += `on(${varName}, "change", () => { if (${varName}.checked) { ${lowerStatement(`${attr.code} = ${valueCode}`)} } });\n`
76
- } else {
77
- code += `effect(() => { ${varName}.checked = (${lowerExpression(attr.code)}).includes(${value}); });\n`
78
- code += `on(${varName}, "change", () => { const $groupValue = ${value}; if (${varName}.checked) { if (!(${lowerExpression(attr.code)}).includes($groupValue)) { ${lowerStatement(`${attr.code}.push($groupValue)`)} } } else { const $groupIndex = (${lowerExpression(attr.code)}).indexOf($groupValue); if ($groupIndex !== -1) { ${lowerStatement(`delete ${attr.code}[$groupIndex]`)} } } });\n`
79
- }
80
- } else {
81
- /* Two-way: drive the property from the path, and write the path
82
- back on the property's native event (`input` for most fields,
83
- but `toggle` for `<details open>`, `change` for checked/select).
84
- The path is an lvalue, so the write lowers to an assignment. */
85
- const event = bindListenEvent(attr.property, node.tag)
86
- code += `effect(() => { ${varName}.${attr.property} = ${lowerExpression(attr.code)}; });\n`
87
- code += `on(${varName}, ${JSON.stringify(event)}, () => { ${lowerStatement(`${attr.code} = ${varName}.${attr.property}`)} });\n`
199
+ openTag += staticAttr(attr.name, attr.value)
88
200
  }
89
201
  }
90
- /* A `<script>` among the children scopes its bindings to this element's
91
- subtree (its later siblings auto-deref them); pop after. */
92
- code += withNestedScripts(node.children, () => generateChildren(node.children, varName))
93
- return { code, varName }
202
+ openTag += '>'
203
+ if (VOID_TAGS.has(node.tag)) {
204
+ return openTag
205
+ }
206
+ if (textLeafBind) {
207
+ /* Clone the element empty, build its text on the located node with the
208
+ imperative path — handles static/reactive/snippet/raw-html text. */
209
+ binds.push(generateChildren(node.children, elVar))
210
+ return `${openTag}</${node.tag}>`
211
+ }
212
+ /* A nested `<script>` among the children scopes its bindings to this subtree (its
213
+ later siblings auto-deref them); pop after. */
214
+ const inner = withNestedScripts(node.children, () =>
215
+ node.children.map((child) => skeletonMarkup(child, skVar, counter, binds)).join(''),
216
+ )
217
+ return `${openTag}${inner}</${node.tag}>`
218
+ }
219
+
220
+ /* Emits a skeletonable subtree via the skeleton path: a marker-stamped static
221
+ skeleton string (parsed once, cloned per mount) plus each hole's wiring against
222
+ its located node. */
223
+ function generateSkeleton(
224
+ node: Extract<TemplateNode, { kind: 'element' | 'component' }>,
225
+ parentVar: string,
226
+ ): string {
227
+ const skVar = nextVar('sk')
228
+ const binds: string[] = []
229
+ const html = skeletonMarkup(node, skVar, { el: 0, an: 0 }, binds)
230
+ return `const ${skVar} = skeleton(${parentVar}, ${JSON.stringify(html)});\n${binds.join('')}`
94
231
  }
95
232
 
96
233
  /* Emits code appending `node` to `parentVar`. */
97
- function generateChild(node: TemplateNode, parentVar: string): string {
234
+ function generateChild(node: TemplateNode, parentVar: string, before = 'null'): string {
98
235
  if (node.kind === 'script') {
99
236
  return `${lowerStatement(node.code)}\n`
100
237
  }
101
238
  /* A `<style>` emits no DOM — its CSS is bundled and its scope attribute is
102
- already stamped onto the elements it covers (see `generateElement`). */
239
+ already stamped onto the elements it covers (see `staticHtml`/`skeletonMarkup`). */
103
240
  if (node.kind === 'style') {
104
241
  return ''
105
242
  }
@@ -122,36 +259,43 @@ export function generateBuild(
122
259
  .join('')
123
260
  }
124
261
  if (node.kind === 'element' && node.tag === 'slot') {
125
- /* In a layout, the unnamed `<slot/>` is the router's page outlet: a bare
126
- structural element the router mounts the next chain layer into. Created
127
- empty (no scope attr, no children) so it matches the SSR placeholder. */
128
- if (isLayout && staticAttrValue(node, 'name') === undefined) {
129
- return `openChild(${parentVar}, ${JSON.stringify(OUTLET_TAG)});\n`
262
+ /* In a layout, `<slot/>` is the router's page outlet: a bare empty `OUTLET_TAG`
263
+ element the router mounts the next chain layer into, cloned (create) / claimed
264
+ (hydrate) so it matches the SSR placeholder. (Top-level/nested-in-element layout
265
+ slots are rewritten to `OUTLET_TAG` up front by `asOutlet`; this covers a slot
266
+ reached inside a control-flow branch.) */
267
+ if (isLayout) {
268
+ return `cloneStatic(${parentVar}, ${JSON.stringify(`<${OUTLET_TAG}></${OUTLET_TAG}>`)});\n`
130
269
  }
131
270
  return generateSlot(node, parentVar)
132
271
  }
133
272
  if (node.kind === 'element') {
134
- /* openChild appends (create) or claims (hydrate) no separate append. */
135
- return generateElement(node, `openChild(${parentVar}, ${JSON.stringify(node.tag)})`)
136
- .code
273
+ /* Every bound element builds through the parser-backed skeleton (one clone +
274
+ located holes / anchors, correct foreign namespaces). A fully-static element
275
+ never reaches here — `generateChildren` coalesces it into a `cloneStatic` run —
276
+ so a non-slot element here always carries a hole and is skeletonable. */
277
+ return generateSkeleton(node, parentVar)
137
278
  }
138
279
  if (node.kind === 'if') {
139
- return generateIf(node, parentVar)
280
+ return generateIf(node, parentVar, before)
140
281
  }
141
282
  if (node.kind === 'await') {
142
- return generateAwait(node, parentVar)
283
+ return generateAwait(node, parentVar, before)
143
284
  }
144
285
  if (node.kind === 'try') {
145
- return generateTry(node, parentVar)
286
+ return generateTry(node, parentVar, before)
146
287
  }
147
288
  if (node.kind === 'branch') {
148
289
  return '' // branches are consumed by their await block, never standalone
149
290
  }
150
291
  if (node.kind === 'component') {
151
- return generateComponent(node, parentVar)
292
+ /* A standalone component builds through the skeleton too — its wrapper element
293
+ is a located hole, the child mounts into it (same as a component nested in a
294
+ skeletonable element). */
295
+ return generateSkeleton(node, parentVar)
152
296
  }
153
297
  if (node.kind === 'switch') {
154
- return generateSwitch(node, parentVar)
298
+ return generateSwitch(node, parentVar, before)
155
299
  }
156
300
  if (node.kind === 'case') {
157
301
  return '' // cases are consumed by their switch/if, never standalone
@@ -159,7 +303,7 @@ export function generateBuild(
159
303
  if (node.kind === 'snippet') {
160
304
  return generateSnippet(node)
161
305
  }
162
- return generateEach(node, parentVar)
306
+ return generateEach(node, parentVar, before)
163
307
  }
164
308
 
165
309
  /* Builds a sibling list, coalescing maximal runs of fully-static element subtrees
@@ -202,6 +346,7 @@ export function generateBuild(
202
346
  function generateSwitch(
203
347
  node: Extract<TemplateNode, { kind: 'switch' }>,
204
348
  parentVar: string,
349
+ before: string,
205
350
  ): string {
206
351
  const cases = node.children
207
352
  .filter(
@@ -215,60 +360,41 @@ export function generateBuild(
215
360
  return `{ match: ${match}, render: ${branchThunk(branch.children)} }`
216
361
  })
217
362
  .join(', ')
218
- return `switchBlock(${parentVar}, () => (${lowerExpression(node.subject)}), [${cases}]);\n`
363
+ return `switchBlock(${parentVar}, () => (${lowerExpression(node.subject)}), [${cases}], ${before});\n`
219
364
  }
220
365
 
221
- /* A `<slot>` outlet: render the parent-provided content for this slot (default
222
- via `$children`, named via `$slots[name]`), falling back to the slot's own
223
- children when the parent supplied none. */
366
+ /* A `<slot>` outlet: render the parent-provided content (`$children`), falling
367
+ back to the slot's own children when the parent supplied none. */
224
368
  function generateSlot(
225
369
  node: Extract<TemplateNode, { kind: 'element' }>,
226
370
  parentVar: string,
227
371
  ): string {
228
- const name = staticAttrValue(node, 'name')
229
- const guard =
230
- name === undefined
231
- ? '$props && $props.$children'
232
- : `$props && $props.$slots && $props.$slots[${JSON.stringify(name)}]`
233
- const invoke =
234
- name === undefined
235
- ? `$props.$children(${parentVar})`
236
- : `$props.$slots[${JSON.stringify(name)}](${parentVar})`
237
- const fallback = node.children.map((child) => generateChild(child, parentVar)).join('')
372
+ const fallback = generateChildren(node.children, parentVar)
373
+ const invoke = `$props.$children(${parentVar})`
238
374
  if (fallback.trim() === '') {
239
- return `if (${guard}) { ${invoke}; }\n`
375
+ return `if ($props && $props.$children) { ${invoke}; }\n`
240
376
  }
241
- return `if (${guard}) { ${invoke}; } else {\n${fallback}}\n`
377
+ return `if ($props && $props.$children) { ${invoke}; } else {\n${fallback}}\n`
242
378
  }
243
379
 
244
380
  /* Mounts a child component into a wrapper element, passing each prop as a
245
381
  reactive thunk so the child re-reads when the parent expression changes. */
246
382
  /* The prop + slot thunks a child mount receives — its props as value thunks and
247
- its slot content as host-taking builders (`$children` / `$slots[name]`). */
383
+ its slot content as a host-taking builder (`$children`). */
248
384
  function componentParts(node: Extract<TemplateNode, { kind: 'component' }>): string[] {
249
385
  const parts = node.props.map(
250
386
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
251
387
  )
252
- const groups = partitionSlots(node.children)
253
- const slotCode = groups.default.map((child) => generateChild(child, '$slot')).join('')
388
+ const slotCode = generateChildren(node.children, '$slot')
254
389
  if (slotCode.trim() !== '') {
255
390
  parts.push(`"$children": ($slot) => {\n${slotCode}}`)
256
391
  }
257
- if (groups.named.length > 0) {
258
- const entries = groups.named
259
- .map((group) => {
260
- const code = group.nodes.map((child) => generateChild(child, '$slot')).join('')
261
- return `${JSON.stringify(group.name)}: ($slot) => {\n${code}}`
262
- })
263
- .join(', ')
264
- parts.push(`"$slots": { ${entries} }`)
265
- }
266
392
  return parts
267
393
  }
268
394
 
269
- /* Mounts a child into a wrapper obtained via `varExpr` (openChild appends on
270
- create / claims on hydrate). Hydration stays active, so the child adopts its
271
- server markup inside the wrapper. Returns the wrapper var. */
395
+ /* Mounts a child into a wrapper obtained via `varExpr` (a skeleton-located node).
396
+ Hydration stays active, so the child adopts its server markup inside the wrapper.
397
+ Returns the wrapper var. */
272
398
  function mountComponent(
273
399
  node: Extract<TemplateNode, { kind: 'component' }>,
274
400
  varExpr: string,
@@ -280,25 +406,12 @@ export function generateBuild(
280
406
  return { code, varName: wrapper }
281
407
  }
282
408
 
283
- function generateComponent(
284
- node: Extract<TemplateNode, { kind: 'component' }>,
285
- parentVar: string,
286
- ): string {
287
- const { tag, transparent } = componentWrapperTag(node.name)
288
- const { code, varName } = mountComponent(
289
- node,
290
- `openChild(${parentVar}, ${JSON.stringify(tag)})`,
291
- )
292
- /* A void-name remap is layout-transparent so the child's root stays a direct
293
- child of the parent (idempotent on a claimed SSR node that already has it). */
294
- return transparent ? `${code}${varName}.setAttribute("style", "display:contents");\n` : code
295
- }
296
-
297
409
  /* An await block: pending → resolved(value) / error branches. Each branch is a
298
410
  single-element root; a render thunk returns its node. */
299
411
  function generateAwait(
300
412
  node: Extract<TemplateNode, { kind: 'await' }>,
301
413
  parentVar: string,
414
+ before: string,
302
415
  ): string {
303
416
  const isBranch = (which: 'then' | 'catch' | 'finally') => (child: TemplateNode) =>
304
417
  child.kind === 'branch' && child.branch === which
@@ -337,7 +450,7 @@ export function generateBuild(
337
450
  `awaitBlock(${parentVar}, nextBlockId(), () => (${lowerExpression(node.promise)}), ` +
338
451
  `${pendingThunk}, ` +
339
452
  `${thenThunk}, ` +
340
- `${catchThunk});\n`
453
+ `${catchThunk}, ${before});\n`
341
454
  )
342
455
  }
343
456
 
@@ -407,7 +520,11 @@ export function generateBuild(
407
520
  /* A sync error boundary: build the guarded subtree (++ finally); a throw while
408
521
  building swaps to the catch branch (++ finally). No catch → `undefined`, which
409
522
  makes the runtime re-throw to the nearest enclosing boundary. */
410
- function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, parentVar: string): string {
523
+ function generateTry(
524
+ node: Extract<TemplateNode, { kind: 'try' }>,
525
+ parentVar: string,
526
+ before: string,
527
+ ): string {
411
528
  const catchBranch = findBranch(node.children, 'catch')
412
529
  const finallyChildren = branchChildren(findBranch(node.children, 'finally'))
413
530
  const guarded = node.children.filter((child) => child.kind !== 'branch')
@@ -420,22 +537,23 @@ export function generateBuild(
420
537
  branchVar(catchBranch) ?? '_error',
421
538
  finallyChildren,
422
539
  )
423
- return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk});\n`
540
+ return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
424
541
  }
425
542
 
426
543
  /* A conditional with an optional nested `<template else>` (a `case` child). Each
427
544
  branch is a content range the runtime tracks between markers. */
428
- function generateIf(node: Extract<TemplateNode, { kind: 'if' }>, parentVar: string): string {
545
+ function generateIf(
546
+ node: Extract<TemplateNode, { kind: 'if' }>,
547
+ parentVar: string,
548
+ before: string,
549
+ ): string {
429
550
  const elseBranch = node.children.find(
430
551
  (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
431
552
  )
432
553
  const thenChildren = node.children.filter((child) => child.kind !== 'case')
433
554
  const thenThunk = branchThunk(thenChildren)
434
- if (elseBranch === undefined) {
435
- return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk});\n`
436
- }
437
- const elseThunk = branchThunk(elseBranch.children)
438
- return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk});\n`
555
+ const elseThunk = elseBranch === undefined ? 'undefined' : branchThunk(elseBranch.children)
556
+ return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
439
557
  }
440
558
 
441
559
  /* A keyed each. Each row is a content RANGE (any content, tracked between the
@@ -443,6 +561,7 @@ export function generateBuild(
443
561
  function generateEach(
444
562
  node: Extract<TemplateNode, { kind: 'each' }>,
445
563
  parentVar: string,
564
+ before: string,
446
565
  ): string {
447
566
  const rowParam = nextVar('p')
448
567
  /* The row body builds its children (a `<script>` declares per-row local signals,
@@ -464,11 +583,36 @@ export function generateBuild(
464
583
  : ''
465
584
  return (
466
585
  `${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
467
- `(${node.as}) => (${keyExpression}), (${rowParam}, ${node.as}) => {\n${rowBody}}${catchArg});\n`
586
+ `(${node.as}) => (${keyExpression}), (${rowParam}, ${node.as}) => {\n${rowBody}}${catchArg}, ${before});\n`
468
587
  )
469
588
  }
470
589
 
471
- return generateChildren(nodes, hostVar)
590
+ /* In a layout the `<slot/>` page outlet is a bare empty `OUTLET_TAG` element (the
591
+ router fills it later) — exactly the SSR placeholder. Rewriting it to an element
592
+ node up front lets the static-clone path carry it as ordinary structure. */
593
+ function asOutlet(node: TemplateNode): TemplateNode {
594
+ if (node.kind !== 'element') {
595
+ return node
596
+ }
597
+ if (node.tag === 'slot') {
598
+ return { ...node, tag: OUTLET_TAG, attrs: [], children: [] }
599
+ }
600
+ return { ...node, children: node.children.map(asOutlet) }
601
+ }
602
+
603
+ return generateChildren(isLayout ? nodes.map(asOutlet) : nodes, hostVar)
604
+ }
605
+
606
+ /* A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton each mounts at
607
+ an `<!--a-->` anchor cloned into its located parent at the block's position. */
608
+ function isControlFlowNode(node: TemplateNode): boolean {
609
+ return (
610
+ node.kind === 'if' ||
611
+ node.kind === 'each' ||
612
+ node.kind === 'await' ||
613
+ node.kind === 'switch' ||
614
+ node.kind === 'try'
615
+ )
472
616
  }
473
617
 
474
618
  /* A text node that is purely whitespace (no interpolation, only blank static