@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.
Files changed (44) hide show
  1. package/AGENTS.md +327 -326
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +87 -78
  4. package/package.json +1 -1
  5. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  6. package/src/lib/ui/compile/compileShadow.ts +5 -0
  7. package/src/lib/ui/compile/destructureBindingNames.ts +38 -0
  8. package/src/lib/ui/compile/generateBuild.ts +96 -12
  9. package/src/lib/ui/compile/generateSSR.ts +26 -9
  10. package/src/lib/ui/compile/lowerContext.ts +29 -9
  11. package/src/lib/ui/compile/parseTemplate.ts +2 -0
  12. package/src/lib/ui/compile/renameSignalRefs.ts +19 -2
  13. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  14. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  15. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  16. package/src/lib/ui/compile/types/TemplateNode.ts +2 -0
  17. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  18. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  19. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  20. package/src/lib/ui/dom/applyResolved.ts +3 -4
  21. package/src/lib/ui/dom/awaitBlock.ts +106 -46
  22. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  23. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  24. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  25. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  26. package/src/lib/ui/dom/each.ts +33 -17
  27. package/src/lib/ui/dom/eachAsync.ts +34 -26
  28. package/src/lib/ui/dom/isElement.ts +6 -0
  29. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  30. package/src/lib/ui/dom/mountRange.ts +4 -3
  31. package/src/lib/ui/dom/mountSlot.ts +4 -3
  32. package/src/lib/ui/dom/on.ts +6 -1
  33. package/src/lib/ui/dom/replaceRange.ts +24 -0
  34. package/src/lib/ui/dom/skeleton.ts +35 -92
  35. package/src/lib/ui/dom/switchBlock.ts +13 -10
  36. package/src/lib/ui/dom/types/EachRow.ts +9 -2
  37. package/src/lib/ui/dom/when.ts +13 -10
  38. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  39. package/src/lib/ui/runtime/batch.ts +22 -0
  40. package/src/lib/ui/runtime/clientPage.ts +3 -8
  41. package/src/lib/ui/runtime/createDoc.ts +9 -13
  42. package/src/lib/ui/seedResolved.ts +28 -0
  43. package/src/lib/ui/startClient.ts +6 -4
  44. 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. They
20
- serialize exactly as the client's `document.createComment('[' | ']')` markers, so
21
- the client claims the same `[ ]` boundary it builds the comment-marked range
22
- that lets a branch hold any content. */
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, '<!--a-->') : ''
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('<!--a-->' + ${value});\n`
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
- return `${anchor}for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
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) they deref to
26
- `.value` like a `computed`. Pushed while a branch's script + markup compile,
27
- popped after, so they shadow only within that subtree. */
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. `derefScope()` is read
34
- per call so a nested-`<script>` binding pushed mid-compile is honoured. */
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, derefScope(), computedNames),
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
- return { expression, statement, withNestedScripts, bindRead, bindWrite }
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>([...stateNames, ...derivedNames, ...computedNames])
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 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,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
+ }