@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/src/engine/actor.ts
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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]
|
package/src/engine/index.ts
CHANGED
package/src/engine/types.ts
CHANGED
|
@@ -288,6 +288,13 @@ export type ReadsMap<TReads extends readonly AnyMachineDef[]> = {
|
|
|
288
288
|
export type EventOf<D extends AnyMachineDef> =
|
|
289
289
|
D extends MachineDef<any, infer E, any, any, any> ? E : never
|
|
290
290
|
|
|
291
|
+
/** The state-name union a machine declares (the keys of its `states`). Threads
|
|
292
|
+
* the literal names into `instance.state` so a template comparison
|
|
293
|
+
* (`read(m, s => s.state === 'ready')`) autocompletes and a typo is a compile
|
|
294
|
+
* error, instead of `state: string` swallowing it. */
|
|
295
|
+
export type StateNameOf<D extends AnyMachineDef> =
|
|
296
|
+
D extends MachineDef<any, any, infer S, any, any> ? S : string
|
|
297
|
+
|
|
291
298
|
export function isStatorMachine(v: unknown): v is AnyMachineDef {
|
|
292
299
|
return (
|
|
293
300
|
typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isStatorMachine === true
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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,
|
package/src/server/recompute.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
+
}
|
package/src/template/html.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
registerBinding,
|
|
5
5
|
requireCurrentRenderState,
|
|
6
6
|
} from '../server/render-context.ts'
|
|
7
|
-
import {
|
|
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(
|
|
118
|
+
builder.pushRaw(escapeText(textValue(value)))
|
|
127
119
|
return
|
|
128
120
|
}
|
|
129
121
|
if (pos.kind === 'attr-value') {
|
|
130
|
-
|
|
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(
|
|
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
|
-
//
|
|
187
|
-
// (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
builder.
|
|
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(
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
builder.pushRaw(escapeAttribute(
|
|
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
|
-
}
|
package/src/template/index.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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'
|