@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,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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function escapeAttribute(s: string): string {
|
|
336
|
+
return s
|
|
337
|
+
.replace(/&/g, '&')
|
|
338
|
+
.replace(/"/g, '"')
|
|
339
|
+
.replace(/</g, '<')
|
|
340
|
+
.replace(/>/g, '>')
|
|
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,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
|
+
}
|
package/src/vite/stub.ts
ADDED
|
@@ -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
|
+
}
|