@abide/abide 0.38.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 (39) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/package.json +2 -1
  4. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
  5. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  6. package/src/lib/ui/compile/asOutlet.ts +6 -5
  7. package/src/lib/ui/compile/compileModule.ts +13 -9
  8. package/src/lib/ui/compile/generateBuild.ts +55 -44
  9. package/src/lib/ui/compile/generateSSR.ts +30 -21
  10. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  11. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  12. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  13. package/src/lib/ui/compile/skeletonContext.ts +19 -20
  14. package/src/lib/ui/compile/skeletonable.ts +3 -2
  15. package/src/lib/ui/dom/commentData.ts +14 -0
  16. package/src/lib/ui/dom/disposeRange.ts +21 -0
  17. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  18. package/src/lib/ui/dom/fillRange.ts +30 -0
  19. package/src/lib/ui/dom/hydrate.ts +11 -21
  20. package/src/lib/ui/dom/mount.ts +16 -25
  21. package/src/lib/ui/dom/mountChild.ts +27 -14
  22. package/src/lib/ui/dom/mountRange.ts +45 -0
  23. package/src/lib/ui/dom/outlet.ts +62 -0
  24. package/src/lib/ui/dom/scopeLabel.ts +8 -6
  25. package/src/lib/ui/dom/skeleton.ts +11 -30
  26. package/src/lib/ui/dom/withScope.ts +33 -0
  27. package/src/lib/ui/installHotBridge.ts +2 -0
  28. package/src/lib/ui/renderChain.ts +23 -15
  29. package/src/lib/ui/router.ts +78 -38
  30. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  31. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  32. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  33. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  34. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  35. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  36. package/src/lib/ui/runtime/types/Route.ts +6 -5
  37. package/src/lib/ui/runtime/types/UiComponent.ts +5 -0
  38. package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
  39. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
package/AGENTS.md CHANGED
@@ -162,7 +162,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
162
162
  - `abide/ui/enterScope`, `abide/ui/exitScope` — push/pop the lexical scope around an SSR render.
163
163
  - `abide/ui/router`, `abide/ui/startClient`, `abide/ui/renderToStream` — the client router, the official client entry (reads `window.__SSR__`, seeds cache, starts the router), and the out-of-order SSR streamer.
164
164
  - `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the bundler-emitted client substitutes that swap a server verb/socket import for a `fetch` proxy / ws-multiplexed `Socket`.
165
- - `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mountSlot`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`.
165
+ - `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mountSlot`, `outlet`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`. `outlet` is the marker-range a layout `<slot/>` compiles to (no wrapper element — the router fills it with the next chain layer as a direct child).
166
166
  - `abide/ui/runtime/*` — render-pass helpers: `escapeKey` (JSON-Pointer key escaping), `nextBlockId`, `enterRenderPass`, `exitRenderPass`.
167
167
 
168
168
  ## Build / tooling — @documentation building
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # abide
2
2
 
3
+ ## 0.38.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`67ac25f`](https://github.com/briancray/abide/commit/67ac25faf78b029d0b14659df1e4ec1a05ae7a10) - document outlet marker-range in AGENTS.md surface map ([`ab23673`](https://github.com/briancray/abide/commit/ab236733ee1f81e19ce09ee35fe44624e955a6e4))
8
+
9
+ - [`67ac25f`](https://github.com/briancray/abide/commit/67ac25faf78b029d0b14659df1e4ec1a05ae7a10) - unify pages/layouts/outlets on marker-range model ([`df1c95f`](https://github.com/briancray/abide/commit/df1c95f89ff8d6d6806606dbece531c6be7aabdf))
10
+
3
11
  ## 0.38.0
4
12
 
5
13
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -89,6 +89,7 @@
89
89
  "./ui/dom/skeleton": "./src/lib/ui/dom/skeleton.ts",
90
90
  "./ui/dom/anchorCursor": "./src/lib/ui/dom/anchorCursor.ts",
91
91
  "./ui/dom/mountSlot": "./src/lib/ui/dom/mountSlot.ts",
92
+ "./ui/dom/outlet": "./src/lib/ui/dom/outlet.ts",
92
93
  "./ui/dom/attr": "./src/lib/ui/dom/attr.ts",
93
94
  "./ui/dom/on": "./src/lib/ui/dom/on.ts",
94
95
  "./ui/dom/attach": "./src/lib/ui/dom/attach.ts",
@@ -1,4 +1,5 @@
1
- /* The tag prefix every component instance mounts into (`abide-<name>`). One source
2
- for the compiler (componentWrapperTag) and the runtime opacity checks (skeleton,
3
- scopeLabel) so the wrapper convention can't drift between build and hydrate. */
1
+ /* The `abide-` tag prefix on framework-owned custom elements (the router's
2
+ `abide-outlet`, the streaming `abide-resolve`/`abide-cache` fragments). `scopeLabel`
3
+ strips it to read the outlet host's bare name. Child components no longer mount into
4
+ an `abide-<name>` wrapper — they build as marker ranges (see `mountRange`). */
4
5
  export const COMPONENT_WRAPPER_PREFIX = 'abide-'
