@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +44 -0
- package/package.json +1 -1
- package/src/abideResolverPlugin.ts +3 -17
- package/src/appEntry.ts +18 -8
- package/src/controlServerWorker.ts +8 -1
- package/src/lib/cli/parseArgvForRpc.ts +8 -3
- package/src/lib/mcp/createMcpServer.ts +8 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +20 -2
- package/src/lib/mcp/toolResultFromResponse.ts +5 -0
- package/src/lib/server/rpc/parseArgs.ts +12 -1
- package/src/lib/server/runtime/acceptsGzip.ts +10 -1
- package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
- package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
- package/src/lib/server/runtime/createServer.ts +10 -1
- package/src/lib/server/runtime/gzipResponse.ts +2 -1
- package/src/lib/server/runtime/inFlightRequests.ts +13 -0
- package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -1
- package/src/lib/server/runtime/streamFromIterator.ts +8 -0
- package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
- package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
- package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
- package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
- package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -1
- package/src/lib/server/sockets/defineSocket.ts +6 -1
- package/src/lib/shared/cache.ts +6 -0
- package/src/lib/shared/createPushIterator.ts +7 -2
- package/src/lib/shared/emitLogRecord.ts +18 -5
- package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/asOutlet.ts +29 -0
- package/src/lib/ui/compile/componentWrapperTag.ts +12 -20
- package/src/lib/ui/compile/generateBuild.ts +45 -36
- package/src/lib/ui/compile/generateSSR.ts +31 -21
- package/src/lib/ui/compile/parseTemplate.ts +10 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +25 -2
- package/src/lib/ui/compile/skeletonContext.ts +97 -34
- package/src/lib/ui/compile/types/SkeletonContext.ts +6 -0
- package/src/lib/ui/createScope.ts +11 -0
- package/src/lib/ui/dom/appendSnippet.ts +60 -20
- package/src/lib/ui/dom/fillBefore.ts +10 -0
- package/src/lib/ui/dom/hydrate.ts +2 -1
- package/src/lib/ui/dom/mount.ts +2 -1
- package/src/lib/ui/dom/mountSlot.ts +7 -2
- package/src/lib/ui/dom/scopeLabel.ts +19 -0
- package/src/lib/ui/dom/skeleton.ts +16 -1
- package/src/lib/ui/installInspectorBridge.ts +138 -0
- package/src/lib/ui/navigate.ts +11 -3
- package/src/lib/ui/persist.ts +4 -1
- package/src/lib/ui/router.ts +77 -9
- package/src/lib/ui/runtime/createDoc.ts +20 -7
- package/src/lib/ui/runtime/historyEntries.ts +113 -0
- package/src/lib/ui/runtime/liveScopes.ts +15 -0
- package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
- package/src/lib/ui/seedStreamedResolution.ts +10 -1
- package/src/lib/ui/startClient.ts +6 -0
- package/src/lib/ui/types/Scope.ts +3 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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`.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 =
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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[${
|
|
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[${
|
|
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
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const { code } = mountComponent(node, `${skVar}.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
|
-
|
|
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[${
|
|
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
|
|
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
|
-
|
|
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[${
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
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}
|
|
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
|
-
/*
|
|
218
|
-
|
|
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
|
|
295
|
-
|
|
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 =
|
|
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
|
|
346
|
-
|
|
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(
|
|
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
|
-
|
|
39
|
-
|
|
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 {
|