@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,341 @@
1
+ import { allocElementId, type ElementId, type RenderState } from '../server/render-context.ts'
2
+
3
+ type Mode =
4
+ | 'text'
5
+ | 'after_lt'
6
+ | 'tag_name'
7
+ | 'in_tag'
8
+ | 'attr_name'
9
+ | 'attr_after_eq'
10
+ | 'attr_value_dq'
11
+ | 'attr_value_sq'
12
+ | 'closing_tag'
13
+
14
+ export type ValuePosition =
15
+ | { kind: 'text' }
16
+ | {
17
+ kind: 'attr-value'
18
+ attrName: string
19
+ elementId: ElementId
20
+ /** True if the static template literal had any non-whitespace content
21
+ * in this attribute value before the interpolation. Used to enforce
22
+ * the one-source-per-attribute rule. */
23
+ hasLiteralText: boolean
24
+ }
25
+ | { kind: 'directive'; elementId: ElementId }
26
+ | { kind: 'invalid'; reason: string }
27
+
28
+ /**
29
+ * Streaming HTML builder. Consume static template strings via pushStatic, then
30
+ * call positionForValue between strings to learn where an interpolation will land.
31
+ * For directive / attr-value positions, the parser will assign a data-stator-id
32
+ * to the parent element on demand via ensureCurrentElementId / addAttribute.
33
+ */
34
+ export class HtmlBuilder {
35
+ private chunks: string[] = []
36
+ private mode: Mode = 'text'
37
+
38
+ // Position in chunks where additional attributes for the current open tag
39
+ // should be spliced in. Null when not inside a tag-open.
40
+ private tagOpenInsertIdx: number | null = null
41
+ private currentElementId: ElementId | null = null
42
+
43
+ // The attribute name currently being built or just completed.
44
+ private attrNameBuf = ''
45
+
46
+ // Whether the current attribute value has any non-whitespace literal text.
47
+ // Reset when an attribute value starts (opening quote) and consulted at
48
+ // interpolation time to enforce the one-source-per-attribute rule.
49
+ private attrValueLiteralHasText = false
50
+
51
+ // Chunk index where the current attribute's first name character sits —
52
+ // the start of the splice range for whole-attribute omission (boolean
53
+ // attributes bound to a falsy read). Null outside attr parsing.
54
+ private attrStartIdx: number | null = null
55
+ // Set by omitCurrentAttribute(); consumed at the closing quote.
56
+ private omitPending = false
57
+
58
+ constructor(private readonly state: RenderState) {}
59
+
60
+ pushStatic(s: string): void {
61
+ for (let i = 0; i < s.length; i++) {
62
+ this.consume(s[i]!)
63
+ }
64
+ }
65
+
66
+ /** Append raw HTML at the current position (text content only). */
67
+ pushRaw(s: string): void {
68
+ if (this.mode !== 'text' && this.mode !== 'attr_value_dq' && this.mode !== 'attr_value_sq') {
69
+ throw new Error(`stator: cannot insert content at this position (parser mode: ${this.mode})`)
70
+ }
71
+ this.chunks.push(s)
72
+ }
73
+
74
+ /** Classify the next interpolation's position based on current parser state. */
75
+ positionForValue(): ValuePosition {
76
+ if (this.mode === 'text') return { kind: 'text' }
77
+ if (this.mode === 'attr_value_dq' || this.mode === 'attr_value_sq') {
78
+ const elementId = this.ensureCurrentElementId()
79
+ return {
80
+ kind: 'attr-value',
81
+ attrName: this.attrNameBuf,
82
+ elementId,
83
+ hasLiteralText: this.attrValueLiteralHasText,
84
+ }
85
+ }
86
+ if (this.mode === 'in_tag' || this.mode === 'tag_name') {
87
+ const elementId = this.ensureCurrentElementId()
88
+ return { kind: 'directive', elementId }
89
+ }
90
+ if (this.mode === 'attr_name') {
91
+ return {
92
+ kind: 'invalid',
93
+ reason: 'cannot interpolate inside an attribute name; use a directive instead',
94
+ }
95
+ }
96
+ if (this.mode === 'attr_after_eq') {
97
+ return {
98
+ kind: 'invalid',
99
+ reason: 'unquoted attribute values are not supported; wrap the value in quotes',
100
+ }
101
+ }
102
+ return { kind: 'invalid', reason: `unexpected parser state: ${this.mode}` }
103
+ }
104
+
105
+ /**
106
+ * Add an attribute to the current open tag.
107
+ * Throws if not currently inside a tag-open.
108
+ */
109
+ addAttribute(name: string, value: string): ElementId {
110
+ if (this.tagOpenInsertIdx === null) {
111
+ throw new Error('stator: addAttribute called outside of an open tag')
112
+ }
113
+ const id = this.ensureCurrentElementId()
114
+ this.chunks.splice(this.tagOpenInsertIdx, 0, ` ${name}="${escapeAttribute(value)}"`)
115
+ this.tagOpenInsertIdx += 1
116
+ if (this.attrStartIdx !== null) this.attrStartIdx += 1
117
+ return id
118
+ }
119
+
120
+ /**
121
+ * Drop the attribute currently being parsed from the output — the whole
122
+ * `name="value"` span — once its closing quote arrives. This is how a
123
+ * boolean attribute bound to a falsy read() renders as ABSENT: by the time
124
+ * the value is known, `name="` is already in the chunk stream, so omission
125
+ * is a retroactive splice.
126
+ */
127
+ omitCurrentAttribute(): void {
128
+ if (
129
+ (this.mode !== 'attr_value_dq' && this.mode !== 'attr_value_sq') ||
130
+ this.attrStartIdx === null
131
+ ) {
132
+ throw new Error('stator: omitCurrentAttribute called outside an attribute value')
133
+ }
134
+ this.omitPending = true
135
+ }
136
+
137
+ /**
138
+ * Ensure the current open tag has a data-stator-id and return it.
139
+ * Throws if not currently inside a tag-open.
140
+ */
141
+ ensureCurrentElementId(): ElementId {
142
+ if (this.tagOpenInsertIdx === null) {
143
+ throw new Error('stator: ensureCurrentElementId called outside of an open tag')
144
+ }
145
+ if (!this.currentElementId) {
146
+ this.currentElementId = allocElementId(this.state)
147
+ this.chunks.splice(this.tagOpenInsertIdx, 0, ` data-stator-id="${this.currentElementId}"`)
148
+ this.tagOpenInsertIdx += 1
149
+ if (this.attrStartIdx !== null) this.attrStartIdx += 1
150
+ }
151
+ return this.currentElementId
152
+ }
153
+
154
+ toString(): string {
155
+ return this.chunks.join('')
156
+ }
157
+
158
+ private consume(c: string): void {
159
+ switch (this.mode) {
160
+ case 'text':
161
+ this.chunks.push(c)
162
+ if (c === '<') this.mode = 'after_lt'
163
+ return
164
+
165
+ case 'after_lt':
166
+ this.chunks.push(c)
167
+ if (c === '/') {
168
+ this.mode = 'closing_tag'
169
+ } else if (isAlpha(c)) {
170
+ this.mode = 'tag_name'
171
+ } else {
172
+ this.mode = 'text'
173
+ }
174
+ return
175
+
176
+ case 'tag_name':
177
+ if (isAlphaNum(c) || c === '-') {
178
+ this.chunks.push(c)
179
+ return
180
+ }
181
+ // tag name ends — record splice position before consuming this char
182
+ this.tagOpenInsertIdx = this.chunks.length
183
+ this.currentElementId = null
184
+ this.mode = 'in_tag'
185
+ this.handleInTag(c)
186
+ return
187
+
188
+ case 'in_tag':
189
+ this.handleInTag(c)
190
+ return
191
+
192
+ case 'attr_name':
193
+ if (isAlphaNum(c) || c === '-' || c === '_' || c === ':') {
194
+ this.attrNameBuf += c
195
+ this.chunks.push(c)
196
+ return
197
+ }
198
+ if (c === '=') {
199
+ this.chunks.push(c)
200
+ this.mode = 'attr_after_eq'
201
+ return
202
+ }
203
+ // attr name ended without '=' — boolean attribute or end of tag
204
+ if (c === '>') {
205
+ this.chunks.push(c)
206
+ this.tagOpenInsertIdx = null
207
+ this.currentElementId = null
208
+ this.attrNameBuf = ''
209
+ this.mode = 'text'
210
+ return
211
+ }
212
+ this.chunks.push(c)
213
+ this.attrNameBuf = ''
214
+ this.mode = 'in_tag'
215
+ return
216
+
217
+ case 'attr_after_eq':
218
+ if (c === '"') {
219
+ this.chunks.push(c)
220
+ this.attrValueLiteralHasText = false
221
+ this.mode = 'attr_value_dq'
222
+ return
223
+ }
224
+ if (c === "'") {
225
+ this.chunks.push(c)
226
+ this.attrValueLiteralHasText = false
227
+ this.mode = 'attr_value_sq'
228
+ return
229
+ }
230
+ // Unquoted attribute value — POC requires quotes, but be lenient on whitespace/>.
231
+ if (c === '>' || isWhitespace(c)) {
232
+ this.chunks.push(c)
233
+ this.attrNameBuf = ''
234
+ if (c === '>') {
235
+ this.tagOpenInsertIdx = null
236
+ this.currentElementId = null
237
+ this.mode = 'text'
238
+ } else {
239
+ this.mode = 'in_tag'
240
+ }
241
+ return
242
+ }
243
+ // tolerate but treat as in_tag
244
+ this.chunks.push(c)
245
+ this.mode = 'in_tag'
246
+ return
247
+
248
+ case 'attr_value_dq':
249
+ if (c === '"' && this.omitPending) {
250
+ this.spliceOutCurrentAttribute()
251
+ return
252
+ }
253
+ this.chunks.push(c)
254
+ if (c === '"') {
255
+ this.attrNameBuf = ''
256
+ this.attrStartIdx = null
257
+ this.mode = 'in_tag'
258
+ } else if (!isWhitespace(c)) {
259
+ this.attrValueLiteralHasText = true
260
+ }
261
+ return
262
+
263
+ case 'attr_value_sq':
264
+ if (c === "'" && this.omitPending) {
265
+ this.spliceOutCurrentAttribute()
266
+ return
267
+ }
268
+ this.chunks.push(c)
269
+ if (c === "'") {
270
+ this.attrNameBuf = ''
271
+ this.attrStartIdx = null
272
+ this.mode = 'in_tag'
273
+ } else if (!isWhitespace(c)) {
274
+ this.attrValueLiteralHasText = true
275
+ }
276
+ return
277
+
278
+ case 'closing_tag':
279
+ this.chunks.push(c)
280
+ if (c === '>') this.mode = 'text'
281
+ return
282
+ }
283
+ }
284
+
285
+ private handleInTag(c: string): void {
286
+ if (c === '>') {
287
+ this.chunks.push(c)
288
+ this.tagOpenInsertIdx = null
289
+ this.currentElementId = null
290
+ this.attrNameBuf = ''
291
+ this.attrStartIdx = null
292
+ this.mode = 'text'
293
+ return
294
+ }
295
+ if (isWhitespace(c) || c === '/') {
296
+ this.chunks.push(c)
297
+ return
298
+ }
299
+ if (isAlpha(c) || c === '_' || c === ':' || c === '@') {
300
+ this.attrNameBuf = c
301
+ this.attrStartIdx = this.chunks.length
302
+ this.chunks.push(c)
303
+ this.mode = 'attr_name'
304
+ return
305
+ }
306
+ this.chunks.push(c)
307
+ }
308
+
309
+ /** Remove chunks from the current attribute's start through now, plus the
310
+ * preceding whitespace separator if present. */
311
+ private spliceOutCurrentAttribute(): void {
312
+ const start = this.attrStartIdx!
313
+ this.chunks.splice(start, this.chunks.length - start)
314
+ this.omitPending = false
315
+ this.attrNameBuf = ''
316
+ this.attrStartIdx = null
317
+ this.mode = 'in_tag'
318
+ }
319
+ }
320
+
321
+ function isAlpha(c: string): boolean {
322
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
323
+ }
324
+ function isAlphaNum(c: string): boolean {
325
+ return isAlpha(c) || (c >= '0' && c <= '9')
326
+ }
327
+ function isWhitespace(c: string): boolean {
328
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'
329
+ }
330
+
331
+ export function escapeText(s: string): string {
332
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
333
+ }
334
+
335
+ export function escapeAttribute(s: string): string {
336
+ return s
337
+ .replace(/&/g, '&amp;')
338
+ .replace(/"/g, '&quot;')
339
+ .replace(/</g, '&lt;')
340
+ .replace(/>/g, '&gt;')
341
+ }
@@ -0,0 +1,50 @@
1
+ import type { AnyMachineDef } from '../server/define-machine.ts'
2
+ import { defForProxy } from '../server/instance-proxy.ts'
3
+ import {
4
+ allocSlotId,
5
+ type ErasedSelector,
6
+ requireCurrentRenderState,
7
+ type SlotId,
8
+ } from '../server/render-context.ts'
9
+ import type { InstanceOf } from './types.ts'
10
+
11
+ export interface ReadResult<T = unknown> {
12
+ readonly __isReadResult: true
13
+ slotId: SlotId
14
+ machineName: string
15
+ selector: ErasedSelector
16
+ /** The machine proxy this read was bound against. Re-evaluating
17
+ * `selector(instance)` always returns fresh state because the proxy
18
+ * reads through `actor.getSnapshot()` on every access. */
19
+ instance: unknown
20
+ value: T
21
+ }
22
+
23
+ export function isReadResult(v: unknown): v is ReadResult {
24
+ return (
25
+ typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isReadResult === true
26
+ )
27
+ }
28
+
29
+ export function read<TDef extends AnyMachineDef, TResult>(
30
+ instance: InstanceOf<TDef>,
31
+ selector: (instance: InstanceOf<TDef>) => TResult,
32
+ ): ReadResult<TResult> {
33
+ const state = requireCurrentRenderState()
34
+ const def = defForProxy(instance as unknown as object)
35
+ if (!def) {
36
+ throw new Error(
37
+ 'stator: read() must be called with a machine instance produced by the framework',
38
+ )
39
+ }
40
+ const slotId = allocSlotId(state)
41
+ const value = selector(instance)
42
+ return {
43
+ __isReadResult: true,
44
+ slotId,
45
+ machineName: def.name,
46
+ selector: selector as ErasedSelector,
47
+ instance,
48
+ value: value as TResult,
49
+ }
50
+ }
@@ -0,0 +1,33 @@
1
+ import type { AnyMachineDef, InstanceOf as SelectorsOf } from '../engine/index.ts'
2
+ import type { EventDescriptor } from '../server/render-context.ts'
3
+
4
+ /**
5
+ * The template-facing shape of a machine instance: the engine's selector view
6
+ * (each selector as a property carrying its return type, callable if it returns
7
+ * a function) plus the framework-provided `send` / `state` / `snapshot`.
8
+ */
9
+ export type InstanceOf<TDef extends AnyMachineDef> = SelectorsOf<TDef> & InstanceCommon
10
+
11
+ export interface InstanceCommon<TStateKey extends string = string> {
12
+ send(event: { type: string; [k: string]: unknown }): EventDescriptor | undefined
13
+ readonly state: TStateKey
14
+ readonly snapshot: unknown
15
+ }
16
+
17
+ /**
18
+ * Opaque marker for rendered HTML chunks. Produced by the `html` tag.
19
+ */
20
+ export interface HtmlFragment {
21
+ readonly __isHtmlFragment: true
22
+ readonly html: string
23
+ }
24
+
25
+ export function createHtmlFragment(html: string): HtmlFragment {
26
+ return { __isHtmlFragment: true, html }
27
+ }
28
+
29
+ export function isHtmlFragment(v: unknown): v is HtmlFragment {
30
+ return (
31
+ typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isHtmlFragment === true
32
+ )
33
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Vite integration for `.stator` single-file components.
3
+ * `import { stator } from '@statorjs/stator/vite'` and add it to `plugins`.
4
+ */
5
+ export { stator } from './plugin.ts'
6
+ export { machineStub } from './stub.ts'
@@ -0,0 +1,151 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { transform } from 'esbuild'
5
+ import type { Plugin } from 'vite'
6
+ import {
7
+ CompileError,
8
+ type CompileResult,
9
+ compile,
10
+ componentImportSpecifier,
11
+ declaredRegions,
12
+ splitStator,
13
+ } from '../compiler/index.ts'
14
+
15
+ /** Build a region resolver for a file: maps a component identifier used in
16
+ * `file` to the named regions its imported `.stator` declares. Reads sibling
17
+ * files synchronously (resolution happens mid-compile). Returns null when the
18
+ * identifier isn't a `.stator` default import or the file can't be read. */
19
+ function regionResolverFor(file: string, source: string) {
20
+ const { frontmatter } = splitStator(source)
21
+ return (componentName: string): Set<string> | null => {
22
+ const spec = componentImportSpecifier(frontmatter, componentName)
23
+ if (!spec) return null
24
+ const target = spec.startsWith('.') ? resolve(dirname(file), spec) : null
25
+ if (!target) return null
26
+ try {
27
+ return declaredRegions(readFileSync(target, 'utf8'))
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+ }
33
+
34
+ /** Map a `CompileError` to a Vite/Rollup-friendly error so the dev overlay and
35
+ * terminal show file:line:column with a code frame. */
36
+ function toViteError(err: unknown): unknown {
37
+ if (!(err instanceof CompileError) || !err.loc) return err
38
+ const { file, line, column, frame } = err.loc
39
+ return Object.assign(new Error(err.message), {
40
+ id: file,
41
+ loc: file ? { file, line, column } : undefined,
42
+ frame,
43
+ })
44
+ }
45
+
46
+ /**
47
+ * The `.stator` Vite plugin — a thin adapter over the pure compiler
48
+ * (`@statorjs/stator/compiler`). It routes one `.stator` file to its outputs via
49
+ * the virtual-query pattern Astro/Svelte/Vue use:
50
+ *
51
+ * - `Foo.stator` → the server render module
52
+ * - `Foo.stator?stator&type=style&lang.css` → scoped CSS (Vite's CSS pipeline
53
+ * claims it via the `lang.css` suffix; the server module imports it so the
54
+ * CSS lands in the module graph for SSR head collection)
55
+ * - `Foo.stator?stator&type=client` → the client `<script>` entry (3b)
56
+ *
57
+ * The plugin does no CSS or JSX parsing itself — that's all in the compiler.
58
+ */
59
+
60
+ const STYLE_QUERY = 'stator&type=style&lang.css'
61
+ const CLIENT_QUERY = 'stator&type=client'
62
+
63
+ export function stator(): Plugin {
64
+ const cache = new Map<string, CompileResult>()
65
+
66
+ async function compileFile(file: string): Promise<CompileResult> {
67
+ const cached = cache.get(file)
68
+ if (cached) return cached
69
+ const source = await readFile(file, 'utf8')
70
+ // routes/*.stator are route pages; everything else is a component.
71
+ const kind = /[\\/]routes[\\/].*\.stator$/.test(file) ? 'route' : 'component'
72
+ let result: CompileResult
73
+ try {
74
+ result = compile(source, {
75
+ id: file,
76
+ kind,
77
+ resolveRegions: regionResolverFor(file, source),
78
+ })
79
+ } catch (err) {
80
+ throw toViteError(err)
81
+ }
82
+ cache.set(file, result)
83
+ return result
84
+ }
85
+
86
+ return {
87
+ name: 'vite-plugin-stator',
88
+
89
+ resolveId(id, importer) {
90
+ if (!id.includes('.stator')) return null
91
+ const qIdx = id.indexOf('?')
92
+ const path = qIdx === -1 ? id : id.slice(0, qIdx)
93
+ const query = qIdx === -1 ? '' : id.slice(qIdx)
94
+ if (path.startsWith('.') && importer) {
95
+ const dir = importer.replace(/\/[^/]*$/, '')
96
+ return `${dir}/${path}${query}`
97
+ }
98
+ return null
99
+ },
100
+
101
+ async load(id) {
102
+ if (!id.includes('.stator')) return null
103
+ const qIdx = id.indexOf('?')
104
+ const file = qIdx === -1 ? id : id.slice(0, qIdx)
105
+ const query = qIdx === -1 ? '' : id.slice(qIdx + 1)
106
+ const result = await compileFile(file)
107
+
108
+ if (query.includes('type=style')) {
109
+ return result.css
110
+ }
111
+ if (query.includes('type=client')) {
112
+ // The generated client entry module (StatorElement subclass + wiring).
113
+ // It's TS (the author's <script> may have annotations) → strip types.
114
+ if (!result.isClient) return 'export {}'
115
+ const transformed = await transform(result.clientCode, {
116
+ loader: 'ts',
117
+ format: 'esm',
118
+ sourcefile: file,
119
+ sourcemap: true,
120
+ })
121
+ return { code: transformed.code, map: transformed.map }
122
+ }
123
+
124
+ // Default: the server render module. Import the style virtual (if any) so
125
+ // the scoped CSS participates in the module graph — the dev server walks
126
+ // the graph after render to inject component CSS into <head>.
127
+ const moduleSource = result.css
128
+ ? `import ${JSON.stringify(`${file}?${STYLE_QUERY}`)}\n${result.serverCode}`
129
+ : result.serverCode
130
+
131
+ // The emitted module is TypeScript (type-only imports, prop annotations);
132
+ // Vite won't run its TS transform on a `.stator` id, so strip types here.
133
+ const transformed = await transform(moduleSource, {
134
+ loader: 'ts',
135
+ format: 'esm',
136
+ sourcefile: file,
137
+ sourcemap: true,
138
+ })
139
+ return { code: transformed.code, map: transformed.map }
140
+ },
141
+
142
+ handleHotUpdate(ctx) {
143
+ if (!ctx.file.endsWith('.stator')) return
144
+ cache.delete(ctx.file)
145
+ const affected = ['', `?${STYLE_QUERY}`, `?${CLIENT_QUERY}`]
146
+ .map((q) => ctx.server.moduleGraph.getModuleById(ctx.file + q))
147
+ .filter((m): m is NonNullable<typeof m> => Boolean(m))
148
+ return affected.length > 0 ? affected : undefined
149
+ },
150
+ }
151
+ }
@@ -0,0 +1,79 @@
1
+ import { dirname, resolve, sep } from 'node:path'
2
+ import { pathToFileURL } from 'node:url'
3
+ import type { Plugin } from 'vite'
4
+
5
+ /**
6
+ * Identity-import stubbing: in a *browser* module graph, an import of a server
7
+ * machine resolves to a stub carrying only `{ name }` — the one field client
8
+ * dispatch needs (`dispatch(Cart, ev)` posts `machine.name` over the wire).
9
+ * The machine's real module (actions, guards, whatever server-only code it
10
+ * pulls in) never ships to the client.
11
+ *
12
+ * Scoping is environment-based, not syntax-based: SSR resolutions pass
13
+ * through untouched (the server needs the real machine), and only ids that
14
+ * resolve inside `machinesDir` are stubbed. Applied in two places:
15
+ * - the dev server's Vite instance (browser requests for island imports)
16
+ * - the production client build in `buildApp`
17
+ *
18
+ * The stub's name is read by importing the machine module in Node at
19
+ * build/serve time (machine defs are side-effect-free by convention — boot
20
+ * discovery already imports them all).
21
+ */
22
+
23
+ const STUB_PREFIX = '\0stator-machine-stub:'
24
+
25
+ export function machineStub(opts: { machinesDir: string }): Plugin {
26
+ const machinesDir = resolve(opts.machinesDir)
27
+ const inMachinesDir = (abs: string): boolean =>
28
+ abs === machinesDir || abs.startsWith(machinesDir + sep)
29
+
30
+ let root = ''
31
+
32
+ return {
33
+ name: 'vite-plugin-stator-machine-stub',
34
+ enforce: 'pre',
35
+
36
+ configResolved(config) {
37
+ root = config.root
38
+ },
39
+
40
+ resolveId(id, importer, options) {
41
+ if (options.ssr) return null
42
+ if (id.startsWith(STUB_PREFIX)) return id
43
+ if (!id.startsWith('.') && !id.startsWith('/')) return null
44
+ const importerPath = importer?.split('?')[0]
45
+ // A `/`-prefixed id may be a filesystem path (build inputs) or a
46
+ // vite-root-relative browser URL (dev import analysis) — try both.
47
+ const candidates = id.startsWith('.')
48
+ ? importerPath
49
+ ? [resolve(dirname(importerPath), id)]
50
+ : []
51
+ : [resolve(id), ...(root ? [resolve(root, id.slice(1))] : [])]
52
+ const abs = candidates.find(inMachinesDir)
53
+ if (!abs) return null
54
+ return STUB_PREFIX + abs
55
+ },
56
+
57
+ async load(id) {
58
+ if (!id.startsWith(STUB_PREFIX)) return null
59
+ const file = id.slice(STUB_PREFIX.length)
60
+ let name: unknown
61
+ try {
62
+ const mod = (await import(pathToFileURL(file).href)) as { default?: { name?: unknown } }
63
+ name = mod.default?.name
64
+ } catch (err) {
65
+ throw new Error(
66
+ `stator: cannot stub server-machine import "${file}" for the client bundle — ` +
67
+ `importing it in Node failed: ${(err as Error).message}`,
68
+ )
69
+ }
70
+ if (typeof name !== 'string') {
71
+ throw new Error(
72
+ `stator: cannot stub "${file}" — it does not default-export a machine with a name. ` +
73
+ `Only machine defs may be imported from the machines directory in client code.`,
74
+ )
75
+ }
76
+ return `export default { name: ${JSON.stringify(name)} }\n`
77
+ },
78
+ }
79
+ }