@statorjs/stator 1.8.0 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.8.0",
3
+ "version": "2.0.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": "2.0.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,11 +4,12 @@
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 { attrValue, setAttr } from '../wire/attr-value.ts'
8
+ export { bind, bindSlot, effect } from './bind.ts'
8
9
  export type { DispatchResult } from './dispatch.ts'
9
10
  export { dispatch } from './dispatch.ts'
10
11
  export { defineElement, StatorElement } from './element.ts'
11
- export type { ClientBehavior, LegacyMachineConfig } from './machine.ts'
12
+ export type { ClientBehavior } from './machine.ts'
12
13
  export { machine } from './machine.ts'
13
14
  export type { DispatchError } from './transport.ts'
14
15
  export type { ClientInstance, ClientInstanceBase } from './use.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
@@ -42,59 +63,53 @@ export interface ClientBehavior<C> {
42
63
  select?: Record<string, (ctx: C) => unknown>
43
64
  }
44
65
 
45
- /** @deprecated One-bag form: context keys mixed with `on`/`select`. Kept for
46
- * compatibility, but handlers see `any` — TypeScript cannot infer a
47
- * context from the same object the handlers live in. Prefer
48
- * `machine(context, behavior)`. */
49
- export interface LegacyMachineConfig {
50
- name?: string
51
- // biome-ignore lint/suspicious/noExplicitAny: the one-bag form is untypeable by construction — that is exactly why the two-arg form exists
52
- on?: Record<string, ((ctx: any, ev: ClientEvent) => void) | ClientTransitionObject<any>>
53
- // biome-ignore lint/suspicious/noExplicitAny: same
54
- select?: Record<string, (ctx: any) => unknown>
55
- [key: string]: unknown
56
- }
57
-
58
- const RESERVED = new Set(['name', 'on', 'select'])
59
-
60
- // Data-only: no behavior, no selectors.
66
+ // Data-only: no behavior, no events. With `@set` gone (2.0) a machine with
67
+ // no `on` map accepts NOTHING — its state is set at construction (a seed)
68
+ // and read for display; `send` is a type error by construction.
61
69
  export function machine<C extends Record<string, unknown>>(
62
- context: C & { on?: never; select?: never; name?: never },
63
- ): MachineDef<C, ClientEvent, 'active', Record<string, never>>
64
- // Data + behavior: handlers and selectors typed against the context.
70
+ context: C & { on?: never; select?: never; name?: never; events?: never },
71
+ ): MachineDef<C, never, 'active', Record<string, never>>
72
+ // Derived union — no `events:` declared: event NAMES come from the `on` keys
73
+ // (typo-safe send), payloads stay open. Zero authoring change. Ordered BEFORE
74
+ // the declared overload: `events?: never` keeps declared calls falling through.
65
75
  export function machine<
66
76
  C extends Record<string, unknown>,
77
+ O extends Record<string, ClientTransition<C>>,
67
78
  S extends Record<string, (ctx: C) => unknown>,
