@statorjs/stator 1.9.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.9.0",
3
+ "version": "2.0.1",
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.9.0"
91
+ "@statorjs/stator": "2.0.1"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -4,11 +4,12 @@
4
4
  * island needs; these are also the symbols an author references directly.
5
5
  */
6
6
 
7
+ export { attrValue, setAttr } from '../wire/attr-value.ts'
7
8
  export { bind, bindSlot, effect } from './bind.ts'
8
9
  export type { DispatchResult } from './dispatch.ts'
9
10
  export { dispatch } from './dispatch.ts'
10
11
  export { defineElement, StatorElement } from './element.ts'
11
- export type { ClientBehavior, LegacyMachineConfig } from './machine.ts'
12
+ export type { ClientBehavior } from './machine.ts'
12
13
  export { machine } from './machine.ts'
13
14
  export type { DispatchError } from './transport.ts'
14
15
  export type { ClientInstance, ClientInstanceBase } from './use.ts'
@@ -63,28 +63,12 @@ export interface ClientBehavior<C> {
63
63
  select?: Record<string, (ctx: C) => unknown>
64
64
  }
65
65
 
66
- /** @deprecated One-bag form: context keys mixed with `on`/`select`. Kept for
67
- * compatibility, but handlers see `any` — TypeScript cannot infer a
68
- * context from the same object the handlers live in. Prefer
69
- * `machine(context, behavior)`. */
70
- export interface LegacyMachineConfig {
71
- name?: string
72
- // biome-ignore lint/suspicious/noExplicitAny: the one-bag form is untypeable by construction — that is exactly why the two-arg form exists
73
- on?: Record<string, ((ctx: any, ev: ClientEvent) => void) | ClientTransitionObject<any>>
74
- // biome-ignore lint/suspicious/noExplicitAny: same
75
- select?: Record<string, (ctx: any) => unknown>
76
- [key: string]: unknown
77
- }
78
-
79
- const RESERVED = new Set(['name', 'on', 'select'])
80
-
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.
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.
85
69
  export function machine<C extends Record<string, unknown>>(
86
70
  context: C & { on?: never; select?: never; name?: never; events?: never },
87
- ): MachineDef<C, ClientEvent, 'active', Record<string, never>>
71
+ ): MachineDef<C, never, 'active', Record<string, never>>
88
72
  // Derived union — no `events:` declared: event NAMES come from the `on` keys
89
73
  // (typo-safe send), payloads stay open. Zero authoring change. Ordered BEFORE
90
74
  // the declared overload: `events?: never` keeps declared calls falling through.
@@ -106,19 +90,15 @@ export function machine<
106
90
  context: C & { on?: never; select?: never; name?: never; events?: never },
107
91
  behavior: { name?: string; events: E; on?: TypedClientOnMap<C, E>; select?: S },
108
92
  ): MachineDef<C, E, 'active', S>
109
- // Behavior without `on` (selectors only): loose, as data-only.
93
+ // Behavior without `on` (selectors only): derived state, still nothing to
94
+ // send — events are `never`, as data-only.
110
95
  export function machine<
111
96
  C extends Record<string, unknown>,
112
97
  S extends Record<string, (ctx: C) => unknown>,
