@abide/abide 0.41.0 → 0.42.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 +333 -326
- package/CHANGELOG.md +92 -0
- package/README.md +91 -83
- package/package.json +1 -1
- package/src/abideLsp.ts +46 -8
- package/src/abideResolverPlugin.ts +3 -5
- package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
- package/src/lib/shared/escapeRegex.ts +9 -0
- package/src/lib/shared/fileName.ts +9 -0
- package/src/lib/shared/fileStem.ts +3 -1
- package/src/lib/shared/programNameForPackage.ts +3 -1
- package/src/lib/shared/stripImport.ts +3 -1
- package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
- package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
- package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
- package/src/lib/ui/compile/compileShadow.ts +46 -9
- package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
- package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
- package/src/lib/ui/compile/generateBuild.ts +13 -6
- package/src/lib/ui/compile/generateSSR.ts +23 -14
- package/src/lib/ui/compile/makeVarNamer.ts +10 -0
- package/src/lib/ui/compile/offsetToPosition.ts +9 -0
- package/src/lib/ui/compile/parseTemplate.ts +524 -104
- package/src/lib/ui/compile/skeletonContext.ts +67 -79
- package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
- package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
- package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
- package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +9 -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/appendText.ts +1 -5
- package/src/lib/ui/dom/applyResolved.ts +4 -9
- package/src/lib/ui/dom/awaitBlock.ts +37 -31
- 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 +7 -11
- package/src/lib/ui/dom/eachAsync.ts +12 -17
- package/src/lib/ui/dom/isComment.ts +6 -0
- 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/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 +12 -16
- package/src/lib/ui/runtime/pathSegments.ts +10 -0
- 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
- package/template/.zed/settings.json +4 -0
- package/template/src/ui/pages/page.abide +4 -4
|
@@ -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,101 @@
|
|
|
1
|
+
import type { SemanticToken } from './types/SemanticToken.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Lexical highlighting for `{#…}`/`{:…}`/`{/…}` control-flow framing — the part the
|
|
5
|
+
HTML grammar sees only as text and the shadow program lowers away. A pure scan of
|
|
6
|
+
raw source (independent of a successful parse, so it survives mid-edit), it emits
|
|
7
|
+
an `operator` token for the `{`+sigil opener and a `keyword` token for the block
|
|
8
|
+
word, plus the `of`/`by` connectors inside a `{#for …}` head. Expression interiors
|
|
9
|
+
(and the `{@const}`/`{@html}` tags and bare `{expr}` interpolations) are NOT touched
|
|
10
|
+
here — those are the shadow's job. A keyword allowlist after the sigil prevents
|
|
11
|
+
matching arbitrary `{:foo}` text. Longest phrases first so `for await` beats `for`
|
|
12
|
+
and `else if` beats `else`.
|
|
13
|
+
*/
|
|
14
|
+
const BLOCK_KEYWORDS = [
|
|
15
|
+
'for await',
|
|
16
|
+
'else if',
|
|
17
|
+
'if',
|
|
18
|
+
'for',
|
|
19
|
+
'await',
|
|
20
|
+
'switch',
|
|
21
|
+
'case',
|
|
22
|
+
'default',
|
|
23
|
+
'try',
|
|
24
|
+
'catch',
|
|
25
|
+
'finally',
|
|
26
|
+
'then',
|
|
27
|
+
'else',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const BLOCK_HEAD = new RegExp(`\\{([#:/])\\s*(${BLOCK_KEYWORDS.join('|')})\\b`, 'g')
|
|
31
|
+
|
|
32
|
+
export function structuralBlockTokens(source: string): SemanticToken[] {
|
|
33
|
+
const tokens: SemanticToken[] = []
|
|
34
|
+
for (const match of source.matchAll(BLOCK_HEAD)) {
|
|
35
|
+
const braceStart = match.index
|
|
36
|
+
const sigil = match[1]
|
|
37
|
+
const keyword = match[2]
|
|
38
|
+
if (keyword === undefined) {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
const keywordStart = braceStart + match[0].length - keyword.length
|
|
42
|
+
tokens.push({ start: braceStart, length: 2, type: 'operator', modifiers: [] })
|
|
43
|
+
tokens.push({ start: keywordStart, length: keyword.length, type: 'keyword', modifiers: [] })
|
|
44
|
+
/* A `{#for …}` head carries abide-only connectors `of`/`by` that the shadow
|
|
45
|
+
lowers away (`of`) or never emits (`by`), so color them here. */
|
|
46
|
+
if (sigil === '#' && (keyword === 'for' || keyword === 'for await')) {
|
|
47
|
+
tokens.push(...forHeadConnectors(source, keywordStart + keyword.length))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return tokens
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
Scans a `{#for …}` head from `from` to its closing `}`, emitting `keyword` tokens
|
|
55
|
+
for the `of`/`by` connectors at brace depth 0 — skipping any `of`/`by` nested in a
|
|
56
|
+
destructure, call, or string so an identifier or object key is never miscolored.
|
|
57
|
+
*/
|
|
58
|
+
function forHeadConnectors(source: string, from: number): SemanticToken[] {
|
|
59
|
+
const tokens: SemanticToken[] = []
|
|
60
|
+
let depth = 0
|
|
61
|
+
let cursor = from
|
|
62
|
+
while (cursor < source.length) {
|
|
63
|
+
const char = source.charAt(cursor)
|
|
64
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
65
|
+
cursor += 1
|
|
66
|
+
while (cursor < source.length && source.charAt(cursor) !== char) {
|
|
67
|
+
if (source.charAt(cursor) === '\\') {
|
|
68
|
+
cursor += 1
|
|
69
|
+
}
|
|
70
|
+
cursor += 1
|
|
71
|
+
}
|
|
72
|
+
} else if (char === '{' || char === '(' || char === '[') {
|
|
73
|
+
depth += 1
|
|
74
|
+
} else if (char === ')' || char === ']') {
|
|
75
|
+
depth -= 1
|
|
76
|
+
} else if (char === '}') {
|
|
77
|
+
if (depth === 0) {
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
depth -= 1
|
|
81
|
+
} else if (depth === 0 && (char === 'o' || char === 'b')) {
|
|
82
|
+
const word = source.startsWith('of', cursor)
|
|
83
|
+
? 'of'
|
|
84
|
+
: source.startsWith('by', cursor)
|
|
85
|
+
? 'by'
|
|
86
|
+
: undefined
|
|
87
|
+
const isWordBoundary = (offset: number): boolean => /\s/.test(source.charAt(offset))
|
|
88
|
+
if (
|
|
89
|
+
word !== undefined &&
|
|
90
|
+
isWordBoundary(cursor - 1) &&
|
|
91
|
+
isWordBoundary(cursor + word.length)
|
|
92
|
+
) {
|
|
93
|
+
tokens.push({ start: cursor, length: word.length, type: 'keyword', modifiers: [] })
|
|
94
|
+
cursor += word.length
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
cursor += 1
|
|
99
|
+
}
|
|
100
|
+
return tokens
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A semantic-highlighting token in original `.abide` source coordinates. `type` and
|
|
3
|
+
`modifiers` are legend names (see ABIDE_SEMANTIC_TOKENS_LEGEND), resolved to wire
|
|
4
|
+
indices only at encode time.
|
|
5
|
+
*/
|
|
6
|
+
export type SemanticToken = {
|
|
7
|
+
start: number
|
|
8
|
+
length: number
|
|
9
|
+
type: string
|
|
10
|
+
modifiers: string[]
|
|
11
|
+
}
|
|
@@ -40,6 +40,11 @@ export type TemplateNode =
|
|
|
40
40
|
async: boolean
|
|
41
41
|
children: TemplateNode[]
|
|
42
42
|
loc?: number
|
|
43
|
+
/* Source offsets of the binding name, `by` key, and index — so the shadow
|
|
44
|
+
maps hover/highlighting onto them (absent for synthesised/missing parts). */
|
|
45
|
+
asLoc?: number
|
|
46
|
+
keyLoc?: number
|
|
47
|
+
indexLoc?: number
|
|
43
48
|
}
|
|
44
49
|
| { kind: 'if'; condition: string; children: TemplateNode[]; loc?: number }
|
|
45
50
|
| {
|
|
@@ -52,6 +57,8 @@ export type TemplateNode =
|
|
|
52
57
|
as: string | undefined
|
|
53
58
|
children: TemplateNode[]
|
|
54
59
|
loc?: number
|
|
60
|
+
/* Source offset of an inline blocking `then` binding (`{#await p then v}`). */
|
|
61
|
+
asLoc?: number
|
|
55
62
|
}
|
|
56
63
|
| { kind: 'try'; children: TemplateNode[] }
|
|
57
64
|
| {
|
|
@@ -59,6 +66,8 @@ export type TemplateNode =
|
|
|
59
66
|
branch: 'then' | 'catch' | 'finally'
|
|
60
67
|
as: string | undefined
|
|
61
68
|
children: TemplateNode[]
|
|
69
|
+
/* Source offset of the `then`/`catch` binding, so the shadow maps it. */
|
|
70
|
+
asLoc?: number
|
|
62
71
|
}
|
|
63
72
|
| {
|
|
64
73
|
kind: 'component'
|
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { snippetPayload } from '../../shared/snippet.ts'
|
|
2
2
|
import { effect } from '../effect.ts'
|
|
3
|
+
import { SNIPPET_CLOSE, SNIPPET_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
3
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
5
|
import { scope } from '../runtime/scope.ts'
|
|
5
6
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
6
|
-
import { clearBetween } from './clearBetween.ts'
|
|
7
7
|
import { fillBefore } from './fillBefore.ts'
|
|
8
8
|
import { openMarker } from './openMarker.ts'
|
|
9
|
+
import { replaceRange } from './replaceRange.ts'
|
|
9
10
|
|
|
10
11
|
/*
|
|
11
12
|
A `{snippet(args)}` interpolation: mount the branded builder's nodes in a range
|
|
@@ -30,7 +31,7 @@ the args; a later argument change rebuilds fresh (the SSR markers stay as the ra
|
|
|
30
31
|
export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
31
32
|
const hydration = RENDER.hydration
|
|
32
33
|
/* Mount scopes register with the owner so they dispose on owner teardown, not
|
|
33
|
-
only on an argument-driven rebuild via
|
|
34
|
+
only on an argument-driven rebuild via replaceRange. */
|
|
34
35
|
const group = scopeGroup()
|
|
35
36
|
let dispose: (() => void) | undefined
|
|
36
37
|
|
|
@@ -44,15 +45,15 @@ export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
|
44
45
|
let open: Comment
|
|
45
46
|
let close: Comment
|
|
46
47
|
if (hydration !== undefined) {
|
|
47
|
-
open = openMarker(parent,
|
|
48
|
+
open = openMarker(parent, SNIPPET_OPEN)
|
|
48
49
|
const builder = builderOf()
|
|
49
50
|
if (builder !== undefined) {
|
|
50
51
|
dispose = group.track(scope(() => builder(parent))) // content claims the SSR nodes in place
|
|
51
52
|
}
|
|
52
|
-
close = openMarker(parent,
|
|
53
|
+
close = openMarker(parent, SNIPPET_CLOSE)
|
|
53
54
|
} else {
|
|
54
|
-
open = openMarker(parent,
|
|
55
|
-
close = openMarker(parent,
|
|
55
|
+
open = openMarker(parent, SNIPPET_OPEN)
|
|
56
|
+
close = openMarker(parent, SNIPPET_CLOSE)
|
|
56
57
|
const builder = builderOf()
|
|
57
58
|
if (builder !== undefined) {
|
|
58
59
|
dispose = group.track(fillBefore(close, builder))
|
|
@@ -68,7 +69,7 @@ export function appendSnippet(parent: Node, read: () => unknown): void {
|
|
|
68
69
|
first = false
|
|
69
70
|
return
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
-
dispose =
|
|
72
|
+
const next = replaceRange(open, close, dispose, builder)
|
|
73
|
+
dispose = next !== undefined ? group.track(next) : undefined
|
|
73
74
|
})
|
|
74
75
|
}
|
|
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
|
|
|
4
4
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
5
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
6
|
import { appendSnippet } from './appendSnippet.ts'
|
|
7
|
+
import { isComment } from './isComment.ts'
|
|
7
8
|
import { parseRawNodes } from './parseRawNodes.ts'
|
|
8
9
|
|
|
9
10
|
const CLOSE = '/abide:html'
|
|
@@ -106,8 +107,3 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
|
|
|
106
107
|
set(markup())
|
|
107
108
|
})
|
|
108
109
|
}
|
|
109
|
-
|
|
110
|
-
/* A comment node carrying exactly `data`. */
|
|
111
|
-
function isComment(node: Node, data: string): boolean {
|
|
112
|
-
return (node as { data?: string }).data === data && node.childNodes.length === 0
|
|
113
|
-
}
|