@abide/abide 0.36.0 → 0.38.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 (61) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/package.json +1 -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/buildInFlightSnapshot.ts +35 -0
  14. package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
  15. package/src/lib/server/runtime/createServer.ts +10 -1
  16. package/src/lib/server/runtime/gzipResponse.ts +2 -1
  17. package/src/lib/server/runtime/inFlightRequests.ts +13 -0
  18. package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
  19. package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
  20. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
  21. package/src/lib/server/runtime/streamFromIterator.ts +8 -0
  22. package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
  23. package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
  24. package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
  25. package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
  26. package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
  27. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
  28. package/src/lib/server/sockets/defineSocket.ts +6 -1
  29. package/src/lib/shared/cache.ts +6 -0
  30. package/src/lib/shared/createPushIterator.ts +7 -2
  31. package/src/lib/shared/emitLogRecord.ts +18 -5
  32. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -0
  33. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  34. package/src/lib/ui/compile/asOutlet.ts +29 -0
  35. package/src/lib/ui/compile/componentWrapperTag.ts +12 -20
  36. package/src/lib/ui/compile/generateBuild.ts +45 -36
  37. package/src/lib/ui/compile/generateSSR.ts +31 -21
  38. package/src/lib/ui/compile/parseTemplate.ts +10 -1
  39. package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
  40. package/src/lib/ui/compile/skeletonContext.ts +97 -34
  41. package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
  42. package/src/lib/ui/createScope.ts +11 -0
  43. package/src/lib/ui/dom/appendSnippet.ts +60 -20
  44. package/src/lib/ui/dom/fillBefore.ts +10 -0
  45. package/src/lib/ui/dom/hydrate.ts +2 -1
  46. package/src/lib/ui/dom/mount.ts +2 -1
  47. package/src/lib/ui/dom/mountSlot.ts +7 -2
  48. package/src/lib/ui/dom/scopeLabel.ts +19 -0
  49. package/src/lib/ui/dom/skeleton.ts +16 -1
  50. package/src/lib/ui/installInspectorBridge.ts +138 -0
  51. package/src/lib/ui/navigate.ts +11 -3
  52. package/src/lib/ui/persist.ts +4 -1
  53. package/src/lib/ui/router.ts +77 -9
  54. package/src/lib/ui/runtime/createDoc.ts +20 -7
  55. package/src/lib/ui/runtime/historyEntries.ts +113 -0
  56. package/src/lib/ui/runtime/liveScopes.ts +15 -0
  57. package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
  58. package/src/lib/ui/seedStreamedResolution.ts +10 -1
  59. package/src/lib/ui/startClient.ts +6 -0
  60. package/src/lib/ui/types/Scope.ts +3 -0
  61. package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
@@ -170,7 +170,13 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
170
170
  can ask for "as many as available, up to N" — `0` (bare
171
171
  `for await`) replays nothing.
172
172
  */
173
- send(ws, { type: 'replay', sub: frame.sub, messages: entry.snapshotTail(frame.replay) })
173
+ /*
174
+ A non-finite `replay` (NaN/Infinity from a buggy client) would make
175
+ snapshotTail's slice(NaN) leak the whole retained buffer — coerce it
176
+ to undefined, matching the REST `?tail=` guard below.
177
+ */
178
+ const replay = Number.isFinite(frame.replay) ? frame.replay : undefined
179
+ send(ws, { type: 'replay', sub: frame.sub, messages: entry.snapshotTail(replay) })
174
180
  }
175
181
 
176
182
  function handleUnsub(
@@ -176,7 +176,12 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
176
176
  clients,
177
177
  snapshotTail: (count?: number) => {
178
178
  pruneExpired(Date.now())
179
- const start = count === undefined ? 0 : Math.max(0, buffer.length - count)
179
+ /* A non-finite count (NaN/Infinity) would make slice() leak the whole
180
+ buffer — treat it as absent (full tail), the same as count === undefined. */
181
+ const start =
182
+ count === undefined || !Number.isFinite(count)
183
+ ? 0
184
+ : Math.max(0, buffer.length - count)
180
185
  return buffer.slice(start).map((entry) => entry.value)
181
186
  },
182
187
  })