113
98
  >(
114
99
  context: C & { on?: never; select?: never; name?: never; events?: never },
115
100
  behavior: { name?: string; events?: never; on?: never; select?: S },
116
- ): MachineDef<C, ClientEvent, 'active', S>
117
- /** @deprecated see LegacyMachineConfig */
118
- export function machine(
119
- config: LegacyMachineConfig,
120
- // biome-ignore lint/suspicious/noExplicitAny: legacy view is deliberately loose
121
- ): MachineDef<Record<string, any>, ClientEvent, 'active', Record<string, (ctx: any) => any>>
101
+ ): MachineDef<C, never, 'active', S>
122
102
  export function machine(
123
103
  first: Record<string, unknown>,
124
104
  // Loose impl signature: the overloads above are the contract. `events` is a
@@ -126,23 +106,10 @@ export function machine(
126
106
  rawBehavior?: unknown,
127
107
  ): MachineDef {
128
108
  const behavior = rawBehavior as (ClientBehavior<never> & { events?: unknown }) | undefined
129
- let context: Record<string, unknown>
130
- let name: string | undefined
131
- let on: Record<string, unknown>
132
- let select: Record<string, unknown>
133
- if (behavior !== undefined || !Object.keys(first).some((k) => RESERVED.has(k))) {
134
- context = first
135
- name = behavior?.name
136
- on = (behavior?.on ?? {}) as Record<string, unknown>
137
- select = (behavior?.select ?? {}) as Record<string, unknown>
138
- } else {
139
- // Legacy one-bag: context is every non-reserved key.
140
- const { name: n, on: o = {}, select: s = {}, ...rest } = first as LegacyMachineConfig
141
- context = rest
142
- name = n
143
- on = o as Record<string, unknown>
144
- select = s as Record<string, unknown>
145
- }
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>
146
113
  return defineMachine({
147
114
  name: name ?? 'ClientMachine',
148
115
  lifecycle: 'session',
package/src/client/use.ts CHANGED
@@ -67,7 +67,14 @@ export interface CollectedActor {
67
67
 
68
68
  /** Active collector: `use()` registers its actor here during element
69
69
  * construction, so `StatorElement` can start/seed/stop them on connect/disconnect.
70
- * 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. */
71
78
  const collectors: CollectedActor[][] = []
72
79
 
73
80
  export function pushCollector(): CollectedActor[] {
@@ -100,10 +107,7 @@ export function use<D extends MachineDef>(
100
107
  context: { ...(def.context as object), ...eager },
101
108
  }
102
109
  : undefined
103
- // Client islands honor framework-internal events (`@set` for two-way
104
- // `bind:value`); the actor lives in the browser and only its own compiled
105
- // bind code sends them. Server actors deliberately do NOT (see createActor).
106
- const actor = createActor(def as AnyMachineDef, { snapshot, internalEvents: true })
110
+ const actor = createActor(def as AnyMachineDef, { snapshot })
107
111
 
108
112
  // Register with the element under construction so its lifecycle owns the actor.
109
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, bindSlot, 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. */
@@ -53,7 +53,9 @@ export function emitClientModule(input: EmitClientInput): string {
53
53
  // Element wiring: every occurrence of the marker is wired — a marked
54
54
  // element inside a `.map()` repeats per row.
55
55
  const node = `n${i++}`
56
- lines.push(` for (const ${node} of this.querySelectorAll('[data-b="${marker}"]')) {`)
56
+ lines.push(
57
+ ` for (const ${node} of this.querySelectorAll(${JSON.stringify(elementMarkerSelector(marker))})) {`,
58
+ )
57
59
  for (const d of group) lines.push(` ${wireDirective(node, d, members)}`)
58
60
  lines.push(' }')
59
61
  }
@@ -78,42 +80,14 @@ function wireDirective(node: string, d: ClientDirective, members: Set<string>):
78
80
  const handler = emitHandler(d.expr, members)
79
81
  return `${node}.addEventListener(${JSON.stringify(d.event)}, ${handler})`
80
82
  }
81
- // bind:
83
+ // kind 'bind': an attribute-position client read() — one-way display.
82
84
  const target = d.target ?? 'text'
83
85
  const thunk = `() => (${rewriteMembers(d.expr, members)})`
84
86
  const deps = `[${d.deps.map((dep) => `this.${dep}`).join(', ')}]`
85
-
86
- // value / checked are TWO-WAY: state→DOM (below) PLUS a DOM→state listener
87
- // that `@set`s the bound context key. The expression must be a settable
88
- // `<actor>.<key>` path (a derived selector can't be assigned).
89
- if (target === 'value' || target === 'checked') {
90
- const path = parseTwoWayPath(d.expr)
91
- if (!path) {
92
- throw new CompileError(
93
- `stator: bind:${target}={${d.expr}} must bind to a settable context path ` +
94
- `like \`actor.key\` (a derived value can't be two-way bound).`,
95
- )
96
- }
97
- const writer = emitWriter(node, target)
98
- const read = target === 'checked' ? `${node}.checked` : `${node}.value`
99
- const guard = target === 'value' ? `if (e.isComposing) return; ` : ''
100
- const setter =
101
- `${node}.addEventListener(${JSON.stringify(target === 'value' ? 'input' : 'change')}, (e) => { ` +
102
- `${guard}this.${path.actor}.send({ type: '@set', key: ${JSON.stringify(path.key)}, value: ${read} }) })`
103
- return `this.track(bind(${deps}, ${thunk}, ${writer}));\n ${setter}`
104
- }
105
-
106
87
  const writer = emitWriter(node, target)
107
88
  return `this.track(bind(${deps}, ${thunk}, ${writer}))`
108
89
  }
109
90
 
110
- /** Parse a two-way bind expression `actor.key` into its parts; null if it isn't
111
- * a simple single-level member access (not assignable). */
112
- function parseTwoWayPath(expr: string): { actor: string; key: string } | null {
113
- const m = expr.trim().match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/)
114
- return m ? { actor: m[1]!, key: m[2]! } : null
115
- }
116
-
117
91
  /** on: handler — a bare method reference becomes `(e) => this.m(e)`; any other
118
92
  * expression is used directly (with member references rewritten). */
119
93
  function emitHandler(expr: string, members: Set<string>): string {
@@ -126,21 +100,13 @@ function emitHandler(expr: string, members: Set<string>): string {
126
100
 
127
101
  function emitWriter(node: string, target: string): string {
128
102
  switch (target) {
129
- case 'text':
130
- return `(v) => { ${node}.textContent = v == null ? '' : String(v) }`
131
- case 'html':
132
- return `(v) => { ${node}.innerHTML = v == null ? '' : String(v) }`
133
- case 'value':
134
- // Loop-break: only write when the DOM differs, so the echo from the user's
135
- // own keystroke no-ops and the caret is preserved.
136
- return `(v) => { const s = v == null ? '' : String(v); if (${node}.value !== s) ${node}.value = s }`
137
103
  case 'disabled':
138
104
  case 'hidden':
139
- case 'checked':
105
+ // Boolean IDL properties write as properties, not attributes.
140
106
  return `(v) => { if (${node}.${target} !== !!v) ${node}.${target} = !!v }`
141
107
  default:
142
- // arbitrary attribute
143
- return `(v) => { if (v == null || v === false) ${node}.removeAttribute(${JSON.stringify(target)}); else ${node}.setAttribute(${JSON.stringify(target)}, v === true ? '' : String(v)) }`
108
+ // arbitrary attribute — the shared attr contract (wire/attr-value.ts)
109
+ return `(v) => setAttr(${node}, ${JSON.stringify(target)}, attrValue(v))`
144
110
  }
145
111
  }
146
112
 
@@ -21,7 +21,32 @@ export function generateDts(
21
21
  if ((opts.kind ?? 'component') === 'route') return null
22
22
 
23
23
  const { frontmatter, template, scripts } = splitStator(source)
24
- const { hoisted, propsType } = extractFrontmatterTypes(frontmatter)
24
+ const { hoisted } = extractFrontmatterTypes(frontmatter)
25
+ const { propsT, needsReadResult } = statorPropsType(frontmatter, template, scripts)
26
+
27
+ const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
28
+ if (needsReadResult) {
29
+ lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
30
+ }
31
+ if (hoisted) lines.push(hoisted)
32
+ lines.push('')
33
+ lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
34
+ lines.push('export default _default')
35
+ return `${lines.join('\n')}\n`
36
+ }
37
+
38
+ /**
39
+ * The component's props type — THE shared computation for the `.d.ts`
40
+ * generator (tsc) and the language-server virtual emit (editor), islands
41
+ * included, so the two can't disagree. `needsReadResult` means the type
42
+ * references `__SReadResult<T>`, which the caller must bring into scope.
43
+ */
44
+ export function statorPropsType(
45
+ frontmatter: string,
46
+ template: string,
47
+ scripts: string[],
48
+ ): { propsT: string; needsReadResult: boolean } {
49
+ const { propsType } = extractFrontmatterTypes(frontmatter)
25
50
  let propsT = componentPropsType(propsType, template)
26
51
  let needsReadResult = false
27
52
 
@@ -29,9 +54,10 @@ export function generateDts(
29
54
  // `Stator.props`. Each attr accepts its coerced kind OR a live `read()`
30
55
  // binding — the compiler lowers read() island props to live attr bindings,
31
56
  // so the type surface must admit both.
32
- if (!propsType && scripts.length > 0) {
57
+ const classes = scripts.length > 0 ? analyzeScriptClasses(scripts.join('\n')) : []
58
+ if (!propsType && classes.length > 0) {
33
59
  const attrs = new Map<string, string>()
34
- for (const cls of analyzeScriptClasses(scripts.join('\n'))) {
60
+ for (const cls of classes) {
35
61
  for (const [key, kind] of cls.staticAttrs) attrs.set(key, kind)
36
62
  }
37
63
  if (attrs.size > 0) {
@@ -50,16 +76,7 @@ export function generateDts(
50
76
  propsT = '{ [prop: string]: unknown }'
51
77
  }
52
78
  }
53
-
54
- const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
55
- if (needsReadResult) {
56
- lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
57
- }
58
- if (hoisted) lines.push(hoisted)
59
- lines.push('')
60
- lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
61
- lines.push('export default _default')
62
- return `${lines.join('\n')}\n`
79
+ return { propsT, needsReadResult }
63
80
  }
64
81
 
65
82
  /**
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { elementMarker, ISLAND_MARKER_ATTR, slotMarker } from '../wire/island-markers.ts'
2
3
  import { type ClientDirective, inferDeps } from './client-script.ts'
3
4
  import { CompileError, type DiagnosticLocation, locAt } from './diagnostics.ts'
4
5
 
@@ -90,10 +91,11 @@ export interface LowerOptions {
90
91
  * validation is skipped for that component. Supplied by the Vite plugin /
91
92
  * build, which can read sibling `.stator` files. */
92
93
  resolveRegions?: (componentName: string) => Set<string> | null
93
- /** Client-component mode (Phase 3b). When set, `on:`/`bind:` directives are
94
- * *collected* (with a node marker injected) instead of emitted as server
95
- * directives — the generated client class wires them. `useFields` is the set
96
- * of `use()` actor names (for dep inference); `directives` is the out-list. */
94
+ /** Client-component mode. When set, `on:` directives and client-machine
95
+ * `read()`s are *collected* (with node/slot markers injected) instead of
96
+ * emitted as server directives — the generated client class wires them.
97
+ * `useFields` is the set of `use()` actor names (for dep inference);
98
+ * `directives` is the out-list. */
97
99
  client?: {
98
100
  useFields: Set<string>
99
101
  directives: ClientDirective[]
@@ -185,6 +187,17 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
185
187
  ts.isIdentifier(n.arguments[0]) &&
186
188
  opts.client.useFields.has(n.arguments[0].text)
187
189
 
190
+ /** `bind:` was removed in 2.0 — display folds into `read()`, input capture
191
+ * is a typed commit event. One error, both compile modes. */
192
+ const bindRemoved = (name: string, at: DiagnosticLocation | undefined): CompileError =>
193
+ new CompileError(
194
+ `stator: bind:${name} was removed in 2.0. Display state with read() — ` +
195
+ `\`{read(m, (s) => s.value)}\` in text position, \`attr={read(m, …)}\` on an ` +
196
+ `attribute — and capture input at a commit boundary with a typed event ` +
197
+ `(ref:/FormData + send/dispatch). See the forms guide.`,
198
+ at,
199
+ )
200
+
188
201
  /** A client read as a plain expression: `(selector)(field)`. Client-emit's
189
202
  * member rewriting turns `field` (and any use-field the selector closes
190
203
  * over) into `this.field`. */
@@ -197,7 +210,7 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
197
210
  const allocSlotMarker = (): string => {
198
211
  const client = opts.client
199
212
  if (!client) throw new CompileError('stator: internal — slot marker outside client mode')
200
- return `s${client.directives.filter((d) => d.kind === 'slot').length}`
213
+ return slotMarker(client.directives.filter((d) => d.kind === 'slot').length)
201
214
  }
202
215
 
203
216
  /** Shell expressions must not contain a client-machine read — the shell
@@ -534,15 +547,14 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
534
547
  const lowered = lowerAttribute(attr)
535
548
  if (lowered) out += ` ${lowered}`
536
549
  }
537
- if (collected?.marker) out += ` data-b="${collected.marker}"`
550
+ if (collected?.marker) out += ` ${ISLAND_MARKER_ATTR}="${collected.marker}"`
538
551
  return out
539
552
  }
540
553
 
541
- // Collect a client element's wiring — on:/bind: directives plus plain
542
- // attributes whose value is a client-machine read — into
543
- // `opts.client.directives` under a single node marker. Returns the marker
544
- // (if any wiring was found) and the set of consumed attribute nodes the
545
- // shell must not render.
554
+ // Collect a client element's wiring — on: directives plus plain attributes
555
+ // whose value is a client-machine read — into `opts.client.directives`
556
+ // under a single node marker. Returns the marker (if any wiring was found)
557
+ // and the set of consumed attribute nodes the shell must not render.
546
558
  const collectClientDirectives = (
547
559
  attrs: ts.JsxAttributes,
548
560
  ): { marker?: string; consumed: Set<ts.JsxAttribute> } => {
@@ -555,22 +567,13 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
555
567
  if (ts.isJsxNamespacedName(attr.name)) {
556
568
  const ns = attr.name.namespace.text
557
569
  const name = attr.name.name.text
558
- if (ns !== 'on' && ns !== 'bind') continue
570
+ if (ns === 'bind') throw bindRemoved(name, loc(attr))
571
+ if (ns !== 'on') continue
559
572
  const expr = attrExpr(attr)
560
573
  if (!expr) {
561
- throw new CompileError(`stator: ${ns}:${name} requires a value ({...})`, loc(attr))
562
- }
563
- if (ns === 'on') {
564
- pending.push({ marker: '', kind: 'on', event: name, expr, deps: [] })
565
- } else {
566
- pending.push({
567
- marker: '',
568
- kind: 'bind',
569
- target: name,
570
- expr,
571
- deps: inferDeps(expr, client.useFields),
572
- })
574
+ throw new CompileError(`stator: on:${name} requires a handler ({...})`, loc(attr))
573
575
  }
576
+ pending.push({ marker: '', kind: 'on', event: name, expr, deps: [] })
574
577
  consumed.add(attr)
575
578
  continue
576
579
  }
@@ -602,7 +605,9 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
602
605
  if (pending.length === 0) return { consumed }
603
606
  // One marker per element (sequential over ELEMENT markers — slots have
604
607
  // their own `s<N>` namespace).
605
- const marker = `b${new Set(client.directives.filter((d) => d.kind !== 'slot').map((d) => d.marker)).size}`
608
+ const marker = elementMarker(
609
+ new Set(client.directives.filter((d) => d.kind !== 'slot').map((d) => d.marker)).size,
610
+ )
606
611
  for (const d of pending) {
607
612
  d.marker = marker
608
613
  client.directives.push(d)
@@ -686,10 +691,8 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
686
691
  if (meta) meta.refs.add(name)
687
692
  return `data-ref=${JSON.stringify(name)}`
688
693
  }
689
- throw new CompileError(
690
- `stator: directive "${ns}:${name}" is not supported yet (Phase 3b)`,
691
- loc(attr),
692
- )
694
+ if (ns === 'bind') throw bindRemoved(name, loc(attr))
695
+ throw new CompileError(`stator: directive "${ns}:${name}" is not supported`, loc(attr))
693
696
  }
694
697
 
695
698
  const name = attr.name.getText(sf)
@@ -732,10 +735,11 @@ export function lowerTemplate(template: string, opts: LowerOptions = {}): string
732
735
  if (ts.isJsxNamespacedName(attr.name)) {
733
736
  const ns = attr.name.namespace.text
734
737
  const dirName = attr.name.name.text
738
+ if (ns === 'bind') throw bindRemoved(dirName, loc(attr))
735
739
  if (ns !== 'on') {
736
740
  throw new CompileError(
737
741
  `stator: forwarding "${ns}:${dirName}" to a component isn't supported yet — only ` +
738
- `on:* event directives forward. Apply bind:/ref: on an element directly.`,
742
+ `on:* event directives forward. Apply ref: on an element directly.`,
739
743
  loc(attr),
740
744
  )
741
745
  }
@@ -20,7 +20,7 @@
20
20
 
21
21
  import ts from 'typescript'
22
22
  import { analyzeScriptClasses } from './client-script.ts'
23
- import { componentPropsType, extractFrontmatterTypes } from './dts.ts'
23
+ import { statorPropsType } from './dts.ts'
24
24
  import { type ScannedRegions, scanRegions } from './split.ts'
25
25
 
26
26
  /** A contiguous run mapping generated code back to source, 1:1 over its length. */
@@ -174,7 +174,14 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
174
174
  // is typed from `Stator.props<P>()` (same extraction as the .d.ts generator),
175
175
  // so `<Component bad={...}/>` in OTHER .stator files is checked in-editor.
176
176
  // Named prop types resolve because their type/interface decls hoisted above.
177
- const propsT = componentPropsType(extractFrontmatterTypes(fm).propsType, regions.template.content)
177
+ const { propsT, needsReadResult } = statorPropsType(
178
+ fm,
179
+ regions.template.content,
180
+ regions.scripts.map((s) => s.content),
181
+ )
182
+ if (needsReadResult) {
183
+ code += "type __SReadResult<T> = import('@statorjs/stator/template').ReadResult<T>\n"
184
+ }
178
185
  code += `export default function (_props: ${propsT}) {\n`
179
186
  for (const seg of body) {
180
187
  code += ' '
@@ -18,9 +18,9 @@ export interface Actor<C, E extends EventObject> {
18
18
  * the element's connect). No-op after start. */
19
19
  seed(partial: Partial<C>): void
20
20
  getSnapshot(): Snapshot<C>
21
- /** Monotonic count of HANDLED events (a matching transition fired or an
22
- * `@set` applied). Guard-dropped and unhandled events don't count — this
23
- * is how the server distinguishes "committed" from "silently dropped". */
21
+ /** Monotonic count of HANDLED events (a matching transition fired).
22
+ * Guard-dropped and unhandled events don't count — this is how the server
23
+ * distinguishes "committed" from "silently dropped". */
24
24
  getCommitCount(): number
25
25
  subscribe(listener: (snapshot: Snapshot<C>) => void): { unsubscribe(): void }
26
26
  /** Listen for a declared emit. Returns a remover. Used by cross-machine
@@ -63,12 +63,6 @@ export interface CreateActorOptions<C> {
63
63
  * effect locally on a microtask and sends its completion event to itself —
64
64
  * the client-plane (and unit-test) behavior. */
65
65
  onEffect?: (invocation: EffectInvocation) => void
66
- /** Honor framework-internal events (currently `@set`, which powers two-way
67
- * `bind:value`). Enabled ONLY for client-island actors, whose `@set` events
68
- * originate from their own compiled bind code. Server actors leave this off:
69
- * they take events straight from the untrusted wire (`/__events`), where a
70
- * `@set` would be an arbitrary-context-write that bypasses every guard. */
71
- internalEvents?: boolean
72
66
  /** Host hook: a state was ENTERED (fresh start or a value-changing transition —
73
67
  * never hydration). The server uses it to arm `after` timers from the state's
74
68
  * config; `ctx` is the current context (for context-dependent delays). */
@@ -224,19 +218,6 @@ export function createActor<C extends object, E extends EventObject, S extends s
224
218
  },
225
219
 
226
220
  send(event: E) {
227
- // Framework-internal `@set`: assign one context key. Powers two-way
228
- // `bind:value` (DOM → state) on client islands without a per-field
229
- // transition. Honored ONLY when the host opted in (`internalEvents`) —
230
- // server actors never do, so a wire-delivered `@set` falls through to
231
- // ordinary (unhandled) resolution and mutates nothing. Without this gate
232
- // `@set` is a guard-bypassing arbitrary-context write over `/__events`.
233
- if (opts.internalEvents && (event as { type: string }).type === '@set') {
234
- const e = event as unknown as { key: string; value: unknown }
235
- context = { ...context, [e.key]: e.value }
236
- commits += 1
237
- notify()
238
- return
239
- }
240
221
  // Resolve the current leaf state (depth-1 today: value[0]).
241
222
  const stateKey = value[value.length - 1]!
242
223
  const node = def.states[stateKey]
@@ -33,15 +33,22 @@ export interface DefineMachineConfig<
33
33
  emits?: EmitsConfig<C, E>
34
34
  context: C
35
35
  initial: NoInfer<S>
36
- /** Transitions see `helpers.reads` typed as `ReadsMap<TReads>`. */
37
- states: Record<S, StateNode<C, E, S, ReadsMap<TReads>>>
36
+ /** Transitions see `helpers.reads` typed as `ReadsMap<TReads>`.
37
+ *
38
+ * The state union S is inferred from this map's KEYS and nowhere else —
39
+ * every interior S position (`to:`, the machine-level `on:`) is NoInfer.
40
+ * Without that, `to:` string literals become competing inference
41
+ * candidates, and whenever they don't happen to cover every state the
42
+ * union silently collapses to the covered subset (surfaced by the store's
43
+ * cart machine when its line ops moved to machine-level `on:`). */
44
+ states: Record<S, StateNode<C, E, NoInfer<S>, ReadsMap<TReads>>>
38
45
  /** Machine-level transitions: handlers that apply in ANY state, consulted only
39
46
  * when the current state does not declare the event (a state-scoped handler
40
47
  * always wins). The home for a completion event whose handling must not depend
41
48
  * on an unrelated machine-wide state — e.g. a per-record save completing while
42
49
  * the machine is busy reloading the collection. Without this, such a
43
50
  * completion is dropped wherever the current state has no handler for it. */
44
- on?: OnMap<C, E, S, ReadsMap<TReads>>
51
+ on?: OnMap<C, E, NoInfer<S>, ReadsMap<TReads>>
45
52
  selectors?: Sel
46
53
  /** APP machines only: persist this machine's snapshot through the AppStore
47
54
  * so its state survives restarts. Opt-in — caches and other
@@ -324,12 +324,12 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
324
324
  return c.json({ error: 'invalid event payload', detail: String(e) }, 400)
325
325
  }
326
326
 
327
- // Reserved `@`-prefixed events (e.g. the engine's built-in `@set`) are
328
- // framework-internal and only ever originate in-browser for client-island
329
- // binds. They must never reach a server machine from the wire, where `@set`
330
- // would be a guard-bypassing arbitrary-context write. Server actors also
331
- // ignore them (createActor without `internalEvents`); this is the clean
332
- // 400 at the boundary rather than a silent no-op.
327
+ // The `@` event-name prefix is RESERVED for the framework. Nothing uses
328
+ // it today (2.0 removed the engine's `@set`), and keeping the wire fence
329
+ // means any future internal event is unreachable from untrusted input by
330
+ // construction — a clean 400 at the boundary rather than a silent no-op.
331
+ // History: `@set` (two-way bind:'s desugaring) was once wire-reachable —
332
+ // a guard-bypassing arbitrary-context write, fixed as a security patch.
333
333
  if (body.event.type.startsWith('@')) {
334
334
  return c.json({ error: `event type "${body.event.type}" is reserved` }, 400)
335
335
  }
@@ -1,3 +1,13 @@
1
+ /**
2
+ * `/server` carries two tiers (see the docs' stability policy): the STABLE
3
+ * app surface (createApp, defineMachine, defineApiRoute, the stores, logger,
4
+ * dispatchToApp, rotateSession) and the TOOLCHAIN plumbing the compiler/Vite
5
+ * module graph needs importable (recompute, renderRoute, buildHonoApp,
6
+ * SessionRuntime, render-context internals, …). The Toolchain tier is not
7
+ * covered by semver and is RESERVED to move to a dedicated subpath
8
+ * (`@statorjs/stator/server/runtime`) in a 2.x minor — import it expecting
9
+ * that move.
10
+ */
1
11
  export type {
2
12
  ElementTarget,
3
13
  Patch,
@@ -1,7 +1,7 @@
1
1
  import { renderBranchBody } from '../template/conditional.ts'
2
2
  import { coerceKeys, renderKeyedItem, renderListBody } from '../template/each.ts'
3
+ import { attrValue, sanitizeAttr, textValue } from '../wire/attr-value.ts'
3
4
  import type { Patch } from '../wire/index.ts'
4
- import { isUrlAttribute, safeAttrUrl } from '../wire/safe-url.ts'
5
5
  import {
6
6
  type ItemBinding,
7
7
  keyedScopePrefix,
@@ -34,7 +34,7 @@ function diffItemBindings(
34
34
  ib.lastValue = nv
35
35
  if (ib.kind === 'text') {
36
36
  pending.push({
37
- patch: { target: { kind: 'slot', id: ib.slotId }, op: 'text', value: stringify(nv) },
37
+ patch: { target: { kind: 'slot', id: ib.slotId }, op: 'text', value: textValue(nv) },
38
38
  sourceSlot: ib.slotId,
39
39
  })
40
40
  } else {
@@ -43,7 +43,7 @@ function diffItemBindings(
43
43
  target: { kind: 'element', id: ib.parentId },
44
44
  op: 'attr',
45
45
  name: ib.attrName,
46
- value: sanitizeAttrWire(ib.attrName, attrWireValue(nv)),
46
+ value: sanitizeAttr(ib.attrName, attrValue(nv)),
47
47
  },
48
48
  sourceSlot: ib.parentId,
49
49
  })
@@ -104,7 +104,7 @@ function recomputeInner(state: RenderState, machineName: string, runtime: Sessio
104
104
  patch: {
105
105
  target: { kind: 'slot', id: slotId },
106
106
  op: 'text',
107
- value: stringify(newValue),
107
+ value: textValue(newValue),
108
108
  },
109
109
  sourceSlot: slotId,
110
110
  })
@@ -117,7 +117,7 @@ function recomputeInner(state: RenderState, machineName: string, runtime: Sessio
117
117
  target: { kind: 'element', id: binding.parentId },
118
118
  op: 'attr',
119
119
  name: binding.attrName,
120
- value: sanitizeAttrWire(binding.attrName, attrWireValue(newValue)),
120
+ value: sanitizeAttr(binding.attrName, attrValue(newValue)),
121
121
  },
122
122
  sourceSlot: slotId,
123
123
  })
@@ -297,12 +297,6 @@ function arrayShallowEqual(a: readonly unknown[], b: readonly unknown[]): boolea
297
297
  return true
298
298
  }
299
299
 
300
- function stringify(v: unknown): string {
301
- if (v == null) return ''
302
- if (typeof v === 'string') return v
303
- return String(v)
304
- }
305
-
306
300
  /** Baseline invalidation marker — never equal to any real value or key. */
307
301
  const NEVER_SYNCED = Symbol('stator.never-synced')
308
302
 
@@ -368,17 +362,3 @@ export function initialSyncPatches(state: RenderState, runtime: SessionRuntime):
368
362
  /** Attr-binding wire semantics, mirroring the render side: false/null/
369
363
  * undefined → null (attribute removed), true → '' (present, empty),
370
364
  * anything else stringified. */
371
- function attrWireValue(v: unknown): string | null {
372
- if (v === false || v === null || v === undefined) return null
373
- if (v === true) return ''
374
- return stringify(v)
375
- }
376
-
377
- /** Mirror of html.ts `sanitizeAttrValue` for the live-update path: strip a
378
- * javascript:/vbscript: value from a url-bearing attribute patch so a binding
379
- * that was safe at first render can't turn dangerous via a diff. Null
380
- * (attribute-absent) passes through. */
381
- function sanitizeAttrWire(attrName: string, value: string | null): string | null {
382
- if (value === null) return null
383
- return isUrlAttribute(attrName) ? safeAttrUrl(value) : value
384
- }
@@ -33,10 +33,7 @@ export interface StatorDirectiveAttributes {
33
33
  'class:list'?: unknown
34
34
  'style:list'?: unknown
35
35
  [key: `on:${string}`]: unknown
36
- [key: `bind:${string}`]: unknown
37
36
  [key: `ref:${string}`]: unknown
38
- [key: `class:${string}`]: unknown
39
- [key: `style:${string}`]: unknown
40
37
  }
41
38
 
42
39
  /** ARIA — `role` plus the open-ended `aria-*` family. */
@@ -65,6 +62,7 @@ export interface GlobalHTMLAttributes {
65
62
 
66
63
  /** Per-element extra attributes. Extend as components need them. */
67
64
  export interface ElementSpecificAttributes {
65
+ time: { datetime?: string }
68
66
  a: { href?: string; target?: string; rel?: string; download?: string | boolean }
69
67
  button: {
70
68
  type?: 'submit' | 'reset' | 'button'
@@ -4,7 +4,7 @@ import {
4
4
  registerBinding,
5
5
  requireCurrentRenderState,
6
6
  } from '../server/render-context.ts'
7
- import { isUrlAttribute, safeAttrUrl } from '../wire/safe-url.ts'
7
+ import { attrValue, sanitizeAttr, textValue } from '../wire/attr-value.ts'
8
8
  import { escapeAttribute, escapeText, HtmlBuilder, type ValuePosition } from './parser.ts'
9
9
  import { createHtmlFragment, type HtmlFragment, isHtmlFragment } from './types.ts'
10
10
 
@@ -46,14 +46,6 @@ export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlF
46
46
  return createHtmlFragment(builder.toString())
47
47
  }
48
48
 
49
- /** URL-scheme guard for attribute interpolation: on url-bearing attributes
50
- * (href/src/…), strip a javascript:/vbscript: value; other attributes pass
51
- * through unchanged. Mirrored on the live-update path (server/recompute.ts) so
52
- * a value that's safe at first render can't turn dangerous via a patch. */
53
- function sanitizeAttrValue(attrName: string, value: string): string {
54
- return isUrlAttribute(attrName) ? safeAttrUrl(value) : value
55
- }
56
-
57
49
  function processValue(builder: HtmlBuilder, state: RenderState, value: unknown): void {
58
50
  const pos = builder.positionForValue()
59
51
  if (pos.kind === 'invalid') {
@@ -123,21 +115,14 @@ function processValue(builder: HtmlBuilder, state: RenderState, value: unknown):
123
115
  }
124
116
 
125
117
  if (pos.kind === 'text') {
126
- builder.pushRaw(escapeText(stringifyValue(value)))
118
+ builder.pushRaw(escapeText(textValue(value)))
127
119
  return
128
120
  }
129
121
  if (pos.kind === 'attr-value') {
130
- // Same boolean semantics as the read-binding and item-read paths:
131
- // false/null/undefined → attribute ABSENT (`checked={expr}` must be able
132
- // to render unchecked; a static render must agree with what a live patch
133
- // of the same attribute would do), true → present-and-empty. Previously
134
- // this path stringified — `checked={false}` rendered `checked="false"`,
135
- // a PRESENT (thus truthy) boolean attribute.
136
- if (value === false || value === null || value === undefined) {
137
- builder.omitCurrentAttribute()
138
- } else if (value !== true) {
139
- builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(value))))
140
- }
122
+ // The shared attr contract (wire/attr-value.ts): null → attribute ABSENT.
123
+ const av = attrValue(value)
124
+ if (av === null) builder.omitCurrentAttribute()
125
+ else builder.pushRaw(escapeAttribute(sanitizeAttr(pos.attrName, av)))
141
126
  return
142
127
  }
143
128
  throw new Error(`stator: cannot interpolate a plain value at ${pos.kind} position`)
@@ -172,7 +157,7 @@ function handleRead(
172
157
  lastValue: r.value,
173
158
  kind: 'text',
174
159
  })
175
- builder.pushRaw(`<span data-slot="${r.slotId}">${escapeText(stringifyValue(r.value))}</span>`)
160
+ builder.pushRaw(`<span data-slot="${r.slotId}">${escapeText(textValue(r.value))}</span>`)
176
161
  return
177
162
  }
178
163
  if (pos.kind === 'attr-value') {
@@ -193,14 +178,12 @@ function handleRead(
193
178
  attrName: pos.attrName,
194
179
  parentId: pos.elementId,
195
180
  })
196
- // Boolean semantics: false/null/undefined mean the attribute is ABSENT
197
- // (`disabled={read(...)}` must be able to un-disable), true means
198
- // present-and-empty. Everything else stringifies as before. The patch
199
- // side mirrors this: see recompute's attrWireValue.
200
- if (r.value === false || r.value === null || r.value === undefined) {
201
- builder.omitCurrentAttribute()
202
- } else if (r.value !== true) {
203
- builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(r.value))))
181
+ // The shared attr contract (wire/attr-value.ts) — the same function the
182
+ // patch side (recompute) normalizes with, so render and diff can't drift.
183
+ {
184
+ const av = attrValue(r.value)
185
+ if (av === null) builder.omitCurrentAttribute()
186
+ else builder.pushRaw(escapeAttribute(sanitizeAttr(pos.attrName, av)))
204
187
  }
205
188
  return
206
189
  }
@@ -226,7 +209,7 @@ function handleItemRead(
226
209
  if (pos.kind === 'text') {
227
210
  const slotId = allocSlotId(state)
228
211
  row.push({ kind: 'text', slotId, selector: r.selector, lastValue: r.value })
229
- builder.pushRaw(`<span data-slot="${slotId}">${escapeText(stringifyValue(r.value))}</span>`)
212
+ builder.pushRaw(`<span data-slot="${slotId}">${escapeText(textValue(r.value))}</span>`)
230
213
  return
231
214
  }
232
215
  if (pos.kind === 'attr-value') {
@@ -243,21 +226,13 @@ function handleItemRead(
243
226
  selector: r.selector,
244
227
  lastValue: r.value,
245
228
  })
246
- // Same boolean semantics as a machine attr read (see handleRead).
247
- if (r.value === false || r.value === null || r.value === undefined) {
248
- builder.omitCurrentAttribute()
249
- } else if (r.value !== true) {
250
- builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(r.value))))
229
+ // Same shared attr contract as every other attr writer.
230
+ {
231
+ const av = attrValue(r.value)
232
+ if (av === null) builder.omitCurrentAttribute()
233
+ else builder.pushRaw(escapeAttribute(sanitizeAttr(pos.attrName, av)))
251
234
  }
252
235
  return
253
236
  }
254
237
  throw new Error(`stator: read(item, …) cannot be interpolated at ${pos.kind} position`)
255
238
  }
256
-
257
- function stringifyValue(v: unknown): string {
258
- if (v == null) return ''
259
- if (typeof v === 'string') return v
260
- if (typeof v === 'number' || typeof v === 'boolean') return String(v)
261
- if (Array.isArray(v)) return v.map(stringifyValue).join('')
262
- return String(v)
263
- }
@@ -8,17 +8,12 @@ export {
8
8
  } from './conditional.ts'
9
9
  export type { DeferArms, DeferResult } from './defer.ts'
10
10
  export { defer, isDeferResult } from './defer.ts'
11
- export type {
12
- Directive,
13
- DirectiveContext,
14
- DirectiveDefinition,
15
- DirectiveInvocation,
16
- } from './directives/core.ts'
17
- export {
18
- defineDirective,
19
- invoke,
20
- isDirectiveInvocation,
21
- } from './directives/core.ts'
11
+ // The custom-directive surface (defineDirective/invoke) was removed from the
12
+ // public barrel in 2.0: it was documented but unusable from `.stator` files
13
+ // (the compiler owns the closed directive namespace), and a future custom-
14
+ // directive system would be global configuration, not per-template calls.
15
+ // The runtime pieces live on in ./directives/core.ts as internals.
16
+ export { isDirectiveInvocation } from './directives/core.ts'
22
17
  export type { ClassListSpec, StyleListSpec } from './directives/list-attr.ts'
23
18
  export { classList, styleList } from './directives/list-attr.ts'
24
19
  export { on } from './directives/on.ts'
package/src/wire/apply.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * CustomEvent on `window` (the inspector's contract), regardless of which
9
9
  * path applied it.
10
10
  */
11
+ import { setAttr } from './attr-value.ts'
11
12
  import type { Directive, Patch } from './index.ts'
12
13
  import {
13
14
  elementAt,
@@ -74,8 +75,7 @@ function applyPatch(
74
75
  return null
75
76
  }
76
77
  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)
78
+ else setAttr(element, patch.name, patch.value)
79
79
  return element
80
80
  }
81
81
 
@@ -0,0 +1,55 @@
1
+ import { isUrlAttribute, safeAttrUrl } from './safe-url.ts'
2
+
3
+ /**
4
+ * THE attribute-value contract — one implementation for every tier that
5
+ * writes an attribute: static render (template/html.ts), the live diff
6
+ * (server/recompute.ts), the island writer codegen (compiler/client-emit.ts,
7
+ * via the client re-export), and the wire applier (wire/apply.ts).
8
+ *
9
+ * Boolean-absent semantics: `false`/`null`/`undefined` → `null`, meaning the
10
+ * attribute is ABSENT (`checked={expr}` must be able to render unchecked, and
11
+ * a static render must agree with what a live patch of the same attribute
12
+ * does). `true` → present-and-empty. Arrays join with no separator (matching
13
+ * text interpolation); everything else stringifies.
14
+ *
15
+ * History: this contract used to live in four drifting copies, and two of
16
+ * them disagreed in ways users hit — `checked={false}` rendered
17
+ * `checked="false"` on the static path, and array values patched as "a,b"
18
+ * but rendered as "ab". One implementation, so the seams can't disagree.
19
+ */
20
+ export function attrValue(v: unknown): string | null {
21
+ if (v === false || v === null || v === undefined) return null
22
+ if (v === true) return ''
23
+ return stringifyAttr(v)
24
+ }
25
+
26
+ function stringifyAttr(v: unknown): string {
27
+ if (v == null) return ''
28
+ if (typeof v === 'string') return v
29
+ if (Array.isArray(v)) return v.map(stringifyAttr).join('')
30
+ return String(v)
31
+ }
32
+
33
+ /** The text-position value contract, shared by static render and text
34
+ * patches: null/undefined → empty, arrays join with no separator (an
35
+ * interpolated list of fragments/strings renders concatenated, not
36
+ * comma-joined), everything else stringifies. */
37
+ export function textValue(v: unknown): string {
38
+ return stringifyAttr(v)
39
+ }
40
+
41
+ /** Scheme guard for url-bearing attributes (href/src/…): strip a
42
+ * javascript:/vbscript: value whether it arrives at first render or through
43
+ * a later diff. `null` (attribute-absent) passes through. */
44
+ export function sanitizeAttr(name: string, value: string): string
45
+ export function sanitizeAttr(name: string, value: string | null): string | null
46
+ export function sanitizeAttr(name: string, value: string | null): string | null {
47
+ if (value === null) return null
48
+ return isUrlAttribute(name) ? safeAttrUrl(value) : value
49
+ }
50
+
51
+ /** The DOM half of the contract: `null` removes, a string sets. */
52
+ export function setAttr(el: Element, name: string, value: string | null): void {
53
+ if (value === null) el.removeAttribute(name)
54
+ else el.setAttribute(name, value)
55
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Island marker formats — the compiler↔island-runtime contract, in one place
3
+ * so codegen (compiler/lower.ts, compiler/client-emit.ts) and the runtime
4
+ * (client/bind.ts's bindSlot, the generated querySelector calls) can't drift.
5
+ *
6
+ * - ELEMENT markers: `data-b="b<N>"` on an element whose on:/bind:/read()
7
+ * attribute wiring the island's setup() attaches. Every occurrence of a
8
+ * marker is wired (a marked element inside a `.map()` repeats per row).
9
+ * - TEXT SLOT markers: an `<!--s<N>-->` comment where a client-machine
10
+ * `read()` sat in text position; the runtime materializes one text node
11
+ * after each occurrence. The comment's data IS the marker string.
12
+ */
13
+
14
+ export const ISLAND_MARKER_ATTR = 'data-b'
15
+
16
+ export const elementMarker = (i: number): string => `b${i}`
17
+
18
+ export const slotMarker = (i: number): string => `s${i}`
19
+
20
+ /** CSS selector for every occurrence of an element marker. */
21
+ export const elementMarkerSelector = (marker: string): string =>
22
+ `[${ISLAND_MARKER_ATTR}="${marker}"]`