@abide/abide 0.31.1 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +26 -0
- package/package.json +1 -2
- package/src/checkAbide.ts +11 -6
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
- package/src/lib/server/runtime/createServer.ts +28 -23
- package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
- package/src/lib/ui/compile/AbideCompileError.ts +16 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
- package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
- package/src/lib/ui/compile/bindListenEvent.ts +19 -0
- package/src/lib/ui/compile/compileShadow.ts +65 -52
- package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
- package/src/lib/ui/compile/generateBuild.ts +114 -212
- package/src/lib/ui/compile/generateSSR.ts +54 -88
- package/src/lib/ui/compile/lowerContext.ts +64 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
- package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
- package/src/lib/ui/compile/scopeAttr.ts +9 -0
- package/src/lib/ui/compile/staticAttr.ts +11 -0
- package/src/lib/ui/compile/staticTextPart.ts +12 -0
- package/src/lib/ui/compile/unwrapParens.ts +10 -0
- package/src/lib/ui/dom/applyResolved.ts +9 -6
- package/src/lib/ui/dom/awaitBlock.ts +27 -21
- package/src/lib/ui/dom/clearBetween.ts +16 -0
- package/src/lib/ui/dom/each.ts +64 -38
- package/src/lib/ui/dom/eachAsync.ts +41 -54
- package/src/lib/ui/dom/fillBefore.ts +16 -0
- package/src/lib/ui/dom/moveRange.ts +19 -0
- package/src/lib/ui/dom/openMarker.ts +22 -0
- package/src/lib/ui/dom/removeRange.ts +18 -0
- package/src/lib/ui/dom/switchBlock.ts +32 -40
- package/src/lib/ui/dom/tryBlock.ts +31 -35
- package/src/lib/ui/dom/types/EachRow.ts +10 -3
- package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
- package/src/lib/ui/dom/when.ts +34 -43
- package/src/lib/ui/installHotBridge.ts +0 -2
- package/src/lib/ui/renderToStream.ts +14 -11
- package/src/lib/ui/state.ts +14 -5
- package/src/lib/ui/compile/branchElements.ts +0 -50
- package/src/lib/ui/dom/openRoot.ts +0 -20
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
-
import {
|
|
3
|
-
import { escapeHtml } from './escapeHtml.ts'
|
|
2
|
+
import { componentWrapperTag } from './componentWrapperTag.ts'
|
|
4
3
|
import { groupBindParts } from './groupBindParts.ts'
|
|
5
|
-
import {
|
|
4
|
+
import { lowerContext } from './lowerContext.ts'
|
|
6
5
|
import { partitionSlots } from './partitionSlots.ts'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { scopeAttr } from './scopeAttr.ts'
|
|
7
|
+
import { staticAttr } from './staticAttr.ts'
|
|
9
8
|
import { staticAttrValue } from './staticAttrValue.ts'
|
|
9
|
+
import { staticTextPart } from './staticTextPart.ts'
|
|
10
10
|
import { stripEffects } from './stripEffects.ts'
|
|
11
11
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
12
12
|
import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
13
13
|
|
|
14
|
+
/* The range boundary comments a control-flow block emits around its content. They
|
|
15
|
+
serialize exactly as the client's `document.createComment('[' | ']')` markers, so
|
|
16
|
+
the client claims the same `[ … ]` boundary it builds — the comment-marked range
|
|
17
|
+
that lets a branch hold any content. */
|
|
18
|
+
const RANGE_OPEN = '<!--[-->'
|
|
19
|
+
const RANGE_CLOSE = '<!--]-->'
|
|
20
|
+
|
|
14
21
|
/*
|
|
15
22
|
Server code generator: turns the parsed template into statements that push HTML
|
|
16
23
|
fragments onto an output array, reading the document synchronously (no DOM, no
|
|
@@ -36,24 +43,16 @@ export function generateSSR(
|
|
|
36
43
|
let varCounter = 0
|
|
37
44
|
const nextVar = (prefix: string): string => `${prefix}${varCounter++}`
|
|
38
45
|
|
|
39
|
-
/*
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return lowerDocAccess(renameSignalRefs(code, stateNames, derefScope()), 'model')
|
|
46
|
-
.trim()
|
|
47
|
-
.replace(/;$/, '')
|
|
48
|
-
}
|
|
46
|
+
/* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
|
|
47
|
+
const {
|
|
48
|
+
expression: lowerExpression,
|
|
49
|
+
statement,
|
|
50
|
+
withNestedScripts,
|
|
51
|
+
} = lowerContext(stateNames, derivedNames)
|
|
49
52
|
|
|
50
|
-
/*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return stripEffects(
|
|
54
|
-
lowerDocAccess(renameSignalRefs(code, stateNames, derefScope()), 'model').trim(),
|
|
55
|
-
)
|
|
56
|
-
}
|
|
53
|
+
/* A scoped-script body for SSR: the shared lowering, then strip effects
|
|
54
|
+
(client-only lifecycle that emits no HTML) — the one SSR-side asymmetry. */
|
|
55
|
+
const lowerScript = (code: string): string => stripEffects(statement(code))
|
|
57
56
|
|
|
58
57
|
function push(target: string, literal: string): string {
|
|
59
58
|
return `${target}.push(${JSON.stringify(literal)});\n`
|
|
@@ -63,41 +62,25 @@ export function generateSSR(
|
|
|
63
62
|
return children.map((child) => generate(child, target)).join('')
|
|
64
63
|
}
|
|
65
64
|
|
|
66
|
-
/* A control-flow branch's
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!localDerived.has(name)) {
|
|
75
|
-
localDerived.add(name)
|
|
76
|
-
added.push(name)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
const scriptCode = children
|
|
82
|
-
.filter(
|
|
83
|
-
(child): child is Extract<TemplateNode, { kind: 'script' }> =>
|
|
84
|
-
child.kind === 'script',
|
|
85
|
-
)
|
|
86
|
-
.map((child) => `${lowerScript(child.code)}\n`)
|
|
87
|
-
.join('')
|
|
88
|
-
const markup = generateInto(branchElements(children, context, true), target)
|
|
89
|
-
for (const name of added) {
|
|
90
|
-
localDerived.delete(name)
|
|
91
|
-
}
|
|
92
|
-
return scriptCode + markup
|
|
65
|
+
/* A control-flow branch's content, generated exactly like a normal child list so
|
|
66
|
+
a branch holds ANY content (components, text, nested blocks). `generate` emits
|
|
67
|
+
nested `<script>`s in document order; `withNestedScripts` puts their bindings in
|
|
68
|
+
scope — matching the client build, so hydration stays aligned. The caller wraps
|
|
69
|
+
it in the `[ … ]` range markers the runtime tracks (unconditionally per block,
|
|
70
|
+
so an empty/false branch still emits the boundary the client claims). */
|
|
71
|
+
function branchContent(children: TemplateNode[], target: string): string {
|
|
72
|
+
return withNestedScripts(children, () => generateInto(children, target))
|
|
93
73
|
}
|
|
74
|
+
const openRange = (target: string): string => push(target, RANGE_OPEN)
|
|
75
|
+
const closeRange = (target: string): string => push(target, RANGE_CLOSE)
|
|
94
76
|
|
|
95
77
|
function generate(node: TemplateNode, target: string): string {
|
|
96
78
|
if (node.kind === 'text') {
|
|
97
79
|
return node.parts
|
|
98
80
|
.map((part) => {
|
|
99
81
|
if (part.kind === 'static') {
|
|
100
|
-
|
|
82
|
+
const markup = staticTextPart(part.value)
|
|
83
|
+
return markup === '' ? '' : push(target, markup)
|
|
101
84
|
}
|
|
102
85
|
return `${target}.push($text(${lowerExpression(part.code)}));\n`
|
|
103
86
|
})
|
|
@@ -106,11 +89,11 @@ export function generateSSR(
|
|
|
106
89
|
if (node.kind === 'if') {
|
|
107
90
|
const elseBranch = node.children.find((child) => child.kind === 'case')
|
|
108
91
|
const thenChildren = node.children.filter((child) => child.kind !== 'case')
|
|
109
|
-
let code = `if (${lowerExpression(node.condition)}) {\n${
|
|
92
|
+
let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
|
|
110
93
|
if (elseBranch !== undefined && elseBranch.kind === 'case') {
|
|
111
|
-
code += ` else {\n${
|
|
94
|
+
code += ` else {\n${branchContent(elseBranch.children, target)}}`
|
|
112
95
|
}
|
|
113
|
-
return `${code}\n`
|
|
96
|
+
return `${openRange(target)}${code}\n${closeRange(target)}`
|
|
114
97
|
}
|
|
115
98
|
if (node.kind === 'switch') {
|
|
116
99
|
const cases = node.children.filter(
|
|
@@ -120,15 +103,15 @@ export function generateSSR(
|
|
|
120
103
|
let started = false
|
|
121
104
|
for (const branch of cases) {
|
|
122
105
|
if (branch.match !== undefined) {
|
|
123
|
-
code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${
|
|
106
|
+
code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${branchContent(branch.children, target)}}\n`
|
|
124
107
|
started = true
|
|
125
108
|
}
|
|
126
109
|
}
|
|
127
110
|
const fallback = cases.find((branch) => branch.match === undefined)
|
|
128
111
|
if (fallback !== undefined) {
|
|
129
|
-
code += `${started ? 'else ' : ''}{\n${
|
|
112
|
+
code += `${started ? 'else ' : ''}{\n${branchContent(fallback.children, target)}}\n`
|
|
130
113
|
}
|
|
131
|
-
return `${code}}\n`
|
|
114
|
+
return `${openRange(target)}${code}}\n${closeRange(target)}`
|
|
132
115
|
}
|
|
133
116
|
if (node.kind === 'case') {
|
|
134
117
|
return ''
|
|
@@ -156,7 +139,7 @@ export function generateSSR(
|
|
|
156
139
|
if (node.async) {
|
|
157
140
|
return ''
|
|
158
141
|
}
|
|
159
|
-
return `for (const ${node.as} of (${lowerExpression(node.items)})) {\n${
|
|
142
|
+
return `for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
|
|
160
143
|
}
|
|
161
144
|
if (node.kind === 'await') {
|
|
162
145
|
return generateAwait(node, target)
|
|
@@ -172,7 +155,7 @@ export function generateSSR(
|
|
|
172
155
|
the same wrapper the client mounts into, so SSR and client agree.
|
|
173
156
|
Props pass as thunks; slot content passes as a string-returning
|
|
174
157
|
`$children` the child invokes from its <slot>. */
|
|
175
|
-
const tag = node.name
|
|
158
|
+
const { tag, transparent } = componentWrapperTag(node.name)
|
|
176
159
|
const parts = node.props.map(
|
|
177
160
|
(prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
|
|
178
161
|
)
|
|
@@ -199,7 +182,7 @@ export function generateSSR(
|
|
|
199
182
|
the enclosing render body, including from branch closures.) */
|
|
200
183
|
const result = nextVar('$child')
|
|
201
184
|
return (
|
|
202
|
-
push(target, `<${tag}>`) +
|
|
185
|
+
push(target, `<${tag}${transparent ? ' style="display:contents"' : ''}>`) +
|
|
203
186
|
`const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
|
|
204
187
|
`${target}.push(${result}.html);\n` +
|
|
205
188
|
`for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
|
|
@@ -218,14 +201,11 @@ export function generateSSR(
|
|
|
218
201
|
/* Every `<style>` active at this element (own siblings + ancestors) — same set
|
|
219
202
|
the client stamps, so server and client markup carry identical attributes. */
|
|
220
203
|
for (const scope of node.scopes ?? []) {
|
|
221
|
-
code += push(target,
|
|
204
|
+
code += push(target, scopeAttr(scope))
|
|
222
205
|
}
|
|
223
206
|
for (const attr of node.attrs) {
|
|
224
207
|
if (attr.kind === 'static') {
|
|
225
|
-
|
|
226
|
-
the attribute or inject markup (the client uses setAttribute, which
|
|
227
|
-
needs no escaping — escaping here keeps SSR and client in sync). */
|
|
228
|
-
code += push(target, ` ${attr.name}="${escapeHtml(attr.value)}"`)
|
|
208
|
+
code += push(target, staticAttr(attr.name, attr.value))
|
|
229
209
|
} else if (attr.kind === 'expression') {
|
|
230
210
|
/* present/absent semantics matching the client `attr` binding:
|
|
231
211
|
false/null/undefined drops it, true emits the bare attribute. */
|
|
@@ -249,21 +229,7 @@ export function generateSSR(
|
|
|
249
229
|
code += push(target, '>')
|
|
250
230
|
if (!VOID_TAGS.has(node.tag)) {
|
|
251
231
|
/* A `<script>` child scopes its bindings to this element's subtree. */
|
|
252
|
-
|
|
253
|
-
for (const child of node.children) {
|
|
254
|
-
if (child.kind === 'script') {
|
|
255
|
-
for (const name of nestedBindingNames(child.code)) {
|
|
256
|
-
if (!localDerived.has(name)) {
|
|
257
|
-
localDerived.add(name)
|
|
258
|
-
added.push(name)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
code += generateInto(node.children, target)
|
|
264
|
-
for (const name of added) {
|
|
265
|
-
localDerived.delete(name)
|
|
266
|
-
}
|
|
232
|
+
code += withNestedScripts(node.children, () => generateInto(node.children, target))
|
|
267
233
|
code += push(target, `</${node.tag}>`)
|
|
268
234
|
}
|
|
269
235
|
return code
|
|
@@ -318,24 +284,24 @@ export function generateSSR(
|
|
|
318
284
|
const id = nextVar('$aid')
|
|
319
285
|
let code = `const ${id} = nextBlockId();\n`
|
|
320
286
|
code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
|
|
321
|
-
code +=
|
|
287
|
+
code += branchContent(pending, target)
|
|
322
288
|
code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
|
|
323
289
|
/* The settled closures append `finally` after the outcome markup, matching the
|
|
324
290
|
client's concatenated node range so hydration aligns. */
|
|
325
|
-
const settled = (binding: string, children: TemplateNode[]
|
|
326
|
-
`(${binding}) => { const $o = []; ${
|
|
291
|
+
const settled = (binding: string, children: TemplateNode[]) =>
|
|
292
|
+
`(${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
|
|
327
293
|
/* Neither catch nor finally → omit `catch` so a rejection surfaces to the
|
|
328
294
|
stream/error path (renderToStream re-throws) instead of rendering an empty
|
|
329
295
|
branch. A finally-only block keeps a catch closure that renders just finally. */
|
|
330
296
|
const catchProp =
|
|
331
297
|
catchBranch === undefined && finallyChildren.length === 0
|
|
332
298
|
? ''
|
|
333
|
-
: `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? []
|
|
299
|
+
: `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [])} `
|
|
334
300
|
code +=
|
|
335
301
|
`$awaits.push({ id: ${id}, ` +
|
|
336
302
|
(node.blocking ? 'blocking: true, ' : '') +
|
|
337
303
|
`promise: () => (${lowerExpression(node.promise)}), ` +
|
|
338
|
-
`then: ${settled(resolvedAs ?? '_value', resolvedChildren
|
|
304
|
+
`then: ${settled(resolvedAs ?? '_value', resolvedChildren)}, ` +
|
|
339
305
|
`${catchProp}});\n`
|
|
340
306
|
return code
|
|
341
307
|
}
|
|
@@ -362,12 +328,12 @@ export function generateSSR(
|
|
|
362
328
|
code += `${target}.push("<!--abide:try:" + ${id} + "-->");\n`
|
|
363
329
|
code += `const ${mark} = ${target}.length;\n`
|
|
364
330
|
code += `try {\n`
|
|
365
|
-
code +=
|
|
366
|
-
code +=
|
|
331
|
+
code += branchContent(guarded, target)
|
|
332
|
+
code += branchContent(finallyChildren, target)
|
|
367
333
|
code += `} catch (${errName}) {\n${target}.length = ${mark};\n`
|
|
368
334
|
if (catchBranch !== undefined) {
|
|
369
|
-
code +=
|
|
370
|
-
code +=
|
|
335
|
+
code += branchContent(catchBranch.children, target)
|
|
336
|
+
code += branchContent(finallyChildren, target)
|
|
371
337
|
} else {
|
|
372
338
|
code += `throw ${errName};\n`
|
|
373
339
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { lowerDocAccess } from './lowerDocAccess.ts'
|
|
2
|
+
import { nestedBindingNames } from './prepareNestedScript.ts'
|
|
3
|
+
import { renameSignalRefs } from './renameSignalRefs.ts'
|
|
4
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
5
|
+
import { unwrapParens } from './unwrapParens.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The shared expression-lowering context both back-ends build on: the signal→`model`
|
|
9
|
+
rewrite and doc-access lowering that turns the signal surface the author writes
|
|
10
|
+
(`count` → `model.count` → patch/read) into the doc API, plus the branch-scoped
|
|
11
|
+
nested-`<script>` deref scope. Identical on both sides by design — server and
|
|
12
|
+
client must lower an expression the same way or their markup diverges and
|
|
13
|
+
hydration breaks — so it lives in one place: a new sugar token or a paren/scope
|
|
14
|
+
fix lands here once instead of in lockstep across `generateBuild`/`generateSSR`.
|
|
15
|
+
SSR's effect-stripping stays a caller-side wrap (`stripEffects`), the one real
|
|
16
|
+
asymmetry; the node-walk skeletons stay in each back-end.
|
|
17
|
+
*/
|
|
18
|
+
export function lowerContext(stateNames: ReadonlySet<string>, derivedNames: ReadonlySet<string>) {
|
|
19
|
+
/* Branch-scoped signal bindings (from nested `<script>`s) — they deref to
|
|
20
|
+
`.value` like a `derived`. Pushed while a branch's script + markup compile,
|
|
21
|
+
popped after, so they shadow only within that subtree. */
|
|
22
|
+
const localDerived = new Set<string>()
|
|
23
|
+
const derefScope = (): ReadonlySet<string> =>
|
|
24
|
+
localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
|
|
25
|
+
|
|
26
|
+
/* Rewrites signal refs, then lowers a single expression (no trailing `;`).
|
|
27
|
+
Wrapped in parens so a bare object literal (`{ a: 1 }`) parses as an
|
|
28
|
+
expression, not a block of labeled statements, through both rewrite passes;
|
|
29
|
+
the wrapper is then peeled back off. */
|
|
30
|
+
function expression(code: string): string {
|
|
31
|
+
const renamed = renameSignalRefs(`(${code})`, stateNames, derefScope())
|
|
32
|
+
return unwrapParens(lowerDocAccess(renamed, 'model').trim().replace(/;$/, ''))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* As above but keeps the trailing `;` for a statement/handler body. */
|
|
36
|
+
function statement(code: string): string {
|
|
37
|
+
const renamed = renameSignalRefs(code, stateNames, derefScope())
|
|
38
|
+
return lowerDocAccess(renamed, 'model').trim()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Adds any `<script>` children's binding names to the deref scope (so the script
|
|
42
|
+
bodies and the branch's markup auto-deref them), runs `body` within that scope,
|
|
43
|
+
then pops the names it added. Returns whatever `body` produces. */
|
|
44
|
+
function withNestedScripts<T>(children: TemplateNode[], body: () => T): T {
|
|
45
|
+
const added: string[] = []
|
|
46
|
+
for (const child of children) {
|
|
47
|
+
if (child.kind === 'script') {
|
|
48
|
+
for (const name of nestedBindingNames(child.code)) {
|
|
49
|
+
if (!localDerived.has(name)) {
|
|
50
|
+
localDerived.add(name)
|
|
51
|
+
added.push(name)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const result = body()
|
|
57
|
+
for (const name of added) {
|
|
58
|
+
localDerived.delete(name)
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { expression, statement, withNestedScripts }
|
|
64
|
+
}
|
|
@@ -32,12 +32,17 @@ export function lowerDocAccess(code: string, docName: string): string {
|
|
|
32
32
|
/* A path segment is either a literal key or a runtime expression (a dynamic index). */
|
|
33
33
|
type Segment = { kind: 'literal'; value: string } | { kind: 'expression'; node: ts.Expression }
|
|
34
34
|
|
|
35
|
-
/* Maps a compound-assignment operator to its plain binary counterpart.
|
|
35
|
+
/* Maps a compound-assignment operator to its plain binary counterpart. Logical
|
|
36
|
+
assignments (`||=`/`&&=`/`??=`) lower to an unconditional replace of the
|
|
37
|
+
combined value — consistent with how `+=` lowers (the patch always writes). */
|
|
36
38
|
const COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
|
|
37
39
|
[ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
|
|
38
40
|
[ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
|
|
39
41
|
[ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
|
|
40
42
|
[ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
|
|
43
|
+
[ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
|
|
44
|
+
[ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
|
|
45
|
+
[ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
|
|
41
46
|
])
|
|
42
47
|
|
|
43
48
|
function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Converts an absolute source offset to 1-based `{ line, column }`. Used to turn a
|
|
3
|
+
compile error's tracked offset into a human location for the loader's message
|
|
4
|
+
(Bun frames plugin throws at `<file>:0`, so abide carries the real position in the
|
|
5
|
+
message text itself).
|
|
6
|
+
*/
|
|
7
|
+
export function offsetToLineColumn(
|
|
8
|
+
source: string,
|
|
9
|
+
offset: number,
|
|
10
|
+
): { line: number; column: number } {
|
|
11
|
+
const clamped = Math.max(0, Math.min(offset, source.length))
|
|
12
|
+
const preceding = source.slice(0, clamped)
|
|
13
|
+
const line = preceding.split('\n').length
|
|
14
|
+
const column = clamped - (preceding.lastIndexOf('\n') + 1) + 1
|
|
15
|
+
return { line, column }
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Serializes one `<style>` scope marker to its empty-valued attribute fragment,
|
|
3
|
+
leading space included. Shared by the SSR generator and the static-clone skeleton
|
|
4
|
+
generator so server markup and the client clone template stamp the same scope
|
|
5
|
+
attributes in the same byte-shape.
|
|
6
|
+
*/
|
|
7
|
+
export function scopeAttr(scope: string): string {
|
|
8
|
+
return ` ${scope}=""`
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { escapeHtml } from './escapeHtml.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Serializes one static attribute to its markup fragment, leading space included.
|
|
5
|
+
Shared by the SSR generator and the static-clone skeleton generator so the two
|
|
6
|
+
back-ends can't diverge on attribute byte-shape or value escaping — the contract
|
|
7
|
+
that lets the client clone template hydrate the server markup.
|
|
8
|
+
*/
|
|
9
|
+
export function staticAttr(name: string, value: string): string {
|
|
10
|
+
return ` ${name}="${escapeHtml(value)}"`
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { escapeHtml } from './escapeHtml.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Serializes one static text part to its markup: whitespace-only parts drop (both
|
|
5
|
+
back-ends omit them, so a blank part neither emits markup nor breaks a clone run),
|
|
6
|
+
everything else is HTML-escaped. Shared by the SSR generator and the static-clone
|
|
7
|
+
skeleton generator so server markup and the client clone template agree on both
|
|
8
|
+
the whitespace rule and escaping.
|
|
9
|
+
*/
|
|
10
|
+
export function staticTextPart(value: string): string {
|
|
11
|
+
return value.trim() === '' ? '' : escapeHtml(value)
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Peels one outer paren pair off a lowered expression. The lowering passes wrap an
|
|
3
|
+
expression in `(…)` to force expression-position parsing (so a bare object literal
|
|
4
|
+
isn't read as a block); the printer preserves that wrapper, so the result is
|
|
5
|
+
always `(EXPR)` and the outermost pair is the one we added. Removed for clean,
|
|
6
|
+
canonical output.
|
|
7
|
+
*/
|
|
8
|
+
export function unwrapParens(code: string): string {
|
|
9
|
+
return code.startsWith('(') && code.endsWith(')') ? code.slice(1, -1) : code
|
|
10
|
+
}
|
|
@@ -2,8 +2,8 @@ import { RESUME } from '../runtime/RESUME.ts'
|
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
4
|
Client consumer of an SSR stream fragment. Parses a streamed
|
|
5
|
-
`<abide-resolve data-id="ID"
|
|
6
|
-
its serialized value in the resume manifest (for later hydration), finds the
|
|
5
|
+
`<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
|
|
6
|
+
frame, registers its serialized value in the resume manifest (for later hydration), finds the
|
|
7
7
|
matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, removes
|
|
8
8
|
the pending nodes between the markers, and inserts the resolved content in their
|
|
9
9
|
place. The pending shell painted instantly; this swaps in each value as it
|
|
@@ -21,14 +21,17 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
21
21
|
if (id === null) {
|
|
22
22
|
return
|
|
23
23
|
}
|
|
24
|
-
/*
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
/* The resolved value rides in a leading <script type=application/json>; parse and
|
|
25
|
+
remove it so only the resolved markup moves into the boundary. Recording it lets a
|
|
26
|
+
later hydrate adopt this branch (no re-fetch). */
|
|
27
|
+
const payload = resolved.firstChild as Element | null
|
|
28
|
+
if (payload !== null && payload.nodeName === 'SCRIPT') {
|
|
27
29
|
try {
|
|
28
|
-
RESUME[Number(id)] = JSON.parse(
|
|
30
|
+
RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
|
|
29
31
|
} catch {
|
|
30
32
|
/* malformed payload — leave unregistered, hydration re-runs the promise */
|
|
31
33
|
}
|
|
34
|
+
payload.remove()
|
|
32
35
|
}
|
|
33
36
|
const open = `abide:await:${id}`
|
|
34
37
|
const close = `/abide:await:${id}`
|
|
@@ -29,11 +29,11 @@ export function awaitBlock(
|
|
|
29
29
|
parent: Node,
|
|
30
30
|
id: number,
|
|
31
31
|
promiseThunk: () => unknown,
|
|
32
|
-
renderPending: ((parent: Node) =>
|
|
33
|
-
renderThen: (parent: Node, value: unknown) =>
|
|
32
|
+
renderPending: ((parent: Node) => void) | undefined,
|
|
33
|
+
renderThen: (parent: Node, value: unknown) => void,
|
|
34
34
|
/* Absent when the block has no catch branch — a rejection then surfaces (re-throws
|
|
35
35
|
to the unhandled-rejection path) instead of rendering an empty branch. */
|
|
36
|
-
renderCatch: ((parent: Node, error: unknown) =>
|
|
36
|
+
renderCatch: ((parent: Node, error: unknown) => void) | undefined,
|
|
37
37
|
): void {
|
|
38
38
|
const hydration = RENDER.hydration
|
|
39
39
|
let active: { nodes: Node[]; dispose: () => void } | undefined
|
|
@@ -45,24 +45,27 @@ export function awaitBlock(
|
|
|
45
45
|
const detach = (): void => {
|
|
46
46
|
if (active !== undefined) {
|
|
47
47
|
active.dispose()
|
|
48
|
+
/* Remove via each node's LIVE parent, not the captured `parent` — when this
|
|
49
|
+
await is a bare child of a control-flow branch, `parent` is the branch's
|
|
50
|
+
build fragment, emptied into the document once the enclosing block placed
|
|
51
|
+
it (`place` already inserts via `anchor.parentNode` for the same reason). */
|
|
48
52
|
for (const node of active.nodes) {
|
|
49
|
-
|
|
53
|
+
node.parentNode?.removeChild(node)
|
|
50
54
|
}
|
|
51
55
|
active = undefined
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
/* Replace the current content with a freshly-built
|
|
56
|
-
|
|
59
|
+
/* Replace the current content with a freshly-built branch, before the anchor. The
|
|
60
|
+
branch builds into a fragment (so any content — components, text, nested blocks
|
|
61
|
+
— appends freely), whose top-level nodes are tracked for the next swap. */
|
|
62
|
+
const place = (build: (parent: Node) => void): void => {
|
|
57
63
|
detach()
|
|
58
|
-
|
|
59
|
-
const dispose = scope(() =>
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const fragment = document.createDocumentFragment()
|
|
65
|
+
const dispose = scope(() => build(fragment))
|
|
66
|
+
const nodes = [...fragment.childNodes]
|
|
67
|
+
;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
|
|
62
68
|
active = { nodes, dispose }
|
|
63
|
-
for (const node of nodes) {
|
|
64
|
-
parent.insertBefore(node, anchor ?? null)
|
|
65
|
-
}
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/* Render a settled-or-pending result into the current generation. */
|
|
@@ -96,17 +99,20 @@ export function awaitBlock(
|
|
|
96
99
|
)
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
/* Adopt an SSR-resolved branch in place (its
|
|
100
|
-
then park an anchor just before the close marker for later swaps.
|
|
101
|
-
|
|
102
|
+
/* Adopt an SSR-resolved branch in place (its content claims the existing nodes),
|
|
103
|
+
then park an anchor just before the close marker for later swaps. The adopted
|
|
104
|
+
content is everything the build claimed between the open and close markers. */
|
|
105
|
+
const adopt = (open: Node | null, build: (parent: Node) => void): void => {
|
|
102
106
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
103
107
|
cursor.next.set(parent, open?.nextSibling ?? null)
|
|
104
|
-
|
|
105
|
-
const dispose = scope(() => {
|
|
106
|
-
nodes = build(parent)
|
|
107
|
-
})
|
|
108
|
+
const dispose = scope(() => build(parent))
|
|
108
109
|
const close = claimChild(cursor, parent)
|
|
109
110
|
cursor.next.set(parent, close?.nextSibling ?? null)
|
|
111
|
+
const nodes: Node[] = []
|
|
112
|
+
for (let node = open?.nextSibling ?? null; node !== null && node !== close; ) {
|
|
113
|
+
nodes.push(node)
|
|
114
|
+
node = node.nextSibling
|
|
115
|
+
}
|
|
110
116
|
anchor = document.createTextNode('')
|
|
111
117
|
parent.insertBefore(anchor, close)
|
|
112
118
|
active = { nodes, dispose }
|
|
@@ -143,7 +149,7 @@ export function awaitBlock(
|
|
|
143
149
|
const open = claimChild(cursor, parent)
|
|
144
150
|
const entry = RESUME[id]
|
|
145
151
|
if (entry !== undefined) {
|
|
146
|
-
let build: (host: Node) =>
|
|
152
|
+
let build: (host: Node) => void
|
|
147
153
|
if (entry.ok) {
|
|
148
154
|
build = (host) => renderThen(host, entry.value)
|
|
149
155
|
} else if (renderCatch !== undefined) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tears down a control-flow range: disposes its reactive scope, then removes every
|
|
3
|
+
node between the `start` and `end` markers (exclusive). Walking between the markers
|
|
4
|
+
at clear time — rather than tracking a captured node list — is what lets a branch
|
|
5
|
+
hold dynamic nested blocks: nodes a nested block inserted after the initial build
|
|
6
|
+
still sit between the markers, so they're removed too.
|
|
7
|
+
*/
|
|
8
|
+
export function clearBetween(start: Node, end: Node, dispose?: () => void): void {
|
|
9
|
+
dispose?.()
|
|
10
|
+
let node = start.nextSibling
|
|
11
|
+
while (node !== null && node !== end) {
|
|
12
|
+
const next = node.nextSibling
|
|
13
|
+
end.parentNode?.removeChild(node)
|
|
14
|
+
node = next
|
|
15
|
+
}
|
|
16
|
+
}
|