@@ -870,6 +870,12 @@ function fireRefetch(store: CacheStore, entry: CacheEntry): void {
870
870
  entry.promise = inflight
871
871
  entry.value = undefined
872
872
  entry.settled = true
873
+ /* Restart the freshness clock from the revalidation — without this the
874
+ entry keeps its original expiresAt and is evicted at the old deadline
875
+ despite holding fresh data. Mirrors registerEntry's settle path. */
876
+ if (entry.ttl !== undefined && entry.ttl !== 0) {
877
+ armTtlExpiry(store, entry, entry.ttl)
878
+ }
873
879
  store.markLifecycle(entry.key)
874
880
  emit(store, [entry.key])
875
881
  },
@@ -59,9 +59,14 @@ export function createPushIterator<T>(
59
59
  wake(slot)
60
60
  return
61
61
  }
62
- // Drop the oldest pending value before exceeding the cap.
62
+ /* Drop the OLDEST value slot before exceeding the cap — never a control or
63
+ terminal slot (end/error/disconnect), which the contract guarantees are
64
+ never dropped. If the buffer holds only non-value slots, drop nothing. */
63
65
  if (slot.kind === 'value' && buffer.length >= maxBuffer) {
64
- buffer.shift()
66
+ const oldestValue = buffer.findIndex((pending) => pending.kind === 'value')
67
+ if (oldestValue !== -1) {
68
+ buffer.splice(oldestValue, 1)
69
+ }
65
70
  }
66
71
  buffer.push(slot)
67
72
  }
@@ -66,6 +66,15 @@ function formatElapsed(ms: number): string {
66
66
  return `+${ms.toFixed(2)}ms`
67
67
  }
68
68
 
69
+ /* `14:23:01.072` — local wall-clock to the ms. Every record carries `ts`, so the
70
+ tsv line always leads with one; `+elapsedMs` still trails as the request-relative
71
+ timing — the two are different axes (when it happened vs how long it took). */
72
+ function formatClock(ts: number): string {
73
+ const date = new Date(ts)
74
+ const pad = (value: number, width = 2): string => String(value).padStart(width, '0')
75
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
76
+ }
77
+
69
78
  /*
70
79
  Builds the record from the emission's own fields plus the ambient request
71
80
  scope (trace, elapsed, verb+path), then renders it through the active
@@ -123,11 +132,12 @@ function consoleFor(level: LogRecord['level']): (...args: unknown[]) => void {
123
132
 
124
133
  /*
125
134
  The unified tsv line (the default format): tab-separated
126
- `<trace8> <verb path> [channel] <message> +0.00ms`. Inside a
127
- request scope the trace column leads and the elapsed-at-emission timing
128
- trails; a closing record emitted outside one (asset hits sidestep the scope)
129
- pads a blank trace column and trails its serve duration instead, so request
130
- lines stay aligned whatever produced them. Every record speaks on a channel
135
+ `<clock> <trace8> <verb path> [channel] <message> +0.00ms`. The
136
+ wall-clock leads every line (each record carries `ts`); inside a request scope
137
+ the trace column follows and the elapsed-at-emission timing trails; a closing
138
+ record emitted outside one (asset hits sidestep the scope) pads a blank trace
139
+ column and trails its serve duration instead, so request lines stay aligned
140
+ whatever produced them. Every record speaks on a channel
131
141
  (the app name, 'abide', or a diagnostic channel), shown as a dim `[name]`
132
142
  tag opening the message field. The verb+path pair is one field — it's the
133
143
  line's anchor unit — and the tag folds into the message field so field
@@ -136,6 +146,9 @@ positions stay stable for cut/awk consumers.
136
146
  function printTsv(record: LogRecord, voice?: LogVoice): void {
137
147
  const fields: string[] = []
138
148
  const closing = record.status !== undefined
149
+ /* Wall-clock leads every line — a stable first column, unlike the conditional
150
+ trace field, and the axis `+elapsedMs` can't give (when, not how-long). */
151
+ fields.push(dim(formatClock(record.ts)))
139
152
  if (record.trace) {
140
153
  fields.push(dim(record.trace.slice(0, 8)))
141
154
  } else if (closing) {
@@ -0,0 +1,4 @@
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. */
4
+ export const COMPONENT_WRAPPER_PREFIX = 'abide-'
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { BunPlugin } from 'bun'
3
+ import { messageFromError } from '../../shared/messageFromError.ts'
3
4
  import { AbideCompileError } from './AbideCompileError.ts'
4
5
  import { analyzeComponent } from './analyzeComponent.ts'
5
6
  import { compileModule } from './compileModule.ts'
@@ -57,7 +58,7 @@ export const abideUiPlugin: BunPlugin = {
57
58
  : (({ line, column }) => `${moduleId}:${line}:${column}`)(
58
59
  offsetToLineColumn(source, offset),
59
60
  )
60
- const message = error instanceof Error ? error.message : String(error)
61
+ const message = messageFromError(error)
61
62
  throw new Error(`${message.replace(/^\[abide\]\s*/, `[abide] ${at} — `)}`)
62
63
  }
63
64
  }