68
79
  >(
69
- context: C & { on?: never; select?: never; name?: never },
70
- behavior: ClientBehavior<C> & { select?: S },
71
- ): MachineDef<C, ClientEvent, 'active', S>
72
- /** @deprecated see LegacyMachineConfig */
73
- export function machine(
74
- config: LegacyMachineConfig,
75
- // biome-ignore lint/suspicious/noExplicitAny: legacy view is deliberately loose
76
- ): MachineDef<Record<string, any>, ClientEvent, 'active', Record<string, (ctx: any) => any>>
80
+ context: C & { on?: never; select?: never; name?: never; events?: never },
81
+ behavior: { name?: string; events?: never; on: O; select?: S },
82
+ ): MachineDef<C, DerivedEvents<O>, 'active', S>
83
+ // Declared union (`events: {} as E`) — mirrors defineMachine: full payload
84
+ // typing on send, handlers narrowed per key, undeclared handler keys error.
85
+ export function machine<
86
+ C extends Record<string, unknown>,
87
+ E extends { type: string },
88
+ S extends Record<string, (ctx: C) => unknown>,
89
+ >(
90
+ context: C & { on?: never; select?: never; name?: never; events?: never },
91
+ behavior: { name?: string; events: E; on?: TypedClientOnMap<C, E>; select?: S },
92
+ ): MachineDef<C, E, 'active', S>
93
+ // Behavior without `on` (selectors only): derived state, still nothing to
94
+ // send — events are `never`, as data-only.
95
+ export function machine<
96
+ C extends Record<string, unknown>,
97
+ S extends Record<string, (ctx: C) => unknown>,
98
+ >(
99
+ context: C & { on?: never; select?: never; name?: never; events?: never },
100
+ behavior: { name?: string; events?: never; on?: never; select?: S },
101
+ ): MachineDef<C, never, 'active', S>
77
102
  export function machine(
78
103
  first: Record<string, unknown>,
79
- behavior?: ClientBehavior<never>,
104
+ // Loose impl signature: the overloads above are the contract. `events` is a
105
+ // type-only phantom (`{} as E`) — the runtime never reads it.
106
+ rawBehavior?: unknown,
80
107
  ): MachineDef {
81
- let context: Record<string, unknown>
82
- let name: string | undefined
83
- let on: Record<string, unknown>
84
- let select: Record<string, unknown>
85
- if (behavior !== undefined || !Object.keys(first).some((k) => RESERVED.has(k))) {
86
- context = first
87
- name = behavior?.name
88
- on = (behavior?.on ?? {}) as Record<string, unknown>
89
- select = (behavior?.select ?? {}) as Record<string, unknown>
90
- } else {
91
- // Legacy one-bag: context is every non-reserved key.
92
- const { name: n, on: o = {}, select: s = {}, ...rest } = first as LegacyMachineConfig
93
- context = rest
94
- name = n
95
- on = o as Record<string, unknown>
96
- select = s as Record<string, unknown>
97
- }
108
+ const behavior = rawBehavior as (ClientBehavior<never> & { events?: unknown }) | undefined
109
+ const context = first
110
+ const name = behavior?.name
111
+ const on = (behavior?.on ?? {}) as Record<string, unknown>
112
+ const select = (behavior?.select ?? {}) as Record<string, unknown>
98
113
  return defineMachine({
99
114
  name: name ?? 'ClientMachine',
100
115
  lifecycle: 'session',
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. */
@@ -52,7 +67,14 @@ export interface CollectedActor {
52
67
 
53
68
  /** Active collector: `use()` registers its actor here during element
54
69
  * construction, so `StatorElement` can start/seed/stop them on connect/disconnect.
55
- * A stack supports nested construction (rare, but correct). */
70
+ * A stack supports nested construction (rare, but correct).
71
+ *
72
+ * Dual-bundle note (the clientId lesson): module state is PER BUNDLE, and
73
+ * the page runtime and island modules are separate bundles. This stack is
74
+ * safe because it is construction-scoped and both its writer (`use()`) and
75
+ * its reader (`StatorElement`) always come from the SAME bundle as the
76
+ * island class using them. State that must be PAGE-scoped (one value across
77
+ * all bundles) must live on `window` — see client-id.ts. */
56
78
  const collectors: CollectedActor[][] = []
57
79
 
58
80
  export function pushCollector(): CollectedActor[] {
@@ -85,10 +107,7 @@ export function use<D extends MachineDef>(
85
107
  context: { ...(def.context as object), ...eager },
86
108
  }
87
109
  : undefined
88
- // Client islands honor framework-internal events (`@set` for two-way
89
- // `bind:value`); the actor lives in the browser and only its own compiled
90
- // bind code sends them. Server actors deliberately do NOT (see createActor).
91
- const actor = createActor(def as AnyMachineDef, { snapshot, internalEvents: true })
110
+ const actor = createActor(def as AnyMachineDef, { snapshot })
92
111
 
93
112
  // Register with the element under construction so its lifecycle owns the actor.
94
113
  const bucket = collectors[collectors.length - 1]
@@ -1,6 +1,6 @@
1
1
  import ts from 'typescript'
2
+ import { elementMarkerSelector } from '../wire/island-markers.ts'
2
3
  import type { ClientDirective, ClientElement } from './client-script.ts'
3
- import { CompileError } from './diagnostics.ts'
4
4
 
5
5
  /**
6
6
  * Phase 3b stage 5 — emit the client entry module for a client component: the
@@ -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, attrValue, setAttr } from '@statorjs/stator/client'"
18
18
 
19
19
  export interface EmitClientInput {
20
20
  /** The author's `<script>` source. */
@@ -40,9 +40,22 @@ 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(
57
+ ` for (const ${node} of this.querySelectorAll(${JSON.stringify(elementMarkerSelector(marker))})) {`,
58
+ )
46
59
  for (const d of group) lines.push(` ${wireDirective(node, d, members)}`)
47
60
  lines.push(' }')
48
61
  }
@@ -67,42 +80,14 @@ function wireDirective(node: string, d: ClientDirective, members: Set<string>):
67
80
  const handler = emitHandler(d.expr, members)
68
81
  return `${node}.addEventListener(${JSON.stringify(d.event)}, ${handler})`
69
82
  }
70
- // bind:
83
+ // kind 'bind': an attribute-position client read() — one-way display.
71
84
  const target = d.target ?? 'text'
72
85
  const thunk = `() => (${rewriteMembers(d.expr, members)})`
73
86
  const deps = `[${d.deps.map((dep) => `this.${dep}`).join(', ')}]`
74
-
75
- // value / checked are TWO-WAY: state→DOM (below) PLUS a DOM→state listener
76
- // that `@set`s the bound context key. The expression must be a settable
77
- // `<actor>.<key>` path (a derived selector can't be assigned).
78
- if (target === 'value' || target === 'checked') {
79
- const path = parseTwoWayPath(d.expr)
80
- if (!path) {
81
- throw new CompileError(
82
- `stator: bind:${target}={${d.expr}} must bind to a settable context path ` +
83
- `like \`actor.key\` (a derived value can't be two-way bound).`,
84
- )
85
- }
86
- const writer = emitWriter(node, target)
87
- const read = target === 'checked' ? `${node}.checked` : `${node}.value`
88
- const guard = target === 'value' ? `if (e.isComposing) return; ` : ''
89
- const setter =
90
- `${node}.addEventListener(${JSON.stringify(target === 'value' ? 'input' : 'change')}, (e) => { ` +
91
- `${guard}this.${path.actor}.send({ type: '@set', key: ${JSON.stringify(path.key)}, value: ${read} }) })`
92
- return `this.track(bind(${deps}, ${thunk}, ${writer}));\n ${setter}`
93
- }
94
-
95
87
  const writer = emitWriter(node, target)
96
88
  return `this.track(bind(${deps}, ${thunk}, ${writer}))`
97
89
  }
98
90
 
99
- /** Parse a two-way bind expression `actor.key` into its parts; null if it isn't
100
- * a simple single-level member access (not assignable). */
101
- function parseTwoWayPath(expr: string): { actor: string; key: string } | null {
102
- const m = expr.trim().match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/)
103
- return m ? { actor: m[1]!, key: m[2]! } : null
104
- }
105
-
106
91
  /** on: handler — a bare method reference becomes `(e) => this.m(e)`; any other
107
92
  * expression is used directly (with member references rewritten). */
108
93
  function emitHandler(expr: string, members: Set<string>): string {
@@ -115,21 +100,13 @@ function emitHandler(expr: string, members: Set<string>): string {
115
100
 
116
101
  function emitWriter(node: string, target: string): string {
117
102
  switch (target) {
118
- case 'text':
119
- return `(v) => { ${node}.textContent = v == null ? '' : String(v) }`
120
- case 'html':
121
- return `(v) => { ${node}.innerHTML = v == null ? '' : String(v) }`
122
- case 'value':
123
- // Loop-break: only write when the DOM differs, so the echo from the user's
124
- // own keystroke no-ops and the caret is preserved.
125
- return `(v) => { const s = v == null ? '' : String(v); if (${node}.value !== s) ${node}.value = s }`
126
103
  case 'disabled':
127
104
  case 'hidden':
128
- case 'checked':
105
+ // Boolean IDL properties write as properties, not attributes.
129
106
  return `(v) => { if (${node}.${target} !== !!v) ${node}.${target} = !!v }`
130
107
  default:
131
- // arbitrary attribute
132
- return `(v) => { if (v == null || v === false) ${node}.removeAttribute(${JSON.stringify(target)}); else ${node}.setAttribute(${JSON.stringify(target)}, v === true ? '' : String(v)) }`
108
+ // arbitrary attribute — the shared attr contract (wire/attr-value.ts)
109
+ return `(v) => setAttr(${node}, ${JSON.stringify(target)}, attrValue(v))`
133
110
  }
134
111
  }
135
112
 
@@ -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'])
@@ -21,7 +21,32 @@ export function generateDts(
21
21
  if ((opts.kind ?? 'component') === 'route') return null
22
22
 
23
23
  const { frontmatter, template, scripts } = splitStator(source)
24
- const { hoisted, propsType } = extractFrontmatterTypes(frontmatter)
24
+ const { hoisted } = extractFrontmatterTypes(frontmatter)
25
+ const { propsT, needsReadResult } = statorPropsType(frontmatter, template, scripts)
26
+
27
+ const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
28
+ if (needsReadResult) {
29
+ lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
30
+ }
31
+ if (hoisted) lines.push(hoisted)
32
+ lines.push('')
33
+ lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
34
+ lines.push('export default _default')
35
+ return `${lines.join('\n')}\n`
36
+ }
37
+
38
+ /**
39
+ * The component's props type — THE shared computation for the `.d.ts`
40
+ * generator (tsc) and the language-server virtual emit (editor), islands
41
+ * included, so the two can't disagree. `needsReadResult` means the type
42
+ * references `__SReadResult<T>`, which the caller must bring into scope.
43
+ */
44
+ export function statorPropsType(
45
+ frontmatter: string,
46
+ template: string,
47
+ scripts: string[],
48
+ ): { propsT: string; needsReadResult: boolean } {
49
+ const { propsType } = extractFrontmatterTypes(frontmatter)
25
50
  let propsT = componentPropsType(propsType, template)
26
51
  let needsReadResult = false
27
52
 
@@ -29,9 +54,10 @@ export function generateDts(
29
54
  // `Stator.props`. Each attr accepts its coerced kind OR a live `read()`
30
55
  // binding — the compiler lowers read() island props to live attr bindings,
31
56
  // so the type surface must admit both.
32
- if (!propsType && scripts.length > 0) {
57
+ const classes = scripts.length > 0 ? analyzeScriptClasses(scripts.join('\n')) : []
58
+ if (!propsType && classes.length > 0) {
33
59
  const attrs = new Map<string, string>()
34
- for (const cls of analyzeScriptClasses(scripts.join('\n'))) {
60
+ for (const cls of classes) {
35
61
  for (const [key, kind] of cls.staticAttrs) attrs.set(key, kind)
36
62
  }
37
63
  if (attrs.size > 0) {
@@ -43,18 +69,14 @@ export function generateDts(
43
69
  // props an island's SERVER-rendered markup may consume (they never
44
70
  // cross the attr wire, so `static attrs` can't know about them).
45
71
  propsT = `{ ${fields} } & { [prop: string]: unknown }`
72
+ } else {
73
+ // No declared attrs, but the hydrate contract still holds: an island's
74
+ // server shell may consume any props its markup references. A closed
75
+ // `Record<string, never>` here would reject every use site.
76
+ propsT = '{ [prop: string]: unknown }'
46
77
  }
47
78
  }
48
-
49
- const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
50
- if (needsReadResult) {
51
- lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
52
- }
53
- if (hoisted) lines.push(hoisted)
54
- lines.push('')
55
- lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
56
- lines.push('export default _default')
57
- return `${lines.join('\n')}\n`
79
+ return { propsT, needsReadResult }
58
80
  }
59
81
 
60
82
  /**
Binary file
@@ -20,7 +20,7 @@
20
20
 
21
21
  import ts from 'typescript'
22
22
  import { analyzeScriptClasses } from './client-script.ts'
23
- import { componentPropsType, extractFrontmatterTypes } from './dts.ts'
23
+ import { statorPropsType } from './dts.ts'
24
24
  import { type ScannedRegions, scanRegions } from './split.ts'
25
25
 
26
26
  /** A contiguous run mapping generated code back to source, 1:1 over its length. */
@@ -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
  `
@@ -172,7 +174,14 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
172
174
  // is typed from `Stator.props<P>()` (same extraction as the .d.ts generator),
173
175
  // so `<Component bad={...}/>` in OTHER .stator files is checked in-editor.
174
176
  // Named prop types resolve because their type/interface decls hoisted above.
175
- const propsT = componentPropsType(extractFrontmatterTypes(fm).propsType, regions.template.content)
177
+ const { propsT, needsReadResult } = statorPropsType(
178
+ fm,
179
+ regions.template.content,
180
+ regions.scripts.map((s) => s.content),
181
+ )
182
+ if (needsReadResult) {
183
+ code += "type __SReadResult<T> = import('@statorjs/stator/template').ReadResult<T>\n"
184
+ }
176
185
  code += `export default function (_props: ${propsT}) {\n`
177
186
  for (const seg of body) {
178
187
  code += ' '