@abide/abide 0.38.1 → 0.39.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 +3 -3
- package/CHANGELOG.md +16 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +11 -3
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +46 -18
- package/src/lib/ui/compile/generateSSR.ts +196 -52
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +44 -1
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +69 -61
- package/src/lib/ui/resumeSeedScript.ts +17 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
|
|
2
2
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
3
3
|
import { asOutlet } from './asOutlet.ts'
|
|
4
|
+
import { composeProps } from './composeProps.ts'
|
|
4
5
|
import { groupBindParts } from './groupBindParts.ts'
|
|
5
6
|
import { isAnchorPositioned } from './isAnchorPositioned.ts'
|
|
6
7
|
import { lowerContext } from './lowerContext.ts'
|
|
7
8
|
import { scopeAttr } from './scopeAttr.ts'
|
|
8
9
|
import { skeletonContext } from './skeletonContext.ts'
|
|
10
|
+
import { spreadExcludedNames } from './spreadExcludedNames.ts'
|
|
9
11
|
import { staticAttr } from './staticAttr.ts'
|
|
10
12
|
import { staticTextPart } from './staticTextPart.ts'
|
|
11
13
|
import { stripEffects } from './stripEffects.ts'
|
|
@@ -37,12 +39,14 @@ listeners). Same expression lowering as the client back-end, so server and clien
|
|
|
37
39
|
render the same markup. Dynamic values go through `$esc`; `if` is a plain `if`,
|
|
38
40
|
`each` a `for…of`.
|
|
39
41
|
|
|
40
|
-
An `await` block emits boundary comments (`<!--abide:await:N-->…<!--/abide:await:N-->`)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
and
|
|
42
|
+
An `await` block emits boundary comments (`<!--abide:await:N-->…<!--/abide:await:N-->`).
|
|
43
|
+
A streaming block (no `then` on the tag) puts its pending branch between the markers and
|
|
44
|
+
registers the promise + async resolved/error renderers on `$awaits`; `renderToStream`
|
|
45
|
+
flushes each resolved fragment out of order — the await-block-streams half of the cache
|
|
46
|
+
rule. A blocking block (`then` on the tag) is `await`ed INLINE at its structural position
|
|
47
|
+
and its resolved branch rendered between the markers, so the render body is async and its
|
|
48
|
+
id (`$ctx.next++`) allocates depth-first like the client; the resolved value lands in
|
|
49
|
+
`$resume`. Block ids draw from the request-local `$ctx`, shared with inlined child renders.
|
|
46
50
|
*/
|
|
47
51
|
export function generateSSR(
|
|
48
52
|
nodes: TemplateNode[],
|
|
@@ -52,7 +56,7 @@ export function generateSSR(
|
|
|
52
56
|
isLayout = false,
|
|
53
57
|
): string {
|
|
54
58
|
/* Compile-time counter for unique temp var names (runtime block ids, child render
|
|
55
|
-
results) — block ids themselves are allocated at runtime via
|
|
59
|
+
results) — block ids themselves are allocated at runtime via `$ctx.next++`. */
|
|
56
60
|
let varCounter = 0
|
|
57
61
|
const nextVar = (prefix: string): string => `${prefix}${varCounter++}`
|
|
58
62
|
|
|
@@ -82,6 +86,75 @@ export function generateSSR(
|
|
|
82
86
|
through the generic element path exactly as the client clones it. */
|
|
83
87
|
const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
|
|
84
88
|
|
|
89
|
+
/* A snippet name (any identifier, `$` included) interpolated into a RegExp must have its
|
|
90
|
+
regex metacharacters escaped, or e.g. a trailing `$` would read as an end-anchor and the
|
|
91
|
+
call site would never match — leaving an un-awaited Promise stringified as `[object
|
|
92
|
+
Promise]`. */
|
|
93
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
94
|
+
/* A leading boundary that, unlike `\b`, also fires before a `$`-leading name: `\b$row`
|
|
95
|
+
never matches (`$` is a non-word char, so there is no word boundary before it), which
|
|
96
|
+
would silently miss every `$row(...)` call. A negative lookbehind for word-or-`$`
|
|
97
|
+
matches the same call sites as `\b` for word-leading names while also catching them. */
|
|
98
|
+
const callPattern = (name: string): RegExp =>
|
|
99
|
+
new RegExp(`(?<![$\\w])${escapeRegExp(name)}\\s*\\(`)
|
|
100
|
+
/* A subtree call to any of `names` from a TEXT interpolation (`name()` / `name(args)`). */
|
|
101
|
+
const subtreeCalls = (children: TemplateNode[], names: ReadonlySet<string>): boolean =>
|
|
102
|
+
children.some((child) => {
|
|
103
|
+
if (child.kind === 'text') {
|
|
104
|
+
return child.parts.some(
|
|
105
|
+
(part) =>
|
|
106
|
+
part.kind !== 'static' &&
|
|
107
|
+
[...names].some((name) => callPattern(name).test(part.code)),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
return 'children' in child && subtreeCalls(child.children, names)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
/* Snippet names whose body produces an `await`, so the snippet must be an `async function`
|
|
114
|
+
and its `{name(...)}` call sites awaited: it inlines a child component / holds an await
|
|
115
|
+
block (a structural scan), OR it text-calls another async snippet. The latter is a
|
|
116
|
+
dependency between snippets, so resolve it to a fixpoint — seed with the structural set,
|
|
117
|
+
then keep adding any snippet that calls an already-async one until nothing changes. */
|
|
118
|
+
const subtreeAwaits = (children: TemplateNode[]): boolean =>
|
|
119
|
+
children.some(
|
|
120
|
+
(child) =>
|
|
121
|
+
child.kind === 'component' ||
|
|
122
|
+
child.kind === 'await' ||
|
|
123
|
+
('children' in child && subtreeAwaits(child.children)),
|
|
124
|
+
)
|
|
125
|
+
const snippetDefs = new Map<string, TemplateNode[]>()
|
|
126
|
+
const collectSnippetDefs = (children: TemplateNode[]): void => {
|
|
127
|
+
for (const child of children) {
|
|
128
|
+
if (child.kind === 'snippet') {
|
|
129
|
+
snippetDefs.set(child.name, child.children)
|
|
130
|
+
}
|
|
131
|
+
if ('children' in child) {
|
|
132
|
+
collectSnippetDefs(child.children)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
collectSnippetDefs(rootNodes)
|
|
137
|
+
const asyncSnippets = new Set<string>()
|
|
138
|
+
for (const [name, children] of snippetDefs) {
|
|
139
|
+
if (subtreeAwaits(children)) {
|
|
140
|
+
asyncSnippets.add(name)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let grew = true
|
|
144
|
+
while (grew) {
|
|
145
|
+
grew = false
|
|
146
|
+
for (const [name, children] of snippetDefs) {
|
|
147
|
+
if (!asyncSnippets.has(name) && subtreeCalls(children, asyncSnippets)) {
|
|
148
|
+
asyncSnippets.add(name)
|
|
149
|
+
grew = true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/* A text-part expression that calls an async snippet, so its value is `await`ed before
|
|
154
|
+
`$text`. */
|
|
155
|
+
const callsAsyncSnippet = (code: string): boolean =>
|
|
156
|
+
[...asyncSnippets].some((name) => callPattern(name).test(code))
|
|
157
|
+
|
|
85
158
|
/* Per-node skeleton position, computed once. Both back-ends read this single source of
|
|
86
159
|
truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
|
|
87
160
|
(control-flow branches, component/slot/snippet content) are enumerated there, not
|
|
@@ -124,7 +197,14 @@ export function generateSSR(
|
|
|
124
197
|
const markup = staticTextPart(part.value)
|
|
125
198
|
return markup === '' ? '' : push(target, markup)
|
|
126
199
|
}
|
|
127
|
-
|
|
200
|
+
/* A call to an async snippet returns a Promise — `await` it before `$text`.
|
|
201
|
+
(The enclosing context is async: the render body and async-snippet bodies.)
|
|
202
|
+
Plain expressions stay sync, so a component with only interpolation keeps a
|
|
203
|
+
sync render. */
|
|
204
|
+
const lowered = lowerExpression(part.code)
|
|
205
|
+
const value = callsAsyncSnippet(part.code)
|
|
206
|
+
? `$text(await (${lowered}))`
|
|
207
|
+
: `$text(${lowered})`
|
|
128
208
|
return markText.get(node)
|
|
129
209
|
? `${target}.push('<!--a-->' + ${value});\n`
|
|
130
210
|
: `${target}.push(${value});\n`
|
|
@@ -165,7 +245,11 @@ export function generateSSR(
|
|
|
165
245
|
/* A hoisted function returning the snippet's `$snip`-branded HTML string;
|
|
166
246
|
`{name(args)}` pushes it via `$text`, which wraps it in markers. */
|
|
167
247
|
const body = generateInto(node.children, '$o')
|
|
168
|
-
|
|
248
|
+
/* `async` only when the body produces an `await` (it inlines a child component) — then
|
|
249
|
+
call sites `await` it (`$text(await frag())`). A sync snippet stays a plain function
|
|
250
|
+
called inline, preserving the sync render contract. */
|
|
251
|
+
const keyword = asyncSnippets.has(node.name) ? 'async function' : 'function'
|
|
252
|
+
return `${keyword} ${node.name}(${node.params ?? ''}) {\nconst $o = [];\n${body}return $snip($o.join(''));\n}\n`
|
|
169
253
|
}
|
|
170
254
|
if (node.kind === 'script') {
|
|
171
255
|
/* A scoped reactive block: re-seed its local signals (lowered, in scope)
|
|
@@ -203,32 +287,42 @@ export function generateSSR(
|
|
|
203
287
|
so SSR and client agree and the child's root lays out as a direct child. Props
|
|
204
288
|
pass as thunks; slot content passes as a string-returning `$children` the child
|
|
205
289
|
invokes from its <slot>. */
|
|
206
|
-
const parts = node.props.map(
|
|
207
|
-
(prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
|
|
208
|
-
)
|
|
209
290
|
/* Slot content is a fresh build context — the child's `<slot>` mounts it via
|
|
210
291
|
`mountSlot`, not the parent skeleton clone, and the client builds it through
|
|
211
|
-
`
|
|
292
|
+
`propsArg`/`generateChildren` (never the skeleton path). `skeletonContext`
|
|
212
293
|
records it reset, so its children emit no enclosing-skeleton anchors the client
|
|
213
294
|
slot builder would lack. */
|
|
214
295
|
const slotCode = generateInto(node.children, '$slot')
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
296
|
+
/* `$children` is an ASYNC builder the child `await`s at its `<slot>` position
|
|
297
|
+
(`generateSlot`), NOT a pre-resolved string. Pre-resolving here would run the
|
|
298
|
+
slot's `$ctx.next++` block ids BEFORE the child render's own, but the client
|
|
299
|
+
builds slot content lazily at the `<slot>` site — so a child with an await/try
|
|
300
|
+
before its `<slot>` would allocate ids in the opposite order and desync hydration.
|
|
301
|
+
Keeping the slot lazy makes the slot's ids draw at the `<slot>` site on both
|
|
302
|
+
sides. The builder shares the enclosing render's `$ctx`/`$awaits`/`$resume` (a
|
|
303
|
+
closure), so nested awaits register and number correctly during the child render.
|
|
304
|
+
A child with a `<slot>` is therefore always an async render. */
|
|
305
|
+
const slotPart =
|
|
306
|
+
slotCode.trim() === ''
|
|
307
|
+
? undefined
|
|
308
|
+
: `"$children": async () => { const $slot = []; ${slotCode}return $slot.join(''); }`
|
|
309
|
+
/* The same last-wins layering the client build emits (`composeProps`), so SSR
|
|
310
|
+
and hydration read the same prop bag. */
|
|
311
|
+
const propsExpr = composeProps(node.props, lowerExpression, slotPart)
|
|
312
|
+
/* Render the child (awaited — render is async) sharing this render's `$ctx`,
|
|
313
|
+
so its `await`/`try` block ids draw from the same depth-first counter,
|
|
314
|
+
unique across page + children, and the streamed fragments resolve into the
|
|
315
|
+
right boundaries. MERGE its streaming awaits into `$awaits` and its inline
|
|
316
|
+
blocking values into `$resume`. ($awaits/$resume are captured from the
|
|
317
|
+
enclosing render body, including from branch closures.) */
|
|
225
318
|
const result = nextVar('$child')
|
|
226
319
|
return (
|
|
227
320
|
anchor +
|
|
228
321
|
push(target, RANGE_OPEN) +
|
|
229
|
-
`const ${result} = ${node.name}.render(
|
|
322
|
+
`const ${result} = await ${node.name}.render(${propsExpr}, $ctx);\n` +
|
|
230
323
|
`${target}.push(${result}.html);\n` +
|
|
231
324
|
`for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
|
|
325
|
+
`Object.assign($resume, ${result}.resume);\n` +
|
|
232
326
|
push(target, RANGE_CLOSE)
|
|
233
327
|
)
|
|
234
328
|
}
|
|
@@ -262,6 +356,12 @@ export function generateSSR(
|
|
|
262
356
|
/* present/absent semantics matching the client `attr` binding:
|
|
263
357
|
false/null/undefined drops it, true emits the bare attribute. */
|
|
264
358
|
code += `${target}.push($attr(${JSON.stringify(attr.name)}, ${lowerExpression(attr.code)}));\n`
|
|
359
|
+
} else if (attr.kind === 'spread') {
|
|
360
|
+
/* `{...expr}` element spread: each key as an attribute, functions (event
|
|
361
|
+
handlers) skipped — the client `spreadAttrs` wires those on hydrate. Keys
|
|
362
|
+
explicitly named on the element are skipped (the explicit attr wins), so
|
|
363
|
+
no duplicate attribute is emitted and the client DOM agrees. */
|
|
364
|
+
code += `${target}.push($spread(${lowerExpression(attr.code)}, ${JSON.stringify(spreadExcludedNames(node.attrs))}));\n`
|
|
265
365
|
} else if (attr.kind === 'bind' && attr.property === 'group') {
|
|
266
366
|
/* Render the checked state as a boolean attribute: present when the
|
|
267
367
|
path holds (radio) or contains (checkbox) this control's value. */
|
|
@@ -304,57 +404,101 @@ export function generateSSR(
|
|
|
304
404
|
): string {
|
|
305
405
|
const wrap = inSkeleton.get(node)
|
|
306
406
|
const fallback = generateInto(node.children, target)
|
|
407
|
+
/* `$children` is an async builder the parent passes lazily; `await` it here so the
|
|
408
|
+
slot content's block ids allocate AT the `<slot>` position — the same order the
|
|
409
|
+
client builds slot content — keeping hydration congruent. The `await` makes a
|
|
410
|
+
component with a `<slot>` an async render (its caller already `await`s `render()`). */
|
|
307
411
|
const body =
|
|
308
412
|
fallback.trim() === ''
|
|
309
|
-
? `if ($props && $props.$children) { ${target}.push($props.$children()); }\n`
|
|
310
|
-
: `if ($props && $props.$children) { ${target}.push($props.$children()); } else {\n${fallback}}\n`
|
|
413
|
+
? `if ($props && $props.$children) { ${target}.push(await $props.$children()); }\n`
|
|
414
|
+
: `if ($props && $props.$children) { ${target}.push(await $props.$children()); } else {\n${fallback}}\n`
|
|
311
415
|
if (!wrap) {
|
|
312
416
|
return body
|
|
313
417
|
}
|
|
314
418
|
return `${anchor}${openRange(target)}${body}${closeRange(target)}`
|
|
315
419
|
}
|
|
316
420
|
|
|
317
|
-
/*
|
|
318
|
-
|
|
319
|
-
branch between the markers (flushed now, value streamed later); blocking emits
|
|
320
|
-
an empty boundary — its resolved branch is the children bound to `node.as` — and
|
|
321
|
-
flags the entry so `renderToStream` settles it before the first flush. */
|
|
421
|
+
/* A blocking await (`then` on the tag) renders INLINE during the async render pass;
|
|
422
|
+
a streaming await defers its resolved branch to `renderToStream`. */
|
|
322
423
|
function generateAwait(node: Extract<TemplateNode, { kind: 'await' }>, target: string): string {
|
|
424
|
+
return node.blocking
|
|
425
|
+
? generateBlockingAwait(node, target)
|
|
426
|
+
: generateStreamingAwait(node, target)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/*
|
|
430
|
+
A blocking await — `await`ed at its structural position in the async render pass and
|
|
431
|
+
its resolved branch rendered INLINE into `target`, between the boundary markers. This
|
|
432
|
+
matches the client (which runs `then` inline during adopt): one render pass, so the
|
|
433
|
+
block id (`$ctx.next++`) and any nested await's id are allocated depth-first in the
|
|
434
|
+
same order the client hydrates them, and the resolved value is captured into `$resume`
|
|
435
|
+
for the manifest. The resolved children bind `node.as`; `finally` appends after, matching
|
|
436
|
+
the client's concatenated node range. With no catch/finally branch a rejection propagates
|
|
437
|
+
(renderToStream / the 500 surfaces it); otherwise the catch branch renders and seeds an
|
|
438
|
+
error resume. The value `const` is block-scoped to its `try`, so sibling blocking awaits
|
|
439
|
+
that bind the same name don't collide.
|
|
440
|
+
*/
|
|
441
|
+
function generateBlockingAwait(
|
|
442
|
+
node: Extract<TemplateNode, { kind: 'await' }>,
|
|
443
|
+
target: string,
|
|
444
|
+
): string {
|
|
445
|
+
const catchBranch = branchNamed(node.children, 'catch')
|
|
446
|
+
const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
|
|
447
|
+
const resolvedChildren = node.children.filter((child) => child.kind !== 'branch')
|
|
448
|
+
const resolvedAs = node.as ?? '_value'
|
|
449
|
+
const errorAs = catchBranch?.as ?? '_error'
|
|
450
|
+
const id = nextVar('$aid')
|
|
451
|
+
let code = `const ${id} = $ctx.next++;\n`
|
|
452
|
+
code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
|
|
453
|
+
code += `try {\n`
|
|
454
|
+
code += `const ${resolvedAs} = await (${lowerExpression(node.promise)});\n`
|
|
455
|
+
code += branchContent(resolvedChildren, target)
|
|
456
|
+
code += branchContent(finallyChildren, target)
|
|
457
|
+
code += `$resume[${id}] = { ok: true, value: ${resolvedAs} };\n`
|
|
458
|
+
if (catchBranch === undefined && finallyChildren.length === 0) {
|
|
459
|
+
/* No catch/finally → let the rejection surface instead of an empty branch. */
|
|
460
|
+
code += `} catch (_error) { throw _error; }\n`
|
|
461
|
+
} else {
|
|
462
|
+
code += `} catch (${errorAs}) {\n`
|
|
463
|
+
code += branchContent(catchBranch?.children ?? [], target)
|
|
464
|
+
code += branchContent(finallyChildren, target)
|
|
465
|
+
code += `$resume[${id}] = { ok: false, error: String(${errorAs}) };\n`
|
|
466
|
+
code += `}\n`
|
|
467
|
+
}
|
|
468
|
+
code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
|
|
469
|
+
return code
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* A streaming await — emits the pending branch between the markers (flushed now) and
|
|
473
|
+
registers the promise + async string-renderers on `$awaits`; `renderToStream`
|
|
474
|
+
flushes the resolved fragment out of order. The renderers are async so a nested
|
|
475
|
+
`await` block inside the branch composes. `finally` appends after the outcome,
|
|
476
|
+
matching the client's concatenated node range. Neither catch nor finally → omit
|
|
477
|
+
`catch` so a rejection surfaces (renderToStream re-throws); a finally-only block
|
|
478
|
+
keeps a catch renderer that renders just finally. */
|
|
479
|
+
function generateStreamingAwait(
|
|
480
|
+
node: Extract<TemplateNode, { kind: 'await' }>,
|
|
481
|
+
target: string,
|
|
482
|
+
): string {
|
|
323
483
|
const catchBranch = branchNamed(node.children, 'catch')
|
|
324
484
|
const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
|
|
325
|
-
/* Resolved branch + its bound value: the children directly when blocking, the
|
|
326
|
-
`then` child when streaming. Pending (streaming only) is the non-branch
|
|
327
|
-
children. */
|
|
328
485
|
const thenBranch = branchNamed(node.children, 'then')
|
|
329
|
-
const
|
|
330
|
-
? node.children.filter((child) => child.kind !== 'branch')
|
|
331
|
-
: (thenBranch?.children ?? [])
|
|
332
|
-
const resolvedAs = node.blocking ? node.as : thenBranch?.as
|
|
333
|
-
const pending = node.blocking
|
|
334
|
-
? []
|
|
335
|
-
: node.children.filter((child) => child.kind !== 'branch')
|
|
336
|
-
/* Runtime block id (shared with the client + child components in this pass). */
|
|
486
|
+
const pending = node.children.filter((child) => child.kind !== 'branch')
|
|
337
487
|
const id = nextVar('$aid')
|
|
338
|
-
let code = `const ${id} =
|
|
488
|
+
let code = `const ${id} = $ctx.next++;\n`
|
|
339
489
|
code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
|
|
340
490
|
code += branchContent(pending, target)
|
|
341
491
|
code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
|
|
342
|
-
/* The settled closures append `finally` after the outcome markup, matching the
|
|
343
|
-
client's concatenated node range so hydration aligns. */
|
|
344
492
|
const settled = (binding: string, children: TemplateNode[]) =>
|
|
345
|
-
`(${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
|
|
346
|
-
/* Neither catch nor finally → omit `catch` so a rejection surfaces to the
|
|
347
|
-
stream/error path (renderToStream re-throws) instead of rendering an empty
|
|
348
|
-
branch. A finally-only block keeps a catch closure that renders just finally. */
|
|
493
|
+
`async (${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
|
|
349
494
|
const catchProp =
|
|
350
495
|
catchBranch === undefined && finallyChildren.length === 0
|
|
351
496
|
? ''
|
|
352
497
|
: `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [])} `
|
|
353
498
|
code +=
|
|
354
499
|
`$awaits.push({ id: ${id}, ` +
|
|
355
|
-
(node.blocking ? 'blocking: true, ' : '') +
|
|
356
500
|
`promise: () => (${lowerExpression(node.promise)}), ` +
|
|
357
|
-
`then: ${settled(
|
|
501
|
+
`then: ${settled(thenBranch?.as ?? '_value', thenBranch?.children ?? [])}, ` +
|
|
358
502
|
`${catchProp}});\n`
|
|
359
503
|
return code
|
|
360
504
|
}
|
|
@@ -372,7 +516,7 @@ export function generateSSR(
|
|
|
372
516
|
const errName = catchBranch?.as ?? '_error'
|
|
373
517
|
const id = nextVar('$tid')
|
|
374
518
|
const mark = nextVar('$trim')
|
|
375
|
-
let code = `const ${id} =
|
|
519
|
+
let code = `const ${id} = $ctx.next++;\n`
|
|
376
520
|
code += `${target}.push("<!--abide:try:" + ${id} + "-->");\n`
|
|
377
521
|
code += `const ${mark} = ${target}.length;\n`
|
|
378
522
|
code += `try {\n`
|
|
@@ -107,35 +107,72 @@ function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceF
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
-
/* doc array `.push(
|
|
110
|
+
/* doc array `.push(a, b, …)` → one `add` patch per argument at the array's `-`
|
|
111
|
+
slot, matching native multi-arg push (each lands at the end in order). */
|
|
111
112
|
if (
|
|
112
113
|
ts.isCallExpression(node) &&
|
|
113
114
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
114
115
|
node.expression.name.text === 'push'
|
|
115
116
|
) {
|
|
116
117
|
const segments = pathSegments(node.expression.expression)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
if (segments && node.arguments.length > 0) {
|
|
119
|
+
const adds = node.arguments.map(
|
|
120
|
+
(pushed) =>
|
|
121
|
+
docCall(docName, 'add', [
|
|
122
|
+
buildPath([...segments, { kind: 'literal', value: '-' }]),
|
|
123
|
+
ts.visitNode(pushed, visit) as ts.Expression,
|
|
124
|
+
]) as ts.Expression,
|
|
125
|
+
)
|
|
126
|
+
/* `node.arguments.length > 0` above guarantees `adds` is non-empty, so the
|
|
127
|
+
single-element branch's `adds[0]` is defined. */
|
|
128
|
+
return adds.length === 1 ? adds[0]! : ts.factory.createCommaListExpression(adds)
|
|
123
129
|
}
|
|
124
130
|
}
|
|
125
131
|
/* A called member on a doc chain is a method on the read value, not a
|
|
126
|
-
deeper path: `model.draft.trim()` → `model.read("draft")
|
|
127
|
-
`model.items.map(f)` → `model.read("items")
|
|
132
|
+
deeper path: `model.draft.trim()` → `readCall(model.read("draft"), …)`,
|
|
133
|
+
`model.items.map(f)` → `readCall(model.read("items"), …)`. (Array `.push`
|
|
128
134
|
above is the exception — it lowers to an `add` patch.) */
|
|
129
135
|
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
130
|
-
const
|
|
136
|
+
const access = node.expression
|
|
137
|
+
const segments = pathSegments(access.expression)
|
|
131
138
|
if (segments) {
|
|
139
|
+
const read = docCall(docName, 'read', [buildPath(segments)])
|
|
140
|
+
const args = node.arguments.map(
|
|
141
|
+
(arg) => ts.visitNode(arg, visit) as ts.Expression,
|
|
142
|
+
)
|
|
143
|
+
/* Optional chaining is the author's explicit skip-if-absent: a nullish
|
|
144
|
+
read short-circuits the whole call to `undefined`. Keep it bare —
|
|
145
|
+
routing it through the throwing guard would invert that semantics.
|
|
146
|
+
The call continues the chain whenever the access OR the call carries a
|
|
147
|
+
`?.`, so `model.x?.m()` short-circuits the call too. */
|
|
148
|
+
if (access.questionDotToken || node.questionDotToken) {
|
|
149
|
+
const member = access.questionDotToken
|
|
150
|
+
? ts.factory.createPropertyAccessChain(
|
|
151
|
+
read,
|
|
152
|
+
access.questionDotToken,
|
|
153
|
+
access.name,
|
|
154
|
+
)
|
|
155
|
+
: ts.factory.createPropertyAccessExpression(read, access.name.text)
|
|
156
|
+
return ts.factory.createCallChain(
|
|
157
|
+
member,
|
|
158
|
+
node.questionDotToken,
|
|
159
|
+
node.typeArguments,
|
|
160
|
+
args,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
/* A non-optional call on an absent read throws the engine's opaque
|
|
164
|
+
`undefined is not an object`. Route it through `readCall`, which throws
|
|
165
|
+
naming the authored scope path and member — the key and member are both
|
|
166
|
+
in hand here, the `.abide` location comes free off the mapped stack. */
|
|
132
167
|
return ts.factory.createCallExpression(
|
|
133
|
-
ts.factory.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
168
|
+
ts.factory.createIdentifier('readCall'),
|
|
169
|
+
undefined,
|
|
170
|
+
[
|
|
171
|
+
read,
|
|
172
|
+
buildPath(segments),
|
|
173
|
+
ts.factory.createStringLiteral(access.name.text),
|
|
174
|
+
ts.factory.createArrayLiteralExpression(args),
|
|
175
|
+
],
|
|
139
176
|
)
|
|
140
177
|
}
|
|
141
178
|
}
|
|
@@ -121,6 +121,24 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
121
121
|
if (char === '>' || char === '/' || char === undefined) {
|
|
122
122
|
break
|
|
123
123
|
}
|
|
124
|
+
/* `{...expr}` standing where an attribute name would — a spread of an object's
|
|
125
|
+
keys onto the tag: props on a component, attributes on a native element. Only
|
|
126
|
+
a `<template>` directive rejects it (see `readElement`). */
|
|
127
|
+
if (char === '{') {
|
|
128
|
+
const { code, loc } = readBracedExpression()
|
|
129
|
+
if (!code.startsWith('...')) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[abide] a bare {expr} is not a valid attribute — write {...expr} to spread an object's keys as props`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
/* Advance `loc` past `...` and any whitespace so it points at the spread
|
|
135
|
+
EXPRESSION, not the dots — the shadow source-map invariant (source text at
|
|
136
|
+
`loc` equals the emitted code) requires `loc` and `code` to align. */
|
|
137
|
+
const inner = code.slice(3)
|
|
138
|
+
const leading = inner.length - inner.trimStart().length
|
|
139
|
+
attrs.push({ kind: 'spread', code: inner.trim(), loc: loc + 3 + leading })
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
124
142
|
let name = ''
|
|
125
143
|
while (cursor < source.length && !/[\s=>/]/.test(source.charAt(cursor))) {
|
|
126
144
|
name += source.charAt(cursor)
|
|
@@ -163,6 +181,12 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
163
181
|
`>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
|
|
164
182
|
let value = ''
|
|
165
183
|
while (cursor < source.length && !/[\s>]/.test(source.charAt(cursor))) {
|
|
184
|
+
/* Stop before a `/` that closes the tag (`<Comp x=y/>`) so the value
|
|
185
|
+
doesn't swallow the self-closing slash and defeat detection; a `/`
|
|
186
|
+
elsewhere (e.g. a URL `href=/a/b`) stays part of the value. */
|
|
187
|
+
if (source.charAt(cursor) === '/' && source.charAt(cursor + 1) === '>') {
|
|
188
|
+
break
|
|
189
|
+
}
|
|
166
190
|
value += source.charAt(cursor)
|
|
167
191
|
cursor += 1
|
|
168
192
|
}
|
|
@@ -214,6 +238,11 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
214
238
|
const slotted = selfClosing ? [] : readChildren(tag)
|
|
215
239
|
return { kind: 'component', name: tag, props: toProps(attrs), children: slotted }
|
|
216
240
|
}
|
|
241
|
+
/* `{...expr}` spreads onto a component (its props) or a native element (its
|
|
242
|
+
attributes), but a `<template>` directive has no such bag — reject it there. */
|
|
243
|
+
if (tag === 'template' && attrs.some((attr) => attr.kind === 'spread')) {
|
|
244
|
+
throw new Error('[abide] {...expr} spread is not supported on a <template> directive')
|
|
245
|
+
}
|
|
217
246
|
const children = selfClosing || VOID_TAGS.has(tag) ? [] : readChildren(tag)
|
|
218
247
|
if (tag === 'template') {
|
|
219
248
|
return toControlFlow(attrs, children)
|
|
@@ -296,8 +325,15 @@ function rejectStrayBranches(
|
|
|
296
325
|
assigned) instead of being dropped. A static value becomes a string literal —
|
|
297
326
|
a bare attribute coerces to `true` instead; every other kind keeps its `code`,
|
|
298
327
|
letting a prop hold any value, functions included (e.g. an `onclick` callback). */
|
|
299
|
-
function toProps(
|
|
328
|
+
function toProps(
|
|
329
|
+
attrs: TemplateAttr[],
|
|
330
|
+
): { name: string; code: string; loc?: number; spread?: boolean }[] {
|
|
300
331
|
return attrs.map((attr) => {
|
|
332
|
+
/* A `{...expr}` spread carries no name — its keys merge in at runtime
|
|
333
|
+
(`mergeProps`/`spreadProps`); `spread: true` marks it for the back-ends. */
|
|
334
|
+
if (attr.kind === 'spread') {
|
|
335
|
+
return { name: '', code: attr.code, loc: attr.loc, spread: true }
|
|
336
|
+
}
|
|
301
337
|
if (attr.kind === 'static') {
|
|
302
338
|
/* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
|
|
303
339
|
`true` so the prop reads as a boolean, not the empty string a native
|
|
@@ -347,6 +383,13 @@ function attrName(attr: TemplateAttr): string {
|
|
|
347
383
|
if (attr.kind === 'attach') {
|
|
348
384
|
return 'attach'
|
|
349
385
|
}
|
|
386
|
+
/* A spread has no name. `attrName` only feeds `<template>` directive lookups, and a
|
|
387
|
+
spread on a `<template>` is rejected at parse, so this branch is unreachable in
|
|
388
|
+
practice. (Spread itself IS supported — on components as props and on native
|
|
389
|
+
elements as attributes; only `<template>` directives reject it.) */
|
|
390
|
+
if (attr.kind === 'spread') {
|
|
391
|
+
return ''
|
|
392
|
+
}
|
|
350
393
|
return attr.name
|
|
351
394
|
}
|
|
352
395
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { TemplateAttr } from './types/TemplateAttr.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The explicitly-authored attribute names on a native element that also carries a
|
|
5
|
+
`{...spread}`. An explicit attribute wins over a spread key of the same name, so
|
|
6
|
+
both back-ends pass this set to skip those keys from the spread — keeping the
|
|
7
|
+
server string and the client DOM congruent (no duplicate attribute, and no
|
|
8
|
+
SSR-first-wins vs client-spread-wins divergence). Excluding by NAME (not by a
|
|
9
|
+
runtime present/absent check) means the two sides agree regardless of a dynamic
|
|
10
|
+
attribute's runtime value. Static and dynamic (`{expr}`) attrs contribute their
|
|
11
|
+
name, a `bind:` its property, an `on<event>` its `on…` form; `attach`/`spread`
|
|
12
|
+
carry no attribute name.
|
|
13
|
+
*/
|
|
14
|
+
export function spreadExcludedNames(attrs: TemplateAttr[]): string[] {
|
|
15
|
+
return attrs.flatMap((attr) => {
|
|
16
|
+
if (attr.kind === 'static' || attr.kind === 'expression') {
|
|
17
|
+
return [attr.name]
|
|
18
|
+
}
|
|
19
|
+
if (attr.kind === 'bind') {
|
|
20
|
+
return [attr.property]
|
|
21
|
+
}
|
|
22
|
+
if (attr.kind === 'event') {
|
|
23
|
+
return [`on${attr.event}`]
|
|
24
|
+
}
|
|
25
|
+
return []
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -6,7 +6,9 @@ reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
|
|
|
6
6
|
`bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
|
|
7
7
|
writable doc path read into the property and written back on input; `attach` is
|
|
8
8
|
`attach={code}` where `code` evaluates to an attachment `(node) => teardown` run
|
|
9
|
-
at build with node-lifetime teardown
|
|
9
|
+
at build with node-lifetime teardown; `spread` is `{...code}` where `code`
|
|
10
|
+
evaluates to an object whose own keys each become a prop on a component or an
|
|
11
|
+
attribute on a native element. `loc` is the
|
|
10
12
|
absolute offset of `code` in the original `.abide` source (see TextPart) —
|
|
11
13
|
optional, set only when the parser tracks positions for the type-checking shadow.
|
|
12
14
|
*/
|
|
@@ -16,3 +18,4 @@ export type TemplateAttr =
|
|
|
16
18
|
| { kind: 'event'; event: string; code: string; loc?: number }
|
|
17
19
|
| { kind: 'bind'; property: string; code: string; loc?: number }
|
|
18
20
|
| { kind: 'attach'; code: string; loc?: number }
|
|
21
|
+
| { kind: 'spread'; code: string; loc?: number }
|
|
@@ -61,7 +61,9 @@ export type TemplateNode =
|
|
|
61
61
|
| {
|
|
62
62
|
kind: 'component'
|
|
63
63
|
name: string
|
|
64
|
-
|
|
64
|
+
/* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
|
|
65
|
+
`name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). */
|
|
66
|
+
props: { name: string; code: string; loc?: number; spread?: boolean }[]
|
|
65
67
|
children: TemplateNode[]
|
|
66
68
|
}
|
|
67
69
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Composes a child's props from ordered layers — explicit prop runs (`{ name: () =>
|
|
5
|
+
value }` thunk maps), `{...spread}` layers (`spreadProps`), and a trailing slot
|
|
6
|
+
layer (`$children`) — into one prop bag, last layer wins per key (source order, like
|
|
7
|
+
JSX). A key resolves by scanning layers in reverse for the first that carries it, so
|
|
8
|
+
an explicit prop after a spread overrides it and vice-versa. Emitted only when a
|
|
9
|
+
component carries a spread; the plain object literal stays the path otherwise.
|
|
10
|
+
*/
|
|
11
|
+
// @documentation plumbing
|
|
12
|
+
export function mergeProps(layers: Record<string | symbol, unknown>[]): UiProps {
|
|
13
|
+
return new Proxy(Object.create(null), {
|
|
14
|
+
get(_target, key) {
|
|
15
|
+
for (let index = layers.length - 1; index >= 0; index -= 1) {
|
|
16
|
+
const layer = layers[index]
|
|
17
|
+
if (layer !== undefined && key in layer) {
|
|
18
|
+
return layer[key]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined
|
|
22
|
+
},
|
|
23
|
+
has: (_target, key) => layers.some((layer) => key in layer),
|
|
24
|
+
/* Enumerable across every layer (deduped), so `restProps` can collect a
|
|
25
|
+
parent-spread bag's keys. */
|
|
26
|
+
ownKeys: () => [...new Set(layers.flatMap((layer) => Reflect.ownKeys(layer)))],
|
|
27
|
+
getOwnPropertyDescriptor: (_target, key) =>
|
|
28
|
+
layers.some((layer) => key in layer)
|
|
29
|
+
? { enumerable: true, configurable: true }
|
|
30
|
+
: undefined,
|
|
31
|
+
}) as UiProps
|
|
32
|
+
}
|