@statorjs/stator 1.9.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/index.ts +2 -1
- package/src/client/machine.ts +11 -44
- package/src/client/use.ts +9 -5
- package/src/compiler/client-emit.ts +9 -43
- package/src/compiler/dts.ts +30 -13
- package/src/compiler/lower.ts +34 -30
- package/src/compiler/virtual-code.ts +9 -2
- package/src/engine/actor.ts +3 -22
- package/src/server/http.ts +6 -6
- package/src/server/index.ts +10 -0
- package/src/server/recompute.ts +5 -25
- package/src/template/html-attributes.ts +0 -3
- package/src/template/html.ts +19 -44
- package/src/template/index.ts +6 -11
- package/src/wire/apply.ts +2 -2
- package/src/wire/attr-value.ts +55 -0
- package/src/wire/island-markers.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"pino-pretty": "^13.1.3",
|
|
89
89
|
"vite": "^6.0.0",
|
|
90
90
|
"vitest": "^2.1.0",
|
|
91
|
-
"@statorjs/stator": "
|
|
91
|
+
"@statorjs/stator": "2.0.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/client/index.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* island needs; these are also the symbols an author references directly.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export { 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
|
|
12
|
+
export type { ClientBehavior } from './machine.ts'
|
|
12
13
|
export { machine } from './machine.ts'
|
|
13
14
|
export type { DispatchError } from './transport.ts'
|
|
14
15
|
export type { ClientInstance, ClientInstanceBase } from './use.ts'
|
package/src/client/machine.ts
CHANGED
|
@@ -63,28 +63,12 @@ export interface ClientBehavior<C> {
|
|
|
63
63
|
select?: Record<string, (ctx: C) => unknown>
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
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):
|
|
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,
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
package/src/compiler/dts.ts
CHANGED
|
@@ -21,7 +21,32 @@ export function generateDts(
|
|
|
21
21
|
if ((opts.kind ?? 'component') === 'route') return null
|
|
22
22
|
|
|
23
23
|
const { frontmatter, template, scripts } = splitStator(source)
|
|
24
|
-
const { hoisted
|
|
24
|
+
const { hoisted } = extractFrontmatterTypes(frontmatter)
|
|
25
|
+
const { propsT, needsReadResult } = statorPropsType(frontmatter, template, scripts)
|
|
26
|
+
|
|
27
|
+
const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
|
|
28
|
+
if (needsReadResult) {
|
|
29
|
+
lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
|
|
30
|
+
}
|
|
31
|
+
if (hoisted) lines.push(hoisted)
|
|
32
|
+
lines.push('')
|
|
33
|
+
lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
|
|
34
|
+
lines.push('export default _default')
|
|
35
|
+
return `${lines.join('\n')}\n`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The component's props type — THE shared computation for the `.d.ts`
|
|
40
|
+
* generator (tsc) and the language-server virtual emit (editor), islands
|
|
41
|
+
* included, so the two can't disagree. `needsReadResult` means the type
|
|
42
|
+
* references `__SReadResult<T>`, which the caller must bring into scope.
|
|
43
|
+
*/
|
|
44
|
+
export function statorPropsType(
|
|
45
|
+
frontmatter: string,
|
|
46
|
+
template: string,
|
|
47
|
+
scripts: string[],
|
|
48
|
+
): { propsT: string; needsReadResult: boolean } {
|
|
49
|
+
const { propsType } = extractFrontmatterTypes(frontmatter)
|
|
25
50
|
let propsT = componentPropsType(propsType, template)
|
|
26
51
|
let needsReadResult = false
|
|
27
52
|
|
|
@@ -29,9 +54,10 @@ export function generateDts(
|
|
|
29
54
|
// `Stator.props`. Each attr accepts its coerced kind OR a live `read()`
|
|
30
55
|
// binding — the compiler lowers read() island props to live attr bindings,
|
|
31
56
|
// so the type surface must admit both.
|
|
32
|
-
|
|
57
|
+
const classes = scripts.length > 0 ? analyzeScriptClasses(scripts.join('\n')) : []
|
|
58
|
+
if (!propsType && classes.length > 0) {
|
|
33
59
|
const attrs = new Map<string, string>()
|
|
34
|
-
for (const cls of
|
|
60
|
+
for (const cls of classes) {
|
|
35
61
|
for (const [key, kind] of cls.staticAttrs) attrs.set(key, kind)
|
|
36
62
|
}
|
|
37
63
|
if (attrs.size > 0) {
|
|
@@ -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
|
/**
|
package/src/compiler/lower.ts
CHANGED
|
@@ -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
|
|
94
|
-
* *collected* (with
|
|
95
|
-
* directives — the generated client class wires them.
|
|
96
|
-
* of `use()` actor names (for dep inference);
|
|
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
|
|
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 += `
|
|
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
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
690
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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 += ' '
|
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/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
|
-
}
|
|
@@ -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. */
|
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,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(
|
|
118
|
+
builder.pushRaw(escapeText(textValue(value)))
|
|
127
119
|
return
|
|
128
120
|
}
|
|
129
121
|
if (pos.kind === 'attr-value') {
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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(
|
|
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
|
-
//
|
|
197
|
-
// (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
builder.
|
|
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(
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
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)))
|
|
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
|
-
}
|
package/src/template/index.ts
CHANGED
|
@@ -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
|
-
|
|
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'
|
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
|
|
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}"]`
|