@statorjs/stator 1.0.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
@@ -0,0 +1,287 @@
1
+ import { renderBranchBody } from '../template/conditional.ts'
2
+ import { coerceKeys, renderKeyedItem, renderListBody } from '../template/each.ts'
3
+ import type { Patch } from '../wire/index.ts'
4
+ import {
5
+ keyedScopePrefix,
6
+ keyToken,
7
+ type RenderState,
8
+ runInRender,
9
+ unregisterBindingsForScope,
10
+ } from './render-context.ts'
11
+ import type { SessionRuntime } from './session-runtime.ts'
12
+
13
+ /** A patch paired with its source slot id — the slot in the binding tree
14
+ * this patch came from. Used internally for scope-subsumption. The wire
15
+ * shape drops this field. */
16
+ type PendingPatch = { patch: Patch; sourceSlot: string }
17
+
18
+ /**
19
+ * Walk every binding tied to `machineName`, re-evaluate its selector against
20
+ * the current machine snapshot, and produce patches for any binding whose
21
+ * value changed.
22
+ *
23
+ * Scope subsumption: when a list (`each`) or branch (`when`/`match`) body
24
+ * is replaced via an 'html' patch, the new body's HTML already contains the
25
+ * fresh values of every descendant binding. Any text/attr patches whose
26
+ * source slots live inside that scope are therefore redundant and would
27
+ * either no-op (descendant slot IDs no longer exist in the DOM) or, worse,
28
+ * target unrelated elements once finer-grained patches arrive. The filter
29
+ * at the bottom of this function drops them explicitly.
30
+ */
31
+ export function recompute(
32
+ state: RenderState,
33
+ machineName: string,
34
+ runtime: SessionRuntime,
35
+ ): Patch[] {
36
+ const proxy = runtime.proxyFor(machineName)
37
+ if (!proxy) return []
38
+
39
+ const pending: PendingPatch[] = []
40
+ // Keyed-list scopes torn down this pass (removed rows) — any patch already
41
+ // emitted for a slot inside one targets DOM the remove op deletes.
42
+ const removedScopes: string[] = []
43
+ const slotIds = state.byMachine.get(machineName)
44
+ if (!slotIds) return []
45
+
46
+ for (const slotId of [...slotIds]) {
47
+ const binding = state.bindings.get(slotId)
48
+ if (!binding) continue
49
+
50
+ const newValue = binding.selector(proxy)
51
+
52
+ if (binding.kind === 'text') {
53
+ if (!valuesEqual(newValue, binding.lastValue)) {
54
+ pending.push({
55
+ patch: {
56
+ target: { kind: 'slot', id: slotId },
57
+ op: 'text',
58
+ value: stringify(newValue),
59
+ },
60
+ sourceSlot: slotId,
61
+ })
62
+ binding.lastValue = newValue
63
+ }
64
+ } else if (binding.kind === 'attr') {
65
+ if (!valuesEqual(newValue, binding.lastValue)) {
66
+ pending.push({
67
+ patch: {
68
+ target: { kind: 'element', id: binding.parentId },
69
+ op: 'attr',
70
+ name: binding.attrName,
71
+ value: attrWireValue(newValue),
72
+ },
73
+ sourceSlot: slotId,
74
+ })
75
+ binding.lastValue = newValue
76
+ }
77
+ } else if (binding.kind === 'list') {
78
+ const newArray = newValue as readonly unknown[]
79
+ const oldArray = binding.lastValue as readonly unknown[]
80
+ if (!arrayShallowEqual(newArray, oldArray)) {
81
+ const fn = binding.itemRenderer
82
+ const newInner = runInRender(state, () => renderListBody(state, slotId, newArray, fn))
83
+ pending.push({
84
+ patch: {
85
+ target: { kind: 'slot', id: slotId },
86
+ op: 'html',
87
+ value: newInner,
88
+ },
89
+ sourceSlot: slotId,
90
+ })
91
+ binding.lastValue = newArray
92
+ }
93
+ } else if (binding.kind === 'list-keyed') {
94
+ // Keyed diff: shape changes become per-item insert/remove/move ops from
95
+ // a replay simulation (`work` mirrors what the client's DOM will look
96
+ // like after each emitted op — see the wire contract). Content inside
97
+ // retained items updates through the items' own nested bindings; the
98
+ // keyed path never re-renders a retained row.
99
+ const newArray = newValue as readonly unknown[]
100
+ const newKeys = coerceKeys(newArray, binding.keyFn, slotId)
101
+ const oldKeys = binding.lastKeys
102
+ if (!stringArraysEqual(newKeys, oldKeys)) {
103
+ const target = { kind: 'slot', id: slotId } as const
104
+ const newKeySet = new Set(newKeys)
105
+ const work = [...oldKeys]
106
+ // Removals right-to-left so earlier indices stay valid within the pass.
107
+ for (let i = work.length - 1; i >= 0; i--) {
108
+ const key = work[i]!
109
+ if (newKeySet.has(key)) continue
110
+ pending.push({ patch: { target, op: 'remove', index: i }, sourceSlot: slotId })
111
+ const scope = keyedScopePrefix(slotId, keyToken(key))
112
+ removedScopes.push(`${scope}:`)
113
+ unregisterBindingsForScope(state, scope)
114
+ work.splice(i, 1)
115
+ }
116
+ // Settle each position left-to-right: an existing key moves up, a new
117
+ // key renders under its key scope and inserts.
118
+ for (let i = 0; i < newKeys.length; i++) {
119
+ const key = newKeys[i]!
120
+ if (work[i] === key) continue
121
+ const from = work.indexOf(key, i + 1)
122
+ if (from !== -1) {
123
+ pending.push({ patch: { target, op: 'move', from, to: i }, sourceSlot: slotId })
124
+ work.splice(from, 1)
125
+ work.splice(i, 0, key)
126
+ } else {
127
+ const itemHtml = runInRender(state, () =>
128
+ renderKeyedItem(state, slotId, newArray[i], i, key, binding.itemRenderer),
129
+ )
130
+ pending.push({
131
+ patch: { target, op: 'insert', index: i, value: itemHtml },
132
+ sourceSlot: slotId,
133
+ })
134
+ work.splice(i, 0, key)
135
+ }
136
+ }
137
+ binding.lastKeys = newKeys
138
+ }
139
+ binding.lastValue = newArray
140
+ } else if (binding.kind === 'branch') {
141
+ const newKey = binding.branchKeyFn(newValue)
142
+ if (!Object.is(newKey, binding.lastBranchKey)) {
143
+ const renderer = binding.branchRenderFn(newValue)
144
+ const newInner = runInRender(state, () => renderBranchBody(state, slotId, newKey, renderer))
145
+ pending.push({
146
+ patch: {
147
+ target: { kind: 'slot', id: slotId },
148
+ op: 'html',
149
+ value: newInner,
150
+ },
151
+ sourceSlot: slotId,
152
+ })
153
+ binding.lastBranchKey = newKey
154
+ binding.lastValue = newValue
155
+ }
156
+ }
157
+ }
158
+
159
+ return subsumeScopes(pending, removedScopes)
160
+ }
161
+
162
+ /** Positional string-array equality — the keyed diff's "did anything change". */
163
+ function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
164
+ if (a.length !== b.length) return false
165
+ for (let i = 0; i < a.length; i++) {
166
+ if (a[i] !== b[i]) return false
167
+ }
168
+ return true
169
+ }
170
+
171
+ /**
172
+ * Drop any patch whose source slot is a descendant of an 'html'-op patch's
173
+ * scope, or of a keyed-list scope removed this pass. Descendants share the
174
+ * prefix `<scope-slot-id>:` per our slot-id scheme (see allocSlotId /
175
+ * pushListScope / pushKeyedScope in render-context.ts).
176
+ */
177
+ function subsumeScopes(pending: PendingPatch[], removedScopes: string[]): Patch[] {
178
+ const scopePrefixes: string[] = [...removedScopes]
179
+ for (const p of pending) {
180
+ if (p.patch.op === 'html') scopePrefixes.push(`${p.sourceSlot}:`)
181
+ }
182
+ if (scopePrefixes.length === 0) return pending.map((p) => p.patch)
183
+
184
+ const out: Patch[] = []
185
+ for (const p of pending) {
186
+ let inside = false
187
+ for (const prefix of scopePrefixes) {
188
+ // The html patch itself isn't a descendant of its own scope — skip
189
+ // the exact match.
190
+ if (`${p.sourceSlot}:` === prefix) continue
191
+ if (p.sourceSlot.startsWith(prefix)) {
192
+ inside = true
193
+ break
194
+ }
195
+ }
196
+ if (!inside) out.push(p.patch)
197
+ }
198
+ return out
199
+ }
200
+
201
+ function valuesEqual(a: unknown, b: unknown): boolean {
202
+ if (Object.is(a, b)) return true
203
+ if (a == null || b == null) return false
204
+ if (typeof a !== typeof b) return false
205
+ if (typeof a !== 'object') return false
206
+ return JSON.stringify(a) === JSON.stringify(b)
207
+ }
208
+
209
+ function arrayShallowEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
210
+ if (a === b) return true
211
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
212
+ if (a.length !== b.length) return false
213
+ for (let i = 0; i < a.length; i++) {
214
+ if (a[i] !== b[i]) return false
215
+ }
216
+ return true
217
+ }
218
+
219
+ function stringify(v: unknown): string {
220
+ if (v == null) return ''
221
+ if (typeof v === 'string') return v
222
+ return String(v)
223
+ }
224
+
225
+ /** Baseline invalidation marker — never equal to any real value or key. */
226
+ const NEVER_SYNCED = Symbol('stator.never-synced')
227
+
228
+ /**
229
+ * Initial-sync patches for a freshly opened live connection.
230
+ *
231
+ * The connection's diff baseline is built by re-rendering the route AT
232
+ * CONNECT time, but the browser's DOM was rendered at page-GET time — and
233
+ * any state change in the gap (an effect settling during navigation is the
234
+ * classic case) would otherwise be permanently invisible: future diffs run
235
+ * against a baseline the DOM never received. This forces every binding to
236
+ * emit its CURRENT value once, so the DOM converges to the baseline; the
237
+ * patches are idempotent, so an already-fresh page just repaints in place.
238
+ *
239
+ * Keyed lists are reset wholesale (one `html` patch of freshly rendered
240
+ * rows) — positional insert/remove/move ops assume a known DOM row set,
241
+ * which is exactly what a possibly-stale page can't offer.
242
+ */
243
+ export function initialSyncPatches(state: RenderState, runtime: SessionRuntime): Patch[] {
244
+ const patches: Patch[] = []
245
+
246
+ // Keyed lists first: re-render every row under its key scope (replacing
247
+ // the scope's binding registrations) and emit one wholesale reset.
248
+ for (const [slotId, binding] of [...state.bindings]) {
249
+ if (binding.kind !== 'list-keyed') continue
250
+ const items = (binding.lastValue ?? []) as readonly unknown[]
251
+ const keys = binding.lastKeys
252
+ for (const key of keys) {
253
+ unregisterBindingsForScope(state, keyedScopePrefix(slotId, keyToken(key)))
254
+ }
255
+ let rowsHtml = ''
256
+ for (let i = 0; i < items.length; i++) {
257
+ rowsHtml += runInRender(state, () =>
258
+ renderKeyedItem(state, slotId, items[i], i, keys[i]!, binding.itemRenderer),
259
+ )
260
+ }
261
+ patches.push({ target: { kind: 'slot', id: slotId }, op: 'html', value: rowsHtml })
262
+ }
263
+
264
+ // Everything else: invalidate baselines and let the normal recompute paths
265
+ // re-emit at current values (branch bodies re-render through the same code
266
+ // that handles real branch flips).
267
+ for (const binding of state.bindings.values()) {
268
+ if (binding.kind === 'text' || binding.kind === 'attr' || binding.kind === 'list') {
269
+ binding.lastValue = NEVER_SYNCED
270
+ } else if (binding.kind === 'branch') {
271
+ binding.lastBranchKey = NEVER_SYNCED
272
+ }
273
+ }
274
+ for (const machineName of state.byMachine.keys()) {
275
+ patches.push(...recompute(state, machineName, runtime))
276
+ }
277
+ return patches
278
+ }
279
+
280
+ /** Attr-binding wire semantics, mirroring the render side: false/null/
281
+ * undefined → null (attribute removed), true → '' (present, empty),
282
+ * anything else stringified. */
283
+ function attrWireValue(v: unknown): string | null {
284
+ if (v === false || v === null || v === undefined) return null
285
+ if (v === true) return ''
286
+ return stringify(v)
287
+ }
@@ -0,0 +1,116 @@
1
+ import Redis, { type RedisOptions } from 'ioredis'
2
+ import type { AppStore } from './app-store.ts'
3
+ import type { Store } from './store.ts'
4
+
5
+ /**
6
+ * Redis-backed Store. One hash per session, machine names as fields:
7
+ *
8
+ * stator:session:<sid> (hash)
9
+ * CartMachine → JSON snapshot
10
+ * CheckoutMachine → JSON snapshot
11
+ *
12
+ * TTL is per-session: HSET + EXPIRE are pipelined on every set, so the
13
+ * whole hash's expiry refreshes every time the user interacts with any of
14
+ * their machines. Idle sessions expire as a unit; active users never see
15
+ * a machine's state drop while other machines stay alive.
16
+ *
17
+ * Works with Upstash, Fly Redis, hosted Redis Cloud, or any redis:// /
18
+ * rediss:// URL — ioredis handles TLS automatically when the scheme is
19
+ * rediss://. Pass the URL directly or a fully-formed RedisOptions object.
20
+ */
21
+ export class RedisStore implements Store {
22
+ private client: Redis
23
+ private keyPrefix: string
24
+
25
+ constructor(connection: string | RedisOptions, keyPrefix = 'stator:session') {
26
+ this.client = typeof connection === 'string' ? new Redis(connection) : new Redis(connection)
27
+ this.keyPrefix = keyPrefix
28
+ }
29
+
30
+ private key(sid: string): string {
31
+ return `${this.keyPrefix}:${sid}`
32
+ }
33
+
34
+ async get(sid: string, name: string): Promise<unknown | null> {
35
+ const raw = await this.client.hget(this.key(sid), name)
36
+ if (raw === null) return null
37
+ try {
38
+ return JSON.parse(raw)
39
+ } catch {
40
+ // Corrupted entry — treat as missing rather than crash. Logged
41
+ // upstream if the caller cares.
42
+ return null
43
+ }
44
+ }
45
+
46
+ async set(
47
+ sid: string,
48
+ name: string,
49
+ snapshot: unknown,
50
+ opts?: { ttlSeconds?: number },
51
+ ): Promise<void> {
52
+ const key = this.key(sid)
53
+ const payload = JSON.stringify(snapshot)
54
+ if (opts?.ttlSeconds) {
55
+ // Pipeline HSET + EXPIRE so the whole hash's TTL refreshes atomically.
56
+ // Any field write extends the session's idle window — see Store
57
+ // interface docs for the per-session TTL semantic.
58
+ await this.client.multi().hset(key, name, payload).expire(key, opts.ttlSeconds).exec()
59
+ } else {
60
+ await this.client.hset(key, name, payload)
61
+ }
62
+ }
63
+
64
+ async has(sid: string, name: string): Promise<boolean> {
65
+ return (await this.client.hexists(this.key(sid), name)) === 1
66
+ }
67
+
68
+ async deleteSession(sid: string): Promise<void> {
69
+ await this.client.del(this.key(sid))
70
+ }
71
+
72
+ /** Close the connection. Call on graceful shutdown. */
73
+ async close(): Promise<void> {
74
+ await this.client.quit()
75
+ }
76
+
77
+ /** Underlying ioredis client, exposed for health checks / introspection. */
78
+ get raw(): Redis {
79
+ return this.client
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Redis-backed AppStore: one plain key per app machine name, JSON snapshot,
85
+ * NO TTL — app state is process-equivalent persistence (see app-store.ts).
86
+ * Single-writer assumption; multi-replica coordination is out of scope.
87
+ */
88
+ export class RedisAppStore implements AppStore {
89
+ private client: Redis
90
+ private keyPrefix: string
91
+
92
+ constructor(connection: string | RedisOptions, keyPrefix = 'stator:app') {
93
+ this.client = typeof connection === 'string' ? new Redis(connection) : new Redis(connection)
94
+ this.keyPrefix = keyPrefix
95
+ }
96
+
97
+ async loadAppMachine(name: string): Promise<unknown | null> {
98
+ const raw = await this.client.get(`${this.keyPrefix}:${name}`)
99
+ if (raw === null) return null
100
+ try {
101
+ return JSON.parse(raw)
102
+ } catch {
103
+ // Corrupted blob — treat as missing; the boot path logs and starts fresh.
104
+ return null
105
+ }
106
+ }
107
+
108
+ async saveAppMachine(name: string, snapshot: unknown): Promise<void> {
109
+ await this.client.set(`${this.keyPrefix}:${name}`, JSON.stringify(snapshot))
110
+ }
111
+
112
+ /** Close the connection. Call on graceful shutdown. */
113
+ async close(): Promise<void> {
114
+ await this.client.quit()
115
+ }
116
+ }
@@ -0,0 +1,228 @@
1
+ import type { HtmlFragment } from '../template/types.ts'
2
+
3
+ export type SlotId = string
4
+ export type MachineName = string
5
+ export type SessionId = string
6
+ export type ElementId = string
7
+
8
+ export type BindingKind = Binding['kind']
9
+
10
+ /** A binding-stored selector with its instance type erased. `any` (not
11
+ * `unknown`) is deliberate: parameters are contravariant, so a concrete
12
+ * `(instance: CartProxy) => T` is only assignable into an `any`-typed slot. */
13
+ // biome-ignore lint/suspicious/noExplicitAny: contravariant parameter — see doc comment
14
+ export type ErasedSelector = (instance: any) => unknown
15
+
16
+ /** A list binding's per-item renderer, item type erased (same variance argument). */
17
+ // biome-ignore lint/suspicious/noExplicitAny: contravariant parameter — see doc comment
18
+ export type ErasedItemRenderer = (item: any, index: number) => HtmlFragment
19
+
20
+ interface BindingBase {
21
+ slotId: SlotId
22
+ machineName: MachineName
23
+ selector: ErasedSelector
24
+ lastValue: unknown
25
+ }
26
+
27
+ export interface TextBinding extends BindingBase {
28
+ kind: 'text'
29
+ }
30
+
31
+ export interface AttrBinding extends BindingBase {
32
+ kind: 'attr'
33
+ attrName: string
34
+ parentId: ElementId
35
+ }
36
+
37
+ export interface ListBinding extends BindingBase {
38
+ kind: 'list'
39
+ /** Re-invoked per item when the list re-renders. */
40
+ itemRenderer: ErasedItemRenderer
41
+ }
42
+
43
+ /** A keyed list's key selector, item type erased (same variance argument as
44
+ * ErasedSelector). */
45
+ // biome-ignore lint/suspicious/noExplicitAny: contravariant parameter — see ErasedSelector
46
+ export type ErasedKeyFn = (item: any) => unknown
47
+
48
+ export interface KeyedListBinding extends BindingBase {
49
+ kind: 'list-keyed'
50
+ /** Re-invoked per item for inserts and initial render. */
51
+ itemRenderer: ErasedItemRenderer
52
+ /** Derives each item's identity key (validated to string | number). */
53
+ keyFn: ErasedKeyFn
54
+ /** The previous render's keys, in order — the diff baseline. */
55
+ lastKeys: string[]
56
+ }
57
+
58
+ export interface BranchBinding extends BindingBase {
59
+ kind: 'branch'
60
+ /** The selector value reduced to a stable key — re-render only fires when
61
+ * the key changes (so `when` doesn't re-render on every truthy-to-truthy
62
+ * value transition). */
63
+ branchKeyFn: (value: unknown) => unknown
64
+ /** Returns the renderer for a given selector value, or null when nothing
65
+ * should be rendered. */
66
+ branchRenderFn: (value: unknown) => (() => HtmlFragment) | null
67
+ /** The last computed key — distinct from lastValue, which holds the raw
68
+ * selector result. */
69
+ lastBranchKey: unknown
70
+ }
71
+
72
+ /** Discriminated on `kind`, so per-kind fields are required where they apply —
73
+ * recompute narrows on the discriminant instead of asserting optionals. */
74
+ export type Binding = TextBinding | AttrBinding | ListBinding | KeyedListBinding | BranchBinding
75
+
76
+ interface Scope {
77
+ prefix: string
78
+ counter: number
79
+ }
80
+
81
+ export interface RenderState {
82
+ sessionId: SessionId
83
+ routeKey: string
84
+ bindings: Map<SlotId, Binding>
85
+ byMachine: Map<MachineName, Set<SlotId>>
86
+ scopeStack: Scope[]
87
+ elementIdCounter: number
88
+ }
89
+
90
+ export function createRenderState(sessionId: SessionId, routeKey: string): RenderState {
91
+ return {
92
+ sessionId,
93
+ routeKey,
94
+ bindings: new Map(),
95
+ byMachine: new Map(),
96
+ scopeStack: [{ prefix: '', counter: 0 }],
97
+ elementIdCounter: 0,
98
+ }
99
+ }
100
+
101
+ let currentRenderState: RenderState | null = null
102
+
103
+ export function getCurrentRenderState(): RenderState | null {
104
+ return currentRenderState
105
+ }
106
+
107
+ export function requireCurrentRenderState(): RenderState {
108
+ if (!currentRenderState) {
109
+ throw new Error('stator: must be called during a template render (inside runInRender)')
110
+ }
111
+ return currentRenderState
112
+ }
113
+
114
+ export function runInRender<T>(state: RenderState, fn: () => T): T {
115
+ const prev = currentRenderState
116
+ currentRenderState = state
117
+ try {
118
+ return fn()
119
+ } finally {
120
+ currentRenderState = prev
121
+ }
122
+ }
123
+
124
+ function topScope(state: RenderState): Scope {
125
+ const s = state.scopeStack[state.scopeStack.length - 1]
126
+ if (!s) throw new Error('stator: render scope stack is empty')
127
+ return s
128
+ }
129
+
130
+ export function allocSlotId(state: RenderState): SlotId {
131
+ const scope = topScope(state)
132
+ const id = `${scope.prefix ? `${scope.prefix}:` : ''}s${scope.counter++}`
133
+ return id
134
+ }
135
+
136
+ export function allocElementId(state: RenderState): ElementId {
137
+ return `e${state.elementIdCounter++}`
138
+ }
139
+
140
+ export function pushListScope(state: RenderState, listSlotId: SlotId, iterIndex: number): void {
141
+ state.scopeStack.push({ prefix: `${listSlotId}:i${iterIndex}`, counter: 0 })
142
+ }
143
+
144
+ /**
145
+ * Keyed-list scope: descendant slot ids are derived from the item's *key*, not
146
+ * its position — so a row's inner slot ids survive reordering, and a patch can
147
+ * address "the row for p1, wherever it is now". This is the item-identity
148
+ * primitive keyed `each` is built on.
149
+ */
150
+ export function pushKeyedScope(state: RenderState, listSlotId: SlotId, token: string): void {
151
+ state.scopeStack.push({ prefix: `${listSlotId}:k${token}`, counter: 0 })
152
+ }
153
+
154
+ /** Keyed scope prefix for a key token (shared by render and recompute). */
155
+ export function keyedScopePrefix(listSlotId: SlotId, token: string): string {
156
+ return `${listSlotId}:k${token}`
157
+ }
158
+
159
+ /**
160
+ * Encode an item key into a slot-id-safe token. Injective: only `[A-Za-z0-9-]`
161
+ * pass through (NOT `_`, which is the escape character); everything else
162
+ * becomes `_<codepoint hex>`. Keys land in `data-slot` attributes and CSS
163
+ * attribute selectors, so the charset must be quote- and bracket-free.
164
+ */
165
+ export function keyToken(key: string): string {
166
+ return key.replace(/[^A-Za-z0-9-]/gu, (c) => `_${c.codePointAt(0)!.toString(16)}`)
167
+ }
168
+
169
+ export function popListScope(state: RenderState): void {
170
+ if (state.scopeStack.length <= 1) {
171
+ throw new Error('stator: cannot pop root render scope')
172
+ }
173
+ state.scopeStack.pop()
174
+ }
175
+
176
+ export function registerBinding(state: RenderState, binding: Binding): void {
177
+ state.bindings.set(binding.slotId, binding)
178
+ let slotIds = state.byMachine.get(binding.machineName)
179
+ if (!slotIds) {
180
+ slotIds = new Set()
181
+ state.byMachine.set(binding.machineName, slotIds)
182
+ }
183
+ slotIds.add(binding.slotId)
184
+ }
185
+
186
+ /**
187
+ * Remove every binding whose slot id is a *descendant* of the given scope prefix
188
+ * (i.e. starts with `prefix:`). The prefix itself is not removed — useful when
189
+ * re-rendering a list whose own list-binding must persist.
190
+ */
191
+ export function unregisterBindingsForScope(state: RenderState, scopePrefix: string): void {
192
+ const toRemove: SlotId[] = []
193
+ for (const slotId of state.bindings.keys()) {
194
+ if (slotId.startsWith(`${scopePrefix}:`)) {
195
+ toRemove.push(slotId)
196
+ }
197
+ }
198
+ for (const slotId of toRemove) {
199
+ const b = state.bindings.get(slotId)!
200
+ state.bindings.delete(slotId)
201
+ const slotIds = state.byMachine.get(b.machineName)
202
+ if (slotIds) {
203
+ slotIds.delete(slotId)
204
+ if (slotIds.size === 0) state.byMachine.delete(b.machineName)
205
+ }
206
+ }
207
+ }
208
+
209
+ export type EventDescriptor = {
210
+ readonly __isEventDescriptor: true
211
+ machine: string
212
+ event: { type: string; [k: string]: unknown }
213
+ }
214
+
215
+ export function createEventDescriptor(
216
+ machine: string,
217
+ event: { type: string; [k: string]: unknown },
218
+ ): EventDescriptor {
219
+ return { __isEventDescriptor: true, machine, event }
220
+ }
221
+
222
+ export function isEventDescriptor(v: unknown): v is EventDescriptor {
223
+ return (
224
+ typeof v === 'object' &&
225
+ v !== null &&
226
+ (v as Record<string, unknown>).__isEventDescriptor === true
227
+ )
228
+ }