@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,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
|