@@ -30,6 +30,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
30
30
  { name: 'tryBlock', specifier: 'ui/dom/tryBlock' },
31
31
  { name: 'switchBlock', specifier: 'ui/dom/switchBlock' },
32
32
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot' },
33
+ { name: 'outlet', specifier: 'ui/dom/outlet' },
33
34
  { name: 'mountChild', specifier: 'ui/dom/mountChild' },
34
35
  { name: 'hydrate', specifier: 'ui/dom/hydrate' },
35
36
  { name: 'escapeKey', specifier: 'ui/runtime/escapeKey' },
@@ -8,11 +8,12 @@ structure AND lets BOTH back-ends feed the same tree to `skeletonContext` — on
8
8
  for "a layout slot is an outlet, not an anchor", instead of the client running `asOutlet` while
9
9
  SSR mirrors it with an inline special-case.
10
10
 
11
- The outlet is a structural mount container, not styled content, so it carries NO attrs and NO
12
- style scope — keeping it byte-identical to the placeholder `renderChain` folds the child layer
13
- into (an exact `<abide-outlet></abide-outlet>` string match). Stripping `scopes` is what makes
14
- the two back-ends agree: the SSR special-case emitted the outlet bare, but the client clone read
15
- the slot's annotated `scopes` and stamped them — a hydration mismatch for any scoped layout.
11
+ The outlet is a structural mount point, not styled content, so it carries NO attrs and NO style
12
+ scope — both back-ends lower it to the bare `<!--abide:outlet-->`…`<!--/abide:outlet-->` boundary
13
+ `renderChain` folds the child layer into (matching its `OUTLET_PLACEHOLDER`). Stripping `scopes`
14
+ is what makes the two back-ends agree: SSR emits the boundary bare, but without this the client
15
+ clone would read the slot's annotated `scopes` and stamp them — a hydration mismatch for any
16
+ scoped layout.
16
17
 
17
18
  Control-flow children are fresh build contexts (their own runtime mounts a nested slot), so they
18
19
  are not descended into — a `<slot>` inside an `{#if}` stays a slot node, handled at its own
@@ -42,11 +42,13 @@ export function compileModule(
42
42
  const id = JSON.stringify(options.moduleId)
43
43
  return `const { ${names}, hotReplace } = window.__abide
44
44
  ${userImports}
45
- function component(host, $props) {
46
- return mount(host, (host) => {
45
+ function build(host, $props) {
47
46
  ${body}
48
- })
49
47
  }
48
+ function component(host, $props) {
49
+ return mount(host, build, $props)
50
+ }
51
+ component.build = build
50
52
  component.__abideId = ${id}
51
53
  if (!hotReplace(${id}, component)) location.reload()
52
54
  `
@@ -61,17 +63,17 @@ if (!hotReplace(${id}, component)) location.reload()
61
63
  exactly which names it emitted, so it filters here. A name absent from the body
62
64
  is unreferenced; erring toward inclusion (a stray match in user script) only
63
65
  keeps a harmless unused import, never drops a needed one. */
64
- const moduleBody = `export default function component(host, $props) {
65
- return mount(host, (host) => {
66
+ const moduleBody = `function build(host, $props) {
66
67
  ${body}
67
- })
68
+ }
69
+
70
+ export default function component(host, $props) {
71
+ return mount(host, build, $props)
68
72
  }
69
73
 
70
74
  /* Adopt the server-rendered DOM in place instead of rebuilding it. */
71
75
  export function hydrateInto(host, $props) {
72
- return hydrate(host, (host) => {
73
- ${body}
74
- })
76
+ return hydrate(host, build, $props)
75
77
  }
76
78
 
