@statorjs/stator 1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- package/src/wire/index.ts +67 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allocSlotId,
|
|
3
|
+
type ErasedItemRenderer,
|
|
4
|
+
type ErasedKeyFn,
|
|
5
|
+
type ErasedSelector,
|
|
6
|
+
keyedScopePrefix,
|
|
7
|
+
keyToken,
|
|
8
|
+
popListScope,
|
|
9
|
+
pushKeyedScope,
|
|
10
|
+
pushListScope,
|
|
11
|
+
type RenderState,
|
|
12
|
+
registerBinding,
|
|
13
|
+
requireCurrentRenderState,
|
|
14
|
+
type SlotId,
|
|
15
|
+
unregisterBindingsForScope,
|
|
16
|
+
} from '../server/render-context.ts'
|
|
17
|
+
import { isReadResult, type ReadResult } from './read.ts'
|
|
18
|
+
import { type HtmlFragment, isHtmlFragment } from './types.ts'
|
|
19
|
+
|
|
20
|
+
export interface EachOptions<T> {
|
|
21
|
+
/** Item identity. When present, list changes emit per-item insert/remove/move
|
|
22
|
+
* patches instead of a full-body re-render — inner state (focus, transitions)
|
|
23
|
+
* survives reorders. Keys must be strings (numbers are coerced) and unique
|
|
24
|
+
* within the list. Without `key`, any list change re-renders the whole body. */
|
|
25
|
+
key?: (item: T) => string | number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EachResult {
|
|
29
|
+
readonly __isEachResult: true
|
|
30
|
+
readonly html: string
|
|
31
|
+
readonly slotId: SlotId
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isEachResult(v: unknown): v is EachResult {
|
|
35
|
+
return (
|
|
36
|
+
typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isEachResult === true
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function each<T>(
|
|
41
|
+
items: readonly T[] | ReadResult<readonly T[]>,
|
|
42
|
+
fn: (item: T, index: number) => HtmlFragment,
|
|
43
|
+
opts?: EachOptions<T>,
|
|
44
|
+
): EachResult {
|
|
45
|
+
const state = requireCurrentRenderState()
|
|
46
|
+
|
|
47
|
+
let array: T[]
|
|
48
|
+
let slotId: SlotId
|
|
49
|
+
let machineName: string | null = null
|
|
50
|
+
let selector: ErasedSelector | null = null
|
|
51
|
+
|
|
52
|
+
if (isReadResult(items)) {
|
|
53
|
+
array = items.value as T[]
|
|
54
|
+
slotId = items.slotId
|
|
55
|
+
machineName = items.machineName
|
|
56
|
+
selector = items.selector
|
|
57
|
+
} else {
|
|
58
|
+
array = [...items]
|
|
59
|
+
slotId = allocSlotId(state)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const keyFn = opts?.key
|
|
63
|
+
let innerHtml: string
|
|
64
|
+
if (keyFn) {
|
|
65
|
+
const keys = coerceKeys(array, keyFn as ErasedKeyFn, slotId)
|
|
66
|
+
innerHtml = renderKeyedListBody(state, slotId, array, keys, fn)
|
|
67
|
+
if (machineName && selector) {
|
|
68
|
+
registerBinding(state, {
|
|
69
|
+
slotId,
|
|
70
|
+
machineName,
|
|
71
|
+
selector,
|
|
72
|
+
lastValue: array,
|
|
73
|
+
kind: 'list-keyed',
|
|
74
|
+
itemRenderer: fn as ErasedItemRenderer,
|
|
75
|
+
keyFn: keyFn as ErasedKeyFn,
|
|
76
|
+
lastKeys: keys,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
innerHtml = renderListBody(state, slotId, array, fn)
|
|
81
|
+
if (machineName && selector) {
|
|
82
|
+
registerBinding(state, {
|
|
83
|
+
slotId,
|
|
84
|
+
machineName,
|
|
85
|
+
selector,
|
|
86
|
+
lastValue: array,
|
|
87
|
+
kind: 'list',
|
|
88
|
+
itemRenderer: fn as ErasedItemRenderer,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// `display: contents` so the wrapper span is invisible to layout — its
|
|
94
|
+
// children become layout-children of the surrounding element. Without
|
|
95
|
+
// this, putting `each()` inside a CSS grid or flex container would
|
|
96
|
+
// collapse all items into a single grid/flex cell. The span itself stays
|
|
97
|
+
// in the DOM as the patch addressing target.
|
|
98
|
+
const html = `<span data-slot="${slotId}" data-list="true" style="display:contents">${innerHtml}</span>`
|
|
99
|
+
return { __isEachResult: true, html, slotId }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render the body of a list (the contents inside the list span).
|
|
104
|
+
* Used both during initial render and during recompute when a list re-renders.
|
|
105
|
+
*
|
|
106
|
+
* The caller is responsible for ensuring no child bindings exist for this scope
|
|
107
|
+
* before calling — this function clears them and creates fresh ones.
|
|
108
|
+
*/
|
|
109
|
+
export function renderListBody<T>(
|
|
110
|
+
state: RenderState,
|
|
111
|
+
listSlotId: SlotId,
|
|
112
|
+
items: readonly T[],
|
|
113
|
+
fn: (item: T, index: number) => HtmlFragment,
|
|
114
|
+
): string {
|
|
115
|
+
unregisterBindingsForScope(state, listSlotId)
|
|
116
|
+
|
|
117
|
+
const chunks: string[] = []
|
|
118
|
+
for (let i = 0; i < items.length; i++) {
|
|
119
|
+
pushListScope(state, listSlotId, i)
|
|
120
|
+
try {
|
|
121
|
+
const fragment = fn(items[i]!, i)
|
|
122
|
+
if (!isHtmlFragment(fragment)) {
|
|
123
|
+
throw new Error('stator: each() callback must return an html`...` result')
|
|
124
|
+
}
|
|
125
|
+
chunks.push(fragment.html)
|
|
126
|
+
} finally {
|
|
127
|
+
popListScope(state)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return chunks.join('')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate and coerce a keyed list's keys: strings pass, finite numbers are
|
|
135
|
+
* coerced, anything else is an error. Duplicates are an error — two rows with
|
|
136
|
+
* the same key is a data bug, not behavior to be polite about.
|
|
137
|
+
*/
|
|
138
|
+
export function coerceKeys(
|
|
139
|
+
items: readonly unknown[],
|
|
140
|
+
keyFn: ErasedKeyFn,
|
|
141
|
+
listSlotId: SlotId,
|
|
142
|
+
): string[] {
|
|
143
|
+
const keys: string[] = []
|
|
144
|
+
const seen = new Set<string>()
|
|
145
|
+
for (let i = 0; i < items.length; i++) {
|
|
146
|
+
const raw = keyFn(items[i])
|
|
147
|
+
let key: string
|
|
148
|
+
if (typeof raw === 'string') key = raw
|
|
149
|
+
else if (typeof raw === 'number' && Number.isFinite(raw)) key = String(raw)
|
|
150
|
+
else {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`stator: each() key for item ${i} of list ${listSlotId} must be a string or finite ` +
|
|
153
|
+
`number, got ${raw === null ? 'null' : typeof raw}`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
if (seen.has(key)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`stator: duplicate key "${key}" in keyed each() (list ${listSlotId}) — keys must be ` +
|
|
159
|
+
`unique within a list`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
seen.add(key)
|
|
163
|
+
keys.push(key)
|
|
164
|
+
}
|
|
165
|
+
return keys
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Initial render of a keyed list: items render under *key* scopes so their
|
|
169
|
+
* inner slot ids survive reordering. */
|
|
170
|
+
export function renderKeyedListBody<T>(
|
|
171
|
+
state: RenderState,
|
|
172
|
+
listSlotId: SlotId,
|
|
173
|
+
items: readonly T[],
|
|
174
|
+
keys: readonly string[],
|
|
175
|
+
fn: (item: T, index: number) => HtmlFragment,
|
|
176
|
+
): string {
|
|
177
|
+
unregisterBindingsForScope(state, listSlotId)
|
|
178
|
+
const chunks: string[] = []
|
|
179
|
+
for (let i = 0; i < items.length; i++) {
|
|
180
|
+
chunks.push(renderKeyedItem(state, listSlotId, items[i] as T, i, keys[i]!, fn))
|
|
181
|
+
}
|
|
182
|
+
return chunks.join('')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Render one keyed item under its key scope. Used for initial render and for
|
|
187
|
+
* `insert` patches during recompute. The rendered HTML must be a single root
|
|
188
|
+
* element — index-addressed insert/remove/move ops count the list's element
|
|
189
|
+
* children, so a multi-root item would corrupt every index after it.
|
|
190
|
+
*/
|
|
191
|
+
export function renderKeyedItem<T>(
|
|
192
|
+
state: RenderState,
|
|
193
|
+
listSlotId: SlotId,
|
|
194
|
+
item: T,
|
|
195
|
+
index: number,
|
|
196
|
+
key: string,
|
|
197
|
+
fn: (item: T, index: number) => HtmlFragment,
|
|
198
|
+
): string {
|
|
199
|
+
const token = keyToken(key)
|
|
200
|
+
unregisterBindingsForScope(state, keyedScopePrefix(listSlotId, token))
|
|
201
|
+
pushKeyedScope(state, listSlotId, token)
|
|
202
|
+
let html: string
|
|
203
|
+
try {
|
|
204
|
+
const fragment = fn(item, index)
|
|
205
|
+
if (!isHtmlFragment(fragment)) {
|
|
206
|
+
throw new Error('stator: each() callback must return an html`...` result')
|
|
207
|
+
}
|
|
208
|
+
html = fragment.html
|
|
209
|
+
} finally {
|
|
210
|
+
popListScope(state)
|
|
211
|
+
}
|
|
212
|
+
if (!isSingleRootElement(html)) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`stator: keyed each() item "${key}" must render exactly one root element — ` +
|
|
215
|
+
`per-item patches address list children by index, so multi-root (or bare-text) ` +
|
|
216
|
+
`items would corrupt sibling indices`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
return html
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const VOID_TAGS = new Set([
|
|
223
|
+
'area',
|
|
224
|
+
'base',
|
|
225
|
+
'br',
|
|
226
|
+
'col',
|
|
227
|
+
'embed',
|
|
228
|
+
'hr',
|
|
229
|
+
'img',
|
|
230
|
+
'input',
|
|
231
|
+
'link',
|
|
232
|
+
'meta',
|
|
233
|
+
'param',
|
|
234
|
+
'source',
|
|
235
|
+
'track',
|
|
236
|
+
'wbr',
|
|
237
|
+
])
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* True when `html` consists of exactly one root element (plus optional
|
|
241
|
+
* surrounding whitespace). A small scanner, quote- and comment-aware — not a
|
|
242
|
+
* full parser; it only tracks nesting depth at the top level.
|
|
243
|
+
*/
|
|
244
|
+
export function isSingleRootElement(html: string): boolean {
|
|
245
|
+
let i = 0
|
|
246
|
+
let roots = 0
|
|
247
|
+
let depth = 0
|
|
248
|
+
const n = html.length
|
|
249
|
+
while (i < n) {
|
|
250
|
+
const c = html[i]!
|
|
251
|
+
if (c !== '<') {
|
|
252
|
+
// Bare text at the top level (other than whitespace) is not an element.
|
|
253
|
+
if (depth === 0 && !/\s/.test(c)) return false
|
|
254
|
+
i++
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (html.startsWith('<!--', i)) {
|
|
258
|
+
const end = html.indexOf('-->', i + 4)
|
|
259
|
+
if (end === -1) return false
|
|
260
|
+
i = end + 3
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
const isClosing = html[i + 1] === '/'
|
|
264
|
+
// Scan to the tag's real end, skipping quoted attribute values.
|
|
265
|
+
let j = i + 1
|
|
266
|
+
let quote: string | null = null
|
|
267
|
+
while (j < n) {
|
|
268
|
+
const ch = html[j]!
|
|
269
|
+
if (quote) {
|
|
270
|
+
if (ch === quote) quote = null
|
|
271
|
+
} else if (ch === '"' || ch === "'") {
|
|
272
|
+
quote = ch
|
|
273
|
+
} else if (ch === '>') {
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
j++
|
|
277
|
+
}
|
|
278
|
+
if (j >= n) return false
|
|
279
|
+
if (isClosing) {
|
|
280
|
+
depth--
|
|
281
|
+
if (depth < 0) return false
|
|
282
|
+
} else {
|
|
283
|
+
const nameMatch = html.slice(i + 1, j).match(/^([a-zA-Z][\w-]*)/)
|
|
284
|
+
const tag = nameMatch ? nameMatch[1]!.toLowerCase() : ''
|
|
285
|
+
const selfClosing = html[j - 1] === '/' || VOID_TAGS.has(tag)
|
|
286
|
+
if (depth === 0) {
|
|
287
|
+
roots++
|
|
288
|
+
if (roots > 1) return false
|
|
289
|
+
}
|
|
290
|
+
if (!selfClosing) depth++
|
|
291
|
+
}
|
|
292
|
+
i = j + 1
|
|
293
|
+
}
|
|
294
|
+
return roots === 1 && depth === 0
|
|
295
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type RenderState,
|
|
3
|
+
registerBinding,
|
|
4
|
+
requireCurrentRenderState,
|
|
5
|
+
} from '../server/render-context.ts'
|
|
6
|
+
import { escapeAttribute, escapeText, HtmlBuilder, type ValuePosition } from './parser.ts'
|
|
7
|
+
import { createHtmlFragment, type HtmlFragment, isHtmlFragment } from './types.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wrap a trusted HTML string so it's emitted **verbatim** — bypassing the text
|
|
11
|
+
* auto-escaping that `{value}` interpolation otherwise applies. The server
|
|
12
|
+
* analog of `set:html` / `dangerouslySetInnerHTML`.
|
|
13
|
+
*
|
|
14
|
+
* The string is injected unescaped: only pass markup you constructed or fully
|
|
15
|
+
* trust, never unsanitized user input. Typical use is a serialized data block
|
|
16
|
+
* (e.g. `<script type="application/ld+json">`) where the payload is already
|
|
17
|
+
* escaped for its context.
|
|
18
|
+
*/
|
|
19
|
+
export function raw(html: string): HtmlFragment {
|
|
20
|
+
return createHtmlFragment(html)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
import { isBranchResult } from './conditional.ts'
|
|
24
|
+
import {
|
|
25
|
+
type DirectiveContext,
|
|
26
|
+
type DirectiveInvocation,
|
|
27
|
+
isDirectiveInvocation,
|
|
28
|
+
} from './directives/core.ts'
|
|
29
|
+
import { isEachResult } from './each.ts'
|
|
30
|
+
import { isReadResult, type ReadResult } from './read.ts'
|
|
31
|
+
|
|
32
|
+
export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlFragment {
|
|
33
|
+
const state = requireCurrentRenderState()
|
|
34
|
+
const builder = new HtmlBuilder(state)
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < strings.length; i++) {
|
|
37
|
+
builder.pushStatic(strings[i]!)
|
|
38
|
+
if (i < values.length) {
|
|
39
|
+
processValue(builder, state, values[i])
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return createHtmlFragment(builder.toString())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function processValue(builder: HtmlBuilder, state: RenderState, value: unknown): void {
|
|
47
|
+
const pos = builder.positionForValue()
|
|
48
|
+
if (pos.kind === 'invalid') {
|
|
49
|
+
throw new Error(`stator: ${pos.reason}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (isDirectiveInvocation(value)) {
|
|
53
|
+
if (pos.kind !== 'directive') {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'stator: directive must be in attribute-name position (between tag name and `>`, not inside an attribute value)',
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
invokeDirective(builder, value, pos.elementId)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isHtmlFragment(value)) {
|
|
63
|
+
if (pos.kind !== 'text') {
|
|
64
|
+
throw new Error('stator: cannot inline an html`...` fragment outside text position')
|
|
65
|
+
}
|
|
66
|
+
builder.pushRaw(value.html)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Arrays splice recursively: `{items.map((i) => <li>…</li>)}` is the
|
|
71
|
+
// static-list idiom (each() remains the REACTIVE list primitive). Mixed
|
|
72
|
+
// arrays are fine — fragments splice, scalars escape.
|
|
73
|
+
if (Array.isArray(value) && pos.kind === 'text') {
|
|
74
|
+
for (const item of value) processValue(builder, state, item)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (isEachResult(value)) {
|
|
79
|
+
if (pos.kind !== 'text') {
|
|
80
|
+
throw new Error('stator: cannot inline an each() result outside text position')
|
|
81
|
+
}
|
|
82
|
+
builder.pushRaw(value.html)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isBranchResult(value)) {
|
|
87
|
+
if (pos.kind !== 'text') {
|
|
88
|
+
throw new Error('stator: cannot inline a when()/match() result outside text position')
|
|
89
|
+
}
|
|
90
|
+
builder.pushRaw(value.html)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (isReadResult(value)) {
|
|
95
|
+
handleRead(builder, state, value, pos)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (pos.kind === 'text') {
|
|
100
|
+
builder.pushRaw(escapeText(stringifyValue(value)))
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (pos.kind === 'attr-value') {
|
|
104
|
+
builder.pushRaw(escapeAttribute(stringifyValue(value)))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`stator: cannot interpolate a plain value at ${pos.kind} position`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function invokeDirective(builder: HtmlBuilder, inv: DirectiveInvocation, elementId: string): void {
|
|
111
|
+
const ctx: DirectiveContext<unknown> = {
|
|
112
|
+
elementId,
|
|
113
|
+
modifier: inv.modifier,
|
|
114
|
+
arg: inv.arg,
|
|
115
|
+
addAttribute: (name, value) => {
|
|
116
|
+
builder.addAttribute(name, value)
|
|
117
|
+
},
|
|
118
|
+
registerCleanup: () => {
|
|
119
|
+
// POC: no server-side cleanup
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
inv.directive.apply(ctx)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleRead(
|
|
126
|
+
builder: HtmlBuilder,
|
|
127
|
+
state: RenderState,
|
|
128
|
+
r: ReadResult,
|
|
129
|
+
pos: ValuePosition,
|
|
130
|
+
): void {
|
|
131
|
+
if (pos.kind === 'text') {
|
|
132
|
+
registerBinding(state, {
|
|
133
|
+
slotId: r.slotId,
|
|
134
|
+
machineName: r.machineName,
|
|
135
|
+
selector: r.selector,
|
|
136
|
+
lastValue: r.value,
|
|
137
|
+
kind: 'text',
|
|
138
|
+
})
|
|
139
|
+
builder.pushRaw(`<span data-slot="${r.slotId}">${escapeText(stringifyValue(r.value))}</span>`)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (pos.kind === 'attr-value') {
|
|
143
|
+
if (pos.hasLiteralText) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`stator: attribute "${pos.attrName}" mixes literal text with a read(). ` +
|
|
146
|
+
`An attribute value must come from a single source — either the entire value ` +
|
|
147
|
+
`inside one read() / selector, or a directive like class:list / style:list ` +
|
|
148
|
+
`that owns the whole attribute.`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
registerBinding(state, {
|
|
152
|
+
slotId: r.slotId,
|
|
153
|
+
machineName: r.machineName,
|
|
154
|
+
selector: r.selector,
|
|
155
|
+
lastValue: r.value,
|
|
156
|
+
kind: 'attr',
|
|
157
|
+
attrName: pos.attrName,
|
|
158
|
+
parentId: pos.elementId,
|
|
159
|
+
})
|
|
160
|
+
// Boolean semantics: false/null/undefined mean the attribute is ABSENT
|
|
161
|
+
// (`disabled={read(...)}` must be able to un-disable), true means
|
|
162
|
+
// present-and-empty. Everything else stringifies as before. The patch
|
|
163
|
+
// side mirrors this: see recompute's attrWireValue.
|
|
164
|
+
if (r.value === false || r.value === null || r.value === undefined) {
|
|
165
|
+
builder.omitCurrentAttribute()
|
|
166
|
+
} else if (r.value !== true) {
|
|
167
|
+
builder.pushRaw(escapeAttribute(stringifyValue(r.value)))
|
|
168
|
+
}
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`stator: read() result cannot be interpolated at ${pos.kind} position`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function stringifyValue(v: unknown): string {
|
|
175
|
+
if (v == null) return ''
|
|
176
|
+
if (typeof v === 'string') return v
|
|
177
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
178
|
+
if (Array.isArray(v)) return v.map(stringifyValue).join('')
|
|
179
|
+
return String(v)
|
|
180
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { clientShellAttrs } from './client-shell.ts'
|
|
2
|
+
export type { BranchResult } from './conditional.ts'
|
|
3
|
+
export {
|
|
4
|
+
isBranchResult,
|
|
5
|
+
match,
|
|
6
|
+
renderBranchBody,
|
|
7
|
+
when,
|
|
8
|
+
} from './conditional.ts'
|
|
9
|
+
export type {
|
|
10
|
+
Directive,
|
|
11
|
+
DirectiveContext,
|
|
12
|
+
DirectiveDefinition,
|
|
13
|
+
DirectiveInvocation,
|
|
14
|
+
} from './directives/core.ts'
|
|
15
|
+
export {
|
|
16
|
+
defineDirective,
|
|
17
|
+
invoke,
|
|
18
|
+
isDirectiveInvocation,
|
|
19
|
+
} from './directives/core.ts'
|
|
20
|
+
export type { ClassListSpec, StyleListSpec } from './directives/list-attr.ts'
|
|
21
|
+
export { classList, styleList } from './directives/list-attr.ts'
|
|
22
|
+
export { on } from './directives/on.ts'
|
|
23
|
+
export type { EachResult } from './each.ts'
|
|
24
|
+
export { each, isEachResult, renderListBody } from './each.ts'
|
|
25
|
+
export { html, raw } from './html.ts'
|
|
26
|
+
export type { ReadResult } from './read.ts'
|
|
27
|
+
export { isReadResult, read } from './read.ts'
|
|
28
|
+
export type { HtmlFragment, InstanceOf } from './types.ts'
|
|
29
|
+
export { createHtmlFragment, isHtmlFragment } from './types.ts'
|