@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,166 @@
1
+ import {
2
+ allocSlotId,
3
+ type ErasedSelector,
4
+ keyToken,
5
+ popListScope,
6
+ type RenderState,
7
+ registerBinding,
8
+ requireCurrentRenderState,
9
+ type SlotId,
10
+ unregisterBindingsForScope,
11
+ } from '../server/render-context.ts'
12
+ import { isReadResult, type ReadResult } from './read.ts'
13
+ import { type HtmlFragment, isHtmlFragment } from './types.ts'
14
+
15
+ /**
16
+ * A conditional fragment. Same wire shape as EachResult — a position-marker
17
+ * span whose innerHTML is swapped on key change. When inactive, the span has
18
+ * no inner content, so the conditional body's DOM is genuinely absent (not
19
+ * hidden via CSS).
20
+ */
21
+ export interface BranchResult {
22
+ readonly __isBranchResult: true
23
+ readonly html: string
24
+ readonly slotId: SlotId
25
+ }
26
+
27
+ export function isBranchResult(v: unknown): v is BranchResult {
28
+ return (
29
+ typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isBranchResult === true
30
+ )
31
+ }
32
+
33
+ /**
34
+ * Render the body for a branch result. Mirrors renderListBody: clears any
35
+ * existing descendant bindings under this slot, pushes a child scope, and
36
+ * runs the renderer. Returns the inner HTML (empty when renderer is null).
37
+ *
38
+ * Descendant slot ids are scoped by the ARM (`s2:btrue:s0`,
39
+ * `s2:bconfirmed:s0`) — the branch analogue of keyed rows' key scoping. Two
40
+ * arms of one branch never share slot ids, so a patch computed for one arm
41
+ * can never land inside the other's DOM (the stale-page hazard: a non-live
42
+ * page showing arm A while the server diffs arm B must skip, not miswrite).
43
+ */
44
+ export function renderBranchBody(
45
+ state: RenderState,
46
+ slotId: SlotId,
47
+ armKey: unknown,
48
+ renderer: (() => HtmlFragment) | null,
49
+ ): string {
50
+ unregisterBindingsForScope(state, slotId)
51
+ if (!renderer) return ''
52
+ state.scopeStack.push({ prefix: `${slotId}:b${keyToken(String(armKey))}`, counter: 0 })
53
+ try {
54
+ const fragment = renderer()
55
+ if (!isHtmlFragment(fragment)) {
56
+ throw new Error('stator: when()/match() renderer must return an html`...` result')
57
+ }
58
+ return fragment.html
59
+ } finally {
60
+ popListScope(state)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Render `fn()` when `cond` is truthy; otherwise render nothing. Re-renders
66
+ * only when the truthiness of `cond` flips — toggling between two truthy
67
+ * values does not cause a swap.
68
+ */
69
+ export function when<T>(cond: T | ReadResult<T>, fn: () => HtmlFragment): BranchResult {
70
+ const state = requireCurrentRenderState()
71
+
72
+ let value: T
73
+ let slotId: SlotId
74
+ let machineName: string | null = null
75
+ let selector: ErasedSelector | null = null
76
+
77
+ if (isReadResult(cond)) {
78
+ value = cond.value as T
79
+ slotId = cond.slotId
80
+ machineName = cond.machineName
81
+ selector = cond.selector
82
+ } else {
83
+ value = cond
84
+ slotId = allocSlotId(state)
85
+ }
86
+
87
+ const keyFn = (v: unknown): boolean => !!v
88
+ const renderFn = (v: unknown): (() => HtmlFragment) | null => (v ? fn : null)
89
+
90
+ const activeKey = keyFn(value)
91
+ const innerHtml = renderBranchBody(state, slotId, activeKey, renderFn(value))
92
+
93
+ if (machineName && selector) {
94
+ registerBinding(state, {
95
+ slotId,
96
+ machineName,
97
+ selector,
98
+ lastValue: value,
99
+ kind: 'branch',
100
+ branchKeyFn: keyFn,
101
+ branchRenderFn: renderFn,
102
+ lastBranchKey: activeKey,
103
+ })
104
+ }
105
+
106
+ // `display: contents` so the wrapper span doesn't break the surrounding
107
+ // element's layout (grid/flex) — see each.ts for the same rationale.
108
+ const html = `<span data-slot="${slotId}" data-branch="true" style="display:contents">${innerHtml}</span>`
109
+ return { __isBranchResult: true, html, slotId }
110
+ }
111
+
112
+ /**
113
+ * Render the body for the matching case key, or nothing if no case matches.
114
+ * Re-renders only when the key changes — toggling other context fields
115
+ * doesn't cause a swap.
116
+ *
117
+ * Type-safe when `key` is a `ReadResult<TKey extends string>`: cases must be
118
+ * keyed by the same literal union.
119
+ */
120
+ export function match<TKey extends string>(
121
+ key: TKey | ReadResult<TKey>,
122
+ cases: Partial<Record<TKey, () => HtmlFragment>>,
123
+ ): BranchResult {
124
+ const state = requireCurrentRenderState()
125
+
126
+ let value: TKey
127
+ let slotId: SlotId
128
+ let machineName: string | null = null
129
+ let selector: ErasedSelector | null = null
130
+
131
+ if (isReadResult(key)) {
132
+ value = key.value as TKey
133
+ slotId = key.slotId
134
+ machineName = key.machineName
135
+ selector = key.selector
136
+ } else {
137
+ value = key
138
+ slotId = allocSlotId(state)
139
+ }
140
+
141
+ const keyFn = (v: unknown): unknown => v
142
+ const renderFn = (v: unknown): (() => HtmlFragment) | null => {
143
+ const fn = (cases as Record<string, (() => HtmlFragment) | undefined>)[String(v)]
144
+ return fn ?? null
145
+ }
146
+
147
+ const innerHtml = renderBranchBody(state, slotId, keyFn(value), renderFn(value))
148
+
149
+ if (machineName && selector) {
150
+ registerBinding(state, {
151
+ slotId,
152
+ machineName,
153
+ selector,
154
+ lastValue: value,
155
+ kind: 'branch',
156
+ branchKeyFn: keyFn,
157
+ branchRenderFn: renderFn,
158
+ lastBranchKey: keyFn(value),
159
+ })
160
+ }
161
+
162
+ // `display: contents` so the wrapper span doesn't break the surrounding
163
+ // element's layout (grid/flex) — see each.ts for the same rationale.
164
+ const html = `<span data-slot="${slotId}" data-branch="true" style="display:contents">${innerHtml}</span>`
165
+ return { __isBranchResult: true, html, slotId }
166
+ }
@@ -0,0 +1,58 @@
1
+ import type { ElementId } from '../../server/render-context.ts'
2
+
3
+ export interface DirectiveContext<TArg> {
4
+ /** The synthetic id of the element this directive is attached to. */
5
+ elementId: ElementId
6
+ /** The modifier portion of the directive, e.g. 'click' in `on('click', ...)`. */
7
+ modifier: string
8
+ /** The argument passed to the directive, e.g. the handler function. */
9
+ arg: TArg
10
+ /** Adds an attribute to the element (e.g. `data-event-click="..."`). */
11
+ addAttribute(name: string, value: string): void
12
+ /**
13
+ * Records a cleanup function. Unused server-side in the POC; reserved
14
+ * for V1 directives that bind server-side timers / SSE.
15
+ */
16
+ registerCleanup(fn: () => void): void
17
+ }
18
+
19
+ export interface DirectiveDefinition<TArg> {
20
+ name: string
21
+ apply(ctx: DirectiveContext<TArg>): void
22
+ }
23
+
24
+ export interface Directive<TArg> extends DirectiveDefinition<TArg> {
25
+ readonly __isStatorDirective: true
26
+ }
27
+
28
+ export function defineDirective<TArg>(def: DirectiveDefinition<TArg>): Directive<TArg> {
29
+ return { ...def, __isStatorDirective: true }
30
+ }
31
+
32
+ export interface DirectiveInvocation<TArg = unknown> {
33
+ readonly __isStatorDirectiveInvocation: true
34
+ directive: Directive<TArg>
35
+ modifier: string
36
+ arg: TArg
37
+ }
38
+
39
+ export function isDirectiveInvocation(v: unknown): v is DirectiveInvocation {
40
+ return (
41
+ typeof v === 'object' &&
42
+ v !== null &&
43
+ (v as Record<string, unknown>).__isStatorDirectiveInvocation === true
44
+ )
45
+ }
46
+
47
+ export function invoke<TArg>(
48
+ directive: Directive<TArg>,
49
+ modifier: string,
50
+ arg: TArg,
51
+ ): DirectiveInvocation<TArg> {
52
+ return {
53
+ __isStatorDirectiveInvocation: true,
54
+ directive,
55
+ modifier,
56
+ arg,
57
+ }
58
+ }
@@ -0,0 +1,183 @@
1
+ import {
2
+ allocSlotId,
3
+ type RenderState,
4
+ registerBinding,
5
+ requireCurrentRenderState,
6
+ } from '../../server/render-context.ts'
7
+ import { isReadResult, type ReadResult } from '../read.ts'
8
+ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
9
+
10
+ /**
11
+ * Shared shape for compound-attribute directives (`class:list`, `style:list`).
12
+ * Each directive owns an entire attribute. Conditional reads appear *inside*
13
+ * the spec, never alongside literal text — that rule is enforced by html.ts.
14
+ *
15
+ * The directive registers one attr binding per unique machine appearing in
16
+ * the spec; each binding's selector recomposes the full attribute value, so
17
+ * any machine event that affects the spec emits exactly one patch for the
18
+ * attribute (and the value always includes every static entry).
19
+ */
20
+
21
+ type ConditionalEntry<TValue> = TValue | ReadResult<TValue>
22
+
23
+ /** Recursive shape accepted by class:list. */
24
+ export type ClassListSpec =
25
+ | string
26
+ | false
27
+ | null
28
+ | undefined
29
+ | ReadResult<string | boolean | null | undefined>
30
+ | Record<string, ConditionalEntry<boolean | null | undefined>>
31
+ | ClassListSpec[]
32
+
33
+ /** Recursive shape accepted by style:list. */
34
+ export type StyleListSpec =
35
+ | string
36
+ | false
37
+ | null
38
+ | undefined
39
+ | ReadResult<string | null | undefined>
40
+ | Record<string, ConditionalEntry<string | number | null | undefined>>
41
+ | StyleListSpec[]
42
+
43
+ function evalRead<T>(value: ConditionalEntry<T>): T {
44
+ return isReadResult(value) ? (value.selector(value.instance) as T) : value
45
+ }
46
+
47
+ function collectMachines(spec: unknown, out: Set<string>): void {
48
+ if (spec == null || spec === false) return
49
+ if (typeof spec === 'string') return
50
+ if (isReadResult(spec)) {
51
+ out.add(spec.machineName)
52
+ return
53
+ }
54
+ if (Array.isArray(spec)) {
55
+ for (const item of spec) collectMachines(item, out)
56
+ return
57
+ }
58
+ if (typeof spec === 'object') {
59
+ for (const v of Object.values(spec)) {
60
+ if (isReadResult(v)) out.add(v.machineName)
61
+ }
62
+ }
63
+ }
64
+
65
+ function composeClass(spec: ClassListSpec): string {
66
+ const parts: string[] = []
67
+ walkClass(spec, parts)
68
+ return parts.join(' ')
69
+ }
70
+
71
+ function walkClass(spec: ClassListSpec, out: string[]): void {
72
+ if (spec == null || spec === false) return
73
+ if (typeof spec === 'string') {
74
+ if (spec.length > 0) out.push(spec)
75
+ return
76
+ }
77
+ if (isReadResult(spec)) {
78
+ const v = spec.selector(spec.instance)
79
+ if (typeof v === 'string' && v.length > 0) out.push(v)
80
+ return
81
+ }
82
+ if (Array.isArray(spec)) {
83
+ for (const item of spec) walkClass(item, out)
84
+ return
85
+ }
86
+ if (typeof spec === 'object') {
87
+ for (const [name, cond] of Object.entries(spec)) {
88
+ const truthy = evalRead(cond)
89
+ if (truthy) out.push(name)
90
+ }
91
+ }
92
+ }
93
+
94
+ function composeStyle(spec: StyleListSpec): string {
95
+ const decls: string[] = []
96
+ walkStyle(spec, decls)
97
+ return decls.join('; ')
98
+ }
99
+
100
+ function walkStyle(spec: StyleListSpec, out: string[]): void {
101
+ if (spec == null || spec === false) return
102
+ if (typeof spec === 'string') {
103
+ const trimmed = spec.trim()
104
+ if (trimmed.length > 0) out.push(trimmed.replace(/;\s*$/, ''))
105
+ return
106
+ }
107
+ if (isReadResult(spec)) {
108
+ const v = spec.selector(spec.instance)
109
+ if (typeof v === 'string' && v.trim().length > 0) {
110
+ out.push(v.trim().replace(/;\s*$/, ''))
111
+ }
112
+ return
113
+ }
114
+ if (Array.isArray(spec)) {
115
+ for (const item of spec) walkStyle(item, out)
116
+ return
117
+ }
118
+ if (typeof spec === 'object') {
119
+ for (const [prop, raw] of Object.entries(spec)) {
120
+ const value = evalRead(raw)
121
+ if (value == null || value === '') continue
122
+ out.push(`${prop}: ${value}`)
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Register one attr-binding per unique machine the spec depends on. Each
129
+ * binding's selector ignores its arg and recomputes the entire attribute
130
+ * by re-walking the spec — every ReadResult inside re-evaluates its
131
+ * `.selector(.instance)`, returning fresh state.
132
+ */
133
+ function bindListAttr<TSpec>(
134
+ state: RenderState,
135
+ spec: TSpec,
136
+ attrName: string,
137
+ elementId: string,
138
+ compose: (spec: TSpec) => string,
139
+ ): string {
140
+ const machines = new Set<string>()
141
+ collectMachines(spec, machines)
142
+
143
+ const initial = compose(spec)
144
+ for (const machineName of machines) {
145
+ const slotId = allocSlotId(state)
146
+ registerBinding(state, {
147
+ slotId,
148
+ machineName,
149
+ selector: () => compose(spec),
150
+ lastValue: initial,
151
+ kind: 'attr',
152
+ attrName,
153
+ parentId: elementId,
154
+ })
155
+ }
156
+ return initial
157
+ }
158
+
159
+ const classListDirective = defineDirective<ClassListSpec>({
160
+ name: 'class:list',
161
+ apply({ elementId, arg, addAttribute }) {
162
+ const state = requireCurrentRenderState()
163
+ const initial = bindListAttr(state, arg, 'class', elementId, composeClass)
164
+ addAttribute('class', initial)
165
+ },
166
+ })
167
+
168
+ const styleListDirective = defineDirective<StyleListSpec>({
169
+ name: 'style:list',
170
+ apply({ elementId, arg, addAttribute }) {
171
+ const state = requireCurrentRenderState()
172
+ const initial = bindListAttr(state, arg, 'style', elementId, composeStyle)
173
+ addAttribute('style', initial)
174
+ },
175
+ })
176
+
177
+ export function classList(spec: ClassListSpec): DirectiveInvocation<ClassListSpec> {
178
+ return invoke(classListDirective, '', spec)
179
+ }
180
+
181
+ export function styleList(spec: StyleListSpec): DirectiveInvocation<StyleListSpec> {
182
+ return invoke(styleListDirective, '', spec)
183
+ }
@@ -0,0 +1,22 @@
1
+ import { type EventDescriptor, isEventDescriptor } from '../../server/render-context.ts'
2
+ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
3
+
4
+ type Handler = () => EventDescriptor | undefined
5
+
6
+ const onDirective = defineDirective<Handler>({
7
+ name: 'on',
8
+ apply({ modifier, arg, addAttribute }) {
9
+ const result = arg()
10
+ if (!isEventDescriptor(result)) {
11
+ throw new Error(
12
+ `stator: on('${modifier}', ...) handler must be exactly one machine.send(...) call. ` +
13
+ `Multi-statement handlers and arbitrary side effects are not supported in the POC.`,
14
+ )
15
+ }
16
+ addAttribute(`data-event-${modifier}`, JSON.stringify(result))
17
+ },
18
+ })
19
+
20
+ export function on(modifier: string, handler: Handler): DirectiveInvocation<Handler> {
21
+ return invoke(onDirective, modifier, handler)
22
+ }