@statorjs/stator 1.7.0 → 1.8.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/package.json +3 -2
- package/src/compiler/lower.ts +0 -0
- package/src/engine/actor.ts +10 -4
- package/src/engine/define-machine.ts +9 -0
- package/src/engine/types.ts +3 -0
- package/src/server/query-route.ts +11 -2
- package/src/server/routing.ts +12 -7
- package/src/template/conditional.ts +6 -6
- package/src/template/defer.ts +3 -3
- package/src/template/each.ts +5 -6
- package/src/wire/apply.ts +75 -32
- package/src/wire/region-apply.ts +103 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -88,12 +88,13 @@
|
|
|
88
88
|
"pino-pretty": "^13.1.3",
|
|
89
89
|
"vite": "^6.0.0",
|
|
90
90
|
"vitest": "^2.1.0",
|
|
91
|
-
"@statorjs/stator": "1.
|
|
91
|
+
"@statorjs/stator": "1.8.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
|
95
95
|
"test": "vitest run",
|
|
96
96
|
"test:watch": "vitest",
|
|
97
|
+
"test:browser": "node browser-tests/tables.mjs",
|
|
97
98
|
"build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js"
|
|
98
99
|
}
|
|
99
100
|
}
|
package/src/compiler/lower.ts
CHANGED
|
Binary file
|
package/src/engine/actor.ts
CHANGED
|
@@ -244,10 +244,16 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
244
244
|
// transition uniformly against the full event union (the runtime event
|
|
245
245
|
// IS the narrowed type for this key, so the coercion is sound).
|
|
246
246
|
type RawTransition = ((ctx: C, ev: E, h: ActionHelpers) => void) | TransitionConfig<C, E, S>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
247
|
+
let entry = node?.on?.[event.type as E['type']] as RawTransition | RawTransition[] | undefined
|
|
248
|
+
// Machine-level fallback: consulted ONLY when the current state does not
|
|
249
|
+
// declare this event (state-scoped handlers win — most specific). This is
|
|
250
|
+
// how a completion is handled regardless of the machine-wide state (a save
|
|
251
|
+
// completing while the machine reloads) without duplicating the handler
|
|
252
|
+
// into every state. A state that declares the event owns it, even if its
|
|
253
|
+
// guard drops — no fall-through to machine level in that case.
|
|
254
|
+
if (entry === undefined) {
|
|
255
|
+
entry = def.on?.[event.type as E['type']] as RawTransition | RawTransition[] | undefined
|
|
256
|
+
}
|
|
251
257
|
if (!entry) return
|
|
252
258
|
|
|
253
259
|
const h = helpers()
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EventObject,
|
|
7
7
|
Lifecycle,
|
|
8
8
|
MachineDef,
|
|
9
|
+
OnMap,
|
|
9
10
|
ReadsMap,
|
|
10
11
|
SelectorMap,
|
|
11
12
|
StateNode,
|
|
@@ -34,6 +35,13 @@ export interface DefineMachineConfig<
|
|
|
34
35
|
initial: NoInfer<S>
|
|
35
36
|
/** Transitions see `helpers.reads` typed as `ReadsMap<TReads>`. */
|
|
36
37
|
states: Record<S, StateNode<C, E, S, ReadsMap<TReads>>>
|
|
38
|
+
/** Machine-level transitions: handlers that apply in ANY state, consulted only
|
|
39
|
+
* when the current state does not declare the event (a state-scoped handler
|
|
40
|
+
* always wins). The home for a completion event whose handling must not depend
|
|
41
|
+
* on an unrelated machine-wide state — e.g. a per-record save completing while
|
|
42
|
+
* the machine is busy reloading the collection. Without this, such a
|
|
43
|
+
* completion is dropped wherever the current state has no handler for it. */
|
|
44
|
+
on?: OnMap<C, E, S, ReadsMap<TReads>>
|
|
37
45
|
selectors?: Sel
|
|
38
46
|
/** APP machines only: persist this machine's snapshot through the AppStore
|
|
39
47
|
* so its state survives restarts. Opt-in — caches and other
|
|
@@ -106,6 +114,7 @@ export function defineMachine<
|
|
|
106
114
|
capabilities: computeCapabilities(reads),
|
|
107
115
|
initial: config.initial,
|
|
108
116
|
states: config.states as Record<string, StateNode<C, E, S>>,
|
|
117
|
+
on: config.on as OnMap<C, E, S> | undefined,
|
|
109
118
|
context: config.context,
|
|
110
119
|
__context: undefined as unknown as C,
|
|
111
120
|
__event: undefined as unknown as E,
|
package/src/engine/types.ts
CHANGED
|
@@ -252,6 +252,9 @@ export interface MachineDef<
|
|
|
252
252
|
* reads typing that matters is enforced at the authoring callsite) and the
|
|
253
253
|
* initial context. */
|
|
254
254
|
states: Record<string, StateNode<C, E, S>>
|
|
255
|
+
/** Machine-level transitions — handlers consulted when the current state does
|
|
256
|
+
* not declare the event (state-scoped handlers take precedence). */
|
|
257
|
+
on?: OnMap<C, E, S>
|
|
255
258
|
context: C
|
|
256
259
|
/** Type-level carriers — never read at runtime. */
|
|
257
260
|
readonly __context: C
|
|
@@ -6,7 +6,12 @@ import { scopedLogger } from './logger.ts'
|
|
|
6
6
|
import type { MachineStore } from './machine-store.ts'
|
|
7
7
|
import type { DiscoveredRoute } from './route-discovery.ts'
|
|
8
8
|
import { buildRouteRequest } from './route-request.ts'
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
QueryRouteDefinition,
|
|
11
|
+
QueryRouteHelpers,
|
|
12
|
+
QueryRouteResult,
|
|
13
|
+
RouteContext,
|
|
14
|
+
} from './routing.ts'
|
|
10
15
|
import { getOrCreateSessionId } from './session.ts'
|
|
11
16
|
import { withSessionLock } from './session-lock.ts'
|
|
12
17
|
import { SessionRuntime } from './session-runtime.ts'
|
|
@@ -74,7 +79,11 @@ export async function runQueryRoute(
|
|
|
74
79
|
|
|
75
80
|
let result: QueryRouteResult
|
|
76
81
|
try {
|
|
77
|
-
|
|
82
|
+
// The map is built dynamically by machine name; the typed ReadsMap
|
|
83
|
+
// guarantee is for the user's handler, recovered at their call site.
|
|
84
|
+
result = await route.handler(request, {
|
|
85
|
+
machines: machines as QueryRouteHelpers['machines'],
|
|
86
|
+
})
|
|
78
87
|
} catch (err) {
|
|
79
88
|
queryLog.error({ err: String(err), path: c.req.path }, 'data route handler threw')
|
|
80
89
|
return c.text('Internal Server Error', 500)
|
package/src/server/routing.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HtmlFragment } from '../template/types.ts'
|
|
2
2
|
import type { WireEnvelope } from '../wire/index.ts'
|
|
3
|
-
import type { AnyMachineDef, EventOf } from './define-machine.ts'
|
|
3
|
+
import type { AnyMachineDef, EventOf, ReadsMap } from './define-machine.ts'
|
|
4
4
|
|
|
5
5
|
/** Machine context passed to a route's render function. Keyed by machine name. */
|
|
6
6
|
export type RouteContext = Record<string, unknown>
|
|
@@ -170,7 +170,7 @@ export interface DefineApiRouteConfig<TReads extends ReadonlyArray<AnyMachineDef
|
|
|
170
170
|
/** `method: 'GET'` discriminates: with it, the handler is a read-only data
|
|
171
171
|
* route typed with `{ machines }`; without it, a command route typed with
|
|
172
172
|
* `{ dispatch, rotateSession }` — exactly as before. */
|
|
173
|
-
export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef
|
|
173
|
+
export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef> = readonly []>(
|
|
174
174
|
config: DefineQueryRouteConfig<TReads>,
|
|
175
175
|
): QueryRouteDefinition
|
|
176
176
|
export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
|
|
@@ -209,10 +209,15 @@ export function isStatorApiRoute(v: unknown): v is ApiRouteDefinition {
|
|
|
209
209
|
* there is no `dispatch` here, and that structural absence is what makes
|
|
210
210
|
* handler reads safe — a handler that cannot dispatch has nothing to
|
|
211
211
|
* interleave with effect completions or other sessions' commits. */
|
|
212
|
-
export interface QueryRouteHelpers
|
|
212
|
+
export interface QueryRouteHelpers<
|
|
213
|
+
TReads extends ReadonlyArray<AnyMachineDef> = ReadonlyArray<AnyMachineDef>,
|
|
214
|
+
> {
|
|
213
215
|
/** Read proxies keyed by machine name — the same shape a page's render
|
|
214
|
-
* context uses (selector + context reads; no send).
|
|
215
|
-
machines
|
|
216
|
+
* context uses (selector + context reads; no send). Typed off the route's
|
|
217
|
+
* `reads:` tuple (`ReadsMap`), so `machines.SitesMachine` is a real proxy
|
|
218
|
+
* and a mistyped name is a compile error rather than `unknown`. The default
|
|
219
|
+
* keeps the erased runtime definition loose. */
|
|
220
|
+
machines: ReadsMap<TReads>
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
/** What a data GET handler may return: a raw `Response` (passed through
|
|
@@ -231,14 +236,14 @@ export interface QueryRouteDefinition {
|
|
|
231
236
|
) => Promise<QueryRouteResult> | QueryRouteResult
|
|
232
237
|
}
|
|
233
238
|
|
|
234
|
-
export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef
|
|
239
|
+
export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef> = readonly []> {
|
|
235
240
|
/** The capability discriminant: `'GET'` declares a read-only data route.
|
|
236
241
|
* Discovery cross-checks it against the export name. */
|
|
237
242
|
method: 'GET'
|
|
238
243
|
reads?: TReads
|
|
239
244
|
handler: (
|
|
240
245
|
request: RouteRequest,
|
|
241
|
-
helpers: QueryRouteHelpers
|
|
246
|
+
helpers: QueryRouteHelpers<TReads>,
|
|
242
247
|
) => Promise<QueryRouteResult> | QueryRouteResult
|
|
243
248
|
}
|
|
244
249
|
|
|
@@ -104,9 +104,9 @@ export function when<T>(cond: T | ReadResult<T>, fn: () => HtmlFragment): Branch
|
|
|
104
104
|
})
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
const html =
|
|
107
|
+
// Comment markers delimit the region (no injected node, valid in restricted
|
|
108
|
+
// parents) — see each.ts for the rationale.
|
|
109
|
+
const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
|
|
110
110
|
return { __isBranchResult: true, html, slotId }
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -160,8 +160,8 @@ export function match<TKey extends string>(
|
|
|
160
160
|
})
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
const html =
|
|
163
|
+
// Comment markers delimit the region (no injected node, valid in restricted
|
|
164
|
+
// parents) — see each.ts for the rationale.
|
|
165
|
+
const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
|
|
166
166
|
return { __isBranchResult: true, html, slotId }
|
|
167
167
|
}
|
package/src/template/defer.ts
CHANGED
|
@@ -32,9 +32,9 @@ export function deferSentinel(slotId: SlotId): string {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function deferPlaceholder(slotId: SlotId, inner: string): DeferResult {
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
const html =
|
|
35
|
+
// Comment markers delimit the region (no injected node, valid in restricted
|
|
36
|
+
// parents) — same rationale as each()/when().
|
|
37
|
+
const html = `<!--s:${slotId}-->${inner}<!--/s:${slotId}-->`
|
|
38
38
|
return { __isDeferResult: true, html, slotId }
|
|
39
39
|
}
|
|
40
40
|
|
package/src/template/each.ts
CHANGED
|
@@ -136,12 +136,11 @@ export function each<T>(
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
const html = `<span data-slot="${slotId}" data-list="true" style="display:contents">${innerHtml}</span>`
|
|
139
|
+
// Comment markers delimit the region instead of a wrapper element: they inject
|
|
140
|
+
// no node into the user's DOM (no layout box, no CSS sibling/child selector
|
|
141
|
+
// pollution) and are valid in restricted parents like <table>/<tbody> where a
|
|
142
|
+
// <span> is foster-parented out. The client addresses the list by its markers.
|
|
143
|
+
const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
|
|
145
144
|
return { __isEachResult: true, html, slotId }
|
|
146
145
|
}
|
|
147
146
|
|
package/src/wire/apply.ts
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* path applied it.
|
|
10
10
|
*/
|
|
11
11
|
import type { Directive, Patch } from './index.ts'
|
|
12
|
+
import {
|
|
13
|
+
elementAt,
|
|
14
|
+
findRegion,
|
|
15
|
+
insertAt,
|
|
16
|
+
moveWithin,
|
|
17
|
+
removeAt,
|
|
18
|
+
replaceRegion,
|
|
19
|
+
} from './region-apply.ts'
|
|
12
20
|
import { isSafeNavigationUrl } from './safe-url.ts'
|
|
13
21
|
|
|
14
22
|
function emit(name: string, detail: unknown): void {
|
|
@@ -22,44 +30,79 @@ export function resolveTarget(target: { kind: 'slot' | 'element'; id: string }):
|
|
|
22
30
|
return document.querySelector(`[data-stator-id="${target.id}"]`)
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
function warnMissing(target: { kind: string; id: string }): void {
|
|
34
|
+
// A missing target means this DOM diverged from server truth (stale non-live
|
|
35
|
+
// page, or client code removed server-owned nodes). Skipping is safe — arm/
|
|
36
|
+
// key-scoped ids guarantee a patch can't land on the wrong content — but the
|
|
37
|
+
// divergence is worth surfacing.
|
|
38
|
+
console.warn(`stator: patch target ${target.kind} "${target.id}" not in DOM — skipped`)
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
export function applyPatches(patches: Patch[]): void {
|
|
42
|
+
// Resolve each region's marker pair once per batch. The markers are stable
|
|
43
|
+
// anchors — ops mutate only the nodes between them — so a pair found for one
|
|
44
|
+
// op stays valid for the rest of the batch (keyed lists emit a run of ops on
|
|
45
|
+
// the same slot). null caches a genuine miss.
|
|
46
|
+
const regions = new Map<string, { start: Comment; end: Comment } | null>()
|
|
47
|
+
const regionFor = (id: string): { start: Comment; end: Comment } | null => {
|
|
48
|
+
let r = regions.get(id)
|
|
49
|
+
if (r === undefined) {
|
|
50
|
+
r = findRegion(document, id)
|
|
51
|
+
regions.set(id, r)
|
|
52
|
+
}
|
|
53
|
+
return r
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
for (const patch of patches) {
|
|
57
|
+
const element = applyPatch(patch, regionFor)
|
|
58
|
+
emit('stator:patch-applied', { patch, element, timestamp: Date.now() })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Apply one patch. Returns the element the inspector should flash (or null when
|
|
63
|
+
* the target is missing or the change left no single node to highlight). */
|
|
64
|
+
function applyPatch(
|
|
65
|
+
patch: Patch,
|
|
66
|
+
regionFor: (id: string) => { start: Comment; end: Comment } | null,
|
|
67
|
+
): Element | null {
|
|
68
|
+
// `text`/`attr` address a single node: a text-binding `data-slot` span or a
|
|
69
|
+
// `data-stator-id` element.
|
|
70
|
+
if (patch.op === 'text' || patch.op === 'attr') {
|
|
27
71
|
const element = resolveTarget(patch.target)
|
|
28
72
|
if (!element) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// is safe — arm/key-scoped slot ids guarantee a patch can't land on
|
|
32
|
-
// the wrong content — but the divergence is worth surfacing.
|
|
33
|
-
console.warn(
|
|
34
|
-
`stator: patch target ${patch.target.kind} "${patch.target.id}" not in DOM — skipped`,
|
|
35
|
-
)
|
|
36
|
-
continue
|
|
73
|
+
warnMissing(patch.target)
|
|
74
|
+
return null
|
|
37
75
|
}
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
if (patch.op === 'text') element.textContent = patch.value
|
|
77
|
+
else if (patch.value === null) element.removeAttribute(patch.name)
|
|
78
|
+
else element.setAttribute(patch.name, patch.value)
|
|
79
|
+
return element
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `html`/`insert`/`remove`/`move` address a REGION delimited by comment
|
|
83
|
+
// markers (`<!--s:id-->…<!--/s:id-->`). Keyed-list ops address element
|
|
84
|
+
// children by index, sequentially: each op sees the DOM as left by the
|
|
85
|
+
// previous one (see wire/index.ts).
|
|
86
|
+
const region = regionFor(patch.target.id)
|
|
87
|
+
if (!region) {
|
|
88
|
+
warnMissing(patch.target)
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
const { start, end } = region
|
|
92
|
+
if (patch.op === 'html') {
|
|
93
|
+
replaceRegion(start, end, patch.value)
|
|
94
|
+
return start.parentElement
|
|
95
|
+
}
|
|
96
|
+
if (patch.op === 'insert') {
|
|
97
|
+
insertAt(start, end, patch.index, patch.value)
|
|
98
|
+
return elementAt(start, end, patch.index)
|
|
99
|
+
}
|
|
100
|
+
if (patch.op === 'remove') {
|
|
101
|
+
removeAt(start, end, patch.index)
|
|
102
|
+
return start.parentElement
|
|
62
103
|
}
|
|
104
|
+
moveWithin(start, end, patch.from, patch.to)
|
|
105
|
+
return elementAt(start, end, patch.to)
|
|
63
106
|
}
|
|
64
107
|
|
|
65
108
|
export function applyDirectives(directives: Directive[]): void {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment-marker region boundaries. A reactive region (`each`/`when`/`match`/
|
|
3
|
+
* `defer`) is delimited on the client by a pair of HTML comments
|
|
4
|
+
* `<!--s:ID-->…<!--/s:ID-->` instead of a wrapper element, so the framework
|
|
5
|
+
* injects no node into the user's authored DOM — comments are valid anywhere
|
|
6
|
+
* (including in `<table>`/`<tbody>` where a `<span>` is foster-parented out) and
|
|
7
|
+
* do not participate in the CSS sibling/child selector graph.
|
|
8
|
+
*
|
|
9
|
+
* A region is the run of nodes strictly between its two markers, sharing the
|
|
10
|
+
* markers' parent. Keyed-list ops (insert/remove/move) address the region's
|
|
11
|
+
* ELEMENT children by index — the same contract as the old wrapper's
|
|
12
|
+
* `element.children[i]`, just counted between markers instead of inside a span.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const startTag = (id: string): string => `s:${id}`
|
|
16
|
+
export const endTag = (id: string): string => `/s:${id}`
|
|
17
|
+
|
|
18
|
+
/** Parse a trusted HTML fragment through a `<template>`, whose "in template"
|
|
19
|
+
* insertion mode preserves table-context children (`<tr>`, `<td>`, `<option>`)
|
|
20
|
+
* that a plain element's `innerHTML` would drop. */
|
|
21
|
+
export function parseFragment(html: string): DocumentFragment {
|
|
22
|
+
const tpl = document.createElement('template')
|
|
23
|
+
tpl.innerHTML = html
|
|
24
|
+
return tpl.content
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Locate a region's marker pair by scanning comment nodes under `root`.
|
|
28
|
+
* (Prototype: linear scan. Production adds a hydration-time index keyed by id.) */
|
|
29
|
+
export function findRegion(root: Node, id: string): { start: Comment; end: Comment } | null {
|
|
30
|
+
const s = startTag(id)
|
|
31
|
+
const e = endTag(id)
|
|
32
|
+
let start: Comment | null = null
|
|
33
|
+
const walk = (node: Node): Comment | null => {
|
|
34
|
+
for (let n = node.firstChild; n; n = n.nextSibling) {
|
|
35
|
+
if (n.nodeType === 8 /* Comment */) {
|
|
36
|
+
const data = (n as Comment).data
|
|
37
|
+
if (data === s) start = n as Comment
|
|
38
|
+
else if (data === e && start) return n as Comment
|
|
39
|
+
}
|
|
40
|
+
const found = walk(n)
|
|
41
|
+
if (found) return found
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
const end = walk(root)
|
|
46
|
+
return start && end ? { start, end } : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The i-th ELEMENT between the markers (mirrors `element.children[i]`); text
|
|
50
|
+
* and nested-region comment nodes are skipped. Null when out of range. */
|
|
51
|
+
export function elementAt(start: Comment, end: Comment, index: number): Element | null {
|
|
52
|
+
let i = 0
|
|
53
|
+
for (let n = start.nextSibling; n && n !== end; n = n.nextSibling) {
|
|
54
|
+
if (n.nodeType === 1 /* Element */) {
|
|
55
|
+
if (i === index) return n as Element
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Count element children in the region. */
|
|
63
|
+
export function elementCount(start: Comment, end: Comment): number {
|
|
64
|
+
let i = 0
|
|
65
|
+
for (let n = start.nextSibling; n && n !== end; n = n.nextSibling) {
|
|
66
|
+
if (n.nodeType === 1) i++
|
|
67
|
+
}
|
|
68
|
+
return i
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Insert `html` so its (single-root) element becomes element-index `index`
|
|
72
|
+
* within the region — before the element currently there, or before the end
|
|
73
|
+
* marker when appending. */
|
|
74
|
+
export function insertAt(start: Comment, end: Comment, index: number, html: string): void {
|
|
75
|
+
const ref = elementAt(start, end, index) ?? end
|
|
76
|
+
ref.parentNode?.insertBefore(parseFragment(html), ref)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove the element at `index`. */
|
|
80
|
+
export function removeAt(start: Comment, end: Comment, index: number): void {
|
|
81
|
+
elementAt(start, end, index)?.remove()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Move the element from `from` to `to`. `to` is the index in the list AFTER the
|
|
85
|
+
* removal — matching the wire contract's sequential-op replay. */
|
|
86
|
+
export function moveWithin(start: Comment, end: Comment, from: number, to: number): void {
|
|
87
|
+
const node = elementAt(start, end, from)
|
|
88
|
+
if (!node) return
|
|
89
|
+
node.remove()
|
|
90
|
+
const ref = elementAt(start, end, to) ?? end
|
|
91
|
+
ref.parentNode?.insertBefore(node, ref)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Replace the whole region body (the `html` op) — remove everything between the
|
|
95
|
+
* markers, then insert the freshly-parsed content before the end marker. */
|
|
96
|
+
export function replaceRegion(start: Comment, end: Comment, html: string): void {
|
|
97
|
+
for (let n = start.nextSibling; n && n !== end; ) {
|
|
98
|
+
const next = n.nextSibling
|
|
99
|
+
n.remove()
|
|
100
|
+
n = next
|
|
101
|
+
}
|
|
102
|
+
end.parentNode?.insertBefore(parseFragment(html), end)
|
|
103
|
+
}
|