@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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. 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'