@statorjs/stator 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/client/bind.ts +34 -0
- package/src/client/client-id.ts +17 -2
- package/src/client/index.ts +3 -2
- package/src/client/machine.ts +61 -46
- package/src/client/use.ts +28 -9
- package/src/compiler/client-emit.ts +21 -44
- package/src/compiler/client-script.ts +6 -2
- package/src/compiler/compile.ts +22 -2
- package/src/compiler/dts.ts +35 -13
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/virtual-code.ts +13 -4
- package/src/engine/actor.ts +3 -22
- package/src/engine/index.ts +1 -0
- package/src/engine/types.ts +7 -0
- package/src/server/http.ts +6 -6
- package/src/server/index.ts +10 -0
- package/src/server/recompute.ts +5 -25
- package/src/template/directives/on.ts +9 -2
- package/src/template/directives/spread.ts +88 -0
- package/src/template/html-attributes.ts +210 -0
- package/src/template/html.ts +19 -34
- package/src/template/index.ts +17 -11
- package/src/template/types.ts +17 -5
- package/src/wire/apply.ts +2 -2
- package/src/wire/attr-value.ts +55 -0
- package/src/wire/island-markers.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"pino-pretty": "^13.1.3",
|
|
89
89
|
"vite": "^6.0.0",
|
|
90
90
|
"vitest": "^2.1.0",
|
|
91
|
-
"@statorjs/stator": "
|
|
91
|
+
"@statorjs/stator": "2.0.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,11 +4,12 @@
|
|
|
4
4
|
* island needs; these are also the symbols an author references directly.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export {
|
|
7
|
+
export { attrValue, setAttr } from '../wire/attr-value.ts'
|
|
8
|
+
export { bind, bindSlot, effect } from './bind.ts'
|
|
8
9
|
export type { DispatchResult } from './dispatch.ts'
|
|
9
10
|
export { dispatch } from './dispatch.ts'
|
|
10
11
|
export { defineElement, StatorElement } from './element.ts'
|
|
11
|
-
export type { ClientBehavior
|
|
12
|
+
export type { ClientBehavior } from './machine.ts'
|
|
12
13
|
export { machine } from './machine.ts'
|
|
13
14
|
export type { DispatchError } from './transport.ts'
|
|
14
15
|
export type { ClientInstance, ClientInstanceBase } from './use.ts'
|
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
|
|
@@ -42,59 +63,53 @@ export interface ClientBehavior<C> {
|
|
|
42
63
|
select?: Record<string, (ctx: C) => unknown>
|
|
43
64
|
}
|
|
44
65
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* `machine(context, behavior)`. */
|
|
49
|
-
export interface LegacyMachineConfig {
|
|
50
|
-
name?: string
|
|
51
|
-
// biome-ignore lint/suspicious/noExplicitAny: the one-bag form is untypeable by construction — that is exactly why the two-arg form exists
|
|
52
|
-
on?: Record<string, ((ctx: any, ev: ClientEvent) => void) | ClientTransitionObject<any>>
|
|
53
|
-
// biome-ignore lint/suspicious/noExplicitAny: same
|
|
54
|
-
select?: Record<string, (ctx: any) => unknown>
|
|
55
|
-
[key: string]: unknown
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const RESERVED = new Set(['name', 'on', 'select'])
|
|
59
|
-
|
|
60
|
-
// Data-only: no behavior, no selectors.
|
|
66
|
+
// Data-only: no behavior, no events. With `@set` gone (2.0) a machine with
|
|
67
|
+
// no `on` map accepts NOTHING — its state is set at construction (a seed)
|
|
68
|
+
// and read for display; `send` is a type error by construction.
|
|
61
69
|
export function machine<C extends Record<string, unknown>>(
|
|
62
|
-
context: C & { on?: never; select?: never; name?: never },
|
|
63
|
-
): MachineDef<C,
|
|
64
|
-
//
|
|
70
|
+
context: C & { on?: never; select?: never; name?: never; events?: never },
|
|
71
|
+
): MachineDef<C, never, 'active', Record<string, never>>
|
|
72
|
+
// Derived union — no `events:` declared: event NAMES come from the `on` keys
|
|
73
|
+
// (typo-safe send), payloads stay open. Zero authoring change. Ordered BEFORE
|
|
74
|
+
// the declared overload: `events?: never` keeps declared calls falling through.
|
|
65
75
|
export function machine<
|
|
66
76
|
C extends Record<string, unknown>,
|
|
77
|
+
O extends Record<string, ClientTransition<C>>,
|
|
67
78
|
S extends Record<string, (ctx: C) => unknown>,
|
|
68
79
|
>(
|
|
69
|
-
context: C & { on?: never; select?: never; name?: never },
|
|
70
|
-
behavior:
|
|
71
|
-
): MachineDef<C,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
context: C & { on?: never; select?: never; name?: never; events?: never },
|
|
81
|
+
behavior: { name?: string; events?: never; on: O; select?: S },
|
|
82
|
+
): MachineDef<C, DerivedEvents<O>, 'active', S>
|
|
83
|
+
// Declared union (`events: {} as E`) — mirrors defineMachine: full payload
|
|
84
|
+
// typing on send, handlers narrowed per key, undeclared handler keys error.
|
|
85
|
+
export function machine<
|
|
86
|
+
C extends Record<string, unknown>,
|
|
87
|
+
E extends { type: string },
|
|
88
|
+
S extends Record<string, (ctx: C) => unknown>,
|
|
89
|
+
>(
|
|
90
|
+
context: C & { on?: never; select?: never; name?: never; events?: never },
|
|
91
|
+
behavior: { name?: string; events: E; on?: TypedClientOnMap<C, E>; select?: S },
|
|
92
|
+
): MachineDef<C, E, 'active', S>
|
|
93
|
+
// Behavior without `on` (selectors only): derived state, still nothing to
|
|
94
|
+
// send — events are `never`, as data-only.
|
|
95
|
+
export function machine<
|
|
96
|
+
C extends Record<string, unknown>,
|
|
97
|
+
S extends Record<string, (ctx: C) => unknown>,
|
|
98
|
+
>(
|
|
99
|
+
context: C & { on?: never; select?: never; name?: never; events?: never },
|
|
100
|
+
behavior: { name?: string; events?: never; on?: never; select?: S },
|
|
101
|
+
): MachineDef<C, never, 'active', S>
|
|
77
102
|
export function machine(
|
|
78
103
|
first: Record<string, unknown>,
|
|
79
|
-
|
|
104
|
+
// Loose impl signature: the overloads above are the contract. `events` is a
|
|
105
|
+
// type-only phantom (`{} as E`) — the runtime never reads it.
|
|
106
|
+
rawBehavior?: unknown,
|
|
80
107
|
): MachineDef {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
context = first
|
|
87
|
-
name = behavior?.name
|
|
88
|
-
on = (behavior?.on ?? {}) as Record<string, unknown>
|
|
89
|
-
select = (behavior?.select ?? {}) as Record<string, unknown>
|
|
90
|
-
} else {
|
|
91
|
-
// Legacy one-bag: context is every non-reserved key.
|
|
92
|
-
const { name: n, on: o = {}, select: s = {}, ...rest } = first as LegacyMachineConfig
|
|
93
|
-
context = rest
|
|
94
|
-
name = n
|
|
95
|
-
on = o as Record<string, unknown>
|
|
96
|
-
select = s as Record<string, unknown>
|
|
97
|
-
}
|
|
108
|
+
const behavior = rawBehavior as (ClientBehavior<never> & { events?: unknown }) | undefined
|
|
109
|
+
const context = first
|
|
110
|
+
const name = behavior?.name
|
|
111
|
+
const on = (behavior?.on ?? {}) as Record<string, unknown>
|
|
112
|
+
const select = (behavior?.select ?? {}) as Record<string, unknown>
|
|
98
113
|
return defineMachine({
|
|
99
114
|
name: name ?? 'ClientMachine',
|
|
100
115
|
lifecycle: 'session',
|
package/src/client/use.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type AnyActor,
|
|
3
3
|
type AnyMachineDef,
|
|
4
4
|
createActor,
|
|
5
|
+
type EventOf,
|
|
5
6
|
type MachineDef,
|
|
6
7
|
type Snapshot,
|
|
7
8
|
} from '../engine/index.ts'
|
|
@@ -17,17 +18,30 @@ const CLIENT_HELPERS = {
|
|
|
17
18
|
),
|
|
18
19
|
} as never
|
|
19
20
|
|
|
21
|
+
/** Event names sendable as a bare string: only members whose shape is
|
|
22
|
+
* satisfied by `{ type }` alone — an event with a required payload must be
|
|
23
|
+
* sent as an object. (For the loose ClientEvent this admits any string,
|
|
24
|
+
* preserving the untyped tier's behavior.) */
|
|
25
|
+
export type BareEventTypes<E extends { type: string }> = E extends { type: infer T extends string }
|
|
26
|
+
? { type: T } extends E
|
|
27
|
+
? T
|
|
28
|
+
: never
|
|
29
|
+
: never
|
|
30
|
+
|
|
20
31
|
/**
|
|
21
32
|
* The client-side reactive handle for a machine. Returned by `use()`, held as a
|
|
22
33
|
* class field (`qty = use(Qty)`). Exposes:
|
|
23
34
|
* - each selector / context key as a live property (read through the actor's
|
|
24
35
|
* current snapshot on every access — the client mirror of the server
|
|
25
36
|
* instance proxy). This is what `bind:text={qty.count}` reads.
|
|
26
|
-
* - `send(event)` to drive transitions
|
|
37
|
+
* - `send(event)` to drive transitions, typed to the machine's event union
|
|
38
|
+
* (declared `events:`, derived from `on` keys, or loose for data-only).
|
|
27
39
|
* - the underlying actor (non-enumerable) for the binding loop to subscribe.
|
|
28
40
|
*/
|
|
29
|
-
export interface ClientInstanceBase
|
|
30
|
-
|
|
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. */
|
|
@@ -52,7 +67,14 @@ export interface CollectedActor {
|
|
|
52
67
|
|
|
53
68
|
/** Active collector: `use()` registers its actor here during element
|
|
54
69
|
* construction, so `StatorElement` can start/seed/stop them on connect/disconnect.
|
|
55
|
-
* A stack supports nested construction (rare, but correct).
|
|
70
|
+
* A stack supports nested construction (rare, but correct).
|
|
71
|
+
*
|
|
72
|
+
* Dual-bundle note (the clientId lesson): module state is PER BUNDLE, and
|
|
73
|
+
* the page runtime and island modules are separate bundles. This stack is
|
|
74
|
+
* safe because it is construction-scoped and both its writer (`use()`) and
|
|
75
|
+
* its reader (`StatorElement`) always come from the SAME bundle as the
|
|
76
|
+
* island class using them. State that must be PAGE-scoped (one value across
|
|
77
|
+
* all bundles) must live on `window` — see client-id.ts. */
|
|
56
78
|
const collectors: CollectedActor[][] = []
|
|
57
79
|
|
|
58
80
|
export function pushCollector(): CollectedActor[] {
|
|
@@ -85,10 +107,7 @@ export function use<D extends MachineDef>(
|
|
|
85
107
|
context: { ...(def.context as object), ...eager },
|
|
86
108
|
}
|
|
87
109
|
: undefined
|
|
88
|
-
|
|
89
|
-
// `bind:value`); the actor lives in the browser and only its own compiled
|
|
90
|
-
// bind code sends them. Server actors deliberately do NOT (see createActor).
|
|
91
|
-
const actor = createActor(def as AnyMachineDef, { snapshot, internalEvents: true })
|
|
110
|
+
const actor = createActor(def as AnyMachineDef, { snapshot })
|
|
92
111
|
|
|
93
112
|
// Register with the element under construction so its lifecycle owns the actor.
|
|
94
113
|
const bucket = collectors[collectors.length - 1]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { elementMarkerSelector } from '../wire/island-markers.ts'
|
|
2
3
|
import type { ClientDirective, ClientElement } from './client-script.ts'
|
|
3
|
-
import { CompileError } from './diagnostics.ts'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Phase 3b stage 5 — emit the client entry module for a client component: the
|
|
@@ -14,7 +14,7 @@ import { CompileError } from './diagnostics.ts'
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const PRIMITIVES =
|
|
17
|
-
"import { StatorElement, defineElement, use, machine, bind, effect, dispatch } from '@statorjs/stator/client'"
|
|
17
|
+
"import { StatorElement, defineElement, use, machine, bind, bindSlot, effect, dispatch, attrValue, setAttr } from '@statorjs/stator/client'"
|
|
18
18
|
|
|
19
19
|
export interface EmitClientInput {
|
|
20
20
|
/** The author's `<script>` source. */
|
|
@@ -40,9 +40,22 @@ export function emitClientModule(input: EmitClientInput): string {
|
|
|
40
40
|
const lines: string[] = []
|
|
41
41
|
let i = 0
|
|
42
42
|
for (const [marker, group] of byMarker) {
|
|
43
|
+
if (group[0]?.kind === 'slot') {
|
|
44
|
+
// Text slot: the runtime finds every `<!--sN-->` comment, materializes a
|
|
45
|
+
// text node per occurrence, and binds them all to the one thunk.
|
|
46
|
+
for (const d of group) {
|
|
47
|
+
const thunk = `() => (${rewriteMembers(d.expr, members)})`
|
|
48
|
+
const deps = `[${d.deps.map((dep) => `this.${dep}`).join(', ')}]`
|
|
49
|
+
lines.push(` this.track(bindSlot(this, ${JSON.stringify(marker)}, ${deps}, ${thunk}))`)
|
|
50
|
+
}
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
// Element wiring: every occurrence of the marker is wired — a marked
|
|
54
|
+
// element inside a `.map()` repeats per row.
|
|
43
55
|
const node = `n${i++}`
|
|
44
|
-
lines.push(
|
|
45
|
-
|
|
56
|
+
lines.push(
|
|
57
|
+
` for (const ${node} of this.querySelectorAll(${JSON.stringify(elementMarkerSelector(marker))})) {`,
|
|
58
|
+
)
|
|
46
59
|
for (const d of group) lines.push(` ${wireDirective(node, d, members)}`)
|
|
47
60
|
lines.push(' }')
|
|
48
61
|
}
|
|
@@ -67,42 +80,14 @@ function wireDirective(node: string, d: ClientDirective, members: Set<string>):
|
|
|
67
80
|
const handler = emitHandler(d.expr, members)
|
|
68
81
|
return `${node}.addEventListener(${JSON.stringify(d.event)}, ${handler})`
|
|
69
82
|
}
|
|
70
|
-
// bind:
|
|
83
|
+
// kind 'bind': an attribute-position client read() — one-way display.
|
|
71
84
|
const target = d.target ?? 'text'
|
|
72
85
|
const thunk = `() => (${rewriteMembers(d.expr, members)})`
|
|
73
86
|
const deps = `[${d.deps.map((dep) => `this.${dep}`).join(', ')}]`
|
|
74
|
-
|
|
75
|
-
// value / checked are TWO-WAY: state→DOM (below) PLUS a DOM→state listener
|
|
76
|
-
// that `@set`s the bound context key. The expression must be a settable
|
|
77
|
-
// `<actor>.<key>` path (a derived selector can't be assigned).
|
|
78
|
-
if (target === 'value' || target === 'checked') {
|
|
79
|
-
const path = parseTwoWayPath(d.expr)
|
|
80
|
-
if (!path) {
|
|
81
|
-
throw new CompileError(
|
|
82
|
-
`stator: bind:${target}={${d.expr}} must bind to a settable context path ` +
|
|
83
|
-
`like \`actor.key\` (a derived value can't be two-way bound).`,
|
|
84
|
-
)
|
|
85
|
-
}
|
|
86
|
-
const writer = emitWriter(node, target)
|
|
87
|
-
const read = target === 'checked' ? `${node}.checked` : `${node}.value`
|
|
88
|
-
const guard = target === 'value' ? `if (e.isComposing) return; ` : ''
|
|
89
|
-
const setter =
|
|
90
|
-
`${node}.addEventListener(${JSON.stringify(target === 'value' ? 'input' : 'change')}, (e) => { ` +
|
|
91
|
-
`${guard}this.${path.actor}.send({ type: '@set', key: ${JSON.stringify(path.key)}, value: ${read} }) })`
|
|
92
|
-
return `this.track(bind(${deps}, ${thunk}, ${writer}));\n ${setter}`
|
|
93
|
-
}
|
|
94
|
-
|
|
95
87
|
const writer = emitWriter(node, target)
|
|
96
88
|
return `this.track(bind(${deps}, ${thunk}, ${writer}))`
|
|
97
89
|
}
|
|
98
90
|
|
|
99
|
-
/** Parse a two-way bind expression `actor.key` into its parts; null if it isn't
|
|
100
|
-
* a simple single-level member access (not assignable). */
|
|
101
|
-
function parseTwoWayPath(expr: string): { actor: string; key: string } | null {
|
|
102
|
-
const m = expr.trim().match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/)
|
|
103
|
-
return m ? { actor: m[1]!, key: m[2]! } : null
|
|
104
|
-
}
|
|
105
|
-
|
|
106
91
|
/** on: handler — a bare method reference becomes `(e) => this.m(e)`; any other
|
|
107
92
|
* expression is used directly (with member references rewritten). */
|
|
108
93
|
function emitHandler(expr: string, members: Set<string>): string {
|
|
@@ -115,21 +100,13 @@ function emitHandler(expr: string, members: Set<string>): string {
|
|
|
115
100
|
|
|
116
101
|
function emitWriter(node: string, target: string): string {
|
|
117
102
|
switch (target) {
|
|
118
|
-
case 'text':
|
|
119
|
-
return `(v) => { ${node}.textContent = v == null ? '' : String(v) }`
|
|
120
|
-
case 'html':
|
|
121
|
-
return `(v) => { ${node}.innerHTML = v == null ? '' : String(v) }`
|
|
122
|
-
case 'value':
|
|
123
|
-
// Loop-break: only write when the DOM differs, so the echo from the user's
|
|
124
|
-
// own keystroke no-ops and the caret is preserved.
|
|
125
|
-
return `(v) => { const s = v == null ? '' : String(v); if (${node}.value !== s) ${node}.value = s }`
|
|
126
103
|
case 'disabled':
|
|
127
104
|
case 'hidden':
|
|
128
|
-
|
|
105
|
+
// Boolean IDL properties write as properties, not attributes.
|
|
129
106
|
return `(v) => { if (${node}.${target} !== !!v) ${node}.${target} = !!v }`
|
|
130
107
|
default:
|
|
131
|
-
// arbitrary attribute
|
|
132
|
-
return `(v) =>
|
|
108
|
+
// arbitrary attribute — the shared attr contract (wire/attr-value.ts)
|
|
109
|
+
return `(v) => setAttr(${node}, ${JSON.stringify(target)}, attrValue(v))`
|
|
133
110
|
}
|
|
134
111
|
}
|
|
135
112
|
|
|
@@ -216,9 +216,13 @@ function loc(file?: string): DiagnosticLocation | undefined {
|
|
|
216
216
|
* emits the wiring from this.
|
|
217
217
|
*/
|
|
218
218
|
export interface ClientDirective {
|
|
219
|
-
/**
|
|
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
|
@@ -21,7 +21,32 @@ export function generateDts(
|
|
|
21
21
|
if ((opts.kind ?? 'component') === 'route') return null
|
|
22
22
|
|
|
23
23
|
const { frontmatter, template, scripts } = splitStator(source)
|
|
24
|
-
const { hoisted
|
|
24
|
+
const { hoisted } = extractFrontmatterTypes(frontmatter)
|
|
25
|
+
const { propsT, needsReadResult } = statorPropsType(frontmatter, template, scripts)
|
|
26
|
+
|
|
27
|
+
const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
|
|
28
|
+
if (needsReadResult) {
|
|
29
|
+
lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
|
|
30
|
+
}
|
|
31
|
+
if (hoisted) lines.push(hoisted)
|
|
32
|
+
lines.push('')
|
|
33
|
+
lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
|
|
34
|
+
lines.push('export default _default')
|
|
35
|
+
return `${lines.join('\n')}\n`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The component's props type — THE shared computation for the `.d.ts`
|
|
40
|
+
* generator (tsc) and the language-server virtual emit (editor), islands
|
|
41
|
+
* included, so the two can't disagree. `needsReadResult` means the type
|
|
42
|
+
* references `__SReadResult<T>`, which the caller must bring into scope.
|
|
43
|
+
*/
|
|
44
|
+
export function statorPropsType(
|
|
45
|
+
frontmatter: string,
|
|
46
|
+
template: string,
|
|
47
|
+
scripts: string[],
|
|
48
|
+
): { propsT: string; needsReadResult: boolean } {
|
|
49
|
+
const { propsType } = extractFrontmatterTypes(frontmatter)
|
|
25
50
|
let propsT = componentPropsType(propsType, template)
|
|
26
51
|
let needsReadResult = false
|
|
27
52
|
|
|
@@ -29,9 +54,10 @@ export function generateDts(
|
|
|
29
54
|
// `Stator.props`. Each attr accepts its coerced kind OR a live `read()`
|
|
30
55
|
// binding — the compiler lowers read() island props to live attr bindings,
|
|
31
56
|
// so the type surface must admit both.
|
|
32
|
-
|
|
57
|
+
const classes = scripts.length > 0 ? analyzeScriptClasses(scripts.join('\n')) : []
|
|
58
|
+
if (!propsType && classes.length > 0) {
|
|
33
59
|
const attrs = new Map<string, string>()
|
|
34
|
-
for (const cls of
|
|
60
|
+
for (const cls of classes) {
|
|
35
61
|
for (const [key, kind] of cls.staticAttrs) attrs.set(key, kind)
|
|
36
62
|
}
|
|
37
63
|
if (attrs.size > 0) {
|
|
@@ -43,18 +69,14 @@ export function generateDts(
|
|
|
43
69
|
// props an island's SERVER-rendered markup may consume (they never
|
|
44
70
|
// cross the attr wire, so `static attrs` can't know about them).
|
|
45
71
|
propsT = `{ ${fields} } & { [prop: string]: unknown }`
|
|
72
|
+
} else {
|
|
73
|
+
// No declared attrs, but the hydrate contract still holds: an island's
|
|
74
|
+
// server shell may consume any props its markup references. A closed
|
|
75
|
+
// `Record<string, never>` here would reject every use site.
|
|
76
|
+
propsT = '{ [prop: string]: unknown }'
|
|
46
77
|
}
|
|
47
78
|
}
|
|
48
|
-
|
|
49
|
-
const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
|
|
50
|
-
if (needsReadResult) {
|
|
51
|
-
lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
|
|
52
|
-
}
|
|
53
|
-
if (hoisted) lines.push(hoisted)
|
|
54
|
-
lines.push('')
|
|
55
|
-
lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
|
|
56
|
-
lines.push('export default _default')
|
|
57
|
-
return `${lines.join('\n')}\n`
|
|
79
|
+
return { propsT, needsReadResult }
|
|
58
80
|
}
|
|
59
81
|
|
|
60
82
|
/**
|
package/src/compiler/lower.ts
CHANGED
|
Binary file
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import ts from 'typescript'
|
|
22
22
|
import { analyzeScriptClasses } from './client-script.ts'
|
|
23
|
-
import {
|
|
23
|
+
import { statorPropsType } from './dts.ts'
|
|
24
24
|
import { type ScannedRegions, scanRegions } from './split.ts'
|
|
25
25
|
|
|
26
26
|
/** A contiguous run mapping generated code back to source, 1:1 over its length. */
|
|
@@ -92,7 +92,8 @@ function injectImports(globals: string[], modulePath: string, userCode: string):
|
|
|
92
92
|
// `Stator.reads` binding unassignable to component props.
|
|
93
93
|
const AMBIENT_TYPE_IMPORTS =
|
|
94
94
|
"import type { AnyMachineDef as __SMachineDef } from '@statorjs/stator/machine';\n" +
|
|
95
|
-
"import type { InstanceOf as __SInstanceOf } from '@statorjs/stator/template';\n"
|
|
95
|
+
"import type { InstanceOf as __SInstanceOf } from '@statorjs/stator/template';\n" +
|
|
96
|
+
"import type { StatorIntrinsicElements as __SIntrinsics } from '@statorjs/stator/template';\n"
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
99
|
* Per-file ambient scaffold prepended to every virtual TSX. `Stator` (the macro
|
|
@@ -108,12 +109,13 @@ const AMBIENT_TYPE_IMPORTS =
|
|
|
108
109
|
const STATOR_AMBIENT = `declare const Stator: {
|
|
109
110
|
props<P>(): P;
|
|
110
111
|
reads<const T extends readonly __SMachineDef[]>(defs: T): { -readonly [K in keyof T]: __SInstanceOf<T[K]> };
|
|
112
|
+
forwarded(name: \`on:\${string}\`): (() => unknown) | undefined;
|
|
111
113
|
request: any;
|
|
112
114
|
response: { status: number; headers: Record<string, string>; cookies: any };
|
|
113
115
|
};
|
|
114
116
|
declare global {
|
|
115
117
|
namespace JSX {
|
|
116
|
-
interface IntrinsicElements extends
|
|
118
|
+
interface IntrinsicElements extends __SIntrinsics {}
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
`
|
|
@@ -172,7 +174,14 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
|
|
|
172
174
|
// is typed from `Stator.props<P>()` (same extraction as the .d.ts generator),
|
|
173
175
|
// so `<Component bad={...}/>` in OTHER .stator files is checked in-editor.
|
|
174
176
|
// Named prop types resolve because their type/interface decls hoisted above.
|
|
175
|
-
const propsT =
|
|
177
|
+
const { propsT, needsReadResult } = statorPropsType(
|
|
178
|
+
fm,
|
|
179
|
+
regions.template.content,
|
|
180
|
+
regions.scripts.map((s) => s.content),
|
|
181
|
+
)
|
|
182
|
+
if (needsReadResult) {
|
|
183
|
+
code += "type __SReadResult<T> = import('@statorjs/stator/template').ReadResult<T>\n"
|
|
184
|
+
}
|
|
176
185
|
code += `export default function (_props: ${propsT}) {\n`
|
|
177
186
|
for (const seg of body) {
|
|
178
187
|
code += ' '
|