@@ -0,0 +1,29 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import type { TemplateNode } from './types/TemplateNode.ts'
3
+
4
+ /*
5
+ In a layout the `<slot/>` page outlet is a bare empty `OUTLET_TAG` element the router fills
6
+ later. Rewriting it to an element node up front lets the static-clone path carry it as ordinary
7
+ structure AND lets BOTH back-ends feed the same tree to `skeletonContext` — one decision site
8
+ for "a layout slot is an outlet, not an anchor", instead of the client running `asOutlet` while
9
+ SSR mirrors it with an inline special-case.
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.
16
+
17
+ Control-flow children are fresh build contexts (their own runtime mounts a nested slot), so they
18
+ are not descended into — a `<slot>` inside an `{#if}` stays a slot node, handled at its own
19
+ mount site in each back-end.
20
+ */
21
+ export function asOutlet(node: TemplateNode): TemplateNode {
22
+ if (node.kind !== 'element') {
23
+ return node
24
+ }
25
+ if (node.tag === 'slot') {
26
+ return { ...node, tag: OUTLET_TAG, attrs: [], children: [], scopes: [] }
27
+ }
28
+ return { ...node, children: node.children.map(asOutlet) }
29
+ }
@@ -1,23 +1,15 @@
1
- import { HTML_TAGS } from './HTML_TAGS.ts'
2
-
3
1
  /*
4
- The element tag a component instance mounts into. Normally the component name
5
- lowercased readable in devtools, a real box like any abide wrapper. But a name
6
- that lowercases to a real HTML element (`Button`→`button`, `Input`→`input`) yields a
7
- wrapper with a content model the parser enforces: void elements self-close, and
8
- `<button>`/`<a>`/table/list/select families reject or foster the component's own
9
- markup as the wrapper's siblings so on hydration the skeleton locates the wrapper
10
- empty, claims `null`, and `attr` throws on it. Those names map to a hyphenated
11
- custom-element tag (a custom element is never void and has no content model) made
12
- layout-transparent with `display:contents`, so the component's real root still lays
13
- out as a direct child of the parent the way the (parse-broken) wrapper would have.
14
- A name that is NOT a known HTML element (the common case — `Card`, `Dropdown`) is an
15
- inert unknown tag that holds any content untouched, so it stays as-is. Both back-ends
16
- call this so the SSR string and the client build agree on the wrapper.
2
+ The element tag a component instance mounts into: always `abide-<name>` lowercased. The
3
+ `abide-` prefix makes every wrapper a valid custom element (contains a hyphen) never
4
+ void, no content model so it holds the component's own markup untouched and hydrates
5
+ cleanly, regardless of whether the name collides with an HTML element (`Button`, `Input`).
6
+ Emitted with `display:contents` (see the back-ends) so the wrapper stays out of layout: a
7
+ pure mount host whose real root lays out as a direct child of the parent, keeping the
8
+ component invisible to `grid`/`subgrid`/`flex`. Both back-ends call this so the SSR string
9
+ and the client build agree on the wrapper.
17
10
  */
