@statorjs/stator 1.8.0 → 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.8.0",
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,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.8.0"
91
+ "@statorjs/stator": "1.9.0"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -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
  `
@@ -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,
@@ -288,6 +288,13 @@ export type ReadsMap<TReads extends readonly AnyMachineDef[]> = {
288
288
  export type EventOf<D extends AnyMachineDef> =
289
289
  D extends MachineDef<any, infer E, any, any, any> ? E : never
290
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
+
291
298
  export function isStatorMachine(v: unknown): v is AnyMachineDef {
292
299
  return (
293
300
  typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isStatorMachine === true
@@ -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
+ }
@@ -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
  }