@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.
@@ -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]
@@ -31,6 +31,7 @@ export type {
31
31
  ReadsMap,
32
32
  SelectorMap,
33
33
  Snapshot,
34
+ StateNameOf,
34
35
  StateNode,
35
36
  SubscribeEntry,
36
37
  Transition,
@@ -288,6 +288,13 @@ export type ReadsMap<TReads extends readonly AnyMachineDef[]> = {
288
288
  export type EventOf<D extends AnyMachineDef> =
289
289
  D extends MachineDef<any, infer E, any, any, any> ? E : never
290
290
 
291
+ /** The state-name union a machine declares (the keys of its `states`). Threads
292
+ * the literal names into `instance.state` so a template comparison
293
+ * (`read(m, s => s.state === 'ready')`) autocompletes and a typo is a compile
294
+ * error, instead of `state: string` swallowing it. */
295
+ export type StateNameOf<D extends AnyMachineDef> =
296
+ D extends MachineDef<any, any, infer S, any, any> ? S : string
297
+
291
298
  export function isStatorMachine(v: unknown): v is AnyMachineDef {
292
299
  return (
293
300
  typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isStatorMachine === true
@@ -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
- }
@@ -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(modifier: string, handler: Handler): DirectiveInvocation<Handler> {
24
+ export function on(
25
+ modifier: string,
26
+ handler: Handler | null | undefined,
27
+ ): DirectiveInvocation<Handler | null | undefined> {
21
28
  return invoke(onDirective, modifier, handler)
22
29
  }
@@ -0,0 +1,88 @@
1
+ import { registerBinding, requireCurrentRenderState } from '../../server/render-context.ts'
2
+ import { isUrlAttribute, safeAttrUrl } from '../../wire/safe-url.ts'
3
+ import { isItemReadResult } from '../each.ts'
4
+ import { isReadResult } from '../read.ts'
5
+ import { type DirectiveInvocation, defineDirective, invoke, isDirectiveInvocation } from './core.ts'
6
+
7
+ /**
8
+ * `{...spread}` on an element — forward a bag of attributes onto it. This is what
9
+ * makes the `HTMLAttributes<Tag>` component pattern practical: a `<Button>` can
10
+ * `<button {...rest} />` instead of hand-forwarding every native attribute.
11
+ *
12
+ * A spread value may be a literal OR a live machine `read(...)` — each read
13
+ * registers the same attribute binding the normal `attr={read(...)}` path does,
14
+ * so it patches on machine events (there is no separate reactive-spread wire).
15
+ * Two value kinds are rejected LOUDLY rather than silently mishandled:
16
+ * - `read(item, …)` (an item binding) — it's owned by its each() row and can't
17
+ * ride through a spread; bind it directly on the element. Mirrors the
18
+ * class:list / style:list item-read rule (deferred util, see spread.test).
19
+ * - a directive invocation (a `classList()` / `on()` result) — a directive owns
20
+ * a whole attribute and isn't a plain value.
21
+ *
22
+ * Boolean/url semantics match the literal attr path (see html.ts handleRead):
23
+ * false / null / undefined ⇒ the attribute is ABSENT, true ⇒ present-and-empty,
24
+ * anything else stringifies; url-bearing names (href/src/…) get the scheme guard.
25
+ */
26
+ export type SpreadAttrs = Record<string, unknown>
27
+
28
+ const spreadDirective = defineDirective<SpreadAttrs>({
29
+ name: 'spread',
30
+ apply({ elementId, arg, addAttribute }) {
31
+ if (arg == null) return
32
+ const state = requireCurrentRenderState()
33
+ for (const [name, value] of Object.entries(arg)) {
34
+ if (isItemReadResult(value)) {
35
+ throw new Error(
36
+ `stator: read(item, …) can't be forwarded through {...spread} (attribute "${name}") — ` +
37
+ `an item read is owned by its each() row. Bind it directly on the element ` +
38
+ `(<el ${name}={read(item, …)} />). Only machine reads and static values can be spread.`,
39
+ )
40
+ }
41
+ if (isDirectiveInvocation(value)) {
42
+ throw new Error(
43
+ `stator: a directive (class:list / style:list / on:) can't be a {...spread} value ` +
44
+ `(attribute "${name}") — a directive owns a whole attribute. Apply it on the element directly.`,
45
+ )
46
+ }
47
+ if (isReadResult(value)) {
48
+ // A live machine read: register the same attr binding the normal
49
+ // `attr={read(...)}` path registers, keyed to this element + name, so a
50
+ // machine event patches it. No new wire — same shape recompute expects.
51
+ registerBinding(state, {
52
+ slotId: value.slotId,
53
+ machineName: value.machineName,
54
+ selector: value.selector,
55
+ lastValue: value.value,
56
+ kind: 'attr',
57
+ attrName: name,
58
+ parentId: elementId,
59
+ })
60
+ emitAttribute(name, value.value, addAttribute)
61
+ continue
62
+ }
63
+ emitAttribute(name, value, addAttribute)
64
+ }
65
+ },
66
+ })
67
+
68
+ /** Emit one attribute with the shared boolean/url semantics. false/null/undefined
69
+ * ⇒ absent (no `addAttribute` call at all); true ⇒ present-and-empty; otherwise
70
+ * stringified, url-guarded on url-bearing names. `addAttribute` HTML-escapes. */
71
+ function emitAttribute(
72
+ name: string,
73
+ value: unknown,
74
+ addAttribute: (name: string, value: string) => void,
75
+ ): void {
76
+ if (value === false || value === null || value === undefined) return
77
+ if (value === true) {
78
+ addAttribute(name, '')
79
+ return
80
+ }
81
+ const str = typeof value === 'string' ? value : String(value)
82
+ addAttribute(name, isUrlAttribute(name) ? safeAttrUrl(str) : str)
83
+ }
84
+
85
+ /** Wrap an attribute bag as the directive the compiler emits for `{...spread}`. */
86
+ export function spreadAttrs(attrs: SpreadAttrs): DirectiveInvocation<SpreadAttrs> {
87
+ return invoke(spreadDirective, '', attrs)
88
+ }
@@ -0,0 +1,210 @@
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: `ref:${string}`]: unknown
37
+ }
38
+
39
+ /** ARIA — `role` plus the open-ended `aria-*` family. */
40
+ export interface AriaAttributes {
41
+ role?: string
42
+ [key: `aria-${string}`]: unknown
43
+ }
44
+
45
+ /** Global attributes present on every HTML element (a lean subset). Numeric
46
+ * attributes accept `string` too, since templates write them as strings. */
47
+ export interface GlobalHTMLAttributes {
48
+ class?: string
49
+ id?: string
50
+ style?: string
51
+ title?: string
52
+ hidden?: boolean
53
+ tabindex?: number | string
54
+ lang?: string
55
+ dir?: 'ltr' | 'rtl' | 'auto'
56
+ draggable?: boolean | 'true' | 'false'
57
+ popover?: boolean | string
58
+ /** Stator named-slot target — `child="header"` fills `<children name="header"/>`. */
59
+ child?: string
60
+ [key: `data-${string}`]: unknown
61
+ }
62
+
63
+ /** Per-element extra attributes. Extend as components need them. */
64
+ export interface ElementSpecificAttributes {
65
+ a: { href?: string; target?: string; rel?: string; download?: string | boolean }
66
+ button: {
67
+ type?: 'submit' | 'reset' | 'button'
68
+ disabled?: boolean
69
+ name?: string
70
+ value?: string
71
+ form?: string
72
+ autofocus?: boolean
73
+ popovertarget?: string
74
+ popovertargetaction?: 'show' | 'hide' | 'toggle'
75
+ }
76
+ input: {
77
+ type?: string
78
+ name?: string
79
+ value?: string | number
80
+ placeholder?: string
81
+ disabled?: boolean
82
+ required?: boolean
83
+ readonly?: boolean
84
+ checked?: boolean
85
+ min?: string | number
86
+ max?: string | number
87
+ step?: string | number
88
+ minlength?: number | string
89
+ maxlength?: number | string
90
+ pattern?: string
91
+ autocomplete?: string
92
+ inputmode?: string
93
+ autofocus?: boolean
94
+ }
95
+ label: { for?: string }
96
+ form: {
97
+ action?: string
98
+ method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
99
+ novalidate?: boolean
100
+ }
101
+ select: {
102
+ name?: string
103
+ disabled?: boolean
104
+ required?: boolean
105
+ multiple?: boolean
106
+ value?: string | number
107
+ }
108
+ option: { value?: string | number; selected?: boolean; disabled?: boolean }
109
+ textarea: {
110
+ name?: string
111
+ placeholder?: string
112
+ rows?: number | string
113
+ cols?: number | string
114
+ disabled?: boolean
115
+ required?: boolean
116
+ readonly?: boolean
117
+ minlength?: number | string
118
+ maxlength?: number | string
119
+ }
120
+ img: {
121
+ src?: string
122
+ alt?: string
123
+ width?: number | string
124
+ height?: number | string
125
+ loading?: 'eager' | 'lazy'
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The attribute type for a native element `Tag`. Intersect it in a component's
131
+ * props to forward native attributes:
132
+ *
133
+ * const { variant, ...rest } =
134
+ * Stator.props<HTMLAttributes<'button'> & { variant: 'primary' | 'danger' }>()
135
+ *
136
+ * `Tag` defaults to the shared base (global + ARIA + directives) for elements not
137
+ * in the per-element map. HTML attribute values are `Reactive` (literal or live
138
+ * binding); directives keep their own value types.
139
+ */
140
+ export type HTMLAttributes<Tag extends string = string> = Reactivize<
141
+ GlobalHTMLAttributes &
142
+ AriaAttributes &
143
+ (Tag extends keyof ElementSpecificAttributes
144
+ ? ElementSpecificAttributes[Tag]
145
+ : Record<never, never>)
146
+ > &
147
+ StatorDirectiveAttributes
148
+
149
+ /**
150
+ * The per-element intrinsic map wired into `JSX.IntrinsicElements`, so a typo on
151
+ * a plain `<button typ=>` is a real error. Common HTML tags are typed;
152
+ * the `[tag: string]: any` fallback keeps custom elements (client-island tags
153
+ * like `<live-sky>`) and everything not listed (SVG, rare tags) permissive — the
154
+ * escape valve that stops the typing from flooding real templates. Every explicit
155
+ * entry is assignable to `any`, so the index signature is satisfied.
156
+ */
157
+ export interface StatorIntrinsicElements {
158
+ a: HTMLAttributes<'a'>
159
+ button: HTMLAttributes<'button'>
160
+ input: HTMLAttributes<'input'>
161
+ select: HTMLAttributes<'select'>
162
+ option: HTMLAttributes<'option'>
163
+ textarea: HTMLAttributes<'textarea'>
164
+ label: HTMLAttributes<'label'>
165
+ form: HTMLAttributes<'form'>
166
+ img: HTMLAttributes<'img'>
167
+ div: HTMLAttributes<'div'>
168
+ span: HTMLAttributes<'span'>
169
+ p: HTMLAttributes<'p'>
170
+ h1: HTMLAttributes<'h1'>
171
+ h2: HTMLAttributes<'h2'>
172
+ h3: HTMLAttributes<'h3'>
173
+ h4: HTMLAttributes<'h4'>
174
+ h5: HTMLAttributes<'h5'>
175
+ h6: HTMLAttributes<'h6'>
176
+ ul: HTMLAttributes<'ul'>
177
+ ol: HTMLAttributes<'ol'>
178
+ li: HTMLAttributes<'li'>
179
+ nav: HTMLAttributes<'nav'>
180
+ header: HTMLAttributes<'header'>
181
+ footer: HTMLAttributes<'footer'>
182
+ section: HTMLAttributes<'section'>
183
+ main: HTMLAttributes<'main'>
184
+ article: HTMLAttributes<'article'>
185
+ aside: HTMLAttributes<'aside'>
186
+ strong: HTMLAttributes<'strong'>
187
+ em: HTMLAttributes<'em'>
188
+ small: HTMLAttributes<'small'>
189
+ code: HTMLAttributes<'code'>
190
+ pre: HTMLAttributes<'pre'>
191
+ time: HTMLAttributes<'time'>
192
+ cite: HTMLAttributes<'cite'>
193
+ hr: HTMLAttributes<'hr'>
194
+ br: HTMLAttributes<'br'>
195
+ figure: HTMLAttributes<'figure'>
196
+ figcaption: HTMLAttributes<'figcaption'>
197
+ details: HTMLAttributes<'details'>
198
+ summary: HTMLAttributes<'summary'>
199
+ dialog: HTMLAttributes<'dialog'>
200
+ table: HTMLAttributes<'table'>
201
+ thead: HTMLAttributes<'thead'>
202
+ tbody: HTMLAttributes<'tbody'>
203
+ tr: HTMLAttributes<'tr'>
204
+ td: HTMLAttributes<'td'>
205
+ th: HTMLAttributes<'th'>
206
+ fieldset: HTMLAttributes<'fieldset'>
207
+ legend: HTMLAttributes<'legend'>
208
+ // 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.
209
+ [tag: string]: any
210
+ }
@@ -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,11 +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
- builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(value))))
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)))
131
126
  return