18
- export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
19
- const lower = name.toLowerCase()
20
- return HTML_TAGS.has(lower)
21
- ? { tag: `abide-${lower}`, transparent: true }
22
- : { tag: lower, transparent: false }
11
+ import { COMPONENT_WRAPPER_PREFIX } from '../COMPONENT_WRAPPER_PREFIX.ts'
12
+
13
+ export function componentWrapperTag(name: string): string {
14
+ return `${COMPONENT_WRAPPER_PREFIX}${name.toLowerCase()}`
23
15
  }
@@ -1,12 +1,13 @@
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
5
  import { componentWrapperTag } from './componentWrapperTag.ts'
5
6
  import { groupBindParts } from './groupBindParts.ts'
6
7
  import { isControlFlow } from './isControlFlow.ts'
7
- import { isTextLeaf } from './isTextLeaf.ts'
8
8
  import { lowerContext } from './lowerContext.ts'
9
9
  import { scopeAttr } from './scopeAttr.ts'
10
+ import { skeletonContext } from './skeletonContext.ts'
10
11
  import { staticAttr } from './staticAttr.ts'
11
12
  import { staticTextPart } from './staticTextPart.ts'
12
13
  import type { TemplateNode } from './types/TemplateNode.ts'
@@ -32,6 +33,30 @@ export function generateBuild(
32
33
  let counter = 0
33
34
  const nextVar = (prefix: string): string => `${prefix}${counter++}`
34
35
 
36
+ /* In a layout, `<slot/>` outlets are rewritten to `OUTLET_TAG` elements up front
37
+ (`asOutlet`) so the static-clone path carries them as ordinary structure. `asOutlet`
38
+ CLONES every element it descends through and drops the slot's anchor hole, so the
39
+ shared skeleton context must walk THIS rewritten tree — the one the build traversal
40
+ below reads — or its node-keyed hole indices key the originals and never match. */
41
+ const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
42
+
43
+ /* Per-node skeleton position from the SAME pass the SSR back-end reads — so the client's
44
+ anchor/text-leaf decisions consult one source of truth instead of re-deriving the
45
+ position structurally (the drift the shared context exists to prevent). */
46
+ const { markText, elIndex, anIndex } = skeletonContext(rootNodes)
47
+
48
+ /* The hole's index, assigned by the shared skeletonContext walk — the sole numberer. A
49
+ missing entry means this back-end reached a hole the shared walk didn't number: a
50
+ structural divergence between the two, surfaced loudly at compile time rather than as a
51
+ runtime hydration desync. */
52
+ function holeIndex(map: WeakMap<object, number>, key: object): number {
53
+ const index = map.get(key)
54
+ if (index === undefined) {
55
+ throw new Error('[abide] skeleton hole not numbered by the shared positional walk')
56
+ }
57
+ return index
58
+ }
59
+
35
60
  /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
36
61
  const {
37
62
  expression: lowerExpression,
@@ -100,12 +125,7 @@ export function generateBuild(
100
125
  block or slot drops an `<!--a-->` anchor at its position and mounts there (see
101
126
  `anchorCursor`), so it can sit ANYWHERE among static siblings. Static descendants are
102
127
  plain markup. */
103
- function skeletonMarkup(
104
- node: TemplateNode,
105
- skVar: string,
106
- counter: { el: number; an: number },
107
- binds: string[],
108
- ): string {
128
+ function skeletonMarkup(node: TemplateNode, skVar: string, binds: string[]): string {
109
129
  if (node.kind === 'text') {
110
130
  /* Reactive text reached here is INTERLEAVED with element siblings (a text-leaf
111
131
  is bound via `generateChildren` instead). It can't be element-positioned, so
@@ -117,7 +137,7 @@ export function generateBuild(
117
137
  return staticTextPart(part.value)
118
138
  }
119
139
  binds.push(
120
- `appendTextAt(${skVar}.an[${counter.an++}], () => (${lowerExpression(part.code)}));\n`,
140
+ `appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], () => (${lowerExpression(part.code)}));\n`,
121
141
  )
122
142
  return '<!--a-->'
123
143
  })
@@ -129,19 +149,18 @@ export function generateBuild(
129
149
  and returns the create insertion reference; the block's parent is the located
130
150
  element the anchor was cloned into (`anchor.parentNode`). */
131
151
  const anchorVar = nextVar('an')
132
- binds.push(`const ${anchorVar} = ${skVar}.an[${counter.an++}];\n`)
152
+ binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\n`)
133
153
  binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
134
154
  return '<!--a-->'
135
155
  }
136
156
  if (node.kind === 'component') {
137
157
  /* The wrapper element is a positioned hole in the skeleton; the child mounts
138
- into the located node (idempotent display:contents for a transparent wrap,
139
- static so it lives in the markup). */
140
- const { tag, transparent } = componentWrapperTag(node.name)
141
- const { code } = mountComponent(node, `${skVar}.el[${counter.el++}]`)
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)}]`)
142
162
  binds.push(code)
143
- const style = transparent ? ' style="display:contents"' : ''
144
- return `<${tag} ${HOLE_ATTRIBUTE}${style}></${tag}>`
163
+ return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
145
164
  }
146
165
  if (node.kind === 'script') {
147
166
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
@@ -164,7 +183,7 @@ export function generateBuild(
164
183
  /* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
165
184
  mounted as a marker-bounded range (`mountSlot`) so it positions like a block. */
166
185
  const anchorVar = nextVar('an')
167
- binds.push(`const ${anchorVar} = ${skVar}.an[${counter.an++}];\n`)
186
+ binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\n`)
168
187
  const hostVar = nextVar('host')
169
188
  binds.push(
170
189
  `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
@@ -172,13 +191,16 @@ export function generateBuild(
172
191
  return '<!--a-->'
173
192
  }
174
193
  const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
175
- const hasReactiveText = node.children.some(
194
+ const reactiveTextChild = node.children.find(
176
195
  (child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
177
196
  )
178
197
  /* A text-leaf (only text/style children) with reactive text binds marker-free via
179
198
  `generateChildren` on the located element; otherwise reactive text is interleaved
180
- and uses `<!--a-->` anchors during the child recursion below. */
181
- const textLeafBind = hasReactiveText && isTextLeaf(node)
199
+ and uses `<!--a-->` anchors during the child recursion below. The shared context
200
+ records the leaf's text as NOT interleaved (`markText` false) — read that flag the
201
+ SSR back-end also reads, rather than re-deriving leaf-ness via `isTextLeaf` here. */
202
+ const textLeafBind =
203
+ reactiveTextChild !== undefined && markText.get(reactiveTextChild) === false
182
204
  let openTag = `<${node.tag}`
183
205
  let elVar = ''
184
206
  if (hasReactiveAttr || textLeafBind) {
@@ -186,7 +208,7 @@ export function generateBuild(
186
208
  index BEFORE recursing, so holes number in pre-order — the order the runtime's
187
209
  path walk produces them. */
188
210
  elVar = nextVar('el')
189
- binds.push(`const ${elVar} = ${skVar}.el[${counter.el++}];\n`)
211
+ binds.push(`const ${elVar} = ${skVar}.el[${holeIndex(elIndex, node)}];\n`)
190
212
  openTag += ` ${HOLE_ATTRIBUTE}`
191
213
  for (const attr of node.attrs) {
192
214
  if (attr.kind !== 'static') {
@@ -215,7 +237,7 @@ export function generateBuild(
215
237
  /* A nested `<script>` among the children scopes its bindings to this subtree (its
216
238
  later siblings auto-deref them); pop after. */
217
239
  const inner = withNestedScripts(node.children, () =>
218
- node.children.map((child) => skeletonMarkup(child, skVar, counter, binds)).join(''),
240
+ node.children.map((child) => skeletonMarkup(child, skVar, binds)).join(''),
219
241
  )
220
242
  return `${openTag}${inner}</${node.tag}>`
221
243
  }
@@ -229,7 +251,7 @@ export function generateBuild(
229
251
  ): string {
230
252
  const skVar = nextVar('sk')
231
253
  const binds: string[] = []
232
- const html = skeletonMarkup(node, skVar, { el: 0, an: 0 }, binds)
254
+ const html = skeletonMarkup(node, skVar, binds)
233
255
  return `const ${skVar} = skeleton(${parentVar}, ${JSON.stringify(html)});\n${binds.join('')}`
234
256
  }
235
257
 
@@ -586,20 +608,7 @@ export function generateBuild(
586
608
  )
587
609
  }
588
610
 
589
- /* In a layout the `<slot/>` page outlet is a bare empty `OUTLET_TAG` element (the
590
- router fills it later) — exactly the SSR placeholder. Rewriting it to an element
591
- node up front lets the static-clone path carry it as ordinary structure. */
592
- function asOutlet(node: TemplateNode): TemplateNode {
593
- if (node.kind !== 'element') {
594
- return node
595
- }
596
- if (node.tag === 'slot') {
597
- return { ...node, tag: OUTLET_TAG, attrs: [], children: [] }
598
- }
599
- return { ...node, children: node.children.map(asOutlet) }
600
- }
601
-
602
- return generateChildren(isLayout ? nodes.map(asOutlet) : nodes, hostVar)
611
+ return generateChildren(rootNodes, hostVar)
603
612
  }
604
613
 
605
614
  /* A text node that is purely whitespace (no interpolation, only blank static
@@ -1,4 +1,5 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { asOutlet } from './asOutlet.ts'
2
3
  import { componentWrapperTag } from './componentWrapperTag.ts'
3
4
  import { groupBindParts } from './groupBindParts.ts'
4
5
  import { isControlFlow } from './isControlFlow.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
@@ -184,7 +202,7 @@ export function generateSSR(
184
202
  the same wrapper the client mounts into, so SSR and client agree.
185
203
  Props pass as thunks; slot content passes as a string-returning
186
204
  `$children` the child invokes from its <slot>. */
187
- const { tag, transparent } = componentWrapperTag(node.name)
205
+ const tag = componentWrapperTag(node.name)
188
206
  const parts = node.props.map(
189
207
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
190
208
  )
@@ -206,7 +224,7 @@ 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}${transparent ? ' style="display:contents"' : ''}>`) +
227
+ push(target, `<${tag} style="display:contents">`) +
210
228
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
211
229
  `${target}.push(${result}.html);\n` +
212
230
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
@@ -214,8 +232,10 @@ export function generateSSR(
214
232
  )
215
233
  }
216
234
  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. */
235
+ /* `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. */
219
239
  if (isLayout) {
220
240
  return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
221
241
  }
@@ -291,17 +311,12 @@ export function generateSSR(
291
311
  an empty boundary — its resolved branch is the children bound to `node.as` — and
292
312
  flags the entry so `renderToStream` settles it before the first flush. */
293
313
  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 ?? []
314
+ const catchBranch = branchNamed(node.children, 'catch')
315
+ const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
301
316
  /* Resolved branch + its bound value: the children directly when blocking, the
302
317
  `then` child when streaming. Pending (streaming only) is the non-branch
303
318
  children. */
304
- const thenBranch = branchOf('then')
319
+ const thenBranch = branchNamed(node.children, 'then')
305
320
  const resolvedChildren = node.blocking
306
321
  ? node.children.filter((child) => child.kind !== 'branch')
307
322
  : (thenBranch?.children ?? [])
@@ -342,13 +357,8 @@ export function generateSSR(
342
357
  an enclosing boundary / the 500 / the stream). Boundary comments let hydration
343
358
  discard the server content if the client adoption fails. */
344
359
  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 ?? []
360
+ const catchBranch = branchNamed(node.children, 'catch')
361
+ const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
352
362
  const guarded = node.children.filter((child) => child.kind !== 'branch')
353
363
  const errName = catchBranch?.as ?? '_error'
354
364
  const id = nextVar('$tid')
@@ -371,5 +381,5 @@ export function generateSSR(
371
381
  return code
372
382
  }
373
383
 
374
- return generateInto(nodes, '$out')
384
+ return generateInto(rootNodes, '$out')
375
385
  }
@@ -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 {