@statorjs/stator 1.7.1 → 1.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.7.1",
3
+ "version": "1.9.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.7.1"
91
+ "@statorjs/stator": "1.9.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
  }
@@ -33,6 +33,40 @@ export function bind(
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Text-slot binding for a client-machine `read()` in text position. The
38
+ * compiler renders an `<!--sN-->` comment where the expression sat; this finds
39
+ * every occurrence of the marker under `root` (a marker inside a `.map()`
40
+ * repeats per row), materializes one text node after each, and binds them all
41
+ * to the thunk through the normal `bind()` diff loop. Returns a disposer.
42
+ */
43
+ export function bindSlot(
44
+ root: Node,
45
+ marker: string,
46
+ deps: ClientInstanceBase[],
47
+ compute: () => unknown,
48
+ ): () => void {
49
+ // Manual walk, not TreeWalker: happy-dom's walker prunes descent at
50
+ // non-matching nodes, so a SHOW_COMMENT filter never reaches nested comments
51
+ // — and the manual loop is as small as the workaround discussion.
52
+ const texts: Text[] = []
53
+ const stack: Node[] = [root]
54
+ while (stack.length > 0) {
55
+ const n = stack.pop() as Node
56
+ if (n.nodeType === 8 /* COMMENT_NODE */ && (n as Comment).data === marker) {
57
+ const t = document.createTextNode('')
58
+ n.parentNode?.insertBefore(t, n.nextSibling)
59
+ texts.push(t)
60
+ } else {
61
+ for (let c = n.firstChild; c; c = c.nextSibling) stack.push(c)
62
+ }
63
+ }
64
+ return bind(deps, compute, (v) => {
65
+ const s = v == null ? '' : String(v)
66
+ for (const t of texts) if (t.data !== s) t.data = s
67
+ })
68
+ }
69
+
36
70
  /**
37
71
  * Imperative reactivity escape hatch: run `fn` now and again whenever any
38
72
  * dependency changes (no diffing — `fn` owns its own DOM writes). The lower-
@@ -4,10 +4,25 @@
4
4
  * diff baseline WITHOUT re-sending patches the POST response already
5
5
  * delivered (text/attr dupes are invisible; keyed inserts are not).
6
6
  */
7
- export const clientId: string =
8
- typeof crypto !== 'undefined' && 'randomUUID' in crypto
7
+ function freshId(): string {
8
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto
9
9
  ? crypto.randomUUID()
10
10
  : `c${Math.random().toString(36).slice(2)}`
11
+ }
12
+
13
+ /** One identity per PAGE, not per bundle. The page runtime (a classic-script
14
+ * bundle) and island modules (the Vite/asset graph) each carry their own copy
15
+ * of this module — a plain module-scoped id would mint one per copy, the SSE
16
+ * connection and an island's dispatch would disagree, and fan-out's
17
+ * originator skip would never fire (double-applying keyed inserts on the
18
+ * dispatching page). The window slot is the page-load singleton both copies
19
+ * converge on. */
20
+ export const clientId: string = (() => {
21
+ if (typeof window === 'undefined') return freshId()
22
+ const w = window as { __statorClientId?: string }
23
+ w.__statorClientId ??= freshId()
24
+ return w.__statorClientId
25
+ })()
11
26
 
12
27
  /** Per-dispatch idempotency key: the server replays a duplicate POST's cached
13
28
  * response instead of re-applying it, which is what makes retrying a lost
@@ -4,7 +4,7 @@
4
4
  * island needs; these are also the symbols an author references directly.
5
5
  */
6
6
 
7
- export { bind, effect } from './bind.ts'
7
+ export { bind, bindSlot, effect } from './bind.ts'
8
8
  export type { DispatchResult } from './dispatch.ts'
9
9
  export { dispatch } from './dispatch.ts'
10
10
  export { defineElement, StatorElement } from './element.ts'
@@ -22,9 +22,30 @@ import { defineMachine, type MachineDef } from '../engine/index.ts'
22
22
  * is just a label and need not be unique.
23
23
  */
24
24
 
25
- /** Client events are structurally loose — the terse form declares no union. */
25
+ /** The loose client event shape — the fallback when a machine declares no
26
+ * union and has no `on` map to derive one from. */
26
27
  export type ClientEvent = { type: string; [k: string]: unknown }
27
28
 
29
+ /** Event-name union derived from an `on` map: the NAMES are typed (a `send`
30
+ * typo is a compile error), payloads stay structurally open. The
31
+ * zero-ceremony tier of client event typing. */
32
+ export type DerivedEvents<O> = {
33
+ [K in keyof O & string]: { type: K } & Record<string, unknown>
34
+ }[keyof O & string]
35
+
36
+ /** The `on` map for a machine with a DECLARED event union: keys are the
37
+ * declared event names, and each handler sees its event narrowed — no
38
+ * annotations needed. Mirrors the server `defineMachine` experience. */
39
+ export type TypedClientOnMap<C, E extends { type: string }> = {
40
+ [K in E['type']]?:
41
+ | ((ctx: C, ev: Extract<E, { type: K }>) => void)
42
+ | {
43
+ when?: (ctx: C, ev: Extract<E, { type: K }>) => boolean
44
+ do?: (ctx: C, ev: Extract<E, { type: K }>) => void
45
+ emit?: string | string[]
46
+ }
47
+ }
48
+
28
49
  interface ClientTransitionObject<C> {
29
50
  when?: (ctx: C, ev: ClientEvent) => boolean
30
51
  do?: (ctx: C, ev: ClientEvent) => void
@@ -57,17 +78,41 @@ export interface LegacyMachineConfig {
57
78
 
58
79
  const RESERVED = new Set(['name', 'on', 'select'])
59
80
 
60
- // Data-only: no behavior, no selectors.
81
+ // Data-only: no behavior. Events stay structurally LOOSE (ClientEvent) on
82
+ // purpose: data-only machines are the `bind:value` / hand-written `@set`
83
+ // carriers until 2.0 removes `@set` — tightening them now would break that
84
+ // still-supported surface. Tighten to `never` when `@set` goes.
61
85
  export function machine<C extends Record<string, unknown>>(
62
- context: C & { on?: never; select?: never; name?: never },
86
+ context: C & { on?: never; select?: never; name?: never; events?: never },
63
87
  ): MachineDef<C, ClientEvent, 'active', Record<string, never>>
64
- // Data + behavior: handlers and selectors typed against the context.
88
+ // Derived union — no `events:` declared: event NAMES come from the `on` keys
89
+ // (typo-safe send), payloads stay open. Zero authoring change. Ordered BEFORE
90
+ // the declared overload: `events?: never` keeps declared calls falling through.
91
+ export function machine<
92
+ C extends Record<string, unknown>,
93
+ O extends Record<string, ClientTransition<C>>,
94
+ S extends Record<string, (ctx: C) => unknown>,
95
+ >(
96
+ context: C & { on?: never; select?: never; name?: never; events?: never },
97
+ behavior: { name?: string; events?: never; on: O; select?: S },
98
+ ): MachineDef<C, DerivedEvents<O>, 'active', S>
99
+ // Declared union (`events: {} as E`) — mirrors defineMachine: full payload
100
+ // typing on send, handlers narrowed per key, undeclared handler keys error.
101
+ export function machine<
102
+ C extends Record<string, unknown>,
103
+ E extends { type: string },
104
+ S extends Record<string, (ctx: C) => unknown>,
105
+ >(
106
+ context: C & { on?: never; select?: never; name?: never; events?: never },
107
+ behavior: { name?: string; events: E; on?: TypedClientOnMap<C, E>; select?: S },
108
+ ): MachineDef<C, E, 'active', S>
109
+ // Behavior without `on` (selectors only): loose, as data-only.
65
110
  export function machine<
66
111
  C extends Record<string, unknown>,
67
112
  S extends Record<string, (ctx: C) => unknown>,
68
113
  >(
69
- context: C & { on?: never; select?: never; name?: never },
70
- behavior: ClientBehavior<C> & { select?: S },
114
+ context: C & { on?: never; select?: never; name?: never; events?: never },
115
+ behavior: { name?: string; events?: never; on?: never; select?: S },
71
116
  ): MachineDef<C, ClientEvent, 'active', S>
72
117
  /** @deprecated see LegacyMachineConfig */
73
118
  export function machine(
@@ -76,8 +121,11 @@ export function machine(
76
121
  ): MachineDef<Record<string, any>, ClientEvent, 'active', Record<string, (ctx: any) => any>>
77
122
  export function machine(
78
123
  first: Record<string, unknown>,
79
- behavior?: ClientBehavior<never>,
124
+ // Loose impl signature: the overloads above are the contract. `events` is a
125
+ // type-only phantom (`{} as E`) — the runtime never reads it.
126
+ rawBehavior?: unknown,
80
127
  ): MachineDef {
128
+ const behavior = rawBehavior as (ClientBehavior<never> & { events?: unknown }) | undefined
81
129
  let context: Record<string, unknown>
82
130
  let name: string | undefined
83
131
  let on: Record<string, unknown>
package/src/client/use.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  type AnyActor,
3
3
  type AnyMachineDef,
4
4
  createActor,
5
+ type EventOf,
5
6
  type MachineDef,
6
7
  type Snapshot,
7
8
  } from '../engine/index.ts'
@@ -17,17 +18,30 @@ const CLIENT_HELPERS = {
17
18
  ),
18
19
  } as never
19
20
 
21
+ /** Event names sendable as a bare string: only members whose shape is
22
+ * satisfied by `{ type }` alone — an event with a required payload must be
23
+ * sent as an object. (For the loose ClientEvent this admits any string,
24
+ * preserving the untyped tier's behavior.) */
25
+ export type BareEventTypes<E extends { type: string }> = E extends { type: infer T extends string }
26
+ ? { type: T } extends E
27
+ ? T
28
+ : never
29
+ : never
30
+
20
31
  /**
21
32
  * The client-side reactive handle for a machine. Returned by `use()`, held as a
22
33
  * class field (`qty = use(Qty)`). Exposes:
23
34
  * - each selector / context key as a live property (read through the actor's
24
35
  * current snapshot on every access — the client mirror of the server
25
36
  * instance proxy). This is what `bind:text={qty.count}` reads.
26
- * - `send(event)` to drive transitions.
37
+ * - `send(event)` to drive transitions, typed to the machine's event union
38
+ * (declared `events:`, derived from `on` keys, or loose for data-only).
27
39
  * - the underlying actor (non-enumerable) for the binding loop to subscribe.
28
40
  */
29
- export interface ClientInstanceBase {
30
- send(event: { type: string; [k: string]: unknown } | string): void
41
+ export interface ClientInstanceBase<
42
+ E extends { type: string } = { type: string; [k: string]: unknown },
43
+ > {
44
+ send(event: E | BareEventTypes<E>): void
31
45
  /** @internal — the actor a binding subscribes to. */
32
46
  readonly __actor: AnyActor
33
47
  }
@@ -41,7 +55,8 @@ type ClientView<D> =
41
55
  }
42
56
  : unknown
43
57
 
44
- export type ClientInstance<D extends MachineDef = MachineDef> = ClientInstanceBase & ClientView<D>
58
+ export type ClientInstance<D extends MachineDef = MachineDef> = ClientInstanceBase<EventOf<D>> &
59
+ ClientView<D>
45
60
 
46
61
  /** An actor plus an optional deferred seed thunk — evaluated at the element's
47
62
  * connect (when attributes are available), not at construction. */
@@ -14,7 +14,7 @@ import { CompileError } from './diagnostics.ts'
14
14
  */
15
15
 
16
16
  const PRIMITIVES =
17
- "import { StatorElement, defineElement, use, machine, bind, effect, dispatch } from '@statorjs/stator/client'"
17
+ "import { StatorElement, defineElement, use, machine, bind, bindSlot, effect, dispatch } from '@statorjs/stator/client'"
18
18
 
19
19
  export interface EmitClientInput {
20
20
  /** The author's `<script>` source. */
@@ -40,9 +40,20 @@ export function emitClientModule(input: EmitClientInput): string {
40
40
  const lines: string[] = []
41
41
  let i = 0
42
42
  for (const [marker, group] of byMarker) {
43
+ if (group[0]?.kind === 'slot') {
44
+ // Text slot: the runtime finds every `<!--sN-->` comment, materializes a
45
+ // text node per occurrence, and binds them all to the one thunk.
46
+ for (const d of group) {
47
+ const thunk = `() => (${rewriteMembers(d.expr, members)})`
48
+ const deps = `[${d.deps.map((dep) => `this.${dep}`).join(', ')}]`
49
+ lines.push(` this.track(bindSlot(this, ${JSON.stringify(marker)}, ${deps}, ${thunk}))`)
50
+ }
51
+ continue
52
+ }
53
+ // Element wiring: every occurrence of the marker is wired — a marked
54
+ // element inside a `.map()` repeats per row.
43
55
  const node = `n${i++}`
44
- lines.push(` const ${node} = this.querySelector('[data-b="${marker}"]')`)
45
- lines.push(` if (${node}) {`)
56
+ lines.push(` for (const ${node} of this.querySelectorAll('[data-b="${marker}"]')) {`)
46
57
  for (const d of group) lines.push(` ${wireDirective(node, d, members)}`)
47
58
  lines.push(' }')
48
59
  }
@@ -216,9 +216,13 @@ function loc(file?: string): DiagnosticLocation | undefined {
216
216
  * emits the wiring from this.
217
217
  */
218
218
  export interface ClientDirective {
219
- /** Unique node marker, e.g. `b0`. The element gets `data-b="b0"`. */
219
+ /** Node marker. Element wiring (`on`/`bind`): `b<N>`, rendered as
220
+ * `data-b="b<N>"` and resolved via querySelectorAll (every occurrence is
221
+ * wired — a marker inside a `.map()` repeats). Text slots (`slot`): `s<N>`,
222
+ * rendered as an `<!--s<N>-->` comment the client materializes a text node
223
+ * after. */
220
224
  marker: string
221
- kind: 'on' | 'bind'
225
+ kind: 'on' | 'bind' | 'slot'
222
226
  /** on: the DOM event name (`click`). */
223
227
  event?: string
224
228
  /** bind: the target (`text` / `html` / `value` / `checked` / `disabled` / attr). */
@@ -48,7 +48,7 @@ export interface CompileResult {
48
48
  }
49
49
 
50
50
  const PRIMITIVES_IMPORT =
51
- "import { html, read, each, itemBind, when, match, defer, on, classList, styleList } from '@statorjs/stator/template'"
51
+ "import { html, read, each, itemBind, when, match, defer, on, classList, styleList, spreadAttrs } from '@statorjs/stator/template'"
52
52
 
53
53
  export interface CompileOptions {
54
54
  /** Stable id for the component (file path). Used for the scope hash so the
@@ -186,7 +186,7 @@ function compileClient(
186
186
  const attrDecl = `{ ${[...cls.staticAttrs].map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`
187
187
  const rootScope = ctx.scopeAttr ? ` data-s-${ctx.hash}` : ''
188
188
  const serverCode = [
189
- "import { html, read, each, when, match, defer, on, classList, styleList, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
189
+ "import { html, read, each, when, match, defer, on, classList, styleList, spreadAttrs, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
190
190
  '',
191
191
  `export default function (props = {}) {`,
192
192
  ` const __inner = ${innerExpr}`,
@@ -373,6 +373,26 @@ function processFrontmatter(
373
373
  repls.push([n.getStart(sf), n.getEnd(), bound])
374
374
  return
375
375
  }
376
+ if (ts.isCallExpression(n) && isStatorCall(n, 'forwarded')) {
377
+ if (kind === 'route') {
378
+ throw new CompileError(
379
+ 'stator: Stator.forwarded() is only available in a component — it reads a directive ' +
380
+ 'forwarded from a parent (`<Child on:click={h}/>`), and a route has no parent.',
381
+ locInFm(n),
382
+ )
383
+ }
384
+ const arg = n.arguments[0]
385
+ if (!arg) {
386
+ throw new CompileError(
387
+ "stator: Stator.forwarded(name) takes a directive name, e.g. Stator.forwarded('on:click')",
388
+ locInFm(n),
389
+ )
390
+ }
391
+ // The parent packs component-level directives into a reserved `$directives`
392
+ // prop; this reads one back so the author can re-attach it where they like.
393
+ repls.push([n.getStart(sf), n.getEnd(), `props.$directives?.[${arg.getText(sf)}]`])
394
+ return
395
+ }
376
396
  if (ts.isPropertyAccessExpression(n) && isStatorMember(n, 'request')) {
377
397
  requireRoute('Stator.request', kind, locInFm(n))
378
398
  repls.push([n.getStart(sf), n.getEnd(), '__req'])
@@ -43,6 +43,11 @@ export function generateDts(
43
43
  // props an island's SERVER-rendered markup may consume (they never
44
44
  // cross the attr wire, so `static attrs` can't know about them).
45
45
  propsT = `{ ${fields} } & { [prop: string]: unknown }`
46
+ } else {
47
+ // No declared attrs, but the hydrate contract still holds: an island's
48
+ // server shell may consume any props its markup references. A closed
49
+ // `Record<string, never>` here would reject every use site.
50
+ propsT = '{ [prop: string]: unknown }'
46
51
  }
47
52
  }
48
53
 
Binary file
@@ -92,7 +92,8 @@ function injectImports(globals: string[], modulePath: string, userCode: string):
92
92
  // `Stator.reads` binding unassignable to component props.
93
93
  const AMBIENT_TYPE_IMPORTS =
94
94
  "import type { AnyMachineDef as __SMachineDef } from '@statorjs/stator/machine';\n" +
95
- "import type { InstanceOf as __SInstanceOf } from '@statorjs/stator/template';\n"
95
+ "import type { InstanceOf as __SInstanceOf } from '@statorjs/stator/template';\n" +
96
+ "import type { StatorIntrinsicElements as __SIntrinsics } from '@statorjs/stator/template';\n"
96
97
 
97
98
  /**
98
99
  * Per-file ambient scaffold prepended to every virtual TSX. `Stator` (the macro
@@ -108,12 +109,13 @@ const AMBIENT_TYPE_IMPORTS =
108
109
  const STATOR_AMBIENT = `declare const Stator: {
109
110
  props<P>(): P;
110
111
  reads<const T extends readonly __SMachineDef[]>(defs: T): { -readonly [K in keyof T]: __SInstanceOf<T[K]> };
112
+ forwarded(name: \`on:\${string}\`): (() => unknown) | undefined;
111
113
  request: any;
112
114
  response: { status: number; headers: Record<string, string>; cookies: any };
113
115
  };
114
116
  declare global {
115
117
  namespace JSX {
116
- interface IntrinsicElements extends Record<string, any> {}
118
+ interface IntrinsicElements extends __SIntrinsics {}
117
119
  }
118
120
  }
119
121
  `
@@ -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
- const entry = node?.on?.[event.type as E['type']] as
248
- | RawTransition
249
- | RawTransition[]
250
- | undefined
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,
@@ -31,6 +31,7 @@ export type {
31
31
  ReadsMap,
32
32
  SelectorMap,
33
33
  Snapshot,
34
+ StateNameOf,
34
35
  StateNode,
35
36
  SubscribeEntry,
36
37
  Transition,
@@ -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
@@ -285,6 +288,13 @@ export type ReadsMap<TReads extends readonly AnyMachineDef[]> = {
285
288
  export type EventOf<D extends AnyMachineDef> =
286
289
  D extends MachineDef<any, infer E, any, any, any> ? E : never
287
290
 
291
+ /** The state-name union a machine declares (the keys of its `states`). Threads
292
+ * the literal names into `instance.state` so a template comparison
293
+ * (`read(m, s => s.state === 'ready')`) autocompletes and a typo is a compile
294
+ * error, instead of `state: string` swallowing it. */
295
+ export type StateNameOf<D extends AnyMachineDef> =
296
+ D extends MachineDef<any, any, infer S, any, any> ? S : string
297
+
288
298
  export function isStatorMachine(v: unknown): v is AnyMachineDef {
289
299
  return (
290
300
  typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isStatorMachine === true
@@ -104,9 +104,9 @@ export function when<T>(cond: T | ReadResult<T>, fn: () => HtmlFragment): Branch
104
104
  })
105
105
  }
106
106
 
107
- // `display: contents` so the wrapper span doesn't break the surrounding
108
- // element's layout (grid/flex) — see each.ts for the same rationale.
109
- const html = `<span data-slot="${slotId}" data-branch="true" style="display:contents">${innerHtml}</span>`
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
- // `display: contents` so the wrapper span doesn't break the surrounding
164
- // element's layout (grid/flex) — see each.ts for the same rationale.
165
- const html = `<span data-slot="${slotId}" data-branch="true" style="display:contents">${innerHtml}</span>`
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
  }
@@ -32,9 +32,9 @@ export function deferSentinel(slotId: SlotId): string {
32
32
  }
33
33
 
34
34
  function deferPlaceholder(slotId: SlotId, inner: string): DeferResult {
35
- // `display: contents` so the wrapper span doesn't disturb the surrounding
36
- // element's layout — same rationale as each()/when().
37
- const html = `<span data-slot="${slotId}" data-defer="true" style="display:contents">${inner}</span>`
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
 
@@ -3,9 +3,13 @@ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
3
3
 
4
4
  type Handler = () => EventDescriptor | undefined
5
5
 
6
- const onDirective = defineDirective<Handler>({
6
+ const onDirective = defineDirective<Handler | null | undefined>({
7
7
  name: 'on',
8
8
  apply({ modifier, arg, addAttribute }) {
9
+ // A forwarded/optional handler may be absent — `on:click={onClick}` where the
10
+ // caller passed no handler (Stator.forwarded returns undefined). Emit no
11
+ // binding rather than crash on `arg()`.
12
+ if (arg == null) return
9
13
  const result = arg()
10
14
  if (!isEventDescriptor(result)) {
11
15
  throw new Error(
@@ -17,6 +21,9 @@ const onDirective = defineDirective<Handler>({
17
21
  },
18
22
  })
19
23
 
20
- export function on(modifier: string, handler: Handler): DirectiveInvocation<Handler> {
24
+ export function on(
25
+ modifier: string,
26
+ handler: Handler | null | undefined,
27
+ ): DirectiveInvocation<Handler | null | undefined> {
21
28
  return invoke(onDirective, modifier, handler)
22
29
  }
@@ -0,0 +1,88 @@
1
+ import { registerBinding, requireCurrentRenderState } from '../../server/render-context.ts'
2
+ import { isUrlAttribute, safeAttrUrl } from '../../wire/safe-url.ts'
3
+ import { isItemReadResult } from '../each.ts'
4
+ import { isReadResult } from '../read.ts'
5
+ import { type DirectiveInvocation, defineDirective, invoke, isDirectiveInvocation } from './core.ts'
6
+
7
+ /**
8
+ * `{...spread}` on an element — forward a bag of attributes onto it. This is what
9
+ * makes the `HTMLAttributes<Tag>` component pattern practical: a `<Button>` can
10
+ * `<button {...rest} />` instead of hand-forwarding every native attribute.
11
+ *
12
+ * A spread value may be a literal OR a live machine `read(...)` — each read
13
+ * registers the same attribute binding the normal `attr={read(...)}` path does,
14
+ * so it patches on machine events (there is no separate reactive-spread wire).
15
+ * Two value kinds are rejected LOUDLY rather than silently mishandled:
16
+ * - `read(item, …)` (an item binding) — it's owned by its each() row and can't
17
+ * ride through a spread; bind it directly on the element. Mirrors the
18
+ * class:list / style:list item-read rule (deferred util, see spread.test).
19
+ * - a directive invocation (a `classList()` / `on()` result) — a directive owns
20
+ * a whole attribute and isn't a plain value.
21
+ *
22
+ * Boolean/url semantics match the literal attr path (see html.ts handleRead):
23
+ * false / null / undefined ⇒ the attribute is ABSENT, true ⇒ present-and-empty,
24
+ * anything else stringifies; url-bearing names (href/src/…) get the scheme guard.
25
+ */
26
+ export type SpreadAttrs = Record<string, unknown>
27
+
28
+ const spreadDirective = defineDirective<SpreadAttrs>({
29
+ name: 'spread',
30
+ apply({ elementId, arg, addAttribute }) {
31
+ if (arg == null) return
32
+ const state = requireCurrentRenderState()
33
+ for (const [name, value] of Object.entries(arg)) {
34
+ if (isItemReadResult(value)) {
35
+ throw new Error(
36
+ `stator: read(item, …) can't be forwarded through {...spread} (attribute "${name}") — ` +
37
+ `an item read is owned by its each() row. Bind it directly on the element ` +
38
+ `(<el ${name}={read(item, …)} />). Only machine reads and static values can be spread.`,
39
+ )
40
+ }
41
+ if (isDirectiveInvocation(value)) {
42
+ throw new Error(
43
+ `stator: a directive (class:list / style:list / on:) can't be a {...spread} value ` +
44
+ `(attribute "${name}") — a directive owns a whole attribute. Apply it on the element directly.`,
45
+ )
46
+ }
47
+ if (isReadResult(value)) {
48
+ // A live machine read: register the same attr binding the normal
49
+ // `attr={read(...)}` path registers, keyed to this element + name, so a
50
+ // machine event patches it. No new wire — same shape recompute expects.
51
+ registerBinding(state, {
52
+ slotId: value.slotId,
53
+ machineName: value.machineName,
54
+ selector: value.selector,
55
+ lastValue: value.value,
56
+ kind: 'attr',
57
+ attrName: name,
58
+ parentId: elementId,
59
+ })
60
+ emitAttribute(name, value.value, addAttribute)
61
+ continue
62
+ }
63
+ emitAttribute(name, value, addAttribute)
64
+ }
65
+ },
66
+ })
67
+
68
+ /** Emit one attribute with the shared boolean/url semantics. false/null/undefined
69
+ * ⇒ absent (no `addAttribute` call at all); true ⇒ present-and-empty; otherwise
70
+ * stringified, url-guarded on url-bearing names. `addAttribute` HTML-escapes. */
71
+ function emitAttribute(
72
+ name: string,
73
+ value: unknown,
74
+ addAttribute: (name: string, value: string) => void,
75
+ ): void {
76
+ if (value === false || value === null || value === undefined) return
77
+ if (value === true) {
78
+ addAttribute(name, '')
79
+ return
80
+ }
81
+ const str = typeof value === 'string' ? value : String(value)
82
+ addAttribute(name, isUrlAttribute(name) ? safeAttrUrl(str) : str)
83
+ }
84
+
85
+ /** Wrap an attribute bag as the directive the compiler emits for `{...spread}`. */
86
+ export function spreadAttrs(attrs: SpreadAttrs): DirectiveInvocation<SpreadAttrs> {
87
+ return invoke(spreadDirective, '', attrs)
88
+ }
@@ -136,12 +136,11 @@ export function each<T>(
136
136
  }
137
137
  }
138
138
 
139
- // `display: contents` so the wrapper span is invisible to layout — its
140
- // children become layout-children of the surrounding element. Without
141
- // this, putting `each()` inside a CSS grid or flex container would
142
- // collapse all items into a single grid/flex cell. The span itself stays
143
- // in the DOM as the patch addressing target.
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
 
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Type-only HTML attribute surface for authoring components that extend a native
3
+ * element — the Astro/Svelte `HTMLAttributes<'button'>` pattern. So a component
4
+ * can be `Stator.props<HTMLAttributes<'button'> & { variant: 'primary' }>()` and
5
+ * get every native button attribute typed + validated, plus its own props.
6
+ *
7
+ * Hand-authored and intentionally LEAN: a useful subset, not the whole DOM. It
8
+ * mirrors `svelte/elements`' shape — a shared base (global attrs + ARIA + the
9
+ * Stator directives) plus a per-element attribute map, combined by tag. Grow the
10
+ * per-element map on demand; there is no compiler machinery behind this.
11
+ *
12
+ * STATOR REACTIVITY: a template attribute value can be a literal OR a live
13
+ * `read(...)` binding (`ReadResult<T>`), so every non-directive attribute value
14
+ * is `Reactive<T>`. This is the adaptation that makes typed intrinsics coexist
15
+ * with Stator templates — the load-bearing difference from Astro/Svelte, where
16
+ * attributes are plain values.
17
+ */
18
+
19
+ import type { ReadResult } from './read.ts'
20
+
21
+ /** A template attribute value: a literal, or a live `read(...)` binding. */
22
+ export type Reactive<T> = T | ReadResult<T>
23
+
24
+ /** Wrap every property value in `Reactive` (bindings allowed on any attribute).
25
+ * Homomorphic, so it preserves the `data-*`/`aria-*` index signatures. */
26
+ type Reactivize<A> = { [K in keyof A]: Reactive<A[K]> }
27
+
28
+ /** Stator template directives — colon-namespaced, valid on any element and on any
29
+ * component that forwards them. Not reactivized: their values are handlers/specs,
30
+ * not bindings. Values are `unknown` for now — a later pass gives `on:`/`bind:`/
31
+ * `ref:` their Stator-specific types. */
32
+ export interface StatorDirectiveAttributes {
33
+ 'class:list'?: unknown
34
+ 'style:list'?: unknown
35
+ [key: `on:${string}`]: unknown
36
+ [key: `bind:${string}`]: unknown
37
+ [key: `ref:${string}`]: unknown
38
+ [key: `class:${string}`]: unknown
39
+ [key: `style:${string}`]: unknown
40
+ }
41
+
42
+ /** ARIA — `role` plus the open-ended `aria-*` family. */
43
+ export interface AriaAttributes {
44
+ role?: string
45
+ [key: `aria-${string}`]: unknown
46
+ }
47
+
48
+ /** Global attributes present on every HTML element (a lean subset). Numeric
49
+ * attributes accept `string` too, since templates write them as strings. */
50
+ export interface GlobalHTMLAttributes {
51
+ class?: string
52
+ id?: string
53
+ style?: string
54
+ title?: string
55
+ hidden?: boolean
56
+ tabindex?: number | string
57
+ lang?: string
58
+ dir?: 'ltr' | 'rtl' | 'auto'
59
+ draggable?: boolean | 'true' | 'false'
60
+ popover?: boolean | string
61
+ /** Stator named-slot target — `child="header"` fills `<children name="header"/>`. */
62
+ child?: string
63
+ [key: `data-${string}`]: unknown
64
+ }
65
+
66
+ /** Per-element extra attributes. Extend as components need them. */
67
+ export interface ElementSpecificAttributes {
68
+ a: { href?: string; target?: string; rel?: string; download?: string | boolean }
69
+ button: {
70
+ type?: 'submit' | 'reset' | 'button'
71
+ disabled?: boolean
72
+ name?: string
73
+ value?: string
74
+ form?: string
75
+ autofocus?: boolean
76
+ popovertarget?: string
77
+ popovertargetaction?: 'show' | 'hide' | 'toggle'
78
+ }
79
+ input: {
80
+ type?: string
81
+ name?: string
82
+ value?: string | number
83
+ placeholder?: string
84
+ disabled?: boolean
85
+ required?: boolean
86
+ readonly?: boolean
87
+ checked?: boolean
88
+ min?: string | number
89
+ max?: string | number
90
+ step?: string | number
91
+ minlength?: number | string
92
+ maxlength?: number | string
93
+ pattern?: string
94
+ autocomplete?: string
95
+ inputmode?: string
96
+ autofocus?: boolean
97
+ }
98
+ label: { for?: string }
99
+ form: {
100
+ action?: string
101
+ method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
102
+ novalidate?: boolean
103
+ }
104
+ select: {
105
+ name?: string
106
+ disabled?: boolean
107
+ required?: boolean
108
+ multiple?: boolean
109
+ value?: string | number
110
+ }
111
+ option: { value?: string | number; selected?: boolean; disabled?: boolean }
112
+ textarea: {
113
+ name?: string
114
+ placeholder?: string
115
+ rows?: number | string
116
+ cols?: number | string
117
+ disabled?: boolean
118
+ required?: boolean
119
+ readonly?: boolean
120
+ minlength?: number | string
121
+ maxlength?: number | string
122
+ }
123
+ img: {
124
+ src?: string
125
+ alt?: string
126
+ width?: number | string
127
+ height?: number | string
128
+ loading?: 'eager' | 'lazy'
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The attribute type for a native element `Tag`. Intersect it in a component's
134
+ * props to forward native attributes:
135
+ *
136
+ * const { variant, ...rest } =
137
+ * Stator.props<HTMLAttributes<'button'> & { variant: 'primary' | 'danger' }>()
138
+ *
139
+ * `Tag` defaults to the shared base (global + ARIA + directives) for elements not
140
+ * in the per-element map. HTML attribute values are `Reactive` (literal or live
141
+ * binding); directives keep their own value types.
142
+ */
143
+ export type HTMLAttributes<Tag extends string = string> = Reactivize<
144
+ GlobalHTMLAttributes &
145
+ AriaAttributes &
146
+ (Tag extends keyof ElementSpecificAttributes
147
+ ? ElementSpecificAttributes[Tag]
148
+ : Record<never, never>)
149
+ > &
150
+ StatorDirectiveAttributes
151
+
152
+ /**
153
+ * The per-element intrinsic map wired into `JSX.IntrinsicElements`, so a typo on
154
+ * a plain `<button typ=>` is a real error. Common HTML tags are typed;
155
+ * the `[tag: string]: any` fallback keeps custom elements (client-island tags
156
+ * like `<live-sky>`) and everything not listed (SVG, rare tags) permissive — the
157
+ * escape valve that stops the typing from flooding real templates. Every explicit
158
+ * entry is assignable to `any`, so the index signature is satisfied.
159
+ */
160
+ export interface StatorIntrinsicElements {
161
+ a: HTMLAttributes<'a'>
162
+ button: HTMLAttributes<'button'>
163
+ input: HTMLAttributes<'input'>
164
+ select: HTMLAttributes<'select'>
165
+ option: HTMLAttributes<'option'>
166
+ textarea: HTMLAttributes<'textarea'>
167
+ label: HTMLAttributes<'label'>
168
+ form: HTMLAttributes<'form'>
169
+ img: HTMLAttributes<'img'>
170
+ div: HTMLAttributes<'div'>
171
+ span: HTMLAttributes<'span'>
172
+ p: HTMLAttributes<'p'>
173
+ h1: HTMLAttributes<'h1'>
174
+ h2: HTMLAttributes<'h2'>
175
+ h3: HTMLAttributes<'h3'>
176
+ h4: HTMLAttributes<'h4'>
177
+ h5: HTMLAttributes<'h5'>
178
+ h6: HTMLAttributes<'h6'>
179
+ ul: HTMLAttributes<'ul'>
180
+ ol: HTMLAttributes<'ol'>
181
+ li: HTMLAttributes<'li'>
182
+ nav: HTMLAttributes<'nav'>
183
+ header: HTMLAttributes<'header'>
184
+ footer: HTMLAttributes<'footer'>
185
+ section: HTMLAttributes<'section'>
186
+ main: HTMLAttributes<'main'>
187
+ article: HTMLAttributes<'article'>
188
+ aside: HTMLAttributes<'aside'>
189
+ strong: HTMLAttributes<'strong'>
190
+ em: HTMLAttributes<'em'>
191
+ small: HTMLAttributes<'small'>
192
+ code: HTMLAttributes<'code'>
193
+ pre: HTMLAttributes<'pre'>
194
+ time: HTMLAttributes<'time'>
195
+ cite: HTMLAttributes<'cite'>
196
+ hr: HTMLAttributes<'hr'>
197
+ br: HTMLAttributes<'br'>
198
+ figure: HTMLAttributes<'figure'>
199
+ figcaption: HTMLAttributes<'figcaption'>
200
+ details: HTMLAttributes<'details'>
201
+ summary: HTMLAttributes<'summary'>
202
+ dialog: HTMLAttributes<'dialog'>
203
+ table: HTMLAttributes<'table'>
204
+ thead: HTMLAttributes<'thead'>
205
+ tbody: HTMLAttributes<'tbody'>
206
+ tr: HTMLAttributes<'tr'>
207
+ td: HTMLAttributes<'td'>
208
+ th: HTMLAttributes<'th'>
209
+ fieldset: HTMLAttributes<'fieldset'>
210
+ legend: HTMLAttributes<'legend'>
211
+ // biome-ignore lint/suspicious/noExplicitAny: escape valve — custom-element islands, SVG, and unlisted tags stay fully permissive; every explicit entry above is assignable to this index signature.
212
+ [tag: string]: any
213
+ }
@@ -127,7 +127,17 @@ function processValue(builder: HtmlBuilder, state: RenderState, value: unknown):
127
127
  return
128
128
  }
129
129
  if (pos.kind === 'attr-value') {
130
- builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(value))))
130
+ // Same boolean semantics as the read-binding and item-read paths:
131
+ // false/null/undefined → attribute ABSENT (`checked={expr}` must be able
132
+ // to render unchecked; a static render must agree with what a live patch
133
+ // of the same attribute would do), true → present-and-empty. Previously
134
+ // this path stringified — `checked={false}` rendered `checked="false"`,
135
+ // a PRESENT (thus truthy) boolean attribute.
136
+ if (value === false || value === null || value === undefined) {
137
+ builder.omitCurrentAttribute()
138
+ } else if (value !== true) {
139
+ builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(value))))
140
+ }
131
141
  return
132
142
  }
133
143
  throw new Error(`stator: cannot interpolate a plain value at ${pos.kind} position`)
@@ -22,9 +22,20 @@ export {
22
22
  export type { ClassListSpec, StyleListSpec } from './directives/list-attr.ts'
23
23
  export { classList, styleList } from './directives/list-attr.ts'
24
24
  export { on } from './directives/on.ts'
25
+ export type { SpreadAttrs } from './directives/spread.ts'
26
+ export { spreadAttrs } from './directives/spread.ts'
25
27
  export type { EachResult } from './each.ts'
26
28
  export { each, isEachResult, itemBind, renderListBody } from './each.ts'
27
29
  export { html, raw } from './html.ts'
30
+ export type {
31
+ AriaAttributes,
32
+ ElementSpecificAttributes,
33
+ GlobalHTMLAttributes,
34
+ HTMLAttributes,
35
+ Reactive,
36
+ StatorDirectiveAttributes,
37
+ StatorIntrinsicElements,
38
+ } from './html-attributes.ts'
28
39
  export type { ReadResult } from './read.ts'
29
40
  export { isReadResult, read } from './read.ts'
30
41
  export type { HtmlFragment, InstanceOf } from './types.ts'
@@ -1,15 +1,27 @@
1
- import type { AnyMachineDef, InstanceOf as SelectorsOf } from '../engine/index.ts'
1
+ import type {
2
+ AnyMachineDef,
3
+ EventOf,
4
+ InstanceOf as SelectorsOf,
5
+ StateNameOf,
6
+ } from '../engine/index.ts'
2
7
  import type { EventDescriptor } from '../server/render-context.ts'
3
8
 
4
9
  /**
5
10
  * The template-facing shape of a machine instance: the engine's selector view
6
11
  * (each selector as a property carrying its return type, callable if it returns
7
- * a function) plus the framework-provided `send` / `state` / `snapshot`.
12
+ * a function) plus the framework-provided `send` / `state` / `snapshot`. Both
13
+ * `state` (the state-name union) and `send` (the event union) are typed to the
14
+ * machine's own def, so `s.state === 'ready'` and `m.send({ type: 'SAVE', … })`
15
+ * autocomplete and a typo — in the name or the payload — is a compile error.
8
16
  */
9
- export type InstanceOf<TDef extends AnyMachineDef> = SelectorsOf<TDef> & InstanceCommon
17
+ export type InstanceOf<TDef extends AnyMachineDef> = SelectorsOf<TDef> &
18
+ InstanceCommon<StateNameOf<TDef>, EventOf<TDef>>
10
19
 
11
- export interface InstanceCommon<TStateKey extends string = string> {
12
- send(event: { type: string; [k: string]: unknown }): EventDescriptor | undefined
20
+ export interface InstanceCommon<
21
+ TStateKey extends string = string,
22
+ TEvent = { type: string; [k: string]: unknown },
23
+ > {
24
+ send(event: TEvent): EventDescriptor | undefined
13
25
  readonly state: TStateKey
14
26
  readonly snapshot: unknown
15
27
  }
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
- // A missing target means this DOM diverged from server truth (stale
30
- // non-live page, or client code removed server-owned nodes). Skipping
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 (element) {
39
- if (patch.op === 'text') element.textContent = patch.value
40
- else if (patch.op === 'html') element.innerHTML = patch.value
41
- else if (patch.op === 'attr') {
42
- if (patch.value === null) element.removeAttribute(patch.name)
43
- else element.setAttribute(patch.name, patch.value)
44
- }
45
- // Keyed-list ops address element children by index, sequentially: each
46
- // op sees the DOM as left by the previous one (see wire/index.ts).
47
- else if (patch.op === 'insert') {
48
- const tpl = document.createElement('template')
49
- tpl.innerHTML = patch.value
50
- element.insertBefore(tpl.content, element.children[patch.index] ?? null)
51
- } else if (patch.op === 'remove') {
52
- element.children[patch.index]?.remove()
53
- } else if (patch.op === 'move') {
54
- const node = element.children[patch.from]
55
- if (node) {
56
- node.remove()
57
- element.insertBefore(node, element.children[patch.to] ?? null)
58
- }
59
- }
60
- }
61
- emit('stator:patch-applied', { patch, element, timestamp: Date.now() })
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
+ }