77
79
  export function render($props) {
@@ -80,6 +82,8 @@ ${ssrBody}
80
82
 
81
83
  component.render = render
82
84
  component.hydrate = hydrateInto
85
+ /* The bare build, so a parent can range-mount this as a nested child (no wrapper). */
86
+ component.build = build
83
87
  component.hydratable = ${analyzed.hydratable}
84
88
  ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(options.moduleId)}\n`}`
85
89
  /* Scope each name to the surface that genuinely references it. The SSR body
@@ -2,7 +2,6 @@ import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
2
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
3
  import { asOutlet } from './asOutlet.ts'
4
4
  import { bindListenEvent } from './bindListenEvent.ts'
5
- import { componentWrapperTag } from './componentWrapperTag.ts'
6
5
  import { groupBindParts } from './groupBindParts.ts'
7
6
  import { isControlFlow } from './isControlFlow.ts'
8
7
  import { lowerContext } from './lowerContext.ts'
@@ -125,6 +124,16 @@ export function generateBuild(
125
124
  block or slot drops an `<!--a-->` anchor at its position and mounts there (see
126
125
  `anchorCursor`), so it can sit ANYWHERE among static siblings. Static descendants are
127
126
  plain markup. */
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
+
128
137
  function skeletonMarkup(node: TemplateNode, skVar: string, binds: string[]): string {
129
138
  if (node.kind === 'text') {
130
139
  /* Reactive text reached here is INTERLEAVED with element siblings (a text-leaf
@@ -143,25 +152,17 @@ export function generateBuild(
143
152
  })
144
153
  .join('')
145
154
  }
146
- if (isControlFlow(node)) {
147
- /* A control-flow block at its position: an `<!--a-->` anchor in the clone, the
148
- block mounted at it. `anchorCursor` parks the hydrate cursor past the anchor
149
- and returns the create insertion reference; the block's parent is the located
150
- element the anchor was cloned into (`anchor.parentNode`). */
151
- const anchorVar = nextVar('an')
152
- binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\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)
153
163
  binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
154
164
  return '<!--a-->'
155
165
  }
