@statorjs/stator 1.7.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -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/actor.ts +10 -4
- package/src/engine/define-machine.ts +9 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/types.ts +10 -0
- package/src/template/conditional.ts +6 -6
- package/src/template/defer.ts +3 -3
- package/src/template/directives/on.ts +9 -2
- package/src/template/directives/spread.ts +88 -0
- package/src/template/each.ts +5 -6
- 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/src/wire/apply.ts +75 -32
- package/src/wire/region-apply.ts +103 -0
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,12 +88,13 @@
|
|
|
88
88
|
"pino-pretty": "^13.1.3",
|
|
89
89
|
"vite": "^6.0.0",
|
|
90
90
|
"vitest": "^2.1.0",
|
|
91
|
-
"@statorjs/stator": "1.
|
|
91
|
+
"@statorjs/stator": "1.9.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
|
95
95
|
"test": "vitest run",
|
|
96
96
|
"test:watch": "vitest",
|
|
97
|
+
"test:browser": "node browser-tests/tables.mjs",
|
|
97
98
|
"build:client": "esbuild src/client/runtime.ts --bundle --format=iife --target=es2020 --outfile=dist/client.js"
|
|
98
99
|
}
|
|
99
100
|
}
|
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/actor.ts
CHANGED
|
@@ -244,10 +244,16 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
244
244
|
// transition uniformly against the full event union (the runtime event
|
|
245
245
|
// IS the narrowed type for this key, so the coercion is sound).
|
|
246
246
|
type RawTransition = ((ctx: C, ev: E, h: ActionHelpers) => void) | TransitionConfig<C, E, S>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
247
|
+
let entry = node?.on?.[event.type as E['type']] as RawTransition | RawTransition[] | undefined
|
|
248
|
+
// Machine-level fallback: consulted ONLY when the current state does not
|
|
249
|
+
// declare this event (state-scoped handlers win — most specific). This is
|
|
250
|
+
// how a completion is handled regardless of the machine-wide state (a save
|
|
251
|
+
// completing while the machine reloads) without duplicating the handler
|
|
252
|
+
// into every state. A state that declares the event owns it, even if its
|
|
253
|
+
// guard drops — no fall-through to machine level in that case.
|
|
254
|
+
if (entry === undefined) {
|
|
255
|
+
entry = def.on?.[event.type as E['type']] as RawTransition | RawTransition[] | undefined
|
|
256
|
+
}
|
|
251
257
|
if (!entry) return
|
|
252
258
|
|
|
253
259
|
const h = helpers()
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EventObject,
|
|
7
7
|
Lifecycle,
|
|
8
8
|
MachineDef,
|
|
9
|
+
OnMap,
|
|
9
10
|
ReadsMap,
|
|
10
11
|
SelectorMap,
|
|
11
12
|
StateNode,
|
|
@@ -34,6 +35,13 @@ export interface DefineMachineConfig<
|
|
|
34
35
|
initial: NoInfer<S>
|
|
35
36
|
/** Transitions see `helpers.reads` typed as `ReadsMap<TReads>`. */
|
|
36
37
|
states: Record<S, StateNode<C, E, S, ReadsMap<TReads>>>
|
|
38
|
+
/** Machine-level transitions: handlers that apply in ANY state, consulted only
|
|
39
|
+
* when the current state does not declare the event (a state-scoped handler
|
|
40
|
+
* always wins). The home for a completion event whose handling must not depend
|
|
41
|
+
* on an unrelated machine-wide state — e.g. a per-record save completing while
|
|
42
|
+
* the machine is busy reloading the collection. Without this, such a
|
|
43
|
+
* completion is dropped wherever the current state has no handler for it. */
|
|
44
|
+
on?: OnMap<C, E, S, ReadsMap<TReads>>
|
|
37
45
|
selectors?: Sel
|
|
38
46
|
/** APP machines only: persist this machine's snapshot through the AppStore
|
|
39
47
|
* so its state survives restarts. Opt-in — caches and other
|
|
@@ -106,6 +114,7 @@ export function defineMachine<
|
|
|
106
114
|
capabilities: computeCapabilities(reads),
|
|
107
115
|
initial: config.initial,
|
|
108
116
|
states: config.states as Record<string, StateNode<C, E, S>>,
|
|
117
|
+
on: config.on as OnMap<C, E, S> | undefined,
|
|
109
118
|
context: config.context,
|
|
110
119
|
__context: undefined as unknown as C,
|
|
111
120
|
__event: undefined as unknown as E,
|
package/src/engine/index.ts
CHANGED
package/src/engine/types.ts
CHANGED
|
@@ -252,6 +252,9 @@ export interface MachineDef<
|
|
|
252
252
|
* reads typing that matters is enforced at the authoring callsite) and the
|
|
253
253
|
* initial context. */
|
|
254
254
|
states: Record<string, StateNode<C, E, S>>
|
|
255
|
+
/** Machine-level transitions — handlers consulted when the current state does
|
|
256
|
+
* not declare the event (state-scoped handlers take precedence). */
|
|
257
|
+
on?: OnMap<C, E, S>
|
|
255
258
|
context: C
|
|
256
259
|
/** Type-level carriers — never read at runtime. */
|
|
257
260
|
readonly __context: C
|
|
@@ -285,6 +288,13 @@ export type ReadsMap<TReads extends readonly AnyMachineDef[]> = {
|
|
|
285
288
|
export type EventOf<D extends AnyMachineDef> =
|
|
286
289
|
D extends MachineDef<any, infer E, any, any, any> ? E : never
|
|
287
290
|
|
|
291
|
+
/** The state-name union a machine declares (the keys of its `states`). Threads
|
|
292
|
+
* the literal names into `instance.state` so a template comparison
|
|
293
|
+
* (`read(m, s => s.state === 'ready')`) autocompletes and a typo is a compile
|
|
294
|
+
* error, instead of `state: string` swallowing it. */
|
|
295
|
+
export type StateNameOf<D extends AnyMachineDef> =
|
|
296
|
+
D extends MachineDef<any, any, infer S, any, any> ? S : string
|
|
297
|
+
|
|
288
298
|
export function isStatorMachine(v: unknown): v is AnyMachineDef {
|
|
289
299
|
return (
|
|
290
300
|
typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isStatorMachine === true
|
|
@@ -104,9 +104,9 @@ export function when<T>(cond: T | ReadResult<T>, fn: () => HtmlFragment): Branch
|
|
|
104
104
|
})
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
const html =
|
|
107
|
+
// Comment markers delimit the region (no injected node, valid in restricted
|
|
108
|
+
// parents) — see each.ts for the rationale.
|
|
109
|
+
const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
|
|
110
110
|
return { __isBranchResult: true, html, slotId }
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -160,8 +160,8 @@ export function match<TKey extends string>(
|
|
|
160
160
|
})
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
const html =
|
|
163
|
+
// Comment markers delimit the region (no injected node, valid in restricted
|
|
164
|
+
// parents) — see each.ts for the rationale.
|
|
165
|
+
const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
|
|
166
166
|
return { __isBranchResult: true, html, slotId }
|
|
167
167
|
}
|
package/src/template/defer.ts
CHANGED
|
@@ -32,9 +32,9 @@ export function deferSentinel(slotId: SlotId): string {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function deferPlaceholder(slotId: SlotId, inner: string): DeferResult {
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
const html =
|
|
35
|
+
// Comment markers delimit the region (no injected node, valid in restricted
|
|
36
|
+
// parents) — same rationale as each()/when().
|
|
37
|
+
const html = `<!--s:${slotId}-->${inner}<!--/s:${slotId}-->`
|
|
38
38
|
return { __isDeferResult: true, html, slotId }
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -3,9 +3,13 @@ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
|
|
|
3
3
|
|
|
4
4
|
type Handler = () => EventDescriptor | undefined
|
|
5
5
|
|
|
6
|
-
const onDirective = defineDirective<Handler>({
|
|
6
|
+
const onDirective = defineDirective<Handler | null | undefined>({
|
|
7
7
|
name: 'on',
|
|
8
8
|
apply({ modifier, arg, addAttribute }) {
|
|
9
|
+
// A forwarded/optional handler may be absent — `on:click={onClick}` where the
|
|
10
|
+
// caller passed no handler (Stator.forwarded returns undefined). Emit no
|
|
11
|
+
// binding rather than crash on `arg()`.
|
|
12
|
+
if (arg == null) return
|
|
9
13
|
const result = arg()
|
|
10
14
|
if (!isEventDescriptor(result)) {
|
|
11
15
|
throw new Error(
|
|
@@ -17,6 +21,9 @@ const onDirective = defineDirective<Handler>({
|
|
|
17
21
|
},
|
|
18
22
|
})
|
|
19
23
|
|
|
20
|
-
export function on(
|
|
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
|
+
}
|
package/src/template/each.ts
CHANGED
|
@@ -136,12 +136,11 @@ export function each<T>(
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
const html = `<span data-slot="${slotId}" data-list="true" style="display:contents">${innerHtml}</span>`
|
|
139
|
+
// Comment markers delimit the region instead of a wrapper element: they inject
|
|
140
|
+
// no node into the user's DOM (no layout box, no CSS sibling/child selector
|
|
141
|
+
// pollution) and are valid in restricted parents like <table>/<tbody> where a
|
|
142
|
+
// <span> is foster-parented out. The client addresses the list by its markers.
|
|
143
|
+
const html = `<!--s:${slotId}-->${innerHtml}<!--/s:${slotId}-->`
|
|
145
144
|
return { __isEachResult: true, html, slotId }
|
|
146
145
|
}
|
|
147
146
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-only HTML attribute surface for authoring components that extend a native
|
|
3
|
+
* element — the Astro/Svelte `HTMLAttributes<'button'>` pattern. So a component
|
|
4
|
+
* can be `Stator.props<HTMLAttributes<'button'> & { variant: 'primary' }>()` and
|
|
5
|
+
* get every native button attribute typed + validated, plus its own props.
|
|
6
|
+
*
|
|
7
|
+
* Hand-authored and intentionally LEAN: a useful subset, not the whole DOM. It
|
|
8
|
+
* mirrors `svelte/elements`' shape — a shared base (global attrs + ARIA + the
|
|
9
|
+
* Stator directives) plus a per-element attribute map, combined by tag. Grow the
|
|
10
|
+
* per-element map on demand; there is no compiler machinery behind this.
|
|
11
|
+
*
|
|
12
|
+
* STATOR REACTIVITY: a template attribute value can be a literal OR a live
|
|
13
|
+
* `read(...)` binding (`ReadResult<T>`), so every non-directive attribute value
|
|
14
|
+
* is `Reactive<T>`. This is the adaptation that makes typed intrinsics coexist
|
|
15
|
+
* with Stator templates — the load-bearing difference from Astro/Svelte, where
|
|
16
|
+
* attributes are plain values.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ReadResult } from './read.ts'
|
|
20
|
+
|
|
21
|
+
/** A template attribute value: a literal, or a live `read(...)` binding. */
|
|
22
|
+
export type Reactive<T> = T | ReadResult<T>
|
|
23
|
+
|
|
24
|
+
/** Wrap every property value in `Reactive` (bindings allowed on any attribute).
|
|
25
|
+
* Homomorphic, so it preserves the `data-*`/`aria-*` index signatures. */
|
|
26
|
+
type Reactivize<A> = { [K in keyof A]: Reactive<A[K]> }
|
|
27
|
+
|
|
28
|
+
/** Stator template directives — colon-namespaced, valid on any element and on any
|
|
29
|
+
* component that forwards them. Not reactivized: their values are handlers/specs,
|
|
30
|
+
* not bindings. Values are `unknown` for now — a later pass gives `on:`/`bind:`/
|
|
31
|
+
* `ref:` their Stator-specific types. */
|
|
32
|
+
export interface StatorDirectiveAttributes {
|
|
33
|
+
'class:list'?: unknown
|
|
34
|
+
'style:list'?: unknown
|
|
35
|
+
[key: `on:${string}`]: unknown
|
|
36
|
+
[key: `bind:${string}`]: unknown
|
|
37
|
+
[key: `ref:${string}`]: unknown
|
|
38
|
+
[key: `class:${string}`]: unknown
|
|
39
|
+
[key: `style:${string}`]: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** ARIA — `role` plus the open-ended `aria-*` family. */
|
|
43
|
+
export interface AriaAttributes {
|
|
44
|
+
role?: string
|
|
45
|
+
[key: `aria-${string}`]: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Global attributes present on every HTML element (a lean subset). Numeric
|
|
49
|
+
* attributes accept `string` too, since templates write them as strings. */
|
|
50
|
+
export interface GlobalHTMLAttributes {
|
|
51
|
+
class?: string
|
|
52
|
+
id?: string
|
|
53
|
+
style?: string
|
|
54
|
+
title?: string
|
|
55
|
+
hidden?: boolean
|
|
56
|
+
tabindex?: number | string
|
|
57
|
+
lang?: string
|
|
58
|
+
dir?: 'ltr' | 'rtl' | 'auto'
|
|
59
|
+
draggable?: boolean | 'true' | 'false'
|
|
60
|
+
popover?: boolean | string
|
|
61
|
+
/** Stator named-slot target — `child="header"` fills `<children name="header"/>`. */
|
|
62
|
+
child?: string
|
|
63
|
+
[key: `data-${string}`]: unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Per-element extra attributes. Extend as components need them. */
|
|
67
|
+
export interface ElementSpecificAttributes {
|
|
68
|
+
a: { href?: string; target?: string; rel?: string; download?: string | boolean }
|
|
69
|
+
button: {
|
|
70
|
+
type?: 'submit' | 'reset' | 'button'
|
|
71
|
+
disabled?: boolean
|
|
72
|
+
name?: string
|
|
73
|
+
value?: string
|
|
74
|
+
form?: string
|
|
75
|
+
autofocus?: boolean
|
|
76
|
+
popovertarget?: string
|
|
77
|
+
popovertargetaction?: 'show' | 'hide' | 'toggle'
|
|
78
|
+
}
|
|
79
|
+
input: {
|
|
80
|
+
type?: string
|
|
81
|
+
name?: string
|
|
82
|
+
value?: string | number
|
|
83
|
+
placeholder?: string
|
|
84
|
+
disabled?: boolean
|
|
85
|
+
required?: boolean
|
|
86
|
+
readonly?: boolean
|
|
87
|
+
checked?: boolean
|
|
88
|
+
min?: string | number
|
|
89
|
+
max?: string | number
|
|
90
|
+
step?: string | number
|
|
91
|
+
minlength?: number | string
|
|
92
|
+
maxlength?: number | string
|
|
93
|
+
pattern?: string
|
|
94
|
+
autocomplete?: string
|
|
95
|
+
inputmode?: string
|
|
96
|
+
autofocus?: boolean
|
|
97
|
+
}
|
|
98
|
+
label: { for?: string }
|
|
99
|
+
form: {
|
|
100
|
+
action?: string
|
|
101
|
+
method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
|
|
102
|
+
novalidate?: boolean
|
|
103
|
+
}
|
|
104
|
+
select: {
|
|
105
|
+
name?: string
|
|
106
|
+
disabled?: boolean
|
|
107
|
+
required?: boolean
|
|
108
|
+
multiple?: boolean
|
|
109
|
+
value?: string | number
|
|
110
|
+
}
|
|
111
|
+
option: { value?: string | number; selected?: boolean; disabled?: boolean }
|
|
112
|
+
textarea: {
|
|
113
|
+
name?: string
|
|
114
|
+
placeholder?: string
|
|
115
|
+
rows?: number | string
|
|
116
|
+
cols?: number | string
|
|
117
|
+
disabled?: boolean
|
|
118
|
+
required?: boolean
|
|
119
|
+
readonly?: boolean
|
|
120
|
+
minlength?: number | string
|
|
121
|
+
maxlength?: number | string
|
|
122
|
+
}
|
|
123
|
+
img: {
|
|
124
|
+
src?: string
|
|
125
|
+
alt?: string
|
|
126
|
+
width?: number | string
|
|
127
|
+
height?: number | string
|
|
128
|
+
loading?: 'eager' | 'lazy'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The attribute type for a native element `Tag`. Intersect it in a component's
|
|
134
|
+
* props to forward native attributes:
|
|
135
|
+
*
|
|
136
|
+
* const { variant, ...rest } =
|
|
137
|
+
* Stator.props<HTMLAttributes<'button'> & { variant: 'primary' | 'danger' }>()
|
|
138
|
+
*
|
|
139
|
+
* `Tag` defaults to the shared base (global + ARIA + directives) for elements not
|
|
140
|
+
* in the per-element map. HTML attribute values are `Reactive` (literal or live
|
|
141
|
+
* binding); directives keep their own value types.
|
|
142
|
+
*/
|
|
143
|
+
export type HTMLAttributes<Tag extends string = string> = Reactivize<
|
|
144
|
+
GlobalHTMLAttributes &
|
|
145
|
+
AriaAttributes &
|
|
146
|
+
(Tag extends keyof ElementSpecificAttributes
|
|
147
|
+
? ElementSpecificAttributes[Tag]
|
|
148
|
+
: Record<never, never>)
|
|
149
|
+
> &
|
|
150
|
+
StatorDirectiveAttributes
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The per-element intrinsic map wired into `JSX.IntrinsicElements`, so a typo on
|
|
154
|
+
* a plain `<button typ=>` is a real error. Common HTML tags are typed;
|
|
155
|
+
* the `[tag: string]: any` fallback keeps custom elements (client-island tags
|
|
156
|
+
* like `<live-sky>`) and everything not listed (SVG, rare tags) permissive — the
|
|
157
|
+
* escape valve that stops the typing from flooding real templates. Every explicit
|
|
158
|
+
* entry is assignable to `any`, so the index signature is satisfied.
|
|
159
|
+
*/
|
|
160
|
+
export interface StatorIntrinsicElements {
|
|
161
|
+
a: HTMLAttributes<'a'>
|
|
162
|
+
button: HTMLAttributes<'button'>
|
|
163
|
+
input: HTMLAttributes<'input'>
|
|
164
|
+
select: HTMLAttributes<'select'>
|
|
165
|
+
option: HTMLAttributes<'option'>
|
|
166
|
+
textarea: HTMLAttributes<'textarea'>
|
|
167
|
+
label: HTMLAttributes<'label'>
|
|
168
|
+
form: HTMLAttributes<'form'>
|
|
169
|
+
img: HTMLAttributes<'img'>
|
|
170
|
+
div: HTMLAttributes<'div'>
|
|
171
|
+
span: HTMLAttributes<'span'>
|
|
172
|
+
p: HTMLAttributes<'p'>
|
|
173
|
+
h1: HTMLAttributes<'h1'>
|
|
174
|
+
h2: HTMLAttributes<'h2'>
|
|
175
|
+
h3: HTMLAttributes<'h3'>
|
|
176
|
+
h4: HTMLAttributes<'h4'>
|
|
177
|
+
h5: HTMLAttributes<'h5'>
|
|
178
|
+
h6: HTMLAttributes<'h6'>
|
|
179
|
+
ul: HTMLAttributes<'ul'>
|
|
180
|
+
ol: HTMLAttributes<'ol'>
|
|
181
|
+
li: HTMLAttributes<'li'>
|
|
182
|
+
nav: HTMLAttributes<'nav'>
|
|
183
|
+
header: HTMLAttributes<'header'>
|
|
184
|
+
footer: HTMLAttributes<'footer'>
|
|
185
|
+
section: HTMLAttributes<'section'>
|
|
186
|
+
main: HTMLAttributes<'main'>
|
|
187
|
+
article: HTMLAttributes<'article'>
|
|
188
|
+
aside: HTMLAttributes<'aside'>
|
|
189
|
+
strong: HTMLAttributes<'strong'>
|
|
190
|
+
em: HTMLAttributes<'em'>
|
|
191
|
+
small: HTMLAttributes<'small'>
|
|
192
|
+
code: HTMLAttributes<'code'>
|
|
193
|
+
pre: HTMLAttributes<'pre'>
|
|
194
|
+
time: HTMLAttributes<'time'>
|
|
195
|
+
cite: HTMLAttributes<'cite'>
|
|
196
|
+
hr: HTMLAttributes<'hr'>
|
|
197
|
+
br: HTMLAttributes<'br'>
|
|
198
|
+
figure: HTMLAttributes<'figure'>
|
|
199
|
+
figcaption: HTMLAttributes<'figcaption'>
|
|
200
|
+
details: HTMLAttributes<'details'>
|
|
201
|
+
summary: HTMLAttributes<'summary'>
|
|
202
|
+
dialog: HTMLAttributes<'dialog'>
|
|
203
|
+
table: HTMLAttributes<'table'>
|
|
204
|
+
thead: HTMLAttributes<'thead'>
|
|
205
|
+
tbody: HTMLAttributes<'tbody'>
|
|
206
|
+
tr: HTMLAttributes<'tr'>
|
|
207
|
+
td: HTMLAttributes<'td'>
|
|
208
|
+
th: HTMLAttributes<'th'>
|
|
209
|
+
fieldset: HTMLAttributes<'fieldset'>
|
|
210
|
+
legend: HTMLAttributes<'legend'>
|
|
211
|
+
// biome-ignore lint/suspicious/noExplicitAny: escape valve — custom-element islands, SVG, and unlisted tags stay fully permissive; every explicit entry above is assignable to this index signature.
|
|
212
|
+
[tag: string]: any
|
|
213
|
+
}
|
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
|
}
|
package/src/wire/apply.ts
CHANGED
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* path applied it.
|
|
10
10
|
*/
|
|
11
11
|
import type { Directive, Patch } from './index.ts'
|
|
12
|
+
import {
|
|
13
|
+
elementAt,
|
|
14
|
+
findRegion,
|
|
15
|
+
insertAt,
|
|
16
|
+
moveWithin,
|
|
17
|
+
removeAt,
|
|
18
|
+
replaceRegion,
|
|
19
|
+
} from './region-apply.ts'
|
|
12
20
|
import { isSafeNavigationUrl } from './safe-url.ts'
|
|
13
21
|
|
|
14
22
|
function emit(name: string, detail: unknown): void {
|
|
@@ -22,44 +30,79 @@ export function resolveTarget(target: { kind: 'slot' | 'element'; id: string }):
|
|
|
22
30
|
return document.querySelector(`[data-stator-id="${target.id}"]`)
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
function warnMissing(target: { kind: string; id: string }): void {
|
|
34
|
+
// A missing target means this DOM diverged from server truth (stale non-live
|
|
35
|
+
// page, or client code removed server-owned nodes). Skipping is safe — arm/
|
|
36
|
+
// key-scoped ids guarantee a patch can't land on the wrong content — but the
|
|
37
|
+
// divergence is worth surfacing.
|
|
38
|
+
console.warn(`stator: patch target ${target.kind} "${target.id}" not in DOM — skipped`)
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
export function applyPatches(patches: Patch[]): void {
|
|
42
|
+
// Resolve each region's marker pair once per batch. The markers are stable
|
|
43
|
+
// anchors — ops mutate only the nodes between them — so a pair found for one
|
|
44
|
+
// op stays valid for the rest of the batch (keyed lists emit a run of ops on
|
|
45
|
+
// the same slot). null caches a genuine miss.
|
|
46
|
+
const regions = new Map<string, { start: Comment; end: Comment } | null>()
|
|
47
|
+
const regionFor = (id: string): { start: Comment; end: Comment } | null => {
|
|
48
|
+
let r = regions.get(id)
|
|
49
|
+
if (r === undefined) {
|
|
50
|
+
r = findRegion(document, id)
|
|
51
|
+
regions.set(id, r)
|
|
52
|
+
}
|
|
53
|
+
return r
|
|
54
|
+
}
|
|
55
|
+
|
|
26
56
|
for (const patch of patches) {
|
|
57
|
+
const element = applyPatch(patch, regionFor)
|
|
58
|
+
emit('stator:patch-applied', { patch, element, timestamp: Date.now() })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Apply one patch. Returns the element the inspector should flash (or null when
|
|
63
|
+
* the target is missing or the change left no single node to highlight). */
|
|
64
|
+
function applyPatch(
|
|
65
|
+
patch: Patch,
|
|
66
|
+
regionFor: (id: string) => { start: Comment; end: Comment } | null,
|
|
67
|
+
): Element | null {
|
|
68
|
+
// `text`/`attr` address a single node: a text-binding `data-slot` span or a
|
|
69
|
+
// `data-stator-id` element.
|
|
70
|
+
if (patch.op === 'text' || patch.op === 'attr') {
|
|
27
71
|
const element = resolveTarget(patch.target)
|
|
28
72
|
if (!element) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// is safe — arm/key-scoped slot ids guarantee a patch can't land on
|
|
32
|
-
// the wrong content — but the divergence is worth surfacing.
|
|
33
|
-
console.warn(
|
|
34
|
-
`stator: patch target ${patch.target.kind} "${patch.target.id}" not in DOM — skipped`,
|
|
35
|
-
)
|
|
36
|
-
continue
|
|
73
|
+
warnMissing(patch.target)
|
|
74
|
+
return null
|
|
37
75
|
}
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
if (patch.op === 'text') element.textContent = patch.value
|
|
77
|
+
else if (patch.value === null) element.removeAttribute(patch.name)
|
|
78
|
+
else element.setAttribute(patch.name, patch.value)
|
|
79
|
+
return element
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `html`/`insert`/`remove`/`move` address a REGION delimited by comment
|
|
83
|
+
// markers (`<!--s:id-->…<!--/s:id-->`). Keyed-list ops address element
|
|
84
|
+
// children by index, sequentially: each op sees the DOM as left by the
|
|
85
|
+
// previous one (see wire/index.ts).
|
|
86
|
+
const region = regionFor(patch.target.id)
|
|
87
|
+
if (!region) {
|
|
88
|
+
warnMissing(patch.target)
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
const { start, end } = region
|
|
92
|
+
if (patch.op === 'html') {
|
|
93
|
+
replaceRegion(start, end, patch.value)
|
|
94
|
+
return start.parentElement
|
|
95
|
+
}
|
|
96
|
+
if (patch.op === 'insert') {
|
|
97
|
+
insertAt(start, end, patch.index, patch.value)
|
|
98
|
+
return elementAt(start, end, patch.index)
|
|
99
|
+
}
|
|
100
|
+
if (patch.op === 'remove') {
|
|
101
|
+
removeAt(start, end, patch.index)
|
|
102
|
+
return start.parentElement
|
|
62
103
|
}
|
|
104
|
+
moveWithin(start, end, patch.from, patch.to)
|
|
105
|
+
return elementAt(start, end, patch.to)
|
|
63
106
|
}
|
|
64
107
|
|
|
65
108
|
export function applyDirectives(directives: Directive[]): void {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment-marker region boundaries. A reactive region (`each`/`when`/`match`/
|
|
3
|
+
* `defer`) is delimited on the client by a pair of HTML comments
|
|
4
|
+
* `<!--s:ID-->…<!--/s:ID-->` instead of a wrapper element, so the framework
|
|
5
|
+
* injects no node into the user's authored DOM — comments are valid anywhere
|
|
6
|
+
* (including in `<table>`/`<tbody>` where a `<span>` is foster-parented out) and
|
|
7
|
+
* do not participate in the CSS sibling/child selector graph.
|
|
8
|
+
*
|
|
9
|
+
* A region is the run of nodes strictly between its two markers, sharing the
|
|
10
|
+
* markers' parent. Keyed-list ops (insert/remove/move) address the region's
|
|
11
|
+
* ELEMENT children by index — the same contract as the old wrapper's
|
|
12
|
+
* `element.children[i]`, just counted between markers instead of inside a span.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const startTag = (id: string): string => `s:${id}`
|
|
16
|
+
export const endTag = (id: string): string => `/s:${id}`
|
|
17
|
+
|
|
18
|
+
/** Parse a trusted HTML fragment through a `<template>`, whose "in template"
|
|
19
|
+
* insertion mode preserves table-context children (`<tr>`, `<td>`, `<option>`)
|
|
20
|
+
* that a plain element's `innerHTML` would drop. */
|
|
21
|
+
export function parseFragment(html: string): DocumentFragment {
|
|
22
|
+
const tpl = document.createElement('template')
|
|
23
|
+
tpl.innerHTML = html
|
|
24
|
+
return tpl.content
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Locate a region's marker pair by scanning comment nodes under `root`.
|
|
28
|
+
* (Prototype: linear scan. Production adds a hydration-time index keyed by id.) */
|
|
29
|
+
export function findRegion(root: Node, id: string): { start: Comment; end: Comment } | null {
|
|
30
|
+
const s = startTag(id)
|
|
31
|
+
const e = endTag(id)
|
|
32
|
+
let start: Comment | null = null
|
|
33
|
+
const walk = (node: Node): Comment | null => {
|
|
34
|
+
for (let n = node.firstChild; n; n = n.nextSibling) {
|
|
35
|
+
if (n.nodeType === 8 /* Comment */) {
|
|
36
|
+
const data = (n as Comment).data
|
|
37
|
+
if (data === s) start = n as Comment
|
|
38
|
+
else if (data === e && start) return n as Comment
|
|
39
|
+
}
|
|
40
|
+
const found = walk(n)
|
|
41
|
+
if (found) return found
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
const end = walk(root)
|
|
46
|
+
return start && end ? { start, end } : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The i-th ELEMENT between the markers (mirrors `element.children[i]`); text
|
|
50
|
+
* and nested-region comment nodes are skipped. Null when out of range. */
|
|
51
|
+
export function elementAt(start: Comment, end: Comment, index: number): Element | null {
|
|
52
|
+
let i = 0
|
|
53
|
+
for (let n = start.nextSibling; n && n !== end; n = n.nextSibling) {
|
|
54
|
+
if (n.nodeType === 1 /* Element */) {
|
|
55
|
+
if (i === index) return n as Element
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Count element children in the region. */
|
|
63
|
+
export function elementCount(start: Comment, end: Comment): number {
|
|
64
|
+
let i = 0
|
|
65
|
+
for (let n = start.nextSibling; n && n !== end; n = n.nextSibling) {
|
|
66
|
+
if (n.nodeType === 1) i++
|
|
67
|
+
}
|
|
68
|
+
return i
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Insert `html` so its (single-root) element becomes element-index `index`
|
|
72
|
+
* within the region — before the element currently there, or before the end
|
|
73
|
+
* marker when appending. */
|
|
74
|
+
export function insertAt(start: Comment, end: Comment, index: number, html: string): void {
|
|
75
|
+
const ref = elementAt(start, end, index) ?? end
|
|
76
|
+
ref.parentNode?.insertBefore(parseFragment(html), ref)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove the element at `index`. */
|
|
80
|
+
export function removeAt(start: Comment, end: Comment, index: number): void {
|
|
81
|
+
elementAt(start, end, index)?.remove()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Move the element from `from` to `to`. `to` is the index in the list AFTER the
|
|
85
|
+
* removal — matching the wire contract's sequential-op replay. */
|
|
86
|
+
export function moveWithin(start: Comment, end: Comment, from: number, to: number): void {
|
|
87
|
+
const node = elementAt(start, end, from)
|
|
88
|
+
if (!node) return
|
|
89
|
+
node.remove()
|
|
90
|
+
const ref = elementAt(start, end, to) ?? end
|
|
91
|
+
ref.parentNode?.insertBefore(node, ref)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Replace the whole region body (the `html` op) — remove everything between the
|
|
95
|
+
* markers, then insert the freshly-parsed content before the end marker. */
|
|
96
|
+
export function replaceRegion(start: Comment, end: Comment, html: string): void {
|
|
97
|
+
for (let n = start.nextSibling; n && n !== end; ) {
|
|
98
|
+
const next = n.nextSibling
|
|
99
|
+
n.remove()
|
|
100
|
+
n = next
|
|
101
|
+
}
|
|
102
|
+
end.parentNode?.insertBefore(parseFragment(html), end)
|
|
103
|
+
}
|