@abide/abide 0.31.1 → 0.32.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 +10 -0
- package/package.json +1 -2
- package/src/checkAbide.ts +11 -6
- 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 +16 -4
- package/src/lib/ui/compile/generateBuild.ts +110 -213
- package/src/lib/ui/compile/generateSSR.ts +51 -86
- 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/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 +39 -52
- 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/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,22 @@
|
|
|
1
1
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
-
import { branchElements } from './branchElements.ts'
|
|
3
|
-
import { escapeHtml } from './escapeHtml.ts'
|
|
4
2
|
import { groupBindParts } from './groupBindParts.ts'
|
|
5
|
-
import {
|
|
3
|
+
import { lowerContext } from './lowerContext.ts'
|
|
6
4
|
import { partitionSlots } from './partitionSlots.ts'
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { scopeAttr } from './scopeAttr.ts'
|
|
6
|
+
import { staticAttr } from './staticAttr.ts'
|
|
9
7
|
import { staticAttrValue } from './staticAttrValue.ts'
|
|
8
|
+
import { staticTextPart } from './staticTextPart.ts'
|
|
10
9
|
import { stripEffects } from './stripEffects.ts'
|
|
11
10
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
12
11
|
import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
13
12
|
|
|
13
|
+
/* The range boundary comments a control-flow block emits around its content. They
|
|
14
|
+
serialize exactly as the client's `document.createComment('[' | ']')` markers, so
|
|
15
|
+
the client claims the same `[ … ]` boundary it builds — the comment-marked range
|
|
16
|
+
that lets a branch hold any content. */
|
|
17
|
+
const RANGE_OPEN = '<!--[-->'
|
|
18
|
+
const RANGE_CLOSE = '<!--]-->'
|
|
19
|
+
|
|
14
20
|
/*
|
|
15
21
|
Server code generator: turns the parsed template into statements that push HTML
|
|
16
22
|
fragments onto an output array, reading the document synchronously (no DOM, no
|
|
@@ -36,24 +42,16 @@ export function generateSSR(
|
|
|
36
42
|
let varCounter = 0
|
|
37
43
|
const nextVar = (prefix: string): string => `${prefix}${varCounter++}`
|
|
38
44
|
|
|
39
|
-
/*
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return lowerDocAccess(renameSignalRefs(code, stateNames, derefScope()), 'model')
|
|
46
|
-
.trim()
|
|
47
|
-
.replace(/;$/, '')
|
|
48
|
-
}
|
|
45
|
+
/* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
|
|
46
|
+
const {
|
|
47
|
+
expression: lowerExpression,
|
|
48
|
+
statement,
|
|
49
|
+
withNestedScripts,
|
|
50
|
+
} = lowerContext(stateNames, derivedNames)
|
|
49
51
|
|
|
50
|
-
/*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return stripEffects(
|
|
54
|
-
lowerDocAccess(renameSignalRefs(code, stateNames, derefScope()), 'model').trim(),
|
|
55
|
-
)
|
|
56
|
-
}
|
|
52
|
+
/* A scoped-script body for SSR: the shared lowering, then strip effects
|
|
53
|
+
(client-only lifecycle that emits no HTML) — the one SSR-side asymmetry. */
|
|
54
|
+
const lowerScript = (code: string): string => stripEffects(statement(code))
|
|
57
55
|
|
|
58
56
|
function push(target: string, literal: string): string {
|
|
59
57
|
return `${target}.push(${JSON.stringify(literal)});\n`
|
|
@@ -63,41 +61,25 @@ export function generateSSR(
|
|
|
63
61
|
return children.map((child) => generate(child, target)).join('')
|
|
64
62
|
}
|
|
65
63
|
|
|
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
|
|
64
|
+
/* A control-flow branch's content, generated exactly like a normal child list so
|
|
65
|
+
a branch holds ANY content (components, text, nested blocks). `generate` emits
|
|
66
|
+
nested `<script>`s in document order; `withNestedScripts` puts their bindings in
|
|
67
|
+
scope — matching the client build, so hydration stays aligned. The caller wraps
|
|
68
|
+
it in the `[ … ]` range markers the runtime tracks (unconditionally per block,
|
|
69
|
+
so an empty/false branch still emits the boundary the client claims). */
|
|
70
|
+
function branchContent(children: TemplateNode[], target: string): string {
|
|
71
|
+
return withNestedScripts(children, () => generateInto(children, target))
|
|
93
72
|
}
|
|
73
|
+
const openRange = (target: string): string => push(target, RANGE_OPEN)
|
|
74
|
+
const closeRange = (target: string): string => push(target, RANGE_CLOSE)
|
|
94
75
|
|
|
95
76
|
function generate(node: TemplateNode, target: string): string {
|
|
96
77
|
if (node.kind === 'text') {
|
|
97
78
|
return node.parts
|
|
98
79
|
.map((part) => {
|
|
99
80
|
if (part.kind === 'static') {
|
|
100
|
-
|
|
81
|
+
const markup = staticTextPart(part.value)
|
|
82
|
+
return markup === '' ? '' : push(target, markup)
|
|
101
83
|
}
|
|
102
84
|
return `${target}.push($text(${lowerExpression(part.code)}));\n`
|
|
103
85
|
})
|
|
@@ -106,11 +88,11 @@ export function generateSSR(
|
|
|
106
88
|
if (node.kind === 'if') {
|
|
107
89
|
const elseBranch = node.children.find((child) => child.kind === 'case')
|
|
108
90
|
const thenChildren = node.children.filter((child) => child.kind !== 'case')
|
|
109
|
-
let code = `if (${lowerExpression(node.condition)}) {\n${
|
|
91
|
+
let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
|
|
110
92
|
if (elseBranch !== undefined && elseBranch.kind === 'case') {
|
|
111
|
-
code += ` else {\n${
|
|
93
|
+
code += ` else {\n${branchContent(elseBranch.children, target)}}`
|
|
112
94
|
}
|
|
113
|
-
return `${code}\n`
|
|
95
|
+
return `${openRange(target)}${code}\n${closeRange(target)}`
|
|
114
96
|
}
|
|
115
97
|
if (node.kind === 'switch') {
|
|
116
98
|
const cases = node.children.filter(
|
|
@@ -120,15 +102,15 @@ export function generateSSR(
|
|
|
120
102
|
let started = false
|
|
121
103
|
for (const branch of cases) {
|
|
122
104
|
if (branch.match !== undefined) {
|
|
123
|
-
code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${
|
|
105
|
+
code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${branchContent(branch.children, target)}}\n`
|
|
124
106
|
started = true
|
|
125
107
|
}
|
|
126
108
|
}
|
|
127
109
|
const fallback = cases.find((branch) => branch.match === undefined)
|
|
128
110
|
if (fallback !== undefined) {
|
|
129
|
-
code += `${started ? 'else ' : ''}{\n${
|
|
111
|
+
code += `${started ? 'else ' : ''}{\n${branchContent(fallback.children, target)}}\n`
|
|
130
112
|
}
|
|
131
|
-
return `${code}}\n`
|
|
113
|
+
return `${openRange(target)}${code}}\n${closeRange(target)}`
|
|
132
114
|
}
|
|
133
115
|
if (node.kind === 'case') {
|
|
134
116
|
return ''
|
|
@@ -156,7 +138,7 @@ export function generateSSR(
|
|
|
156
138
|
if (node.async) {
|
|
157
139
|
return ''
|
|
158
140
|
}
|
|
159
|
-
return `for (const ${node.as} of (${lowerExpression(node.items)})) {\n${
|
|
141
|
+
return `for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
|
|
160
142
|
}
|
|
161
143
|
if (node.kind === 'await') {
|
|
162
144
|
return generateAwait(node, target)
|
|
@@ -218,14 +200,11 @@ export function generateSSR(
|
|
|
218
200
|
/* Every `<style>` active at this element (own siblings + ancestors) — same set
|
|
219
201
|
the client stamps, so server and client markup carry identical attributes. */
|
|
220
202
|
for (const scope of node.scopes ?? []) {
|
|
221
|
-
code += push(target,
|
|
203
|
+
code += push(target, scopeAttr(scope))
|
|
222
204
|
}
|
|
223
205
|
for (const attr of node.attrs) {
|
|
224
206
|
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)}"`)
|
|
207
|
+
code += push(target, staticAttr(attr.name, attr.value))
|
|
229
208
|
} else if (attr.kind === 'expression') {
|
|
230
209
|
/* present/absent semantics matching the client `attr` binding:
|
|
231
210
|
false/null/undefined drops it, true emits the bare attribute. */
|
|
@@ -249,21 +228,7 @@ export function generateSSR(
|
|
|
249
228
|
code += push(target, '>')
|
|
250
229
|
if (!VOID_TAGS.has(node.tag)) {
|
|
251
230
|
/* 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
|
-
}
|
|
231
|
+
code += withNestedScripts(node.children, () => generateInto(node.children, target))
|
|
267
232
|
code += push(target, `</${node.tag}>`)
|
|
268
233
|
}
|
|
269
234
|
return code
|
|
@@ -318,24 +283,24 @@ export function generateSSR(
|
|
|
318
283
|
const id = nextVar('$aid')
|
|
319
284
|
let code = `const ${id} = nextBlockId();\n`
|
|
320
285
|
code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
|
|
321
|
-
code +=
|
|
286
|
+
code += branchContent(pending, target)
|
|
322
287
|
code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
|
|
323
288
|
/* The settled closures append `finally` after the outcome markup, matching the
|
|
324
289
|
client's concatenated node range so hydration aligns. */
|
|
325
|
-
const settled = (binding: string, children: TemplateNode[]
|
|
326
|
-
`(${binding}) => { const $o = []; ${
|
|
290
|
+
const settled = (binding: string, children: TemplateNode[]) =>
|
|
291
|
+
`(${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
|
|
327
292
|
/* Neither catch nor finally → omit `catch` so a rejection surfaces to the
|
|
328
293
|
stream/error path (renderToStream re-throws) instead of rendering an empty
|
|
329
294
|
branch. A finally-only block keeps a catch closure that renders just finally. */
|
|
330
295
|
const catchProp =
|
|
331
296
|
catchBranch === undefined && finallyChildren.length === 0
|
|
332
297
|
? ''
|
|
333
|
-
: `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? []
|
|
298
|
+
: `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [])} `
|
|
334
299
|
code +=
|
|
335
300
|
`$awaits.push({ id: ${id}, ` +
|
|
336
301
|
(node.blocking ? 'blocking: true, ' : '') +
|
|
337
302
|
`promise: () => (${lowerExpression(node.promise)}), ` +
|
|
338
|
-
`then: ${settled(resolvedAs ?? '_value', resolvedChildren
|
|
303
|
+
`then: ${settled(resolvedAs ?? '_value', resolvedChildren)}, ` +
|
|
339
304
|
`${catchProp}});\n`
|
|
340
305
|
return code
|
|
341
306
|
}
|
|
@@ -362,12 +327,12 @@ export function generateSSR(
|
|
|
362
327
|
code += `${target}.push("<!--abide:try:" + ${id} + "-->");\n`
|
|
363
328
|
code += `const ${mark} = ${target}.length;\n`
|
|
364
329
|
code += `try {\n`
|
|
365
|
-
code +=
|
|
366
|
-
code +=
|
|
330
|
+
code += branchContent(guarded, target)
|
|
331
|
+
code += branchContent(finallyChildren, target)
|
|
367
332
|
code += `} catch (${errName}) {\n${target}.length = ${mark};\n`
|
|
368
333
|
if (catchBranch !== undefined) {
|
|
369
|
-
code +=
|
|
370
|
-
code +=
|
|
334
|
+
code += branchContent(catchBranch.children, target)
|
|
335
|
+
code += branchContent(finallyChildren, target)
|
|
371
336
|
} else {
|
|
372
337
|
code += `throw ${errName};\n`
|
|
373
338
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|