@abide/abide 0.37.0 → 0.38.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 (65) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +38 -0
  3. package/package.json +2 -1
  4. package/src/abideResolverPlugin.ts +3 -17
  5. package/src/appEntry.ts +18 -8
  6. package/src/controlServerWorker.ts +8 -1
  7. package/src/lib/cli/parseArgvForRpc.ts +8 -3
  8. package/src/lib/mcp/createMcpServer.ts +8 -0
  9. package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
  10. package/src/lib/mcp/toolResultFromResponse.ts +5 -0
  11. package/src/lib/server/rpc/parseArgs.ts +12 -1
  12. package/src/lib/server/runtime/acceptsGzip.ts +10 -1
  13. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  14. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  15. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  17. package/src/lib/server/sockets/defineSocket.ts +6 -1
  18. package/src/lib/shared/cache.ts +6 -0
  19. package/src/lib/shared/createPushIterator.ts +7 -2
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +5 -0
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  22. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  23. package/src/lib/ui/compile/asOutlet.ts +30 -0
  24. package/src/lib/ui/compile/compileModule.ts +13 -9
  25. package/src/lib/ui/compile/generateBuild.ts +83 -72
  26. package/src/lib/ui/compile/generateSSR.ts +56 -37
  27. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  28. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  29. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  30. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  31. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  32. package/src/lib/ui/compile/skeletonContext.ts +97 -35
  33. package/src/lib/ui/compile/skeletonable.ts +3 -2
  34. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  35. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  36. package/src/lib/ui/dom/commentData.ts +14 -0
  37. package/src/lib/ui/dom/disposeRange.ts +21 -0
  38. package/src/lib/ui/dom/fillBefore.ts +10 -0
  39. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  40. package/src/lib/ui/dom/fillRange.ts +30 -0
  41. package/src/lib/ui/dom/hydrate.ts +11 -21
  42. package/src/lib/ui/dom/mount.ts +16 -25
  43. package/src/lib/ui/dom/mountChild.ts +27 -14
  44. package/src/lib/ui/dom/mountRange.ts +45 -0
  45. package/src/lib/ui/dom/mountSlot.ts +7 -2
  46. package/src/lib/ui/dom/outlet.ts +62 -0
  47. package/src/lib/ui/dom/scopeLabel.ts +13 -7
  48. package/src/lib/ui/dom/skeleton.ts +11 -27
  49. package/src/lib/ui/dom/withScope.ts +33 -0
  50. package/src/lib/ui/installHotBridge.ts +2 -0
  51. package/src/lib/ui/persist.ts +4 -1
  52. package/src/lib/ui/renderChain.ts +23 -15
  53. package/src/lib/ui/router.ts +78 -38
  54. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  55. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  56. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  57. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  58. package/src/lib/ui/runtime/createDoc.ts +20 -7
  59. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  60. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  61. package/src/lib/ui/runtime/types/Route.ts +6 -5
  62. package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
  63. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  64. package/src/lib/ui/compile/componentWrapperTag.ts +0 -13
  65. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
@@ -1,7 +1,7 @@
1
1
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
2
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
+ import { asOutlet } from './asOutlet.ts'
3
4
  import { bindListenEvent } from './bindListenEvent.ts'
4
- import { componentWrapperTag } from './componentWrapperTag.ts'
5
5
  import { groupBindParts } from './groupBindParts.ts'
6
6
  import { isControlFlow } from './isControlFlow.ts'
7
7
  import { lowerContext } from './lowerContext.ts'