156
- if (node.kind === 'component') {
157
- /* The wrapper element is a positioned hole in the skeleton; the child mounts
158
- into the located node. display:contents (idempotent, static so it lives in
159
- the markup) keeps the wrapper out of layout — a pure mount host. */
160
- const tag = componentWrapperTag(node.name)
161
- const { code } = mountComponent(node, `${skVar}.el[${holeIndex(elIndex, node)}]`)
162
- binds.push(code)
163
- return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
164
- }
165
166
  if (node.kind === 'script') {
166
167
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
167
168
  runs as a bind, in document order, so its signals are declared before the later
@@ -179,11 +180,18 @@ export function generateBuild(
179
180
  if (node.kind !== 'element') {
180
181
  return '' // <style> emits no markup
181
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
+ }
182
191
  if (node.tag === 'slot') {
183
192
  /* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
184
193
  mounted as a marker-bounded range (`mountSlot`) so it positions like a block. */
185
- const anchorVar = nextVar('an')
186
- binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\n`)
194
+ const anchorVar = anchorVarAt(node, skVar, binds)
187
195
  const hostVar = nextVar('host')
188
196
  binds.push(
189
197
  `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
@@ -246,7 +254,7 @@ export function generateBuild(
246
254
  skeleton string (parsed once, cloned per mount) plus each hole's wiring against
247
255
  its located node. */
248
256
  function generateSkeleton(
249
- node: Extract<TemplateNode, { kind: 'element' | 'component' }>,
257
+ node: Extract<TemplateNode, { kind: 'element' }>,
250
258
  parentVar: string,
251
259
  ): string {
252
260
  const skVar = nextVar('sk')
@@ -283,14 +291,19 @@ export function generateBuild(
283
291
  })
284
292
  .join('')
285
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
+ }
286
300
  if (node.kind === 'element' && node.tag === 'slot') {
287
- /* In a layout, `<slot/>` is the router's page outlet: a bare empty `OUTLET_TAG`
288
- element the router mounts the next chain layer into, cloned (create) / claimed
289
- (hydrate) so it matches the SSR placeholder. (Top-level/nested-in-element layout
290
- slots are rewritten to `OUTLET_TAG` up front by `asOutlet`; this covers a slot
291
- 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. */
292
305
  if (isLayout) {
293
- return `cloneStatic(${parentVar}, ${JSON.stringify(`<${OUTLET_TAG}></${OUTLET_TAG}>`)});\n`
306
+ return `outlet(${parentVar}, ${before});\n`
294
307
  }
295
308
  return generateSlot(node, parentVar)
296
309
  }
@@ -314,10 +327,10 @@ export function generateBuild(
314
327
  return '' // branches are consumed by their await block, never standalone
315
328
  }
316
329
  if (node.kind === 'component') {
317
- /* A standalone component builds through the skeleton too its wrapper element
318
- is a located hole, the child mounts into it (same as a component nested in a
319
- skeletonable element). */
320
- 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)
321
334
  }
322
335
  if (node.kind === 'switch') {
323
336
  return generateSwitch(node, parentVar, before)
@@ -402,8 +415,6 @@ export function generateBuild(
402
415
  return `if ($props && $props.$children) { ${invoke}; } else {\n${fallback}}\n`
403
416
  }
404
417
 
405
- /* Mounts a child component into a wrapper element, passing each prop as a
406
- reactive thunk so the child re-reads when the parent expression changes. */
407
418
  /* The prop + slot thunks a child mount receives — its props as value thunks and
408
419
  its slot content as a host-taking builder (`$children`). */
409
420
  function componentParts(node: Extract<TemplateNode, { kind: 'component' }>): string[] {
@@ -417,18 +428,18 @@ export function generateBuild(
417
428
  return parts
418
429
  }
419
430
 
420
- /* Mounts a child into a wrapper obtained via `varExpr` (a skeleton-located node).
421
- Hydration stays active, so the child adopts its server markup inside the wrapper.
422
- Returns the wrapper var. */
423
- 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(
424
438
  node: Extract<TemplateNode, { kind: 'component' }>,
425
- varExpr: string,
426
- ): { code: string; varName: string } {
427
- const wrapper = nextVar('cmp')
428
- const code =
429
- `const ${wrapper} = ${varExpr};\n` +
430
- `mountChild(${wrapper}, ${node.name}, { ${componentParts(node).join(', ')} });\n`
431
- 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`
432
443
  }
433
444
 
434
445
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -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,8 +1,8 @@
1
+ import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
1
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
3
  import { asOutlet } from './asOutlet.ts'
3
- import { componentWrapperTag } from './componentWrapperTag.ts'
4
4
  import { groupBindParts } from './groupBindParts.ts'
5
- import { isControlFlow } from './isControlFlow.ts'
5
+ import { isAnchorPositioned } from './isAnchorPositioned.ts'
6
6
  import { lowerContext } from './lowerContext.ts'
7
7
  import { scopeAttr } from './scopeAttr.ts'
8
8
  import { skeletonContext } from './skeletonContext.ts'
@@ -111,12 +111,12 @@ export function generateSSR(
111
111
  inSkeleton.get(node) ? push(target, '<!--a-->') : ''
112
112
 
113
113
  function generate(node: TemplateNode, target: string): string {
114
- /* A control-flow block is positioned by an `<!--a-->` anchor when in a skeleton
115
- context. Emit it ONCE here one decision site gated by the shared `isControlFlow`,
116
- mirroring the client's single `skeletonMarkup` control-flow branch rather than
117
- prefixing each block kind's own `anchorMark` call (six sites that had to stay in
118
- lock-step). `anchorMark` no-ops outside a skeleton, so non-block nodes ignore it. */
119
- 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) : ''
120
120
  if (node.kind === 'text') {
121
121
  return node.parts
122
122
  .map((part) => {
@@ -198,11 +198,11 @@ export function generateSSR(
198
198
  return ''
199
199
  }
200
200
  if (node.kind === 'component') {
201
- /* Server-render the child via its `render` and inline the HTML inside
202
- the same wrapper the client mounts into, so SSR and client agree.
203
- Props pass as thunks; slot content passes as a string-returning
204
- `$children` the child invokes from its <slot>. */
205
- 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>. */
206
206
  const parts = node.props.map(
207
207
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
208
208
  )
@@ -224,22 +224,30 @@ export function generateSSR(
224
224
  the enclosing render body, including from branch closures.) */
225
225
  const result = nextVar('$child')
226
226
  return (
227
- push(target, `<${tag} style="display:contents">`) +
227
+ anchor +
228
+ push(target, RANGE_OPEN) +
228
229
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
229
230
  `${target}.push(${result}.html);\n` +
230
231
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
231
- push(target, `</${tag}>`)
232
+ push(target, RANGE_CLOSE)
232
233
  )
233
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
+ }
234
242
  if (node.kind === 'element' && node.tag === 'slot') {
235
243
  /* `asOutlet` already rewrote a layout's top-level/element-nested `<slot/>` to an
236
- `OUTLET_TAG` element (handled by the generic path below), so a `slot` node reaching
237
- here in a layout is control-flow-nested — emit the same bare outlet placeholder the
238
- client's nested-slot path clones, which the chain composer folds the child into. */
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. */
239
247
  if (isLayout) {
240
- return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
248
+ return push(target, `<!--${OUTLET_OPEN}--><!--${OUTLET_CLOSE}-->`)
241
249
  }
242
- return generateSlot(node, target)
250
+ return generateSlot(node, target, anchor)
243
251
  }
244
252
  let code = push(target, `<${node.tag}`)
245
253
  /* Every `<style>` active at this element (own siblings + ancestors) — same set
@@ -292,6 +300,7 @@ export function generateSSR(
292
300
  function generateSlot(
293
301
  node: Extract<TemplateNode, { kind: 'element' }>,
294
302
  target: string,
303
+ anchor: string,
295
304
  ): string {
296
305
  const wrap = inSkeleton.get(node)
297
306
  const fallback = generateInto(node.children, target)
@@ -302,7 +311,7 @@ export function generateSSR(
302
311
  if (!wrap) {
303
312
  return body
304
313
  }
305
- return `${anchorMark(node, target)}${openRange(target)}${body}${closeRange(target)}`
314
+ return `${anchor}${openRange(target)}${body}${closeRange(target)}`
306
315
  }
307
316
 
308
317
  /* Boundary markers + a `$awaits` registration carrying the promise and
@@ -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')
@@ -1,3 +1,5 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { isAnchorPositioned } from './isAnchorPositioned.ts'
1
3
  import { isControlFlow } from './isControlFlow.ts'
2
4
  import { isTextLeaf } from './isTextLeaf.ts'
3
5
  import { skeletonable } from './skeletonable.ts'
@@ -13,9 +15,9 @@ that decide `<!--a-->` anchor placement — and assigns each hole its `el`/`an`
13
15
  document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
14
16
 
15
17
  The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
16
- skeleton — the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`; a standalone
17
- component roots its own skeleton too). `el` numbers element holes in pre-order; `an` numbers
18
- anchor holes (interleaved reactive text PARTS, control-flow blocks, `<slot>` outlets) in
18
+ skeleton — the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`). `el` numbers
19
+ element holes in pre-order; `an` numbers anchor holes (interleaved reactive text PARTS,
20
+ control-flow blocks, child components, `<slot>` outlets) in
19
21
  document order — the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
20
22
  the realized DOM, so the compile-time numbers and the runtime positions line up.
21
23
 
@@ -26,8 +28,8 @@ content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclos
26
28
  export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
27
29
  const inSkeleton = new WeakMap<TemplateNode, boolean>()
28
30
  const markText = new WeakMap<TemplateNode, boolean>()
29
- /* Element holes keyed by node; anchor holes keyed by node (control-flow/slot) OR by the
30
- reactive text PART object (a text node carries one anchor per reactive part). */
31
+ /* Element holes keyed by node; anchor holes keyed by node (control-flow/component/slot)
32
+ OR by the reactive text PART object (a text node carries one anchor per reactive part). */
31
33
  const elIndex = new WeakMap<TemplateNode, number>()
32
34
  const anIndex = new WeakMap<object, number>()
33
35
 
@@ -45,21 +47,16 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
45
47
  markText.set(node, nodeMarkText)
46
48
 
47
49
  /* Control-flow blocks, components, and snippets are fresh build contexts. The node
48
- ITSELF is a hole in the enclosing skeleton (an anchor for a block, an element hole
49
- for a component); its children re-enter the skeleton only via their own roots. */
50
+ ITSELF is a hole in the enclosing skeleton (an `<!--a-->` anchor for a block OR a
51
+ component both mount as a marker-bounded range at that anchor); its children
52
+ re-enter the skeleton only via their own roots. */
50
53
  if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
51
- /* A control-flow block takes an anchor only inside an enclosing skeleton (a
52
- standalone block routes through `generateIf`/etc, not the skeleton path). */
53
- if (counter !== undefined && isControlFlow(node)) {
54
+ /* A block or a component takes an anchor only inside an enclosing skeleton (a
55
+ standalone one routes through `generateIf`/`mountChild`, not the skeleton path);
56
+ a snippet declares a builder and is never anchor-positioned (`isAnchorPositioned`). */
57
+ if (counter !== undefined && isAnchorPositioned(node)) {
54
58
  anIndex.set(node, counter.an++)
55
59
  }
56
- /* A component is its OWN element hole either way: in the enclosing skeleton's
57
- counter when nested, else as the root (index 0) of its own standalone skeleton
58
- (`generateChild` routes a lone component through `generateSkeleton` too). */
59
- if (node.kind === 'component') {
60
- const componentCounter = counter ?? { el: 0, an: 0 }
61
- elIndex.set(node, componentCounter.el++)
62
- }
63
60
  for (const child of childrenOf(node)) {
64
61
  visit(child, false, false, undefined)
65
62
  }
@@ -90,9 +87,11 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
90
87
  if (node.kind !== 'element') {
91
88
  return // script / style carry no skeleton children and no hole
92
89
  }
93
- if (node.tag === 'slot') {
94
- /* The slot outlet is an anchor hole in the enclosing skeleton; its children are
95
- the fallback a fresh context built by `mountSlot`. */
90
+ if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
91
+ /* A component `<slot>` content fill OR a layout's `OUTLET_TAG` router fill point
92
+ (`asOutlet`) is an anchor hole in the enclosing skeleton — both mount a marker
93
+ range at the anchor (`mountSlot` / `outlet`). A `<slot>`'s children are its
94
+ fallback (a fresh context); an outlet has none. */
96
95
  if (counter !== undefined) {
97
96
  anIndex.set(node, counter.an++)
98
97
  }
@@ -1,3 +1,4 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
1
2
  import { isControlFlow } from './isControlFlow.ts'
2
3
  import type { TemplateNode } from './types/TemplateNode.ts'
3
4
 
@@ -22,7 +23,7 @@ function skeletonStructure(node: TemplateNode): boolean {
22
23
  if (node.kind !== 'element') {
23
24
  return false // standalone branch|case
24
25
  }
25
- if (node.tag === 'slot') {
26
+ if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
26
27
  return true
27
28
  }
28
29
  return node.children.every(skeletonStructure)
@@ -43,7 +44,7 @@ function hasHole(node: TemplateNode): boolean {
43
44
  return node.parts.some((part) => part.kind !== 'static')
44
45
  }
45
46
  if (node.kind === 'element') {
46
- if (node.tag === 'slot') {
47
+ if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
47
48
  return true
48
49
  }
49
50
  return node.attrs.some((attr) => attr.kind !== 'static') || node.children.some(hasHole)
@@ -0,0 +1,14 @@
1
+ /* A comment node's data, or undefined for elements/text. A comment is a node that is
2
+ neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
3
+ exposes no `nodeType`, so detect by method. Shared by every marker-range scan
4
+ (`skeleton`'s anchor walk, `outlet`'s close-marker skip) so the convention is probed
5
+ one way everywhere. */
6
+ export function commentData(node: Node): string | undefined {
7
+ if (
8
+ typeof (node as Element).hasAttribute === 'function' ||
9
+ typeof (node as Text).splitText === 'function'
10
+ ) {
11
+ return undefined
12
+ }
13
+ return (node as Comment).data
14
+ }
@@ -0,0 +1,21 @@
1
+ import type { Scope } from '../types/Scope.ts'
2
+ import { clearBetween } from './clearBetween.ts'
3
+
4
+ /*
5
+ The standard teardown for a marker-bounded range (component, layout/page boundary,
6
+ slot): stop the content's reactivity, dispose its lexical scope, and clear the nodes
7
+ between the markers — leaving the markers in place so a hot swap rebuilds the range.
8
+ Shared by every range mount (`fillRange`, `mountRange`, `fillBoundary`) so the one
9
+ disposer contract lives in a single place.
10
+ */
11
+ export function disposeRange(
12
+ scoped: { stop: () => void; lexical: Scope },
13
+ start: Comment,
14
+ end: Comment,
15
+ ): () => void {
16
+ return () => {
17
+ scoped.stop()
18
+ scoped.lexical.dispose()
19
+ clearBetween(start, end)
20
+ }
21
+ }