132
127
  }
133
128
  throw new Error(`stator: cannot interpolate a plain value at ${pos.kind} position`)
@@ -162,7 +157,7 @@ function handleRead(
162
157
  lastValue: r.value,
163
158
  kind: 'text',
164
159
  })
165
- 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>`)
166
161
  return
167
162
  }
168
163
  if (pos.kind === 'attr-value') {
@@ -183,14 +178,12 @@ function handleRead(
183
178
  attrName: pos.attrName,
184
179
  parentId: pos.elementId,
185
180
  })
186
- // Boolean semantics: false/null/undefined mean the attribute is ABSENT
187
- // (`disabled={read(...)}` must be able to un-disable), true means
188
- // present-and-empty. Everything else stringifies as before. The patch
189
- // side mirrors this: see recompute's attrWireValue.
190
- if (r.value === false || r.value === null || r.value === undefined) {
191
- builder.omitCurrentAttribute()
192
- } else if (r.value !== true) {
193
- 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)))
194
187
  }
195
188
  return
196
189
  }
@@ -216,7 +209,7 @@ function handleItemRead(
216
209
  if (pos.kind === 'text') {
217
210
  const slotId = allocSlotId(state)
218
211
  row.push({ kind: 'text', slotId, selector: r.selector, lastValue: r.value })
219
- builder.pushRaw(`<span data-slot="${slotId}">${escapeText(stringifyValue(r.value))}</span>`)
212
+ builder.pushRaw(`<span data-slot="${slotId}">${escapeText(textValue(r.value))}</span>`)
220
213
  return
221
214
  }
222
215
  if (pos.kind === 'attr-value') {
@@ -233,21 +226,13 @@ function handleItemRead(
233
226
  selector: r.selector,
234
227
  lastValue: r.value,
235
228
  })
236
- // Same boolean semantics as a machine attr read (see handleRead).
237
- if (r.value === false || r.value === null || r.value === undefined) {
238
- builder.omitCurrentAttribute()
239
- } else if (r.value !== true) {
240
- 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)))
241
234
  }
242
235
  return
243
236
  }
244
237
  throw new Error(`stator: read(item, …) cannot be interpolated at ${pos.kind} position`)
245
238
  }
246
-
247
- function stringifyValue(v: unknown): string {
248
- if (v == null) return ''
249
- if (typeof v === 'string') return v
250
- if (typeof v === 'number' || typeof v === 'boolean') return String(v)
251
- if (Array.isArray(v)) return v.map(stringifyValue).join('')
252
- return String(v)
253
- }
@@ -8,23 +8,29 @@ 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'
20
+ export type { SpreadAttrs } from './directives/spread.ts'
21
+ export { spreadAttrs } from './directives/spread.ts'
25
22
  export type { EachResult } from './each.ts'
26
23
  export { each, isEachResult, itemBind, renderListBody } from './each.ts'
27
24
  export { html, raw } from './html.ts'
25
+ export type {
26
+ AriaAttributes,
27
+ ElementSpecificAttributes,
28
+ GlobalHTMLAttributes,
29
+ HTMLAttributes,
30
+ Reactive,
31
+ StatorDirectiveAttributes,
32
+ StatorIntrinsicElements,
33
+ } from './html-attributes.ts'
28
34
  export type { ReadResult } from './read.ts'
29
35
  export { isReadResult, read } from './read.ts'
30
36
  export type { HtmlFragment, InstanceOf } from './types.ts'