@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,459 @@
1
+ import ts from 'typescript'
2
+ import { emitClientModule } from './client-emit.ts'
3
+ import {
4
+ analyzeClient,
5
+ analyzeScriptClasses,
6
+ type ClientDirective,
7
+ isCustomElementTag,
8
+ pascalToKebab,
9
+ } from './client-script.ts'
10
+ import { CompileError, type DiagnosticLocation, locAt } from './diagnostics.ts'
11
+ import { scopeHash } from './hash.ts'
12
+ import { type LowerMeta, lowerTemplate } from './lower.ts'
13
+ import { splitStator } from './split.ts'
14
+ import { scopeCss } from './styles.ts'
15
+
16
+ /**
17
+ * Assemble a `.stator` source into the server `.ts` module the runtime
18
+ * consumes. Phase 3a: server module only (template + frontmatter). Styles and
19
+ * the client `<script>` are layered in later stages.
20
+ *
21
+ * Frontmatter handling:
22
+ * - import / type / interface declarations hoist to the module top
23
+ * - the template runtime primitives are auto-injected (authors write JSX +
24
+ * `{read(...)}`, never `import ... from '@statorjs/stator/template'` for these)
25
+ * - other statements (notably `const { x } = Stator.props<P>()`) move inside
26
+ * the rendered function, with `Stator.props<P>()` rewritten to the `props`
27
+ * parameter and `P` lifted to the parameter's type
28
+ */
29
+
30
+ export interface CompileResult {
31
+ /** The server render module source (shell render for a client component). */
32
+ serverCode: string
33
+ /** Per-component scope hash (the `data-s-<hash>` marker uses it). */
34
+ scopeHash: string
35
+ /** Scoped CSS, ready for the Vite CSS pipeline. Empty when there's no
36
+ * `<style>`. */
37
+ css: string
38
+ /** Raw client `<script>` regions, as authored. */
39
+ scripts: string[]
40
+ /** True when this `.stator` is a client component (whole-file custom element). */
41
+ isClient: boolean
42
+ /** The generated client entry module (Phase 3b) — the `StatorElement` subclass
43
+ * + `setup()` + `defineElement`. Empty for server components. */
44
+ clientCode: string
45
+ /** Custom-element tag this client component defines (e.g. `quantity-stepper`),
46
+ * or undefined for a server component. */
47
+ clientTag?: string
48
+ }
49
+
50
+ const PRIMITIVES_IMPORT =
51
+ "import { html, read, each, when, match, on, classList, styleList } from '@statorjs/stator/template'"
52
+
53
+ export interface CompileOptions {
54
+ /** Stable id for the component (file path). Used for the scope hash so the
55
+ * hash is stable across edits to unrelated files; falls back to the source.
56
+ * Also the diagnostics file path. */
57
+ id?: string
58
+ /** Whether this file is a route page or a reusable component. Gates the
59
+ * frontmatter capability matrix (request/response, reads, props, pragmas).
60
+ * Defaults to 'component'. The Vite plugin / build sets it from the directory. */
61
+ kind?: 'route' | 'component'
62
+ /** Resolve a component identifier (used in this file) to the named regions it
63
+ * declares, for cross-file `child="x"` validation. The Vite plugin / build
64
+ * supplies this (reads sibling `.stator` files). Omitted → validation skipped. */
65
+ resolveRegions?: (componentName: string) => Set<string> | null
66
+ }
67
+
68
+ export function compile(source: string, opts: CompileOptions = {}): CompileResult {
69
+ const { frontmatter, template, styles, scripts, scriptOffsets, templateOffset } =
70
+ splitStator(source)
71
+ const kind = opts.kind ?? 'component'
72
+
73
+ const hasStyles = styles.length > 0
74
+ const hash = scopeHash(opts.id ?? source)
75
+ const scopeAttr = hasStyles ? `data-s-${hash}` : undefined
76
+
77
+ // An inline `<script>` makes this a *client component* (a whole-file custom
78
+ // element) — a different compile path entirely. It must export a
79
+ // `StatorElement`; one that doesn't is a malformed component, not literal
80
+ // markup, so we surface it rather than silently dropping the script.
81
+ const script = scripts.join('\n')
82
+ if (script.trim()) {
83
+ if (analyzeScriptClasses(script).length > 0) {
84
+ return compileClient(template, script, {
85
+ hash,
86
+ scopeAttr,
87
+ styles,
88
+ file: opts.id,
89
+ })
90
+ }
91
+ throw new CompileError(
92
+ `stator: this <script> exports no StatorElement subclass. An inline <script> in a ` +
93
+ `.stator file is compiled as a client component — add ` +
94
+ `\`export class … extends StatorElement { … }\`. To emit a literal script instead, ` +
95
+ `mark it \`<script is:inline>…</script>\` (verbatim inline) or use ` +
96
+ `\`<script src="…">\` (external file).`,
97
+ locAt(source, scriptOffsets[0] ?? 0, opts.id),
98
+ )
99
+ }
100
+
101
+ const meta: LowerMeta = {
102
+ usesChildren: false,
103
+ regions: new Set(),
104
+ components: new Set(),
105
+ customElements: new Set(),
106
+ refs: new Set(),
107
+ }
108
+ const htmlExpr = lowerTemplate(template, {
109
+ scopeAttr,
110
+ source,
111
+ templateOffset,
112
+ file: opts.id,
113
+ meta,
114
+ resolveRegions: opts.resolveRegions,
115
+ })
116
+ const css = hasStyles ? scopeCss(styles.join('\n'), hash) : ''
117
+
118
+ const fm = processFrontmatter(frontmatter, kind, source, opts.id)
119
+ const serverCode =
120
+ kind === 'route' ? emitRoute(fm, htmlExpr, meta) : emitComponent(fm, htmlExpr, meta)
121
+
122
+ return {
123
+ serverCode,
124
+ scopeHash: hash,
125
+ css,
126
+ scripts,
127
+ isClient: false,
128
+ clientCode: '',
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Compile a client component: its template root is the custom element, the
134
+ * `<script>` defines the matching class. Produces (i) a server shell render
135
+ * module — `(props) => HtmlFragment` rendering the custom-element tag with the
136
+ * declared attrs + inner shell (markers + children) — and (ii) the client entry
137
+ * module (the `StatorElement` subclass + wiring).
138
+ */
139
+ function compileClient(
140
+ template: string,
141
+ script: string,
142
+ ctx: { hash: string; scopeAttr?: string; styles: string[]; file?: string },
143
+ ): CompileResult {
144
+ const classes = analyzeScriptClasses(script)
145
+ const root = extractClientRoot(template, ctx.file)
146
+
147
+ // Name-match validation (both directions, hyphen rule).
148
+ const tags = new Set([root.tag])
149
+ analyzeClient(script, tags, { file: ctx.file })
150
+
151
+ const cls = classes.find((c) => pascalToKebab(c.name) === root.tag)
152
+ if (!cls) {
153
+ // analyzeClient validated the tag↔class match just above; reaching here is a bug.
154
+ throw new CompileError(
155
+ `stator: internal — no exported class matches root <${root.tag}> after name validation`,
156
+ )
157
+ }
158
+
159
+ // Lower the inner shell in client mode: collect bind:/on:, strip them, inject
160
+ // markers. Plain expressions (props access, maps with nested JSX, read())
161
+ // flow through to the SHELL and evaluate server-side per use — the hydrate
162
+ // pattern; see the client-components guide.
163
+ const directives: ClientDirective[] = []
164
+ const meta: LowerMeta = {
165
+ usesChildren: false,
166
+ regions: new Set(),
167
+ components: new Set(),
168
+ customElements: new Set(),
169
+ refs: new Set(),
170
+ }
171
+ // Client components scope styles by DESCENDANT of the root (the class may
172
+ // create DOM at runtime — per-element attrs could never reach it), so the
173
+ // inner template needs no per-element stamping: only the root carries the
174
+ // scope attribute.
175
+ const innerExpr = lowerTemplate(root.inner, {
176
+ file: ctx.file,
177
+ meta,
178
+ client: { useFields: new Set(cls.useFields.keys()), directives },
179
+ })
180
+
181
+ const css = ctx.styles.length
182
+ ? scopeCss(ctx.styles.join('\n'), ctx.hash, { strategy: 'descendant', rootTag: root.tag })
183
+ : ''
184
+
185
+ // Server shell module: <tag {attrs}{scope}>{inner}</tag>.
186
+ const attrDecl = `{ ${[...cls.staticAttrs].map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`
187
+ const rootScope = ctx.scopeAttr ? ` data-s-${ctx.hash}` : ''
188
+ const serverCode = [
189
+ "import { html, read, each, when, match, on, classList, styleList, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
190
+ '',
191
+ `export default function (props = {}) {`,
192
+ ` const __inner = ${innerExpr}`,
193
+ ` const __attrs = clientShellAttrs(props, ${attrDecl})`,
194
+ ` return createHtmlFragment(\`<${root.tag}\${__attrs}${rootScope}>\` + __inner.html + \`</${root.tag}>\`)`,
195
+ '}',
196
+ '',
197
+ ].join('\n')
198
+
199
+ const clientCode = emitClientModule({
200
+ script,
201
+ element: { tag: root.tag, className: cls.name },
202
+ directives,
203
+ members: cls.members,
204
+ })
205
+
206
+ return {
207
+ serverCode,
208
+ scopeHash: ctx.hash,
209
+ css,
210
+ scripts: [script],
211
+ isClient: true,
212
+ clientCode,
213
+ clientTag: root.tag,
214
+ }
215
+ }
216
+
217
+ /** Parse a client template, returning the custom-element root tag and the inner
218
+ * source (its children). Enforces "root must be the custom element". */
219
+ function extractClientRoot(template: string, file?: string): { tag: string; inner: string } {
220
+ const wrapped = `const __t = (<>${template}</>);`
221
+ const sf = ts.createSourceFile('t.tsx', wrapped, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
222
+ let fragment: ts.JsxFragment | undefined
223
+ const find = (n: ts.Node): void => {
224
+ if (ts.isJsxFragment(n)) {
225
+ fragment = n
226
+ return
227
+ }
228
+ ts.forEachChild(n, find)
229
+ }
230
+ find(sf)
231
+ const elements = (fragment?.children ?? []).filter(
232
+ (c) => ts.isJsxElement(c) || ts.isJsxSelfClosingElement(c),
233
+ )
234
+ if (elements.length !== 1) {
235
+ throw new CompileError(
236
+ 'stator: a client component must have a single custom-element root.',
237
+ file ? { file, line: 1, column: 1, frame: '' } : undefined,
238
+ )
239
+ }
240
+ const rootEl = elements[0]!
241
+ if (ts.isJsxSelfClosingElement(rootEl)) {
242
+ const tag = rootEl.tagName.getText(sf)
243
+ requireCustomRoot(tag, file)
244
+ return { tag, inner: '' }
245
+ }
246
+ const el = rootEl as ts.JsxElement
247
+ const tag = el.openingElement.tagName.getText(sf)
248
+ requireCustomRoot(tag, file)
249
+ const inner = wrapped.slice(el.openingElement.getEnd(), el.closingElement.getStart())
250
+ return { tag, inner }
251
+ }
252
+
253
+ function requireCustomRoot(tag: string, file?: string): void {
254
+ if (!isCustomElementTag(tag)) {
255
+ throw new CompileError(
256
+ `stator: a client component's root must be a custom element (a hyphenated tag like ` +
257
+ `<my-widget>), found <${tag}>. A nested custom element under server chrome isn't allowed.`,
258
+ file ? { file, line: 1, column: 1, frame: '' } : undefined,
259
+ )
260
+ }
261
+ }
262
+
263
+ interface FrontmatterParts {
264
+ hoisted: string
265
+ body: string
266
+ propsType?: string
267
+ /** Route only: the machine identifiers from `Stator.reads([...])`. */
268
+ reads: string[]
269
+ /** Route only: pragma flags (`// @stator live`). */
270
+ pragmas: Set<string>
271
+ }
272
+
273
+ const VALID_PRAGMAS = new Set(['live'])
274
+
275
+ /**
276
+ * Split frontmatter into hoisted declarations (imports/types) and function-body
277
+ * statements, rewriting the `Stator.*` markers per file kind and enforcing the
278
+ * capability matrix.
279
+ *
280
+ * Component: `Stator.props<P>()` → `props`. Route/request/response/pragmas are
281
+ * errors. Route: `Stator.reads([A,B])` RHS → `[__ctx[A.name], __ctx[B.name]]`
282
+ * (and `[A,B]` captured for the `reads:` config); `Stator.request` → `__req`;
283
+ * `Stator.response` → `__ctx.response`. `Stator.props` is an error.
284
+ */
285
+ function processFrontmatter(
286
+ fm: string,
287
+ kind: 'route' | 'component',
288
+ source: string,
289
+ file?: string,
290
+ ): FrontmatterParts {
291
+ const pragmas = parsePragmas(fm, kind, source, file)
292
+ if (!fm.trim()) return { hoisted: '', body: '', reads: [], pragmas }
293
+
294
+ const sf = ts.createSourceFile('fm.ts', fm, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
295
+ const hoisted: string[] = []
296
+ const body: string[] = []
297
+ let propsType: string | undefined
298
+ const reads: string[] = []
299
+
300
+ const locInFm = (node: ts.Node): DiagnosticLocation =>
301
+ locAt(source, fmOffset(source) + node.getStart(sf), file)
302
+
303
+ for (const stmt of sf.statements) {
304
+ if (
305
+ ts.isImportDeclaration(stmt) ||
306
+ ts.isTypeAliasDeclaration(stmt) ||
307
+ ts.isInterfaceDeclaration(stmt)
308
+ ) {
309
+ hoisted.push(stmt.getText(sf))
310
+ continue
311
+ }
312
+
313
+ const stmtStart = stmt.getStart(sf)
314
+ let text = stmt.getText(sf)
315
+ const repls: Array<[start: number, end: number, replacement: string]> = []
316
+ const visit = (n: ts.Node): void => {
317
+ if (ts.isCallExpression(n) && isStatorCall(n, 'props')) {
318
+ if (kind === 'route') {
319
+ throw new CompileError(
320
+ 'stator: Stator.props() is not available in a route page (a route has no parent props). ' +
321
+ 'Use Stator.reads([...]) to access machines.',
322
+ locInFm(n),
323
+ )
324
+ }
325
+ if (n.typeArguments && n.typeArguments.length > 0) {
326
+ propsType = n.typeArguments[0]!.getText(sf)
327
+ }
328
+ repls.push([n.getStart(sf), n.getEnd(), 'props'])
329
+ return
330
+ }
331
+ if (ts.isCallExpression(n) && isStatorCall(n, 'reads')) {
332
+ if (kind !== 'route') {
333
+ throw new CompileError(
334
+ 'stator: Stator.reads() is only available in a route page. ' +
335
+ 'A component receives machines as props.',
336
+ locInFm(n),
337
+ )
338
+ }
339
+ const arg = n.arguments[0]
340
+ if (!arg || !ts.isArrayLiteralExpression(arg)) {
341
+ throw new CompileError('stator: Stator.reads(...) takes an array of machines', locInFm(n))
342
+ }
343
+ const ids = arg.elements.map((el) => el.getText(sf))
344
+ reads.push(...ids)
345
+ const bound = `[${ids.map((id) => `__ctx[${id}.name]`).join(', ')}]`
346
+ repls.push([n.getStart(sf), n.getEnd(), bound])
347
+ return
348
+ }
349
+ if (ts.isPropertyAccessExpression(n) && isStatorMember(n, 'request')) {
350
+ requireRoute('Stator.request', kind, locInFm(n))
351
+ repls.push([n.getStart(sf), n.getEnd(), '__req'])
352
+ return
353
+ }
354
+ if (ts.isPropertyAccessExpression(n) && isStatorMember(n, 'response')) {
355
+ requireRoute('Stator.response', kind, locInFm(n))
356
+ repls.push([n.getStart(sf), n.getEnd(), '__ctx.response'])
357
+ return
358
+ }
359
+ ts.forEachChild(n, visit)
360
+ }
361
+ visit(stmt)
362
+ repls.sort((a, b) => b[0] - a[0])
363
+ for (const [start, end, replacement] of repls) {
364
+ text = text.slice(0, start - stmtStart) + replacement + text.slice(end - stmtStart)
365
+ }
366
+ body.push(` ${text}`)
367
+ }
368
+
369
+ return {
370
+ hoisted: hoisted.join('\n'),
371
+ body: body.join('\n'),
372
+ propsType,
373
+ reads,
374
+ pragmas,
375
+ }
376
+ }
377
+
378
+ function emitComponent(fm: FrontmatterParts, htmlExpr: string, meta: LowerMeta): string {
379
+ let param = ''
380
+ if (fm.propsType && meta.usesChildren) param = `props: ${fm.propsType} & { children?: any }`
381
+ else if (fm.propsType) param = `props: ${fm.propsType}`
382
+ else if (meta.usesChildren) param = `props: { children?: any }`
383
+
384
+ const lines = [PRIMITIVES_IMPORT]
385
+ if (fm.hoisted) lines.push(fm.hoisted)
386
+ lines.push('')
387
+ lines.push(`export default function (${param}) {`)
388
+ if (fm.body) lines.push(fm.body)
389
+ lines.push(` return ${htmlExpr}`)
390
+ lines.push('}')
391
+ return `${lines.join('\n')}\n`
392
+ }
393
+
394
+ function emitRoute(fm: FrontmatterParts, htmlExpr: string, _meta: LowerMeta): string {
395
+ const lines = [PRIMITIVES_IMPORT, "import { defineRoute } from '@statorjs/stator/server'"]
396
+ if (fm.hoisted) lines.push(fm.hoisted)
397
+ lines.push('')
398
+ lines.push('export const GET = defineRoute({')
399
+ lines.push(` reads: [${fm.reads.join(', ')}],`)
400
+ if (fm.pragmas.has('live')) lines.push(' live: true,')
401
+ lines.push(' render: (__ctx, __req) => {')
402
+ if (fm.body) lines.push(fm.body)
403
+ lines.push(` return ${htmlExpr}`)
404
+ lines.push(' },')
405
+ lines.push('})')
406
+ return `${lines.join('\n')}\n`
407
+ }
408
+
409
+ /** Parse `// @stator <flag>` comment pragmas from the frontmatter text. */
410
+ function parsePragmas(
411
+ fm: string,
412
+ kind: 'route' | 'component',
413
+ source: string,
414
+ file?: string,
415
+ ): Set<string> {
416
+ const out = new Set<string>()
417
+ const re = /\/\/\s*@stator\s+(\S+)/g
418
+ for (const m of fm.matchAll(re)) {
419
+ const flag = m[1]!
420
+ if (!VALID_PRAGMAS.has(flag)) {
421
+ throw new CompileError(
422
+ `stator: unknown pragma "// @stator ${flag}". Known flags: ${[...VALID_PRAGMAS].join(', ')}.`,
423
+ locAt(source, fmOffset(source) + m.index, file),
424
+ )
425
+ }
426
+ if (kind !== 'route') {
427
+ throw new CompileError(
428
+ `stator: "// @stator ${flag}" is only valid in a route page, not a component.`,
429
+ locAt(source, fmOffset(source) + m.index, file),
430
+ )
431
+ }
432
+ out.add(flag)
433
+ }
434
+ return out
435
+ }
436
+
437
+ function requireRoute(what: string, kind: 'route' | 'component', loc: DiagnosticLocation): void {
438
+ if (kind !== 'route') {
439
+ throw new CompileError(
440
+ `stator: ${what} is only available in a route page, not a component. ` +
441
+ `Read it in the route and pass the value down as a prop.`,
442
+ loc,
443
+ )
444
+ }
445
+ }
446
+
447
+ /** Character offset of the frontmatter body in the original source (after the
448
+ * opening `---\n`). 4 = `---\n`; good enough for diagnostics line/col. */
449
+ function fmOffset(source: string): number {
450
+ return source.startsWith('---') ? source.indexOf('\n') + 1 : 0
451
+ }
452
+
453
+ function isStatorCall(call: ts.CallExpression, member: string): boolean {
454
+ return ts.isPropertyAccessExpression(call.expression) && isStatorMember(call.expression, member)
455
+ }
456
+
457
+ function isStatorMember(e: ts.PropertyAccessExpression, member: string): boolean {
458
+ return ts.isIdentifier(e.expression) && e.expression.text === 'Stator' && e.name.text === member
459
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Compiler diagnostics: a located `CompileError` plus helpers to turn a
3
+ * character offset in the original `.stator` source into a line/column + code
4
+ * frame. Every compiler stage throws `CompileError`; the Vite plugin maps its
5
+ * `loc` to Vite's error shape so the dev overlay and terminal show
6
+ * file:line:column with a snippet.
7
+ */
8
+
9
+ export interface DiagnosticLocation {
10
+ file?: string
11
+ /** 1-based line in the original `.stator` source. */
12
+ line: number
13
+ /** 1-based column. */
14
+ column: number
15
+ /** Rendered code-frame snippet (a few lines around the caret). */
16
+ frame: string
17
+ }
18
+
19
+ export class CompileError extends Error {
20
+ readonly loc?: DiagnosticLocation
21
+ constructor(message: string, loc?: DiagnosticLocation) {
22
+ super(message)
23
+ this.name = 'CompileError'
24
+ this.loc = loc
25
+ }
26
+ }
27
+
28
+ /** 1-based line/column for a character offset in `source`. */
29
+ export function offsetToLineCol(source: string, offset: number): { line: number; column: number } {
30
+ const clamped = Math.max(0, Math.min(offset, source.length))
31
+ let line = 1
32
+ let lastNewline = -1
33
+ for (let i = 0; i < clamped; i++) {
34
+ if (source.charCodeAt(i) === 10 /* \n */) {
35
+ line++
36
+ lastNewline = i
37
+ }
38
+ }
39
+ return { line, column: clamped - lastNewline }
40
+ }
41
+
42
+ /** A code frame: the offending line with a caret under the column, plus one
43
+ * line of context on each side. */
44
+ export function codeFrame(source: string, line: number, column: number): string {
45
+ const lines = source.split('\n')
46
+ const start = Math.max(1, line - 1)
47
+ const end = Math.min(lines.length, line + 1)
48
+ const gutterWidth = String(end).length
49
+ const out: string[] = []
50
+ for (let n = start; n <= end; n++) {
51
+ const gutter = String(n).padStart(gutterWidth)
52
+ const marker = n === line ? '>' : ' '
53
+ out.push(`${marker} ${gutter} | ${lines[n - 1] ?? ''}`)
54
+ if (n === line) {
55
+ out.push(` ${' '.repeat(gutterWidth)} | ${' '.repeat(Math.max(0, column - 1))}^`)
56
+ }
57
+ }
58
+ return out.join('\n')
59
+ }
60
+
61
+ /** Build a `DiagnosticLocation` from a character offset in the original source. */
62
+ export function locAt(source: string, offset: number, file?: string): DiagnosticLocation {
63
+ const { line, column } = offsetToLineCol(source, offset)
64
+ return { file, line, column, frame: codeFrame(source, line, column) }
65
+ }
@@ -0,0 +1,87 @@
1
+ import ts from 'typescript'
2
+ import { splitStator } from './split.ts'
3
+
4
+ /**
5
+ * Generate a `<name>.stator.d.ts` for a component, giving callers real prop
6
+ * types. TS resolves `import X from './x.stator'` to this specific `.d.ts` (which
7
+ * beats the `*.stator` ambient wildcard), so `<X prop={...}/>` is type-checked.
8
+ *
9
+ * The `.d.ts` carries the component's frontmatter type imports (so the props
10
+ * type's references resolve) plus the default-export signature derived from
11
+ * `Stator.props<P>()` (+ a loose `children` bag when the body uses `<children>`).
12
+ *
13
+ * Routes don't get a `.d.ts` — they export `GET`, not a callable render
14
+ * function — so `generateDts` returns null for `kind: 'route'`.
15
+ */
16
+ export function generateDts(
17
+ source: string,
18
+ opts: { kind?: 'route' | 'component' } = {},
19
+ ): string | null {
20
+ if ((opts.kind ?? 'component') === 'route') return null
21
+
22
+ const { frontmatter, template } = splitStator(source)
23
+ const { hoisted, propsType } = extractFrontmatterTypes(frontmatter)
24
+ const propsT = componentPropsType(propsType, template)
25
+
26
+ const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
27
+ if (hoisted) lines.push(hoisted)
28
+ lines.push('')
29
+ lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
30
+ lines.push('export default _default')
31
+ return `${lines.join('\n')}\n`
32
+ }
33
+
34
+ /**
35
+ * The full props type for a component's public signature: the
36
+ * `Stator.props<P>()` type argument (verbatim source text — inline literal or
37
+ * a named reference), widened with a children bag when the body renders
38
+ * `<children>`. A component that declares no props accepts none
39
+ * (`Record<string, never>`). Shared by the `.d.ts` generator (tsc) and the
40
+ * language-server virtual emit (editor), so the two can't disagree.
41
+ */
42
+ export function componentPropsType(propsType: string | undefined, template: string): string {
43
+ const usesChildren = /<children[\s/>]/.test(template)
44
+ if (propsType) return usesChildren ? `${propsType} & { children?: any }` : propsType
45
+ return usesChildren ? '{ children?: any }' : 'Record<string, never>'
46
+ }
47
+
48
+ /** Collect the import/type/interface declarations from frontmatter (for the
49
+ * `.d.ts` to re-state) and the `Stator.props<P>()` type argument. */
50
+ export function extractFrontmatterTypes(fm: string): {
51
+ hoisted: string
52
+ propsType?: string
53
+ } {
54
+ if (!fm.trim()) return { hoisted: '' }
55
+ const sf = ts.createSourceFile('fm.ts', fm, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
56
+ const hoisted: string[] = []
57
+ let propsType: string | undefined
58
+
59
+ for (const stmt of sf.statements) {
60
+ if (
61
+ ts.isImportDeclaration(stmt) ||
62
+ ts.isTypeAliasDeclaration(stmt) ||
63
+ ts.isInterfaceDeclaration(stmt)
64
+ ) {
65
+ hoisted.push(stmt.getText(sf))
66
+ continue
67
+ }
68
+ const visit = (n: ts.Node): void => {
69
+ if (
70
+ ts.isCallExpression(n) &&
71
+ ts.isPropertyAccessExpression(n.expression) &&
72
+ ts.isIdentifier(n.expression.expression) &&
73
+ n.expression.expression.text === 'Stator' &&
74
+ n.expression.name.text === 'props' &&
75
+ n.typeArguments &&
76
+ n.typeArguments.length > 0
77
+ ) {
78
+ propsType = n.typeArguments[0]!.getText(sf)
79
+ return
80
+ }
81
+ ts.forEachChild(n, visit)
82
+ }
83
+ visit(stmt)
84
+ }
85
+
86
+ return { hoisted: hoisted.join('\n'), propsType }
87
+ }
@@ -0,0 +1,8 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /** Deterministic per-component scope hash. Derived from a stable identifier
4
+ * (the file path when compiling a real file, else the source). 8 hex chars is
5
+ * ample to avoid collisions across a project's component set. */
6
+ export function scopeHash(input: string): string {
7
+ return createHash('sha256').update(input).digest('hex').slice(0, 8)
8
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The `.stator` compiler. A pure, bundler-agnostic source-to-source transform:
3
+ * a `.stator` file in, the `.ts` modules the existing runtime already consumes
4
+ * out. No Vite imports here — the Vite plugin (`@statorjs/stator/vite`) is a thin
5
+ * adapter over this surface.
6
+ *
7
+ * See spec: stator-compiler-and-vite-plugin-implementation-plan.
8
+ */
9
+
10
+ export type { EmitClientInput } from './client-emit.ts'
11
+ export { emitClientModule, rewriteMembers } from './client-emit.ts'
12
+ export type {
13
+ ClientAnalysis,
14
+ ClientDirective,
15
+ ClientElement,
16
+ ScriptClass,
17
+ } from './client-script.ts'
18
+ export {
19
+ analyzeClient,
20
+ analyzeScriptClasses,
21
+ inferDeps,
22
+ isCustomElementTag,
23
+ kebabToPascal,
24
+ pascalToKebab,
25
+ } from './client-script.ts'
26
+ export type { CompileOptions, CompileResult } from './compile.ts'
27
+ export { compile } from './compile.ts'
28
+ export type { DiagnosticLocation } from './diagnostics.ts'
29
+ export {
30
+ CompileError,
31
+ codeFrame,
32
+ locAt,
33
+ offsetToLineCol,
34
+ } from './diagnostics.ts'
35
+ export { generateDts } from './dts.ts'
36
+ export { scopeHash } from './hash.ts'
37
+ export type { LowerMeta, LowerOptions } from './lower.ts'
38
+ export { lowerTemplate } from './lower.ts'
39
+ export { componentImportSpecifier, declaredRegions } from './regions.ts'
40
+ export type { ParsedStator, ScannedRegions, SourceRegion } from './split.ts'
41
+ export { scanRegions, splitStator } from './split.ts'
42
+ export { scopeCss } from './styles.ts'
43
+ export type {
44
+ VirtualCodeResult,
45
+ VirtualFile,
46
+ VirtualMapping,
47
+ } from './virtual-code.ts'
48
+ export { toVirtualCode } from './virtual-code.ts'
Binary file