@abide/abide 0.40.2 → 0.41.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 +327 -326
- package/CHANGELOG.md +62 -0
- package/README.md +87 -78
- package/package.json +1 -1
- package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
- package/src/lib/ui/compile/compileShadow.ts +5 -0
- package/src/lib/ui/compile/destructureBindingNames.ts +38 -0
- package/src/lib/ui/compile/generateBuild.ts +96 -12
- package/src/lib/ui/compile/generateSSR.ts +26 -9
- package/src/lib/ui/compile/lowerContext.ts +29 -9
- package/src/lib/ui/compile/parseTemplate.ts +2 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +19 -2
- package/src/lib/ui/compile/skeletonContext.ts +67 -79
- package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
- package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +2 -0
- package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
- package/src/lib/ui/compile/walkElementOrder.ts +60 -0
- package/src/lib/ui/dom/appendSnippet.ts +9 -8
- package/src/lib/ui/dom/applyResolved.ts +3 -4
- package/src/lib/ui/dom/awaitBlock.ts +106 -46
- package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
- package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
- package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
- package/src/lib/ui/dom/domElementAdapter.ts +20 -0
- package/src/lib/ui/dom/each.ts +33 -17
- package/src/lib/ui/dom/eachAsync.ts +34 -26
- package/src/lib/ui/dom/isElement.ts +6 -0
- package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
- package/src/lib/ui/dom/mountRange.ts +4 -3
- package/src/lib/ui/dom/mountSlot.ts +4 -3
- package/src/lib/ui/dom/on.ts +6 -1
- package/src/lib/ui/dom/replaceRange.ts +24 -0
- package/src/lib/ui/dom/skeleton.ts +35 -92
- package/src/lib/ui/dom/switchBlock.ts +13 -10
- package/src/lib/ui/dom/types/EachRow.ts +9 -2
- package/src/lib/ui/dom/when.ts +13 -10
- package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
- package/src/lib/ui/runtime/batch.ts +22 -0
- package/src/lib/ui/runtime/clientPage.ts +3 -8
- package/src/lib/ui/runtime/createDoc.ts +9 -13
- package/src/lib/ui/seedResolved.ts +28 -0
- package/src/lib/ui/startClient.ts +6 -4
- package/src/lib/ui/types/ResolvedFrame.ts +15 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { assertExhaustive } from '../../shared/assertExhaustive.ts'
|
|
2
2
|
import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
|
|
3
3
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
4
|
+
import {
|
|
5
|
+
ANCHOR,
|
|
6
|
+
RANGE_CLOSE as RANGE_CLOSE_DATA,
|
|
7
|
+
RANGE_OPEN as RANGE_OPEN_DATA,
|
|
8
|
+
} from '../runtime/RANGE_MARKER.ts'
|
|
4
9
|
import { asOutlet } from './asOutlet.ts'
|
|
5
10
|
import { composeProps } from './composeProps.ts'
|
|
6
11
|
import { groupBindParts } from './groupBindParts.ts'
|
|
@@ -16,12 +21,16 @@ import { stripEffects } from './stripEffects.ts'
|
|
|
16
21
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
17
22
|
import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
18
23
|
|
|
19
|
-
/* The range boundary comments a control-flow block emits around its content.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const RANGE_OPEN =
|
|
24
|
-
const RANGE_CLOSE =
|
|
24
|
+
/* The range boundary comments a control-flow block emits around its content. Sourced
|
|
25
|
+
from the SAME wire-alphabet constants the client's `document.createComment` markers
|
|
26
|
+
use (`RANGE_MARKER`), wrapped in comment syntax — so a server-emitted boundary and the
|
|
27
|
+
client `[ … ]` boundary it claims can never drift on a literal. */
|
|
28
|
+
const RANGE_OPEN = `<!--${RANGE_OPEN_DATA}-->`
|
|
29
|
+
const RANGE_CLOSE = `<!--${RANGE_CLOSE_DATA}-->`
|
|
30
|
+
|
|
31
|
+
/* The skeleton positioning anchor a control-flow block / slot / outlet emits, sourced
|
|
32
|
+
from the same `ANCHOR` constant the client's anchor scan matches. */
|
|
33
|
+
const ANCHOR_COMMENT = `<!--${ANCHOR}-->`
|
|
25
34
|
|
|
26
35
|
/*
|
|
27
36
|
Server code generator: turns the parsed template into statements that push HTML
|
|
@@ -172,7 +181,7 @@ export function generateSSR(
|
|
|
172
181
|
Outside a skeleton (top-level / inside a branch) blocks mount on the host directly,
|
|
173
182
|
so no anchor. */
|
|
174
183
|
const anchorMark = (node: TemplateNode, target: string): string =>
|
|
175
|
-
inSkeleton.get(node) ? push(target,
|
|
184
|
+
inSkeleton.get(node) ? push(target, ANCHOR_COMMENT) : ''
|
|
176
185
|
|
|
177
186
|
function generate(node: TemplateNode, target: string): string {
|
|
178
187
|
/* Every kind that mounts as a marker range is positioned by an `<!--a-->` anchor when
|
|
@@ -197,7 +206,7 @@ export function generateSSR(
|
|
|
197
206
|
? `$text(await (${lowered}))`
|
|
198
207
|
: `$text(${lowered})`
|
|
199
208
|
return markText.get(node)
|
|
200
|
-
? `${target}.push('
|
|
209
|
+
? `${target}.push('${ANCHOR_COMMENT}' + ${value});\n`
|
|
201
210
|
: `${target}.push(${value});\n`
|
|
202
211
|
})
|
|
203
212
|
.join('')
|
|
@@ -269,7 +278,15 @@ export function generateSSR(
|
|
|
269
278
|
if (node.async) {
|
|
270
279
|
return anchor
|
|
271
280
|
}
|
|
272
|
-
|
|
281
|
+
const rowBody = `${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}`
|
|
282
|
+
/* `index="i"` binds the row position. SSR reads it as a plain number from
|
|
283
|
+
`entries()` over a materialized array; the client reads the same number from a
|
|
284
|
+
cell, so first paint is congruent. No index → a plain `for…of` over the items. */
|
|
285
|
+
const header =
|
|
286
|
+
node.index === undefined
|
|
287
|
+
? `for (const ${node.as} of (${lowerExpression(node.items)}))`
|
|
288
|
+
: `for (const [${node.index}, ${node.as}] of [...(${lowerExpression(node.items)})].entries())`
|
|
289
|
+
return `${anchor}${header} {\n${rowBody}}\n`
|
|
273
290
|
}
|
|
274
291
|
if (node.kind === 'await') {
|
|
275
292
|
return `${anchor}${generateAwait(node, target)}`
|
|
@@ -22,20 +22,21 @@ export function lowerContext(
|
|
|
22
22
|
derivedNames: ReadonlySet<string>,
|
|
23
23
|
computedNames: ReadonlySet<string> = new Set(),
|
|
24
24
|
) {
|
|
25
|
-
/* Branch-scoped signal bindings (from nested `<script>`s
|
|
26
|
-
`.value` like a `computed
|
|
27
|
-
|
|
25
|
+
/* Branch-scoped signal bindings (from nested `<script>`s, and the block value params
|
|
26
|
+
pushed by `withLocalDerived`) — they deref to `.value` like a `computed`, and as a
|
|
27
|
+
nearer lexical scope they SHADOW a same-named component signal. Pushed while a
|
|
28
|
+
branch's script + markup compile, popped after, so they shadow only within that
|
|
29
|
+
subtree. */
|
|
28
30
|
const localDerived = new Set<string>()
|
|
29
|
-
const derefScope = (): ReadonlySet<string> =>
|
|
30
|
-
localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
|
|
31
31
|
|
|
32
32
|
/* Parse `code` once and chain the reference rename and doc-access lowering over the
|
|
33
|
-
one tree — the two string passes would each parse + reprint. `
|
|
34
|
-
per call
|
|
33
|
+
one tree — the two string passes would each parse + reprint. `localDerived` is
|
|
34
|
+
snapshotted per call (as the transformer's block-local shadow set) so a binding
|
|
35
|
+
pushed mid-compile is honoured AND shadows a same-named component signal. */
|
|
35
36
|
function lowerOnce(code: string): string {
|
|
36
37
|
const source = ts.createSourceFile('expr.ts', code, ts.ScriptTarget.Latest, true)
|
|
37
38
|
const result = ts.transform(source, [
|
|
38
|
-
signalRefsTransformer(stateNames,
|
|
39
|
+
signalRefsTransformer(stateNames, derivedNames, computedNames, new Set(localDerived)),
|
|
39
40
|
docAccessTransformer('model'),
|
|
40
41
|
])
|
|
41
42
|
const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile).trim()
|
|
@@ -113,5 +114,24 @@ export function lowerContext(
|
|
|
113
114
|
return result
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
/* Pushes explicit names into the deref scope for `body` then pops them — the
|
|
118
|
+
programmatic counterpart to `withNestedScripts`, used to bind a block's value param
|
|
119
|
+
(an `await` `then` value, a keyed `each` item) as a reactive `.value` cell so the
|
|
120
|
+
branch reads it reactively and re-runs in place when the block sets the cell. */
|
|
121
|
+
function withLocalDerived<T>(names: string[], body: () => T): T {
|
|
122
|
+
const added: string[] = []
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
if (!localDerived.has(name)) {
|
|
125
|
+
localDerived.add(name)
|
|
126
|
+
added.push(name)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const result = body()
|
|
130
|
+
for (const name of added) {
|
|
131
|
+
localDerived.delete(name)
|
|
132
|
+
}
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { expression, statement, withNestedScripts, withLocalDerived, bindRead, bindWrite }
|
|
117
137
|
}
|
|
@@ -560,11 +560,13 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
|
|
|
560
560
|
}
|
|
561
561
|
const as = find('as')
|
|
562
562
|
const key = find('key')
|
|
563
|
+
const index = find('index')
|
|
563
564
|
return {
|
|
564
565
|
kind: 'each',
|
|
565
566
|
items: itemsCode,
|
|
566
567
|
as: (as === undefined ? undefined : attrText(as)) ?? '_item',
|
|
567
568
|
key: key === undefined ? undefined : attrText(key),
|
|
569
|
+
index: index === undefined ? undefined : attrText(index),
|
|
568
570
|
async: find('await') !== undefined, // `<template each await>` over an AsyncIterable
|
|
569
571
|
children,
|
|
570
572
|
loc: attrLoc(items),
|
|
@@ -43,10 +43,19 @@ export function signalRefsTransformer(
|
|
|
43
43
|
stateNames: ReadonlySet<string>,
|
|
44
44
|
derivedNames: ReadonlySet<string>,
|
|
45
45
|
computedNames: ReadonlySet<string> = new Set(),
|
|
46
|
+
/* Block-local signal bindings (an `await` `then` / keyed `each` value param, a nested
|
|
47
|
+
`<script>` declaration) — a nearer lexical scope than the component signals, so a
|
|
48
|
+
name here shadows a same-named `state`/`computed`/`derived` and derefs as a cell. */
|
|
49
|
+
blockLocal: ReadonlySet<string> = new Set(),
|
|
46
50
|
): ts.TransformerFactory<ts.SourceFile> {
|
|
47
51
|
/* The signal names that a nested binding can shadow — only these matter for
|
|
48
52
|
scope tracking, so we ignore every other local binding. */
|
|
49
|
-
const signalNames = new Set<string>([
|
|
53
|
+
const signalNames = new Set<string>([
|
|
54
|
+
...stateNames,
|
|
55
|
+
...derivedNames,
|
|
56
|
+
...computedNames,
|
|
57
|
+
...blockLocal,
|
|
58
|
+
])
|
|
50
59
|
return (context) => (root) => {
|
|
51
60
|
/* The identifier nodes that are names, not value reads — collected by walking
|
|
52
61
|
each parent and recording its name children (`parent.name`, a label, a
|
|
@@ -88,6 +97,7 @@ export function signalRefsTransformer(
|
|
|
88
97
|
stateNames,
|
|
89
98
|
derivedNames,
|
|
90
99
|
computedNames,
|
|
100
|
+
blockLocal,
|
|
91
101
|
)
|
|
92
102
|
if (replacement !== undefined) {
|
|
93
103
|
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
@@ -99,6 +109,7 @@ export function signalRefsTransformer(
|
|
|
99
109
|
stateNames,
|
|
100
110
|
derivedNames,
|
|
101
111
|
computedNames,
|
|
112
|
+
blockLocal,
|
|
102
113
|
)
|
|
103
114
|
if (replacement !== undefined) {
|
|
104
115
|
return replacement
|
|
@@ -305,13 +316,19 @@ function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration
|
|
|
305
316
|
|
|
306
317
|
/* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
|
|
307
318
|
string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
|
|
308
|
-
(linked / lens / transform-state), else undefined.
|
|
319
|
+
(linked / lens / transform-state), else undefined. A `blockLocal` binding shadows
|
|
320
|
+
any same-named component signal — it is a nearer lexical scope — so it derefs as a
|
|
321
|
+
cell (`<name>.value`) regardless of a colliding `state`/`computed`/`derived`. */
|
|
309
322
|
function referenceFor(
|
|
310
323
|
name: string,
|
|
311
324
|
stateNames: ReadonlySet<string>,
|
|
312
325
|
derivedNames: ReadonlySet<string>,
|
|
313
326
|
computedNames: ReadonlySet<string>,
|
|
327
|
+
blockLocal: ReadonlySet<string> = new Set(),
|
|
314
328
|
): ts.Expression | undefined {
|
|
329
|
+
if (blockLocal.has(name)) {
|
|
330
|
+
return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
|
|
331
|
+
}
|
|
315
332
|
if (stateNames.has(name)) {
|
|
316
333
|
return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('model'), name)
|
|
317
334
|
}
|
|
@@ -1,87 +1,72 @@
|
|
|
1
1
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
-
import { isAnchorPositioned } from './isAnchorPositioned.ts'
|
|
3
2
|
import { isControlFlow } from './isControlFlow.ts'
|
|
4
3
|
import { isTextLeaf } from './isTextLeaf.ts'
|
|
5
4
|
import { skeletonable } from './skeletonable.ts'
|
|
5
|
+
import { templateAnchorAdapter } from './templateAnchorAdapter.ts'
|
|
6
|
+
import { templateElementAdapter } from './templateElementAdapter.ts'
|
|
6
7
|
import type { SkeletonContext } from './types/SkeletonContext.ts'
|
|
7
8
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
9
|
+
import { walkAnchorOrder } from './walkAnchorOrder.ts'
|
|
10
|
+
import { walkElementOrder } from './walkElementOrder.ts'
|
|
8
11
|
|
|
9
12
|
/*
|
|
10
13
|
The single source of truth for where skeleton markers go AND what their hole indices are.
|
|
11
|
-
One top-down walk records, per node, whether it sits inside a parser-backed skeleton
|
|
12
|
-
(`inSkeleton`)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
One top-down `visit` walk records, per node, whether it sits inside a parser-backed skeleton
|
|
15
|
+
clone (`inSkeleton`), whether its reactive text is interleaved (`markText`), and which
|
|
16
|
+
`skeletonable` elements OPEN a skeleton (`skeletonRoots`). Both hole axes are then numbered by
|
|
17
|
+
the two shared ordering rules — `walkElementOrder` (`el`) and `walkAnchorOrder` (`an`) — run
|
|
18
|
+
once per root, so `generateBuild` reads `sk.el`/`sk.an` rather than re-deriving the numbering,
|
|
19
|
+
and the runtime recovers the same positions through the SAME two walks. One rule per axis, both
|
|
20
|
+
sides; the numbering cannot drift from the decisions.
|
|
16
21
|
|
|
17
22
|
The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
|
|
18
|
-
skeleton — the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`). `el`
|
|
19
|
-
element holes in pre-order; `an` numbers anchor
|
|
20
|
-
control-flow blocks, child components, `<slot>` outlets)
|
|
21
|
-
document order
|
|
22
|
-
the realized DOM, so the compile-time numbers and the runtime positions line up.
|
|
23
|
+
skeleton — the unit `generateSkeleton` instantiates with a fresh `{ el: 0, an: 0 }`). `el`
|
|
24
|
+
numbers element holes in pre-order (the root element itself can be one); `an` numbers anchor
|
|
25
|
+
holes (interleaved reactive text PARTS, control-flow blocks, child components, `<slot>` outlets)
|
|
26
|
+
in document order.
|
|
23
27
|
|
|
24
|
-
A fresh-context boundary resets to NOT-in-skeleton
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
A fresh-context boundary resets to NOT-in-skeleton, because the content there is built by its
|
|
29
|
+
own runtime (a control-flow block's branch, a component's slot content, a `<slot>`'s fallback,
|
|
30
|
+
a snippet's body) — never cloned by the enclosing skeleton.
|
|
27
31
|
*/
|
|
28
32
|
export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
29
33
|
const inSkeleton = new WeakMap<TemplateNode, boolean>()
|
|
30
34
|
const markText = new WeakMap<TemplateNode, boolean>()
|
|
31
|
-
/* Element holes keyed by node; anchor holes keyed by node (control-flow/component/slot)
|
|
32
|
-
|
|
35
|
+
/* Element holes keyed by node; anchor holes keyed by node (control-flow/component/slot) OR
|
|
36
|
+
by the reactive text PART object (a text node carries one anchor per reactive part). Both
|
|
37
|
+
numbered AFTER this context pass by the shared walks, once per skeleton root. */
|
|
33
38
|
const elIndex = new WeakMap<TemplateNode, number>()
|
|
34
39
|
const anIndex = new WeakMap<object, number>()
|
|
40
|
+
/* The skeletonable elements that OPEN a skeleton — each owns a fresh `el`/`an` numbering, so
|
|
41
|
+
the shared walks run once per root. Collected in pre-order here, numbered below. */
|
|
42
|
+
const skeletonRoots: TemplateNode[] = []
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
(`counter === undefined` outside any skeleton — no holes are numbered there). */
|
|
40
|
-
function visit(
|
|
41
|
-
node: TemplateNode,
|
|
42
|
-
nodeInSkeleton: boolean,
|
|
43
|
-
nodeMarkText: boolean,
|
|
44
|
-
counter: Counter | undefined,
|
|
45
|
-
): void {
|
|
44
|
+
/* Record the boundary context (`inSkeleton`/`markText`) at `node` and collect skeleton
|
|
45
|
+
roots. Hole NUMBERING is no longer threaded here — the two shared walks own it. */
|
|
46
|
+
function visit(node: TemplateNode, nodeInSkeleton: boolean, nodeMarkText: boolean): void {
|
|
46
47
|
inSkeleton.set(node, nodeInSkeleton)
|
|
47
48
|
markText.set(node, nodeMarkText)
|
|
48
49
|
|
|
49
50
|
/* Control-flow blocks, components, and snippets are fresh build contexts. The node
|
|
50
|
-
ITSELF is
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
ITSELF is an anchor in the enclosing skeleton (a block OR a component mounts as a
|
|
52
|
+
marker-bounded range at that anchor); its children re-enter the skeleton only via
|
|
53
|
+
their own roots. */
|
|
53
54
|
if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
|
|
54
|
-
/* A block or a component takes an anchor only inside an enclosing skeleton (a
|
|
55
|
-
standalone one routes through `generateIf`/`mountChild`, not the skeleton path);
|
|
56
|
-
a snippet declares a builder and is never anchor-positioned (`isAnchorPositioned`). */
|
|
57
|
-
if (counter !== undefined && isAnchorPositioned(node)) {
|
|
58
|
-
anIndex.set(node, counter.an++)
|
|
59
|
-
}
|
|
60
55
|
for (const child of childrenOf(node)) {
|
|
61
|
-
visit(child, false, false
|
|
56
|
+
visit(child, false, false)
|
|
62
57
|
}
|
|
63
58
|
return
|
|
64
59
|
}
|
|
65
60
|
/* A `branch`/`case` is a transparent grouping inside its control-flow block — pass the
|
|
66
|
-
already-reset context
|
|
67
|
-
|
|
61
|
+
already-reset context through so a skeletonable element inside it opens its own
|
|
62
|
+
skeleton. */
|
|
68
63
|
if (node.kind === 'branch' || node.kind === 'case') {
|
|
69
64
|
for (const child of node.children) {
|
|
70
|
-
visit(child, nodeInSkeleton, nodeMarkText
|
|
65
|
+
visit(child, nodeInSkeleton, nodeMarkText)
|
|
71
66
|
}
|
|
72
67
|
return
|
|
73
68
|
}
|
|
74
69
|
if (node.kind === 'text') {
|
|
75
|
-
/* Interleaved reactive text (markText true): each reactive part takes an `<!--a-->`
|
|
76
|
-
anchor, numbered in document order. A text-leaf's text (markText false) binds
|
|
77
|
-
marker-free via the element, so its parts take no anchor. */
|
|
78
|
-
if (counter !== undefined && nodeMarkText) {
|
|
79
|
-
for (const part of node.parts) {
|
|
80
|
-
if (part.kind !== 'static') {
|
|
81
|
-
anIndex.set(part, counter.an++)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
70
|
return
|
|
86
71
|
}
|
|
87
72
|
if (node.kind !== 'element') {
|
|
@@ -89,51 +74,54 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
|
89
74
|
}
|
|
90
75
|
if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
|
|
91
76
|
/* A component `<slot>` content fill OR a layout's `OUTLET_TAG` router fill point
|
|
92
|
-
(`asOutlet`)
|
|
93
|
-
|
|
94
|
-
fallback (a fresh context); an outlet has none. */
|
|
95
|
-
if (counter !== undefined) {
|
|
96
|
-
anIndex.set(node, counter.an++)
|
|
97
|
-
}
|
|
77
|
+
(`asOutlet`): its children are a fresh context (the `<slot>` fallback / an outlet
|
|
78
|
+
has none). The slot's own anchor is numbered by the shared walk. */
|
|
98
79
|
for (const child of node.children) {
|
|
99
|
-
visit(child, false, false
|
|
80
|
+
visit(child, false, false)
|
|
100
81
|
}
|
|
101
82
|
return
|
|
102
83
|
}
|
|
103
|
-
/* A skeletonable element not already in a skeleton OPENS one (
|
|
104
|
-
|
|
105
|
-
|
|
84
|
+
/* A skeletonable element not already in a skeleton OPENS one (the `generateSkeleton`
|
|
85
|
+
unit). An element already in a skeleton stays in it. A static element outside any
|
|
86
|
+
skeleton numbers nothing. */
|
|
106
87
|
const opensSkeleton = !nodeInSkeleton && skeletonable(node)
|
|
107
88
|
const childInSkeleton = nodeInSkeleton || skeletonable(node)
|
|
108
|
-
const effectiveCounter = nodeInSkeleton
|
|
109
|
-
? counter
|
|
110
|
-
: opensSkeleton
|
|
111
|
-
? { el: 0, an: 0 }
|
|
112
|
-
: undefined
|
|
113
89
|
const childMarkText = childInSkeleton && !isTextLeaf(node)
|
|
114
90
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
binds text-leaf reactive text on itself. Take its `el` index BEFORE recursing,
|
|
118
|
-
so holes number in pre-order — the order the runtime's path walk produces them. */
|
|
119
|
-
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
120
|
-
const reactiveTextChild = node.children.find(
|
|
121
|
-
(child) =>
|
|
122
|
-
child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
|
|
123
|
-
)
|
|
124
|
-
const textLeafBind = reactiveTextChild !== undefined && isTextLeaf(node)
|
|
125
|
-
if (hasReactiveAttr || textLeafBind) {
|
|
126
|
-
elIndex.set(node, effectiveCounter.el++)
|
|
127
|
-
}
|
|
91
|
+
if (opensSkeleton) {
|
|
92
|
+
skeletonRoots.push(node)
|
|
128
93
|
}
|
|
129
94
|
for (const child of node.children) {
|
|
130
|
-
visit(child, childInSkeleton, childMarkText
|
|
95
|
+
visit(child, childInSkeleton, childMarkText)
|
|
131
96
|
}
|
|
132
97
|
}
|
|
133
98
|
|
|
134
99
|
for (const node of nodes) {
|
|
135
|
-
visit(node, false, false
|
|
100
|
+
visit(node, false, false)
|
|
136
101
|
}
|
|
102
|
+
|
|
103
|
+
/* Number element holes via the ONE shared rule (`walkElementOrder`), once per skeleton root
|
|
104
|
+
so each root's holes start at 0 — the element-only pre-order the runtime's clone walk
|
|
105
|
+
re-derives. The walk starts AT the root: the root element itself is a located hole when it
|
|
106
|
+
binds. */
|
|
107
|
+
for (const root of skeletonRoots) {
|
|
108
|
+
let next = 0
|
|
109
|
+
walkElementOrder([root], templateElementAdapter, (node) => {
|
|
110
|
+
elIndex.set(node, next++)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Number anchor holes via the ONE shared rule (`walkAnchorOrder`), once per skeleton root.
|
|
115
|
+
The root's own children are walked — the root element is a located hole, never an
|
|
116
|
+
anchor. */
|
|
117
|
+
const anchorAdapter = templateAnchorAdapter(inSkeleton, markText)
|
|
118
|
+
for (const root of skeletonRoots) {
|
|
119
|
+
let next = 0
|
|
120
|
+
walkAnchorOrder(childrenOf(root), anchorAdapter, (position) => {
|
|
121
|
+
anIndex.set(position, next++)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
137
125
|
return { inSkeleton, markText, elIndex, anIndex }
|
|
138
126
|
}
|
|
139
127
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isAnchorPositioned } from './isAnchorPositioned.ts'
|
|
2
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
3
|
+
import type { AnchorRole, AnchorWalkAdapter } from './walkAnchorOrder.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The template-AST side of the shared anchor-ordering rule (`walkAnchorOrder`). Classifies each
|
|
7
|
+
parsed node by the SAME positions the realized-DOM side recovers, so the compiler's `anIndex`
|
|
8
|
+
numbering and the runtime's `scanAnchors` collection cannot disagree.
|
|
9
|
+
|
|
10
|
+
Anchor positions, in document order: a control-flow/component node and a `<slot>`/outlet
|
|
11
|
+
element each contribute themselves (one anchor, fresh-context body — not descended); a text
|
|
12
|
+
node whose reactive parts are interleaved contributes one anchor per non-static part. A
|
|
13
|
+
skeleton-structure element is a container we descend; everything else (static text, a
|
|
14
|
+
text-leaf's marker-free text, script/style, a node outside any skeleton) contributes nothing.
|
|
15
|
+
|
|
16
|
+
`inSkeleton`/`markText` come from the context pass (the element-hole + boundary axes), read here
|
|
17
|
+
so the adapter stays a pure classifier — the anchor counter never re-derives the context.
|
|
18
|
+
*/
|
|
19
|
+
export function templateAnchorAdapter(
|
|
20
|
+
inSkeleton: WeakMap<TemplateNode, boolean>,
|
|
21
|
+
markText: WeakMap<TemplateNode, boolean>,
|
|
22
|
+
): AnchorWalkAdapter<TemplateNode> {
|
|
23
|
+
return {
|
|
24
|
+
classify: (node: TemplateNode): AnchorRole => {
|
|
25
|
+
/* A node outside an active skeleton numbers nothing — its block/text mounts on the
|
|
26
|
+
host directly (top-level / inside a branch), no anchor. */
|
|
27
|
+
if (inSkeleton.get(node) !== true) {
|
|
28
|
+
/* It may still be a non-skeleton CONTAINER whose descendants open their own
|
|
29
|
+
skeletons (a static wrapper element), so recurse into elements, skip leaves. */
|
|
30
|
+
return node.kind === 'element' ? { kind: 'recurse' } : { kind: 'skip' }
|
|
31
|
+
}
|
|
32
|
+
/* An anchor-positioned node IS one anchor and its body is a fresh context. */
|
|
33
|
+
if (isAnchorPositioned(node)) {
|
|
34
|
+
return { kind: 'anchor', positions: [node] }
|
|
35
|
+
}
|
|
36
|
+
/* A component/snippet inside a skeleton that isn't anchor-positioned (a snippet
|
|
37
|
+
declares a builder) — fresh context, no anchor, no descent. */
|
|
38
|
+
if (node.kind === 'component' || node.kind === 'snippet') {
|
|
39
|
+
return { kind: 'skip' }
|
|
40
|
+
}
|
|
41
|
+
/* Interleaved reactive text: one anchor per non-static part, document order. */
|
|
42
|
+
if (node.kind === 'text') {
|
|
43
|
+
return markText.get(node) === true
|
|
44
|
+
? {
|
|
45
|
+
kind: 'anchor',
|
|
46
|
+
positions: node.parts.filter((part) => part.kind !== 'static'),
|
|
47
|
+
}
|
|
48
|
+
: { kind: 'skip' }
|
|
49
|
+
}
|
|
50
|
+
/* A skeleton-structure element — descend into its children. */
|
|
51
|
+
if (node.kind === 'element') {
|
|
52
|
+
return { kind: 'recurse' }
|
|
53
|
+
}
|
|
54
|
+
return { kind: 'skip' }
|
|
55
|
+
},
|
|
56
|
+
/* A branch/case is a transparent grouping inside its block — the context pass already
|
|
57
|
+
records its children's reset state, so the walk descends straight through it. */
|
|
58
|
+
childrenOf: (node: TemplateNode): readonly TemplateNode[] =>
|
|
59
|
+
'children' in node ? node.children : [],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
+
import { isControlFlow } from './isControlFlow.ts'
|
|
3
|
+
import { isTextLeaf } from './isTextLeaf.ts'
|
|
4
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
5
|
+
import type { ElementRole, ElementWalkAdapter } from './walkElementOrder.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The template-AST side of the shared element-hole numbering rule (`walkElementOrder`). Classifies
|
|
9
|
+
each parsed node by the SAME holes the parsed-DOM side recovers (`domElementAdapter`), so the
|
|
10
|
+
compiler's `elIndex` numbering and the runtime's `HOLE_ATTRIBUTE` path collection cannot
|
|
11
|
+
disagree.
|
|
12
|
+
|
|
13
|
+
A skeleton element is a numbered element (descended into); it is a HOLE when it carries a
|
|
14
|
+
reactive attribute/listener/bind, or binds reactive text marker-free as a text leaf — exactly
|
|
15
|
+
the elements `generateSkeleton` stamps with `HOLE_ATTRIBUTE`. A control-flow block, component,
|
|
16
|
+
snippet, `<slot>`, or outlet is a fresh build context (its content numbers in its own skeleton),
|
|
17
|
+
and a text/script/style/branch/case carries no element index — all skip.
|
|
18
|
+
*/
|
|
19
|
+
export const templateElementAdapter: ElementWalkAdapter<TemplateNode> = {
|
|
20
|
+
classify: (node: TemplateNode): ElementRole => {
|
|
21
|
+
/* Fresh build contexts — their content is numbered by their own skeleton, not here. */
|
|
22
|
+
if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
|
|
23
|
+
return { kind: 'skip' }
|
|
24
|
+
}
|
|
25
|
+
if (node.kind !== 'element') {
|
|
26
|
+
return { kind: 'skip' } // text / script / style / standalone branch|case
|
|
27
|
+
}
|
|
28
|
+
/* A `<slot>` fill point or a layout outlet — fresh context (slot fallback / outlet has
|
|
29
|
+
none), anchor-positioned, never an element hole. */
|
|
30
|
+
if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
|
|
31
|
+
return { kind: 'skip' }
|
|
32
|
+
}
|
|
33
|
+
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
34
|
+
const hasReactiveTextChild = node.children.some(
|
|
35
|
+
(child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
|
|
36
|
+
)
|
|
37
|
+
return {
|
|
38
|
+
kind: 'element',
|
|
39
|
+
isHole: hasReactiveAttr || (hasReactiveTextChild && isTextLeaf(node)),
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
childrenOf: (node: TemplateNode): readonly TemplateNode[] =>
|
|
43
|
+
'children' in node ? node.children : [],
|
|
44
|
+
}
|
|
@@ -34,6 +34,8 @@ export type TemplateNode =
|
|
|
34
34
|
items: string
|
|
35
35
|
as: string
|
|
36
36
|
key: string | undefined
|
|
37
|
+
/* `index="i"` → the row's reactive position, bound to this name; absent → unbound. */
|
|
38
|
+
index: string | undefined
|
|
37
39
|
/* `await` on the tag → `items` is an AsyncIterable, drained on the client. */
|
|
38
40
|
async: boolean
|
|
39
41
|
children: TemplateNode[]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The ONE document-order anchor-numbering rule, shared by every side that recovers a skeleton's
|
|
3
|
+
`<!--a-->` anchor positions. The same "why" — assign every block anchor, slot, and
|
|
4
|
+
interleaved-reactive-text part a position in document order, recover it on the other side — was
|
|
5
|
+
computed twice with no shared definition: `skeletonContext` re-derived it over the template AST
|
|
6
|
+
(assigning `anIndex`), `scanAnchors` re-derived it over the realized DOM (collecting the live
|
|
7
|
+
`a` comments). Each hand-mirrored the same traversal, so a change to one drifted silently from
|
|
8
|
+
the other — the index-desync class the runtime anchor guards exist to catch.
|
|
9
|
+
|
|
10
|
+
This module owns the traversal SHAPE as a sibling-list scan. A per-substrate
|
|
11
|
+
`AnchorWalkAdapter` classifies each node it meets into one of three roles, and that
|
|
12
|
+
classification — plus the document-order scan over it — IS the shared rule. The two substrates
|
|
13
|
+
differ only in how a fresh-context region is delimited (a control-flow/component/slot SUBTREE on
|
|
14
|
+
the AST side; a `[`…`]` / `abide:` marker-bracketed sibling RUN on the realized-DOM side), so
|
|
15
|
+
the adapter, not this walk, skips the region; the walk guarantees both sides emit the same
|
|
16
|
+
positions in the same order.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/* What a node contributes as the scan reaches it, in document order. */
|
|
20
|
+
export type AnchorRole =
|
|
21
|
+
/* Emit this node's anchor positions (zero or more — a reactive text node carries one per
|
|
22
|
+
non-static part), then do NOT descend. A block/component/slot is itself one anchor whose
|
|
23
|
+
body is a fresh context; an interleaved reactive text node is several. */
|
|
24
|
+
| { kind: 'anchor'; positions: readonly object[] }
|
|
25
|
+
/* An own-skeleton container — descend into its children, contributing no anchor itself (a
|
|
26
|
+
static/host element). */
|
|
27
|
+
| { kind: 'recurse' }
|
|
28
|
+
/* Contribute nothing and do not descend — a static text leaf, a script/style, or a
|
|
29
|
+
boundary the adapter handles out-of-band (range markers on the DOM side). */
|
|
30
|
+
| { kind: 'skip' }
|
|
31
|
+
|
|
32
|
+
/* The single substrate fact the shared walk needs: classify a node. The adapter owns substrate
|
|
33
|
+
detail (what an anchor is, what a fresh-context boundary is, how to reach children); the walk
|
|
34
|
+
owns only document order. */
|
|
35
|
+
export type AnchorWalkAdapter<TNode> = {
|
|
36
|
+
classify: (node: TNode) => AnchorRole
|
|
37
|
+
childrenOf: (node: TNode) => readonly TNode[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Scan `nodes` in document order, calling `emit` once per anchor position in the order both
|
|
41
|
+
sides must agree on. Pure: the adapter decides roles, this owns only the order. */
|
|
42
|
+
export function walkAnchorOrder<TNode>(
|
|
43
|
+
nodes: readonly TNode[],
|
|
44
|
+
adapter: AnchorWalkAdapter<TNode>,
|
|
45
|
+
emit: (position: object) => void,
|
|
46
|
+
): void {
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
const role = adapter.classify(node)
|
|
49
|
+
if (role.kind === 'anchor') {
|
|
50
|
+
for (const position of role.positions) {
|
|
51
|
+
emit(position)
|
|
52
|
+
}
|
|
53
|
+
} else if (role.kind === 'recurse') {
|
|
54
|
+
walkAnchorOrder(adapter.childrenOf(node), adapter, emit)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The ONE pre-order element-hole numbering rule, shared by every side that positions a skeleton's
|
|
3
|
+
located elements. The same "why" — number the bound elements in pre-order, recover them on the
|
|
4
|
+
other side — was computed twice with no shared definition: `skeletonContext` threaded an `el`
|
|
5
|
+
counter over the template AST (assigning `elIndex`), `indexElementHoles` re-walked the parsed
|
|
6
|
+
skeleton DOM (recording each `HOLE_ATTRIBUTE` element's path). Each hand-mirrored the same
|
|
7
|
+
element-only pre-order, so a change to one drifted silently from the other — the index-desync
|
|
8
|
+
class `resolveElementHole` exists to catch.
|
|
9
|
+
|
|
10
|
+
This module owns the traversal SHAPE: an element-only pre-order over a sibling list, threading
|
|
11
|
+
an element-only path. A per-substrate `ElementWalkAdapter` classifies each node — a numbered
|
|
12
|
+
element (a hole emits, all elements descend) or a skip (non-element / fresh-context boundary) —
|
|
13
|
+
and that classification, plus the path-threading scan over it, IS the shared rule. The two
|
|
14
|
+
substrates differ only in what marks a hole (a reactive attr/text-leaf on the AST side, the
|
|
15
|
+
`HOLE_ATTRIBUTE` on the parsed-DOM side) and where a fresh context begins (a control-flow/
|
|
16
|
+
component/slot SUBTREE on the AST side; the parsed skeleton already prunes those to `<!--a-->`
|
|
17
|
+
anchors, so the DOM side only meets its own elements), which the adapter owns — the walk
|
|
18
|
+
guarantees both number the same elements in the same element-only order.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/* What a node contributes as the pre-order scan reaches it. */
|
|
22
|
+
export type ElementRole =
|
|
23
|
+
/* An element in this skeleton: it consumes an element-only index (so a later sibling's
|
|
24
|
+
path stays stable), emits its hole position when `isHole`, and is descended into. */
|
|
25
|
+
| { kind: 'element'; isHole: boolean }
|
|
26
|
+
/* A non-element (text/comment) or a fresh-context boundary (control-flow/component/slot):
|
|
27
|
+
contributes no index, no hole, and is not descended. */
|
|
28
|
+
| { kind: 'skip' }
|
|
29
|
+
|
|
30
|
+
/* The substrate facts the shared walk needs: classify a node, reach its children. The adapter
|
|
31
|
+
owns substrate detail (what an element hole is, how to reach children); the walk owns only
|
|
32
|
+
the element-only pre-order and the path it threads. */
|
|
33
|
+
export type ElementWalkAdapter<TNode> = {
|
|
34
|
+
classify: (node: TNode) => ElementRole
|
|
35
|
+
childrenOf: (node: TNode) => readonly TNode[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Scan `nodes` in element-only pre-order, calling `emit` once per hole with its element-only
|
|
39
|
+
path — the order and path both sides must agree on. Pure: the adapter decides roles, this
|
|
40
|
+
owns only the order and the path prefix. */
|
|
41
|
+
export function walkElementOrder<TNode>(
|
|
42
|
+
nodes: readonly TNode[],
|
|
43
|
+
adapter: ElementWalkAdapter<TNode>,
|
|
44
|
+
emit: (node: TNode, path: number[]) => void,
|
|
45
|
+
prefix: number[] = [],
|
|
46
|
+
): void {
|
|
47
|
+
let elementIndex = 0
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
const role = adapter.classify(node)
|
|
50
|
+
if (role.kind === 'skip') {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const path = [...prefix, elementIndex]
|
|
54
|
+
elementIndex += 1
|
|
55
|
+
if (role.isHole) {
|
|
56
|
+
emit(node, path)
|
|
57
|
+
}
|
|
58
|
+
walkElementOrder(adapter.childrenOf(node), adapter, emit, path)
|
|
59
|
+
}
|
|
60
|
+
}
|