@abide/abide 0.32.1 → 0.33.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 +93 -63
- package/package.json +6 -2
- package/src/lib/server/runtime/buildCacheSnapshot.ts +5 -4
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/shared/cache.ts +43 -29
- package/src/lib/shared/types/CacheEntry.ts +12 -12
- package/src/lib/shared/types/CacheOptions.ts +17 -13
- package/src/lib/ui/README.md +3 -3
- package/src/lib/ui/compile/HTML_TAGS.ts +132 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +4 -1
- package/src/lib/ui/compile/componentWrapperTag.ts +13 -10
- package/src/lib/ui/compile/generateBuild.ts +265 -121
- package/src/lib/ui/compile/generateSSR.ts +78 -37
- package/src/lib/ui/compile/parseTemplate.ts +52 -0
- package/src/lib/ui/compile/skeletonable.ts +80 -0
- package/src/lib/ui/dom/MATHML_NAMESPACE.ts +6 -0
- package/src/lib/ui/dom/SVG_NAMESPACE.ts +7 -0
- package/src/lib/ui/dom/anchorCursor.ts +24 -0
- package/src/lib/ui/dom/appendSnippet.ts +1 -1
- package/src/lib/ui/dom/appendTextAt.ts +70 -0
- package/src/lib/ui/dom/awaitBlock.ts +27 -7
- package/src/lib/ui/dom/cloneStatic.ts +15 -24
- package/src/lib/ui/dom/each.ts +44 -25
- package/src/lib/ui/dom/eachAsync.ts +6 -2
- package/src/lib/ui/dom/effectiveChildNamespace.ts +13 -0
- package/src/lib/ui/dom/enterNamespace.ts +20 -0
- package/src/lib/ui/dom/fillBefore.ts +20 -3
- package/src/lib/ui/dom/foreignWrapperTag.ts +22 -0
- package/src/lib/ui/dom/hydrate.ts +1 -1
- package/src/lib/ui/dom/inheritedNamespace.ts +19 -0
- package/src/lib/ui/dom/mountSlot.ts +32 -0
- package/src/lib/ui/dom/openMarker.ts +4 -2
- package/src/lib/ui/dom/skeleton.ts +202 -0
- package/src/lib/ui/dom/switchBlock.ts +10 -3
- package/src/lib/ui/dom/templateFor.ts +28 -0
- package/src/lib/ui/dom/tryBlock.ts +7 -5
- package/src/lib/ui/dom/types/SkeletonHoles.ts +8 -0
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/installHotBridge.ts +8 -2
- package/src/lib/ui/runtime/HOLE_ATTRIBUTE.ts +9 -0
- package/src/lib/ui/runtime/RENDER.ts +7 -0
- package/src/lib/ui/runtime/createDoc.ts +11 -8
- package/src/lib/ui/runtime/types/PathWalk.ts +10 -0
- package/src/lib/ui/runtime/types/UiProps.ts +3 -4
- package/src/lib/ui/runtime/walkPath.ts +27 -0
- package/template/src/ui/pages/about/page.abide +4 -6
- package/template/src/ui/pages/layout.abide +21 -0
- package/template/src/ui/pages/page.abide +5 -8
- package/src/lib/ui/compile/partitionSlots.ts +0 -36
- package/src/lib/ui/dom/openChild.ts +0 -22
- package/src/lib/ui/runtime/pathExists.ts +0 -23
- package/src/lib/ui/runtime/valueAtPath.ts +0 -18
- package/template/src/ui/Layout.abide +0 -19
|
@@ -2,10 +2,9 @@ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
|
2
2
|
import { componentWrapperTag } from './componentWrapperTag.ts'
|
|
3
3
|
import { groupBindParts } from './groupBindParts.ts'
|
|
4
4
|
import { lowerContext } from './lowerContext.ts'
|
|
5
|
-
import { partitionSlots } from './partitionSlots.ts'
|
|
6
5
|
import { scopeAttr } from './scopeAttr.ts'
|
|
6
|
+
import { skeletonable } from './skeletonable.ts'
|
|
7
7
|
import { staticAttr } from './staticAttr.ts'
|
|
8
|
-
import { staticAttrValue } from './staticAttrValue.ts'
|
|
9
8
|
import { staticTextPart } from './staticTextPart.ts'
|
|
10
9
|
import { stripEffects } from './stripEffects.ts'
|
|
11
10
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
@@ -69,11 +68,35 @@ export function generateSSR(
|
|
|
69
68
|
it in the `[ … ]` range markers the runtime tracks (unconditionally per block,
|
|
70
69
|
so an empty/false branch still emits the boundary the client claims). */
|
|
71
70
|
function branchContent(children: TemplateNode[], target: string): string {
|
|
72
|
-
|
|
71
|
+
/* A control-flow branch is a fresh build context — the block runtime mounts it, not
|
|
72
|
+
the parent skeleton — so reset the skeleton/anchor tracking; the branch's own
|
|
73
|
+
skeletonable elements re-enter it. */
|
|
74
|
+
const previousSkeleton = inSkeleton
|
|
75
|
+
const previousMark = markText
|
|
76
|
+
inSkeleton = false
|
|
77
|
+
markText = false
|
|
78
|
+
const out = withNestedScripts(children, () => generateInto(children, target))
|
|
79
|
+
inSkeleton = previousSkeleton
|
|
80
|
+
markText = previousMark
|
|
81
|
+
return out
|
|
73
82
|
}
|
|
74
83
|
const openRange = (target: string): string => push(target, RANGE_OPEN)
|
|
75
84
|
const closeRange = (target: string): string => push(target, RANGE_CLOSE)
|
|
76
85
|
|
|
86
|
+
/* True inside a skeletonable subtree; `markText` true when, additionally, the current
|
|
87
|
+
element is NOT a text-leaf — so its reactive text is interleaved and the client uses
|
|
88
|
+
an `<!--a-->` anchor. The marker is kept both sides (like control-flow ranges), so
|
|
89
|
+
SSR markup stays identical to the client DOM. */
|
|
90
|
+
let inSkeleton = false
|
|
91
|
+
let markText = false
|
|
92
|
+
|
|
93
|
+
/* In a skeleton, a control-flow block or slot is positioned by an `<!--a-->` anchor
|
|
94
|
+
(cloned into the located parent), so it can sit anywhere among static siblings.
|
|
95
|
+
Emitted both sides in document order — the client's anchor scan lines up with it.
|
|
96
|
+
Outside a skeleton (top-level / inside a branch) blocks mount on the host directly,
|
|
97
|
+
so no anchor. */
|
|
98
|
+
const anchorMark = (target: string): string => (inSkeleton ? push(target, '<!--a-->') : '')
|
|
99
|
+
|
|
77
100
|
function generate(node: TemplateNode, target: string): string {
|
|
78
101
|
if (node.kind === 'text') {
|
|
79
102
|
return node.parts
|
|
@@ -82,7 +105,10 @@ export function generateSSR(
|
|
|
82
105
|
const markup = staticTextPart(part.value)
|
|
83
106
|
return markup === '' ? '' : push(target, markup)
|
|
84
107
|
}
|
|
85
|
-
|
|
108
|
+
const value = `$text(${lowerExpression(part.code)})`
|
|
109
|
+
return markText
|
|
110
|
+
? `${target}.push('<!--a-->' + ${value});\n`
|
|
111
|
+
: `${target}.push(${value});\n`
|
|
86
112
|
})
|
|
87
113
|
.join('')
|
|
88
114
|
}
|
|
@@ -93,7 +119,7 @@ export function generateSSR(
|
|
|
93
119
|
if (elseBranch !== undefined && elseBranch.kind === 'case') {
|
|
94
120
|
code += ` else {\n${branchContent(elseBranch.children, target)}}`
|
|
95
121
|
}
|
|
96
|
-
return `${openRange(target)}${code}\n${closeRange(target)}`
|
|
122
|
+
return `${anchorMark(target)}${openRange(target)}${code}\n${closeRange(target)}`
|
|
97
123
|
}
|
|
98
124
|
if (node.kind === 'switch') {
|
|
99
125
|
const cases = node.children.filter(
|
|
@@ -111,7 +137,7 @@ export function generateSSR(
|
|
|
111
137
|
if (fallback !== undefined) {
|
|
112
138
|
code += `${started ? 'else ' : ''}{\n${branchContent(fallback.children, target)}}\n`
|
|
113
139
|
}
|
|
114
|
-
return `${openRange(target)}${code}}\n${closeRange(target)}`
|
|
140
|
+
return `${anchorMark(target)}${openRange(target)}${code}}\n${closeRange(target)}`
|
|
115
141
|
}
|
|
116
142
|
if (node.kind === 'case') {
|
|
117
143
|
return ''
|
|
@@ -135,17 +161,19 @@ export function generateSSR(
|
|
|
135
161
|
if (node.kind === 'each') {
|
|
136
162
|
/* Async each (`await`) is drained on the client — render no rows on the
|
|
137
163
|
server (an infinite stream would hang SSR); the client inserts its anchor
|
|
138
|
-
before the next sibling during hydration, like an empty sync each.
|
|
164
|
+
before the next sibling during hydration, like an empty sync each. In a
|
|
165
|
+
skeleton the `<!--a-->` anchor still marks its position (the client mounts
|
|
166
|
+
there); no range markers, since there are no server rows to claim. */
|
|
139
167
|
if (node.async) {
|
|
140
|
-
return
|
|
168
|
+
return anchorMark(target)
|
|
141
169
|
}
|
|
142
|
-
return
|
|
170
|
+
return `${anchorMark(target)}for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
|
|
143
171
|
}
|
|
144
172
|
if (node.kind === 'await') {
|
|
145
|
-
return generateAwait(node, target)
|
|
173
|
+
return `${anchorMark(target)}${generateAwait(node, target)}`
|
|
146
174
|
}
|
|
147
175
|
if (node.kind === 'try') {
|
|
148
|
-
return generateTry(node, target)
|
|
176
|
+
return `${anchorMark(target)}${generateTry(node, target)}`
|
|
149
177
|
}
|
|
150
178
|
if (node.kind === 'branch') {
|
|
151
179
|
return ''
|
|
@@ -159,22 +187,12 @@ export function generateSSR(
|
|
|
159
187
|
const parts = node.props.map(
|
|
160
188
|
(prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
|
|
161
189
|
)
|
|
162
|
-
const
|
|
163
|
-
const slotCode = generateInto(groups.default, '$slot')
|
|
190
|
+
const slotCode = generateInto(node.children, '$slot')
|
|
164
191
|
if (slotCode.trim() !== '') {
|
|
165
192
|
parts.push(
|
|
166
193
|
`"$children": () => { const $slot = []; ${slotCode}return $slot.join(''); }`,
|
|
167
194
|
)
|
|
168
195
|
}
|
|
169
|
-
if (groups.named.length > 0) {
|
|
170
|
-
const entries = groups.named
|
|
171
|
-
.map((group) => {
|
|
172
|
-
const code = generateInto(group.nodes, '$slot')
|
|
173
|
-
return `${JSON.stringify(group.name)}: () => { const $slot = []; ${code}return $slot.join(''); }`
|
|
174
|
-
})
|
|
175
|
-
.join(', ')
|
|
176
|
-
parts.push(`"$slots": { ${entries} }`)
|
|
177
|
-
}
|
|
178
196
|
/* Render the child and MERGE its await blocks into this page's `$awaits`
|
|
179
197
|
so they join the page's SSR stream — their markers carry render-pass
|
|
180
198
|
block ids (nextBlockId), unique across page + children, so the streamed
|
|
@@ -190,9 +208,9 @@ export function generateSSR(
|
|
|
190
208
|
)
|
|
191
209
|
}
|
|
192
210
|
if (node.kind === 'element' && node.tag === 'slot') {
|
|
193
|
-
/* A layout's
|
|
211
|
+
/* A layout's `<slot/>` is the router's page outlet: emit an empty
|
|
194
212
|
placeholder the chain composer folds the child layer's html into. */
|
|
195
|
-
if (isLayout
|
|
213
|
+
if (isLayout) {
|
|
196
214
|
return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
|
|
197
215
|
}
|
|
198
216
|
return generateSlot(node, target)
|
|
@@ -228,32 +246,55 @@ export function generateSSR(
|
|
|
228
246
|
}
|
|
229
247
|
code += push(target, '>')
|
|
230
248
|
if (!VOID_TAGS.has(node.tag)) {
|
|
249
|
+
/* Track skeleton context: reactive text gets an `<!--a-->` anchor only when
|
|
250
|
+
interleaved (this element has non-text children) inside a skeletonable
|
|
251
|
+
subtree — matching the client's anchor vs marker-free text-leaf choice. */
|
|
252
|
+
const entering = !inSkeleton && skeletonable(node)
|
|
253
|
+
if (entering) {
|
|
254
|
+
inSkeleton = true
|
|
255
|
+
}
|
|
256
|
+
const previousMark = markText
|
|
257
|
+
const isTextLeaf = node.children.every(
|
|
258
|
+
(child) => child.kind === 'text' || child.kind === 'style',
|
|
259
|
+
)
|
|
260
|
+
markText = inSkeleton && !isTextLeaf
|
|
231
261
|
/* A `<script>` child scopes its bindings to this element's subtree. */
|
|
232
262
|
code += withNestedScripts(node.children, () => generateInto(node.children, target))
|
|
263
|
+
markText = previousMark
|
|
264
|
+
if (entering) {
|
|
265
|
+
inSkeleton = false
|
|
266
|
+
}
|
|
233
267
|
code += push(target, `</${node.tag}>`)
|
|
234
268
|
}
|
|
235
269
|
return code
|
|
236
270
|
}
|
|
237
271
|
|
|
238
|
-
/* A `<slot>` outlet: emit the parent-provided content
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
/* A `<slot>` outlet: emit the parent-provided content (`$children`), falling back to the
|
|
273
|
+
slot's own children when none was supplied. Inside a skeleton the slot is positioned
|
|
274
|
+
by an `<!--a-->` anchor and its content bounded by a `[ … ]` range (matching the
|
|
275
|
+
client's `mountSlot`), so it can sit among static siblings. The fallback is a fresh,
|
|
276
|
+
non-skeleton build context — the client builds it via `mountSlot`/`fillBefore`, not the
|
|
277
|
+
skeleton clone — so its reactive text takes no anchor (reset like `branchContent`). */
|
|
241
278
|
function generateSlot(
|
|
242
279
|
node: Extract<TemplateNode, { kind: 'element' }>,
|
|
243
280
|
target: string,
|
|
244
281
|
): string {
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const provided =
|
|
251
|
-
name === undefined ? '$props.$children' : `$props.$slots[${JSON.stringify(name)}]`
|
|
282
|
+
const wrap = inSkeleton
|
|
283
|
+
const previousSkeleton = inSkeleton
|
|
284
|
+
const previousMark = markText
|
|
285
|
+
inSkeleton = false
|
|
286
|
+
markText = false
|
|
252
287
|
const fallback = generateInto(node.children, target)
|
|
253
|
-
|
|
254
|
-
|
|
288
|
+
inSkeleton = previousSkeleton
|
|
289
|
+
markText = previousMark
|
|
290
|
+
const body =
|
|
291
|
+
fallback.trim() === ''
|
|
292
|
+
? `if ($props && $props.$children) { ${target}.push($props.$children()); }\n`
|
|
293
|
+
: `if ($props && $props.$children) { ${target}.push($props.$children()); } else {\n${fallback}}\n`
|
|
294
|
+
if (!wrap) {
|
|
295
|
+
return body
|
|
255
296
|
}
|
|
256
|
-
return
|
|
297
|
+
return `${anchorMark(target)}${openRange(target)}${body}${closeRange(target)}`
|
|
257
298
|
}
|
|
258
299
|
|
|
259
300
|
/* Boundary markers + a `$awaits` registration carrying the promise and
|
|
@@ -20,6 +20,12 @@ text, never mistaken for a real style. Keeping it in the tree lets the front-end
|
|
|
20
20
|
scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
/* A line-leading static `import` in a nested script body. The `(?=\s)` requires
|
|
24
|
+
whitespace after the keyword (sparing `import.meta` and no-space `import(...)`),
|
|
25
|
+
and `(?!\s*\()` spares a dynamic `import (...)` written with whitespace before the
|
|
26
|
+
paren — both legitimate lazy paths — so only a true static import statement matches. */
|
|
27
|
+
const NESTED_STATIC_IMPORT = /^[ \t]*import(?=\s)(?!\s*\()/m
|
|
28
|
+
|
|
23
29
|
/* A braced template expression with the absolute source offset of its first
|
|
24
30
|
(post-trim) character, so the type-checking shadow can map a diagnostic back. */
|
|
25
31
|
type Braced = { code: string; loc: number }
|
|
@@ -179,6 +185,18 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
179
185
|
const end = close === -1 ? source.length : close
|
|
180
186
|
const code = source.slice(cursor, end)
|
|
181
187
|
cursor = close === -1 ? source.length : end + '</script>'.length
|
|
188
|
+
/* A static `import` can't live here: a nested script compiles INTO the
|
|
189
|
+
branch's render-function body, where an import is illegal — and an
|
|
190
|
+
import nested in a branch falsely implies conditional/lazy loading ES
|
|
191
|
+
imports can't do (they hoist module-wide and load unconditionally). The
|
|
192
|
+
leading `<script>` hoists imports to module scope for the whole template,
|
|
193
|
+
so they belong there. The pattern spares dynamic `import(...)` (with or
|
|
194
|
+
without whitespace) and `import.meta` — the real lazy paths. */
|
|
195
|
+
if (NESTED_STATIC_IMPORT.test(code)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
"import statements must live in the component's leading <script>, not a nested <template> script — they hoist to module scope for the whole template. For lazy loading, use a dynamic import() inside an effect.",
|
|
198
|
+
)
|
|
199
|
+
}
|
|
182
200
|
return { kind: 'script', code }
|
|
183
201
|
}
|
|
184
202
|
/* A capitalised tag is a child component; its attributes become props and
|
|
@@ -226,9 +244,43 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
226
244
|
roots.push(readText())
|
|
227
245
|
}
|
|
228
246
|
}
|
|
247
|
+
rejectStrayBranches(roots, undefined)
|
|
229
248
|
return { nodes: roots }
|
|
230
249
|
}
|
|
231
250
|
|
|
251
|
+
/* A `case` node (`<template else>`/`<template case>`/`<template default>`) is valid
|
|
252
|
+
only as a direct child of its `<template if>`/`<template switch>`; a `branch`
|
|
253
|
+
(`then`/`catch`/`finally`) only inside its `<template await>`/`<template try>`.
|
|
254
|
+
A sibling `<template else>` — closed off from its `if` — parses to a stray `case`
|
|
255
|
+
sitting beside the `if`, which would otherwise be silently dropped. Reject it so
|
|
256
|
+
the mistake surfaces at compile time. Recurses so a stray branch nested anywhere
|
|
257
|
+
is caught, not just at the root. */
|
|
258
|
+
function rejectStrayBranches(
|
|
259
|
+
nodes: TemplateNode[],
|
|
260
|
+
parentKind: TemplateNode['kind'] | undefined,
|
|
261
|
+
): void {
|
|
262
|
+
for (const node of nodes) {
|
|
263
|
+
if (node.kind === 'case' && parentKind !== 'if' && parentKind !== 'switch') {
|
|
264
|
+
throw new Error(
|
|
265
|
+
'[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
if (
|
|
269
|
+
node.kind === 'branch' &&
|
|
270
|
+
parentKind !== 'await' &&
|
|
271
|
+
parentKind !== 'try' &&
|
|
272
|
+
parentKind !== 'each'
|
|
273
|
+
) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
'[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
if ('children' in node) {
|
|
279
|
+
rejectStrayBranches(node.children, node.kind)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
232
284
|
/* Turns a component's attributes into props. A component has no directives —
|
|
233
285
|
every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
|
|
234
286
|
round-trip to their original names (the kinds the tag-blind attribute parser
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
2
|
+
|
|
3
|
+
/* A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton it mounts at an
|
|
4
|
+
`<!--a-->` anchor cloned into the located parent at the block's position (see
|
|
5
|
+
`anchorCursor`), so a block can sit ANYWHERE among static siblings — no contiguity or
|
|
6
|
+
prefix-shape constraint. */
|
|
7
|
+
function isControlFlow(node: TemplateNode): boolean {
|
|
8
|
+
return (
|
|
9
|
+
node.kind === 'if' ||
|
|
10
|
+
node.kind === 'each' ||
|
|
11
|
+
node.kind === 'await' ||
|
|
12
|
+
node.kind === 'switch' ||
|
|
13
|
+
node.kind === 'try'
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Whether a subtree is skeleton STRUCTURE — anything the parser-backed clone can carry with
|
|
18
|
+
its holes mounted in place: elements, `<style>`, text (static OR reactive), child
|
|
19
|
+
components (their wrapper element is positioned in the skeleton), control-flow blocks and
|
|
20
|
+
`<slot>` outlets (each anchor-mounted at its position), and emit-nothing nodes — a nested
|
|
21
|
+
`<script>` (a scoped reactive block) and a `<template name>` snippet (a hoisted builder),
|
|
22
|
+
both of which the skeleton's bind list runs in document order, scoped like
|
|
23
|
+
`generateElement` does. Only a standalone `branch`/`case` (consumed by its block, never a
|
|
24
|
+
loose child) disqualifies. */
|
|
25
|
+
function skeletonStructure(node: TemplateNode): boolean {
|
|
26
|
+
if (isControlFlow(node) || node.kind === 'component') {
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
if (node.kind === 'style' || node.kind === 'text') {
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
if (node.kind === 'script' || node.kind === 'snippet') {
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
if (node.kind !== 'element') {
|
|
36
|
+
return false // standalone branch|case
|
|
37
|
+
}
|
|
38
|
+
if (node.tag === 'slot') {
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
return node.children.every(skeletonStructure)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Whether a subtree carries at least one hole — anything needing a runtime bind rather than
|
|
45
|
+
constant markup: a reactive attribute/listener/bind on an element, a reactive text part, a
|
|
46
|
+
control-flow block, a `<slot>` outlet, a child component (each needs a located node or an
|
|
47
|
+
anchor), or a nested `<script>`/snippet (emits no markup but must run). */
|
|
48
|
+
function hasHole(node: TemplateNode): boolean {
|
|
49
|
+
if (isControlFlow(node) || node.kind === 'component') {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
if (node.kind === 'script' || node.kind === 'snippet') {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
if (node.kind === 'text') {
|
|
56
|
+
return node.parts.some((part) => part.kind !== 'static')
|
|
57
|
+
}
|
|
58
|
+
if (node.kind === 'element') {
|
|
59
|
+
if (node.tag === 'slot') {
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
return node.attrs.some((attr) => attr.kind !== 'static') || node.children.some(hasHole)
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
A subtree of skeleton structure carrying one or more holes. It builds through the
|
|
69
|
+
parser-backed `skeleton` (one clone, correct foreign namespaces) — element holes located by
|
|
70
|
+
element-only path, anchor holes (reactive text interleaved with elements, control flow,
|
|
71
|
+
slots) by document-order scan — instead of the imperative path. A bound element's reactive
|
|
72
|
+
attributes wire to the located node; a text-leaf element's reactive text binds via
|
|
73
|
+
`appendText` on it (marker-free, so SSR === client). Fully-static subtrees (no hole) stay on
|
|
74
|
+
`cloneStatic`.
|
|
75
|
+
*/
|
|
76
|
+
export function skeletonable(node: TemplateNode): boolean {
|
|
77
|
+
return (
|
|
78
|
+
node.kind === 'element' && node.tag !== 'slot' && skeletonStructure(node) && hasHole(node)
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The SVG foreign-content namespace. An element created in it (or cloned from a
|
|
3
|
+
parser-built `<svg>` subtree) renders as SVG; the same tag in the HTML namespace
|
|
4
|
+
renders as nothing. The imperative build reads it to namespace foreign elements that
|
|
5
|
+
the static `skeleton` path doesn't cover (a foreign parent with dynamic children).
|
|
6
|
+
*/
|
|
7
|
+
export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Positions a skeleton-anchored control-flow block or slot. The anchor (`<!--a-->`) sits
|
|
5
|
+
just before the block's range in BOTH the cloned skeleton and the server DOM, so it marks
|
|
6
|
+
the block's position independently of how wide the surrounding values or earlier blocks
|
|
7
|
+
render — the wall element-count positioning hits once block content varies in element
|
|
8
|
+
count.
|
|
9
|
+
|
|
10
|
+
Returns the CREATE insertion reference (the node right after the anchor), so the block's
|
|
11
|
+
range lands at the anchor's position rather than the parent's end. On hydrate it also parks
|
|
12
|
+
the parent's claim cursor there, so the block claims its own server range in place (the
|
|
13
|
+
block ignores the returned reference on hydrate). `parentNode` is the located element the
|
|
14
|
+
anchor was cloned into.
|
|
15
|
+
*/
|
|
16
|
+
// @readme plumbing
|
|
17
|
+
export function anchorCursor(anchor: Node): Node | null {
|
|
18
|
+
const reference = anchor.nextSibling
|
|
19
|
+
const hydration = RENDER.hydration
|
|
20
|
+
if (hydration !== undefined) {
|
|
21
|
+
hydration.next.set(anchor.parentNode as Node, reference)
|
|
22
|
+
}
|
|
23
|
+
return reference
|
|
24
|
+
}
|
|
@@ -10,7 +10,7 @@ component scope (so they tear down with it). The body's reactive reads update
|
|
|
10
10
|
fine-grained via those effects; an enclosing `each` re-mounts on list changes.
|
|
11
11
|
|
|
12
12
|
On hydrate the builder runs against the server-rendered nodes between the
|
|
13
|
-
`<!--abide:snippet-->`/`<!--/abide:snippet-->` markers — its `
|
|
13
|
+
`<!--abide:snippet-->`/`<!--/abide:snippet-->` markers — its `skeleton`/`appendText`
|
|
14
14
|
claim them in place. The cursor is advanced past the open marker before, and past
|
|
15
15
|
the close marker after, so the markers themselves are skipped.
|
|
16
16
|
*/
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { rawHtmlString } from '../../shared/html.ts'
|
|
2
|
+
import { snippetPayload } from '../../shared/snippet.ts'
|
|
3
|
+
import { effect } from '../effect.ts'
|
|
4
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
|
+
import { appendSnippet } from './appendSnippet.ts'
|
|
6
|
+
import { appendText } from './appendText.ts'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
|
|
10
|
+
for reactive text INTERLEAVED with element siblings (where element-only positioning can't
|
|
11
|
+
reach it). The anchor is located by `skeleton`'s scan, and content mounts immediately
|
|
12
|
+
after it. Handles the same three value kinds as `appendText` — escaped text, a
|
|
13
|
+
`{snippet(args)}` builder, and `html\`\``-branded raw markup — since the kind is only
|
|
14
|
+
known at runtime.
|
|
15
|
+
|
|
16
|
+
The anchor is KEPT (in both the SSR markup and the client DOM, like control flow's
|
|
17
|
+
`<!--[-->` range markers), so server and client render identical markup — `appendTextAt`
|
|
18
|
+
never strips it.
|
|
19
|
+
|
|
20
|
+
Hydrate delegates to `appendText` with the cursor temporarily pointed at the anchor's
|
|
21
|
+
content (the server rendered `<!--a-->value`, so the value is the anchor's next sibling),
|
|
22
|
+
reusing its text-split / snippet / raw-html claiming.
|
|
23
|
+
*/
|
|
24
|
+
// @readme plumbing
|
|
25
|
+
export function appendTextAt(anchor: Node, read: () => unknown): void {
|
|
26
|
+
const parent = anchor.parentNode as Node
|
|
27
|
+
if (RENDER.hydration !== undefined) {
|
|
28
|
+
const hydration = RENDER.hydration
|
|
29
|
+
const had = hydration.next.has(parent)
|
|
30
|
+
const saved = hydration.next.get(parent)
|
|
31
|
+
hydration.next.set(parent, anchor.nextSibling)
|
|
32
|
+
appendText(parent, read)
|
|
33
|
+
if (had) {
|
|
34
|
+
hydration.next.set(parent, saved ?? null)
|
|
35
|
+
} else {
|
|
36
|
+
hydration.next.delete(parent)
|
|
37
|
+
}
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const first = read()
|
|
42
|
+
if (typeof snippetPayload(first) === 'function') {
|
|
43
|
+
const fragment = document.createDocumentFragment()
|
|
44
|
+
appendSnippet(fragment, read)
|
|
45
|
+
parent.insertBefore(fragment, anchor.nextSibling)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (rawHtmlString(first) !== undefined) {
|
|
49
|
+
let nodes: Node[] = []
|
|
50
|
+
effect(() => {
|
|
51
|
+
for (const node of nodes) {
|
|
52
|
+
parent.removeChild(node)
|
|
53
|
+
}
|
|
54
|
+
const holder = document.createElement('div')
|
|
55
|
+
holder.innerHTML = rawHtmlString(read()) ?? ''
|
|
56
|
+
nodes = [...holder.childNodes]
|
|
57
|
+
/* Insert the fresh markup just after the anchor (its live re-insertion point). */
|
|
58
|
+
const after = anchor.nextSibling
|
|
59
|
+
for (const node of nodes) {
|
|
60
|
+
parent.insertBefore(node, after)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const node = document.createTextNode('')
|
|
66
|
+
parent.insertBefore(node, anchor.nextSibling)
|
|
67
|
+
effect(() => {
|
|
68
|
+
node.data = String(read())
|
|
69
|
+
})
|
|
70
|
+
}
|
|
@@ -4,6 +4,7 @@ import { RENDER } from '../runtime/RENDER.ts'
|
|
|
4
4
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
6
|
import { discardBoundary } from './discardBoundary.ts'
|
|
7
|
+
import { enterNamespace } from './enterNamespace.ts'
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
Async binding — the runtime for `<template await>`. Renders the pending branch,
|
|
@@ -34,6 +35,10 @@ export function awaitBlock(
|
|
|
34
35
|
/* Absent when the block has no catch branch — a rejection then surfaces (re-throws
|
|
35
36
|
to the unhandled-rejection path) instead of rendering an empty branch. */
|
|
36
37
|
renderCatch: ((parent: Node, error: unknown) => void) | undefined,
|
|
38
|
+
/* A static node located by the skeleton: the block's anchor inserts before it on
|
|
39
|
+
create (block before a static suffix). Null appends (tail). insertBefore(x, null)
|
|
40
|
+
=== appendChild, so the default is the prior behaviour. */
|
|
41
|
+
before: Node | null = null,
|
|
37
42
|
): void {
|
|
38
43
|
const hydration = RENDER.hydration
|
|
39
44
|
let active: { nodes: Node[]; dispose: () => void } | undefined
|
|
@@ -62,7 +67,9 @@ export function awaitBlock(
|
|
|
62
67
|
const place = (build: (parent: Node) => void): void => {
|
|
63
68
|
detach()
|
|
64
69
|
const fragment = document.createDocumentFragment()
|
|
65
|
-
const dispose =
|
|
70
|
+
const dispose = enterNamespace(anchor?.parentNode ?? parent, () =>
|
|
71
|
+
scope(() => build(fragment)),
|
|
72
|
+
)
|
|
66
73
|
const nodes = [...fragment.childNodes]
|
|
67
74
|
;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
|
|
68
75
|
active = { nodes, dispose }
|
|
@@ -122,14 +129,17 @@ export function awaitBlock(
|
|
|
122
129
|
(hydration off) — the recovery path when adoption can't use the server markup. */
|
|
123
130
|
const rebuildCold = (open: Node | null): void => {
|
|
124
131
|
detach()
|
|
125
|
-
|
|
132
|
+
/* Insert at the node AFTER the discarded boundary (its return) — NOT the captured
|
|
133
|
+
`before`, which for a skeleton-anchored block is the open boundary itself and is
|
|
134
|
+
removed here, so reusing it throws `NotFoundError` in a strict DOM. */
|
|
135
|
+
const after = discardBoundary(
|
|
126
136
|
parent,
|
|
127
137
|
open,
|
|
128
138
|
`/abide:await:${id}`,
|
|
129
139
|
hydration as NonNullable<typeof hydration>,
|
|
130
140
|
)
|
|
131
141
|
anchor = document.createTextNode('')
|
|
132
|
-
parent.
|
|
142
|
+
parent.insertBefore(anchor, after)
|
|
133
143
|
const previous = RENDER.hydration
|
|
134
144
|
RENDER.hydration = undefined
|
|
135
145
|
try {
|
|
@@ -174,10 +184,20 @@ export function awaitBlock(
|
|
|
174
184
|
}
|
|
175
185
|
return
|
|
176
186
|
}
|
|
177
|
-
|
|
187
|
+
/* Insert at the node after the discarded boundary (see `rebuildCold`). */
|
|
188
|
+
const after = discardBoundary(parent, open, `/abide:await:${id}`, cursor)
|
|
178
189
|
anchor = document.createTextNode('')
|
|
179
|
-
parent.
|
|
180
|
-
|
|
190
|
+
parent.insertBefore(anchor, after)
|
|
191
|
+
/* The boundary's server nodes are gone, so the pending branch builds FRESH — clear
|
|
192
|
+
the claim cursor (mirrors `rebuildCold`) so its `cloneStatic`/text don't try to
|
|
193
|
+
claim discarded nodes and silently render nothing. */
|
|
194
|
+
const previous = RENDER.hydration
|
|
195
|
+
RENDER.hydration = undefined
|
|
196
|
+
try {
|
|
197
|
+
render(result)
|
|
198
|
+
} finally {
|
|
199
|
+
RENDER.hydration = previous
|
|
200
|
+
}
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
effect(() => {
|
|
@@ -195,7 +215,7 @@ export function awaitBlock(
|
|
|
195
215
|
return
|
|
196
216
|
}
|
|
197
217
|
anchor = document.createTextNode('')
|
|
198
|
-
parent.
|
|
218
|
+
parent.insertBefore(anchor, before)
|
|
199
219
|
}
|
|
200
220
|
render(result)
|
|
201
221
|
})
|
|
@@ -1,29 +1,14 @@
|
|
|
1
1
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
2
2
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Parsed-once `<template>` per unique static-skeleton string, reused across every
|
|
6
|
-
mount. A `<template>` (not a detached `<div>`) so table/select content parses by
|
|
7
|
-
the real content model, exactly as the browser parsed the server markup.
|
|
8
|
-
*/
|
|
9
|
-
const TEMPLATES = new Map<string, HTMLTemplateElement>()
|
|
10
|
-
|
|
11
|
-
function templateFor(html: string): HTMLTemplateElement {
|
|
12
|
-
let template = TEMPLATES.get(html)
|
|
13
|
-
if (template === undefined) {
|
|
14
|
-
template = document.createElement('template')
|
|
15
|
-
template.innerHTML = html
|
|
16
|
-
TEMPLATES.set(html, template)
|
|
17
|
-
}
|
|
18
|
-
return template
|
|
19
|
-
}
|
|
3
|
+
import { foreignWrapperTag } from './foreignWrapperTag.ts'
|
|
4
|
+
import { templateFor } from './templateFor.ts'
|
|
20
5
|
|
|
21
6
|
/*
|
|
22
7
|
Appends a fully-static subtree (one or more top-level nodes) under `parent` — what
|
|
23
|
-
the compiler emits
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
8
|
+
the compiler emits for an element subtree that carries no bindings, control flow, or
|
|
9
|
+
listeners (its bound sibling, `skeleton`, carries the same clone plus located holes).
|
|
10
|
+
The skeleton string is generated by the same rules as the SSR back-end, so it is
|
|
11
|
+
byte-identical to the server markup.
|
|
27
12
|
|
|
28
13
|
Create mode: clone the cached template once per mount — a single deep clone (native
|
|
29
14
|
in the browser) instead of N create/append calls. Hydrate mode: nothing is built;
|
|
@@ -34,11 +19,17 @@ the same top-level node count as the server markup.
|
|
|
34
19
|
*/
|
|
35
20
|
// @readme plumbing
|
|
36
21
|
export function cloneStatic(parent: Node, html: string): void {
|
|
37
|
-
|
|
22
|
+
/* When `parent` is a foreign element built imperatively, the run's markup has no
|
|
23
|
+
foreign ancestor of its own, so a bare `<path>` would parse into the HTML
|
|
24
|
+
namespace. Parse it inside a matching `<svg>`/`<math>` wrapper and clone the
|
|
25
|
+
wrapper's children — the parser namespaces them correctly. */
|
|
26
|
+
const wrapper = foreignWrapperTag(parent)
|
|
27
|
+
const template = templateFor(wrapper === undefined ? html : `<${wrapper}>${html}</${wrapper}>`)
|
|
28
|
+
const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
|
|
38
29
|
const hydration = RENDER.hydration
|
|
39
30
|
if (hydration !== undefined) {
|
|
40
31
|
let node = claimChild(hydration, parent)
|
|
41
|
-
let remaining =
|
|
32
|
+
let remaining = source.childNodes.length
|
|
42
33
|
while (remaining > 0 && node !== null) {
|
|
43
34
|
node = node.nextSibling
|
|
44
35
|
remaining -= 1
|
|
@@ -46,7 +37,7 @@ export function cloneStatic(parent: Node, html: string): void {
|
|
|
46
37
|
hydration.next.set(parent, node)
|
|
47
38
|
return
|
|
48
39
|
}
|
|
49
|
-
for (const child of
|
|
40
|
+
for (const child of source.childNodes) {
|
|
50
41
|
parent.appendChild(child.cloneNode(true))
|
|
51
42
|
}
|
|
52
43
|
}
|