@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.
Files changed (61) hide show
  1. package/AGENTS.md +333 -326
  2. package/CHANGELOG.md +92 -0
  3. package/README.md +91 -83
  4. package/package.json +1 -1
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +3 -5
  7. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  8. package/src/lib/shared/escapeRegex.ts +9 -0
  9. package/src/lib/shared/fileName.ts +9 -0
  10. package/src/lib/shared/fileStem.ts +3 -1
  11. package/src/lib/shared/programNameForPackage.ts +3 -1
  12. package/src/lib/shared/stripImport.ts +3 -1
  13. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  14. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  15. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  16. package/src/lib/ui/compile/compileShadow.ts +46 -9
  17. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  18. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  19. package/src/lib/ui/compile/generateBuild.ts +13 -6
  20. package/src/lib/ui/compile/generateSSR.ts +23 -14
  21. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  22. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  23. package/src/lib/ui/compile/parseTemplate.ts +524 -104
  24. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  25. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  26. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  27. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  28. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  29. package/src/lib/ui/compile/types/TemplateNode.ts +9 -0
  30. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  31. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  32. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  33. package/src/lib/ui/dom/appendText.ts +1 -5
  34. package/src/lib/ui/dom/applyResolved.ts +4 -9
  35. package/src/lib/ui/dom/awaitBlock.ts +37 -31
  36. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  37. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  38. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  39. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  40. package/src/lib/ui/dom/each.ts +7 -11
  41. package/src/lib/ui/dom/eachAsync.ts +12 -17
  42. package/src/lib/ui/dom/isComment.ts +6 -0
  43. package/src/lib/ui/dom/isElement.ts +6 -0
  44. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  45. package/src/lib/ui/dom/mountRange.ts +4 -3
  46. package/src/lib/ui/dom/mountSlot.ts +4 -3
  47. package/src/lib/ui/dom/on.ts +6 -1
  48. package/src/lib/ui/dom/replaceRange.ts +24 -0
  49. package/src/lib/ui/dom/skeleton.ts +35 -92
  50. package/src/lib/ui/dom/switchBlock.ts +13 -10
  51. package/src/lib/ui/dom/when.ts +13 -10
  52. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  53. package/src/lib/ui/runtime/batch.ts +22 -0
  54. package/src/lib/ui/runtime/clientPage.ts +3 -8
  55. package/src/lib/ui/runtime/createDoc.ts +12 -16
  56. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  57. package/src/lib/ui/seedResolved.ts +28 -0
  58. package/src/lib/ui/startClient.ts +6 -4
  59. package/src/lib/ui/types/ResolvedFrame.ts +15 -0
  60. package/template/.zed/settings.json +4 -0
  61. 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 clone
12
- (`inSkeleton`) and whether its reactive text is interleaved (`markText`) the two facts
13
- that decide `<!--a-->` anchor placement and assigns each hole its `el`/`an` index.
14
- `generateBuild` reads these indices instead of threading its own counter through a second
15
- document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
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` numbers
19
- element holes in pre-order; `an` numbers anchor holes (interleaved reactive text PARTS,
20
- control-flow blocks, child components, `<slot>` outlets) in
21
- document order — the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
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 (and to no active counter), because the
25
- content there is built by its own runtime (a control-flow block's branch, a component's slot
26
- content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclosing skeleton.
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
- OR by the reactive text PART object (a text node carries one anchor per reactive part). */
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
- type Counter = { el: number; an: number }
37
-
38
- /* Walk `node` carrying the context AND the active skeleton counter that apply AT it
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 a hole in the enclosing skeleton (an `<!--a-->` anchor for a block OR a
51
- component — both mount as a marker-bounded range at that anchor); its children
52
- re-enter the skeleton only via their own roots. */
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, undefined)
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 (and absent counter) through so a skeletonable element inside
67
- it opens its own skeleton. */
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, counter)
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`) is an anchor hole in the enclosing skeleton both mount a marker
93
- range at the anchor (`mountSlot` / `outlet`). A `<slot>`'s children are its
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, undefined)
80
+ visit(child, false, false)
100
81
  }
101
82
  return
102
83
  }
103
- /* A skeletonable element not already in a skeleton OPENS one (a fresh counter — the
104
- `generateSkeleton` unit). An element already in a skeleton uses the enclosing
105
- counter. A static element outside any skeleton numbers nothing. */
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 (effectiveCounter !== undefined) {
116
- /* The element is a located hole when it carries a reactive attr/listener/bind, or
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, effectiveCounter)
95
+ visit(child, childInSkeleton, childMarkText)
131
96
  }
132
97
  }
133
98
 
134
99
  for (const node of nodes) {
135
- visit(node, false, false, undefined)
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 clearBetween. */
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, 'abide:snippet')
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, '/abide:snippet')
53
+ close = openMarker(parent, SNIPPET_CLOSE)
53
54
  } else {
54
- open = openMarker(parent, 'abide:snippet')
55
- close = openMarker(parent, '/abide:snippet')
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
- clearBetween(open, close, dispose)
72
- dispose = builder !== undefined ? group.track(fillBefore(close, builder)) : undefined
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
- }