@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 +2 -2
- package/src/client/bind.ts +34 -0
- package/src/client/client-id.ts +17 -2
- package/src/client/index.ts +1 -1
- package/src/client/machine.ts +55 -7
- package/src/client/use.ts +19 -4
- package/src/compiler/client-emit.ts +14 -3
- package/src/compiler/client-script.ts +6 -2
- package/src/compiler/compile.ts +22 -2
- package/src/compiler/dts.ts +5 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/virtual-code.ts +4 -2
- package/src/engine/index.ts +1 -0
- package/src/engine/types.ts +7 -0
- package/src/template/directives/on.ts +9 -2
- package/src/template/directives/spread.ts +88 -0
- package/src/template/html-attributes.ts +213 -0
- package/src/template/html.ts +11 -1
- package/src/template/index.ts +11 -0
- package/src/template/types.ts +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "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,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.
|
|
91
|
+
"@statorjs/stator": "1.9.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/client/bind.ts
CHANGED
|
@@ -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-
|
package/src/client/client-id.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/client/index.ts
CHANGED
|
@@ -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'
|
package/src/client/machine.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 &
|
|
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}
|
|
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
|
-
/**
|
|
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). */
|
package/src/compiler/compile.ts
CHANGED
|
@@ -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'])
|
package/src/compiler/dts.ts
CHANGED
|
@@ -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
|
|
package/src/compiler/lower.ts
CHANGED
|
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
|
|
118
|
+
interface IntrinsicElements extends __SIntrinsics {}
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
`
|
package/src/engine/index.ts
CHANGED
package/src/engine/types.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/src/template/html.ts
CHANGED
|
@@ -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
|
-
|
|
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`)
|
package/src/template/index.ts
CHANGED
|
@@ -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'
|
package/src/template/types.ts
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
|
-
import type {
|
|
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> &
|
|
17
|
+
export type InstanceOf<TDef extends AnyMachineDef> = SelectorsOf<TDef> &
|
|
18
|
+
InstanceCommon<StateNameOf<TDef>, EventOf<TDef>>
|
|
10
19
|
|
|
11
|
-
export interface InstanceCommon<
|
|
12
|
-
|
|
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
|
}
|