@@ -32,12 +32,29 @@ export function generateBuild(
32
32
  let counter = 0
33
33
  const nextVar = (prefix: string): string => `${prefix}${counter++}`
34
34
 
35
+ /* In a layout, `<slot/>` outlets are rewritten to `OUTLET_TAG` elements up front
36
+ (`asOutlet`) so the static-clone path carries them as ordinary structure. `asOutlet`
37
+ CLONES every element it descends through and drops the slot's anchor hole, so the
38
+ shared skeleton context must walk THIS rewritten tree — the one the build traversal
39
+ below reads — or its node-keyed hole indices key the originals and never match. */
40
+ const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
41
+
35
42
  /* Per-node skeleton position from the SAME pass the SSR back-end reads — so the client's
36
43
  anchor/text-leaf decisions consult one source of truth instead of re-deriving the
37
- position structurally (the drift the shared context exists to prevent). `asOutlet`
38
- only rewrites layout `<slot>`s and preserves text-node identity, so the original
39
- `nodes` key the same text markers the build traversal looks up. */
40
- const { markText } = skeletonContext(nodes)
44
+ position structurally (the drift the shared context exists to prevent). */
45
+ const { markText, elIndex, anIndex } = skeletonContext(rootNodes)
46
+
47
+ /* The hole's index, assigned by the shared skeletonContext walk — the sole numberer. A
48
+ missing entry means this back-end reached a hole the shared walk didn't number: a
49
+ structural divergence between the two, surfaced loudly at compile time rather than as a
50
+ runtime hydration desync. */
51
+ function holeIndex(map: WeakMap<object, number>, key: object): number {
52
+ const index = map.get(key)
53
+ if (index === undefined) {
54
+ throw new Error('[abide] skeleton hole not numbered by the shared positional walk')
55
+ }
56
+ return index
57
+ }
41
58
 
42
59
  /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
43
60
  const {
@@ -107,12 +124,17 @@ export function generateBuild(
107
124
  block or slot drops an `<!--a-->` anchor at its position and mounts there (see
108
125
  `anchorCursor`), so it can sit ANYWHERE among static siblings. Static descendants are
109
126
  plain markup. */
110
- function skeletonMarkup(
111
- node: TemplateNode,
112
- skVar: string,
113
- counter: { el: number; an: number },
114
- binds: string[],
115
- ): string {
127
+ /* The skeleton anchor var for an anchor-positioned node: declares `an<n> = sk.an[i]` as
128
+ a bind and returns the var name. The three anchored kinds (control-flow/component,
129
+ outlet, slot) all mount at this `<!--a-->` anchor, so they number through this ONE
130
+ site (`anIndex`) no per-branch copy of the lookup to drift from the runtime scan. */
131
+ function anchorVarAt(node: TemplateNode, skVar: string, binds: string[]): string {
132
+ const anchorVar = nextVar('an')
133
+ binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\n`)
134
+ return anchorVar
135
+ }
136
+
137
+ function skeletonMarkup(node: TemplateNode, skVar: string, binds: string[]): string {
116
138
  if (node.kind === 'text') {
117
139
  /* Reactive text reached here is INTERLEAVED with element siblings (a text-leaf
118
140
  is bound via `generateChildren` instead). It can't be element-positioned, so
@@ -124,31 +146,23 @@ export function generateBuild(
124
146
  return staticTextPart(part.value)
125
147
  }
126
148
  binds.push(
127
- `appendTextAt(${skVar}.an[${counter.an++}], () => (${lowerExpression(part.code)}));\n`,
149
+ `appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], () => (${lowerExpression(part.code)}));\n`,
128
150
  )
129
151
  return '<!--a-->'
130
152
  })
131
153
  .join('')
132
154
  }
133
- if (isControlFlow(node)) {
134
- /* A control-flow block at its position: an `<!--a-->` anchor in the clone, the
135
- block mounted at it. `anchorCursor` parks the hydrate cursor past the anchor
136
- and returns the create insertion reference; the block's parent is the located
137
- element the anchor was cloned into (`anchor.parentNode`). */
138
- const anchorVar = nextVar('an')
139
- binds.push(`const ${anchorVar} = ${skVar}.an[${counter.an++}];\n`)
155
+ if (isControlFlow(node) || node.kind === 'component') {
156
+ /* A control-flow block OR a child component at its position: an `<!--a-->` anchor
157
+ in the clone, its content mounted as a marker-bounded range at it. `anchorCursor`
158
+ parks the hydrate cursor past the anchor and returns the create insertion
159
+ reference; the parent is the located element the anchor was cloned into
160
+ (`anchor.parentNode`). A component takes an anchor like a block — no wrapper
161
+ element so its root lays out as a true direct child of `anchor.parentNode`. */
162
+ const anchorVar = anchorVarAt(node, skVar, binds)
140
163
  binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
141
164
  return '<!--a-->'
142
165
  }
143
- if (node.kind === 'component') {
144
- /* The wrapper element is a positioned hole in the skeleton; the child mounts
145
- into the located node. display:contents (idempotent, static so it lives in
146
- the markup) keeps the wrapper out of layout — a pure mount host. */
147
- const tag = componentWrapperTag(node.name)
148
- const { code } = mountComponent(node, `${skVar}.el[${counter.el++}]`)
149
- binds.push(code)
150
- return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
151
- }
152
166
  if (node.kind === 'script') {
153
167
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
154
168
  runs as a bind, in document order, so its signals are declared before the later
@@ -166,11 +180,18 @@ export function generateBuild(
166
180
  if (node.kind !== 'element') {
167
181
  return '' // <style> emits no markup
168
182
  }
183
+ if (node.tag === OUTLET_TAG) {
184
+ /* A layout's router fill point at its position: an `<!--a-->` anchor, an empty
185
+ `outlet` boundary the router fills with the next chain layer (`fillBoundary`).
186
+ No wrapper element — the filled child lays out as a direct child of the parent. */
187
+ const anchorVar = anchorVarAt(node, skVar, binds)
188
+ binds.push(`outlet(${anchorVar}.parentNode, anchorCursor(${anchorVar}));\n`)
189
+ return '<!--a-->'
190
+ }
169
191
  if (node.tag === 'slot') {
170
192
  /* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
171
193
  mounted as a marker-bounded range (`mountSlot`) so it positions like a block. */
172
- const anchorVar = nextVar('an')
173
- binds.push(`const ${anchorVar} = ${skVar}.an[${counter.an++}];\n`)
194
+ const anchorVar = anchorVarAt(node, skVar, binds)
174
195
  const hostVar = nextVar('host')
175
196
  binds.push(
176
197
  `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
@@ -195,7 +216,7 @@ export function generateBuild(
195
216
  index BEFORE recursing, so holes number in pre-order — the order the runtime's
196
217
  path walk produces them. */
197
218
  elVar = nextVar('el')
198
- binds.push(`const ${elVar} = ${skVar}.el[${counter.el++}];\n`)
219
+ binds.push(`const ${elVar} = ${skVar}.el[${holeIndex(elIndex, node)}];\n`)
199
220
  openTag += ` ${HOLE_ATTRIBUTE}`
200
221
  for (const attr of node.attrs) {
201
222
  if (attr.kind !== 'static') {
@@ -224,7 +245,7 @@ export function generateBuild(
224
245
  /* A nested `<script>` among the children scopes its bindings to this subtree (its
225
246
  later siblings auto-deref them); pop after. */
226
247
  const inner = withNestedScripts(node.children, () =>
227
- node.children.map((child) => skeletonMarkup(child, skVar, counter, binds)).join(''),
248
+ node.children.map((child) => skeletonMarkup(child, skVar, binds)).join(''),
228
249
  )
229
250
  return `${openTag}${inner}</${node.tag}>`
230
251
  }
@@ -233,12 +254,12 @@ export function generateBuild(
233
254
  skeleton string (parsed once, cloned per mount) plus each hole's wiring against
234
255
  its located node. */
235
256
  function generateSkeleton(
236
- node: Extract<TemplateNode, { kind: 'element' | 'component' }>,
257
+ node: Extract<TemplateNode, { kind: 'element' }>,
237
258
  parentVar: string,
238
259
  ): string {
239
260
  const skVar = nextVar('sk')
240
261
  const binds: string[] = []
241
- const html = skeletonMarkup(node, skVar, { el: 0, an: 0 }, binds)
262
+ const html = skeletonMarkup(node, skVar, binds)
242
263
  return `const ${skVar} = skeleton(${parentVar}, ${JSON.stringify(html)});\n${binds.join('')}`
243
264
  }
244
265
 
@@ -270,14 +291,19 @@ export function generateBuild(
270
291
  })
271
292
  .join('')
272
293
  }
294
+ if (node.kind === 'element' && node.tag === OUTLET_TAG) {
295
+ /* A standalone layout outlet (a top-level/element-nested `<slot/>` rewritten by
296
+ `asOutlet`, reached outside any skeleton): an empty `outlet` boundary at
297
+ `before`, no anchor — the router fills it with the next chain layer. */
298
+ return `outlet(${parentVar}, ${before});\n`
299
+ }
273
300
  if (node.kind === 'element' && node.tag === 'slot') {
274
- /* In a layout, `<slot/>` is the router's page outlet: a bare empty `OUTLET_TAG`
275
- element the router mounts the next chain layer into, cloned (create) / claimed
276
- (hydrate) so it matches the SSR placeholder. (Top-level/nested-in-element layout
277
- slots are rewritten to `OUTLET_TAG` up front by `asOutlet`; this covers a slot
278
- reached inside a control-flow branch.) */
301
+ /* In a layout, `<slot/>` is the router's page outlet (`outlet` boundary the
302
+ router fills with the next chain layer). Top-level/element-nested layout slots
303
+ are rewritten to `OUTLET_TAG` up front by `asOutlet` and handled above; this
304
+ covers a layout slot reached inside a control-flow branch. */
279
305
  if (isLayout) {
280
- return `cloneStatic(${parentVar}, ${JSON.stringify(`<${OUTLET_TAG}></${OUTLET_TAG}>`)});\n`
306
+ return `outlet(${parentVar}, ${before});\n`
281
307
  }
282
308
  return generateSlot(node, parentVar)
283
309
  }
@@ -301,10 +327,10 @@ export function generateBuild(
301
327
  return '' // branches are consumed by their await block, never standalone
302
328
  }
303
329
  if (node.kind === 'component') {
304
- /* A standalone component builds through the skeleton too its wrapper element
305
- is a located hole, the child mounts into it (same as a component nested in a
306
- skeletonable element). */
307
- return generateSkeleton(node, parentVar)
330
+ /* A standalone component (top-level, or a bare child of a branch/row/slot) mounts
331
+ directly as a marker range on `parentVar` at `before` no anchor, no wrapper,
332
+ same as a standalone control-flow block routes through `generateIf`/etc. */
333
+ return generateChildComponent(node, parentVar, before)
308
334
  }
309
335
  if (node.kind === 'switch') {
310
336
  return generateSwitch(node, parentVar, before)
@@ -389,8 +415,6 @@ export function generateBuild(
389
415
  return `if ($props && $props.$children) { ${invoke}; } else {\n${fallback}}\n`
390
416
  }
391
417
 
392
- /* Mounts a child component into a wrapper element, passing each prop as a
393
- reactive thunk so the child re-reads when the parent expression changes. */
394
418
  /* The prop + slot thunks a child mount receives — its props as value thunks and
395
419
  its slot content as a host-taking builder (`$children`). */
396
420
  function componentParts(node: Extract<TemplateNode, { kind: 'component' }>): string[] {
@@ -404,18 +428,18 @@ export function generateBuild(
404
428
  return parts
405
429
  }
406
430
 
407
- /* Mounts a child into a wrapper obtained via `varExpr` (a skeleton-located node).
408
- Hydration stays active, so the child adopts its server markup inside the wrapper.
409
- Returns the wrapper var. */
410
- function mountComponent(
431
+ /* Mounts a child component as a marker-bounded range on `parentVar`, positioned at
432
+ `before` (a skeleton anchor's `anchorCursor`, or `null` for a standalone child).
433
+ `mountRange` opens the `[`/`]` markers and builds the child between them — no
434
+ wrapper element — so the child's root is a true direct child of `parentVar`.
435
+ Hydration stays ambient, so the child claims its server range in place. The
436
+ component name passes as the scope label (the inspector's `<Counter>` name). */
437
+ function generateChildComponent(
411
438
  node: Extract<TemplateNode, { kind: 'component' }>,
412
- varExpr: string,
413
- ): { code: string; varName: string } {
414
- const wrapper = nextVar('cmp')
415
- const code =
416
- `const ${wrapper} = ${varExpr};\n` +
417
- `mountChild(${wrapper}, ${node.name}, { ${componentParts(node).join(', ')} });\n`
418
- return { code, varName: wrapper }
439
+ parentVar: string,
440
+ before: string,
441
+ ): string {
442
+ return `mountChild(${parentVar}, ${node.name}, { ${componentParts(node).join(', ')} }, ${before}, ${JSON.stringify(node.name)});\n`
419
443
  }
420
444
 
421
445
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -595,20 +619,7 @@ export function generateBuild(
595
619
  )
596
620
  }
597
621
 
598
- /* In a layout the `<slot/>` page outlet is a bare empty `OUTLET_TAG` element (the
599
- router fills it later) — exactly the SSR placeholder. Rewriting it to an element
600
- node up front lets the static-clone path carry it as ordinary structure. */
601
- function asOutlet(node: TemplateNode): TemplateNode {
602
- if (node.kind !== 'element') {
603
- return node
604
- }
605
- if (node.tag === 'slot') {
606
- return { ...node, tag: OUTLET_TAG, attrs: [], children: [] }
607
- }
608
- return { ...node, children: node.children.map(asOutlet) }
609
- }
610
-
611
- return generateChildren(isLayout ? nodes.map(asOutlet) : nodes, hostVar)
622
+ return generateChildren(rootNodes, hostVar)
612
623
  }
613
624
 
614
625
  /* A text node that is purely whitespace (no interpolation, only blank static
@@ -633,7 +644,7 @@ imperative. Static text and elements nested INSIDE a qualifying element are fine
633
644
  enclosed by its tags.
634
645
  */
635
646
  function isStaticCloneableElement(node: TemplateNode): boolean {
636
- if (node.kind !== 'element' || node.tag === 'slot') {
647
+ if (node.kind !== 'element' || node.tag === 'slot' || node.tag === OUTLET_TAG) {
637
648
  return false
638
649
  }
639
650
  if (node.attrs.some((attr) => attr.kind !== 'static')) {
@@ -1,7 +1,8 @@
1
+ import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
1
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
- import { componentWrapperTag } from './componentWrapperTag.ts'
3
+ import { asOutlet } from './asOutlet.ts'
3
4
  import { groupBindParts } from './groupBindParts.ts'
4
- import { isControlFlow } from './isControlFlow.ts'
5
+ import { isAnchorPositioned } from './isAnchorPositioned.ts'
5
6
  import { lowerContext } from './lowerContext.ts'
6
7
  import { scopeAttr } from './scopeAttr.ts'
7
8
  import { skeletonContext } from './skeletonContext.ts'
@@ -18,6 +19,17 @@ import { VOID_TAGS } from './VOID_TAGS.ts'
18
19
  const RANGE_OPEN = '<!--[-->'
19
20
  const RANGE_CLOSE = '<!--]-->'
20
21
 
22
+ /* The `then`/`catch`/`finally` branch child of an await/try block, or undefined. */
23
+ function branchNamed(
24
+ children: TemplateNode[],
25
+ which: 'then' | 'catch' | 'finally',
26
+ ): Extract<TemplateNode, { kind: 'branch' }> | undefined {
27
+ return children.find(
28
+ (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
29
+ child.kind === 'branch' && child.branch === which,
30
+ )
31
+ }
32
+
21
33
  /*
22
34
  Server code generator: turns the parsed template into statements that push HTML
23
35
  fragments onto an output array, reading the document synchronously (no DOM, no
@@ -64,11 +76,17 @@ export function generateSSR(
64
76
  return children.map((child) => generate(child, target)).join('')
65
77
  }
66
78
 
79
+ /* In a layout, rewrite `<slot/>` outlets to `OUTLET_TAG` elements up front (the same shared
80
+ `asOutlet` the client back-end runs), then drive both the skeleton context and the
81
+ traversal from this tree — one decision site for the outlet, and the outlet emitted bare
82
+ through the generic element path exactly as the client clones it. */
83
+ const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
84
+
67
85
  /* Per-node skeleton position, computed once. Both back-ends read this single source of
68
86
  truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
69
87
  (control-flow branches, component/slot/snippet content) are enumerated there, not
70
88
  re-tracked here as mutable state that a forgotten reset could leak past. */
71
- const { inSkeleton, markText } = skeletonContext(nodes)
89
+ const { inSkeleton, markText } = skeletonContext(rootNodes)
72
90
 
73
91
  /* A control-flow branch's content, generated exactly like a normal child list so
74
92
  a branch holds ANY content (components, text, nested blocks). `generate` emits
@@ -93,12 +111,12 @@ export function generateSSR(
93
111
  inSkeleton.get(node) ? push(target, '<!--a-->') : ''
94
112
 
95
113
  function generate(node: TemplateNode, target: string): string {
96
- /* A control-flow block is positioned by an `<!--a-->` anchor when in a skeleton
97
- context. Emit it ONCE here one decision site gated by the shared `isControlFlow`,
98
- mirroring the client's single `skeletonMarkup` control-flow branch rather than
99
- prefixing each block kind's own `anchorMark` call (six sites that had to stay in
100
- lock-step). `anchorMark` no-ops outside a skeleton, so non-block nodes ignore it. */
101
- const anchor = isControlFlow(node) ? anchorMark(node, target) : ''
114
+ /* Every kind that mounts as a marker range is positioned by an `<!--a-->` anchor when
115
+ in a skeleton context: control-flow blocks, child components, and a layout's outlet /
116
+ a component's `<slot>` (both elements). `isAnchorPositioned` is the ONE decision site
117
+ (mirrored by the client's `skeletonMarkup`); `anchorMark` no-ops outside a skeleton,
118
+ so non-anchored nodes ignore the precomputed `anchor`. */
119
+ const anchor = isAnchorPositioned(node) ? anchorMark(node, target) : ''
102
120
  if (node.kind === 'text') {
103
121
  return node.parts
104
122
  .map((part) => {
@@ -180,11 +198,11 @@ export function generateSSR(
180
198
  return ''
181
199
  }
182
200
  if (node.kind === 'component') {
183
- /* Server-render the child via its `render` and inline the HTML inside
184
- the same wrapper the client mounts into, so SSR and client agree.
185
- Props pass as thunks; slot content passes as a string-returning
186
- `$children` the child invokes from its <slot>. */
187
- const tag = componentWrapperTag(node.name)
201
+ /* Server-render the child via its `render` and inline the HTML inside the same
202
+ `[ ]` marker range the client mounts into (`mountRange`) no wrapper element,
203
+ so SSR and client agree and the child's root lays out as a direct child. Props
204
+ pass as thunks; slot content passes as a string-returning `$children` the child
205
+ invokes from its <slot>. */
188
206
  const parts = node.props.map(
189
207
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
190
208
  )
@@ -206,20 +224,30 @@ export function generateSSR(
206
224
  the enclosing render body, including from branch closures.) */
207
225
  const result = nextVar('$child')
208
226
  return (
209
- push(target, `<${tag} style="display:contents">`) +
227
+ anchor +
228
+ push(target, RANGE_OPEN) +
210
229
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
211
230
  `${target}.push(${result}.html);\n` +
212
231
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
213
- push(target, `</${tag}>`)
232
+ push(target, RANGE_CLOSE)
214
233
  )
215
234
  }
235
+ if (node.kind === 'element' && node.tag === OUTLET_TAG) {
236
+ /* A layout's router fill point (`asOutlet` rewrote its `<slot/>`): an `<!--a-->`
237
+ anchor (in a skeleton) + an empty `<!--abide:outlet-->`…`<!--/abide:outlet-->`
238
+ boundary the chain composer folds the child layer into (`renderChain`) and the
239
+ client router fills/hydrates (`outlet`/`fillBoundary`) — no wrapper element. */
240
+ return anchor + push(target, `<!--${OUTLET_OPEN}--><!--${OUTLET_CLOSE}-->`)
241
+ }
216
242
  if (node.kind === 'element' && node.tag === 'slot') {
217
- /* A layout's `<slot/>` is the router's page outlet: emit an empty
218
- placeholder the chain composer folds the child layer's html into. */
243
+ /* `asOutlet` already rewrote a layout's top-level/element-nested `<slot/>` to an
244
+ `OUTLET_TAG` element (handled above), so a `slot` node reaching here in a layout
245
+ is control-flow-nested — emit the same empty outlet boundary the client's
246
+ control-flow-nested path builds, which the chain composer folds the child into. */
219
247
  if (isLayout) {
220
- return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
248
+ return push(target, `<!--${OUTLET_OPEN}--><!--${OUTLET_CLOSE}-->`)
221
249
  }
222
- return generateSlot(node, target)
250
+ return generateSlot(node, target, anchor)
223
251
  }
224
252
  let code = push(target, `<${node.tag}`)
225
253
  /* Every `<style>` active at this element (own siblings + ancestors) — same set
@@ -272,6 +300,7 @@ export function generateSSR(
272
300
  function generateSlot(
273
301
  node: Extract<TemplateNode, { kind: 'element' }>,
274
302
  target: string,
303
+ anchor: string,
275
304
  ): string {
276
305
  const wrap = inSkeleton.get(node)
277
306
  const fallback = generateInto(node.children, target)
@@ -282,7 +311,7 @@ export function generateSSR(
282
311
  if (!wrap) {
283
312
  return body
284
313
  }
285
- return `${anchorMark(node, target)}${openRange(target)}${body}${closeRange(target)}`
314
+ return `${anchor}${openRange(target)}${body}${closeRange(target)}`
286
315
  }
287
316
 
288
317
  /* Boundary markers + a `$awaits` registration carrying the promise and
@@ -291,17 +320,12 @@ export function generateSSR(
291
320
  an empty boundary — its resolved branch is the children bound to `node.as` — and
292
321
  flags the entry so `renderToStream` settles it before the first flush. */
293
322
  function generateAwait(node: Extract<TemplateNode, { kind: 'await' }>, target: string): string {
294
- const branchOf = (which: 'then' | 'catch' | 'finally') =>
295
- node.children.find(
296
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
297
- child.kind === 'branch' && child.branch === which,
298
- )
299
- const catchBranch = branchOf('catch')
300
- const finallyChildren = branchOf('finally')?.children ?? []
323
+ const catchBranch = branchNamed(node.children, 'catch')
324
+ const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
301
325
  /* Resolved branch + its bound value: the children directly when blocking, the
302
326
  `then` child when streaming. Pending (streaming only) is the non-branch
303
327
  children. */
304
- const thenBranch = branchOf('then')
328
+ const thenBranch = branchNamed(node.children, 'then')
305
329
  const resolvedChildren = node.blocking
306
330
  ? node.children.filter((child) => child.kind !== 'branch')
307
331
  : (thenBranch?.children ?? [])
@@ -342,13 +366,8 @@ export function generateSSR(
342
366
  an enclosing boundary / the 500 / the stream). Boundary comments let hydration
343
367
  discard the server content if the client adoption fails. */
344
368
  function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
345
- const branchOf = (which: 'catch' | 'finally') =>
346
- node.children.find(
347
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
348
- child.kind === 'branch' && child.branch === which,
349
- )
350
- const catchBranch = branchOf('catch')
351
- const finallyChildren = branchOf('finally')?.children ?? []
369
+ const catchBranch = branchNamed(node.children, 'catch')
370
+ const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
352
371
  const guarded = node.children.filter((child) => child.kind !== 'branch')
353
372
  const errName = catchBranch?.as ?? '_error'
354
373
  const id = nextVar('$tid')
@@ -371,5 +390,5 @@ export function generateSSR(
371
390
  return code
372
391
  }
373
392
 
374
- return generateInto(nodes, '$out')
393
+ return generateInto(rootNodes, '$out')
375
394
  }
@@ -0,0 +1,19 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { isControlFlow } from './isControlFlow.ts'
3
+ import type { TemplateNode } from './types/TemplateNode.ts'
4
+
5
+ /*
6
+ The node kinds that mount as a marker range and so take an `<!--a-->` anchor when in a
7
+ skeleton — control-flow, child component, and the two outlet elements (a layout's
8
+ `OUTLET_TAG`, a component's `<slot>`). The single positioning predicate every back-end
9
+ shares — `generateSSR` gates its anchor emission on it, `skeletonContext` gates its anchor
10
+ indexing on it — so SSR, the client clone, and the shared numberer cannot disagree on which
11
+ nodes anchor.
12
+ */
13
+ export function isAnchorPositioned(node: TemplateNode): boolean {
14
+ return (
15
+ isControlFlow(node) ||
16
+ node.kind === 'component' ||
17
+ (node.kind === 'element' && (node.tag === OUTLET_TAG || node.tag === 'slot'))
18
+ )
19
+ }
@@ -5,7 +5,9 @@ A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton each
5
5
  `<!--a-->` anchor cloned into its located parent at the block's position (see `anchorCursor`),
6
6
  so a block can sit ANYWHERE among static siblings. The shared classification under block
7
7
  anchor placement: both back-ends — and `skeletonable` — consult this so they agree on which
8
- nodes are anchor-positioned, the way `componentWrapperTag`/`isTextLeaf` already are.
8
+ nodes are anchor-positioned. A child component is anchor-positioned too (it mounts as a
9
+ `[`…`]` range like a block, see `mountRange`); the back-ends OR this with `kind ===
10
+ 'component'` rather than folding it in here, since a standalone component routes differently.
9
11
  */
10
12
  export function isControlFlow(node: TemplateNode): boolean {
11
13
  return (
@@ -8,7 +8,7 @@ located element (`generateBuild`) / emits it without an `<!--a-->` prefix (`gene
8
8
  reactive text interleaved with element children is anchor-positioned instead. Computing it
9
9
  once keeps the SSR string and the client skeleton from disagreeing about a `<!--a-->` — the
10
10
  first slice of lifting the positional model out of the two parallel traversals, alongside
11
- the already-shared `componentWrapperTag` and `skeletonable`.
11
+ the already-shared `isControlFlow` and `skeletonable`.
12
12
  */
13
13
  export function isTextLeaf(node: Extract<TemplateNode, { kind: 'element' }>): boolean {
14
14
  return node.children.every((child) => child.kind === 'text' || child.kind === 'style')
@@ -148,7 +148,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
148
148
  } else {
149
149
  attrs.push({ kind: 'expression', name, code, loc })
150
150
  }
151
- } else {
151
+ } else if (source.charAt(cursor) === '"' || source.charAt(cursor) === "'") {
152
152
  const quote = source.charAt(cursor)
153
153
  cursor += 1
154
154
  let value = ''
@@ -158,6 +158,15 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
158
158
  }
159
159
  cursor += 1 // past closing quote
160
160
  attrs.push({ kind: 'static', name, value })
161
+ } else {
162
+ /* Unquoted value (`<input type=text>`): runs to the next whitespace or
163
+ `>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
164
+ let value = ''
165
+ while (cursor < source.length && !/[\s>]/.test(source.charAt(cursor))) {
166
+ value += source.charAt(cursor)
167
+ cursor += 1
168
+ }
169
+ attrs.push({ kind: 'static', name, value })
161
170
  }
162
171
  }
163
172
  return attrs
@@ -35,8 +35,15 @@ export function renameSignalRefs(
35
35
  if (ts.isPropertyAccessExpression(node)) {
36
36
  skip.add(node.name)
37
37
  }
38
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
39
- skip.add(node.name)
38
+ /* A declaration name is a binding, not a value read. A plain identifier is
39
+ skipped directly; a destructuring pattern's leaf names are collected so a
40
+ bound name shadowing a signal (`const { count } = …`) is never rewritten. */
41
+ if (ts.isVariableDeclaration(node)) {
42
+ if (ts.isIdentifier(node.name)) {
43
+ skip.add(node.name)
44
+ } else {
45
+ collectBindingIdentifiers(node.name, skip)
46
+ }
40
47
  }
41
48
  if (ts.isParameter(node) && ts.isIdentifier(node.name)) {
42
49
  skip.add(node.name)
@@ -180,6 +187,22 @@ function collectStatementBindings(statement: ts.Statement, into: Set<string>): v
180
187
  }
181
188
  }
182
189
 
190
+ /* The identifier NODES a binding name binds — a plain identifier or the leaves of a
191
+ destructuring pattern (object/array, including nested patterns and rest elements).
192
+ For `{ a: b }` only the bound name `b` is a binding; `a` is the source property.
193
+ Feeds the skip set so a destructured name shadowing a signal is never rewritten. */
194
+ function collectBindingIdentifiers(name: ts.BindingName, into: Set<ts.Node>): void {
195
+ if (ts.isIdentifier(name)) {
196
+ into.add(name)
197
+ return
198
+ }
199
+ for (const element of name.elements) {
200
+ if (ts.isBindingElement(element)) {
201
+ collectBindingIdentifiers(element.name, into)
202
+ }
203
+ }
204
+ }
205
+
183
206
  /* Every identifier bound by a binding name — a plain identifier or the leaves of a
184
207
  destructuring pattern (object/array, including rest elements). */
185
208
  function collectBindingNames(name: ts.BindingName, into: Set<string>): void {