@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,79 @@
1
+ import ts from 'typescript'
2
+ import { splitStator } from './split.ts'
3
+
4
+ /**
5
+ * Lightweight analysis of a `.stator` file's *public composition surface* — the
6
+ * named `<children name="x"/>` regions it declares — without a full compile.
7
+ * Used for cross-file named-child validation: when compiling a caller, we
8
+ * resolve each `<Component>` to its file and check every `child="x"` against the
9
+ * callee's declared regions.
10
+ *
11
+ * Cheap and side-effect-free: parse the template body, collect `<children
12
+ * name=…>` literals. (`default` is always implicitly available.)
13
+ */
14
+ export function declaredRegions(source: string): Set<string> {
15
+ const { template } = splitStator(source)
16
+ const sf = ts.createSourceFile(
17
+ 'regions.tsx',
18
+ `const __t = (<>${template.replace(/^\s*<!doctype[^>]*>/i, '')}</>);`,
19
+ ts.ScriptTarget.Latest,
20
+ true,
21
+ ts.ScriptKind.TSX,
22
+ )
23
+ const regions = new Set<string>()
24
+ const visit = (node: ts.Node): void => {
25
+ const tagName = ts.isJsxElement(node)
26
+ ? node.openingElement.tagName.getText(sf)
27
+ : ts.isJsxSelfClosingElement(node)
28
+ ? node.tagName.getText(sf)
29
+ : undefined
30
+ if (tagName === 'children') {
31
+ const attrs = ts.isJsxElement(node)
32
+ ? node.openingElement.attributes
33
+ : (node as ts.JsxSelfClosingElement).attributes
34
+ for (const attr of attrs.properties) {
35
+ if (
36
+ ts.isJsxAttribute(attr) &&
37
+ !ts.isJsxNamespacedName(attr.name) &&
38
+ attr.name.getText(sf) === 'name' &&
39
+ attr.initializer &&
40
+ ts.isStringLiteral(attr.initializer)
41
+ ) {
42
+ regions.add(attr.initializer.text)
43
+ }
44
+ }
45
+ }
46
+ ts.forEachChild(node, visit)
47
+ }
48
+ visit(sf)
49
+ return regions
50
+ }
51
+
52
+ /**
53
+ * Map a component identifier used in a file to the `.stator` file it's imported
54
+ * from, by reading the file's frontmatter import declarations. Returns the
55
+ * resolved import specifier (e.g. `'./customer-layout.stator'`) or null if the
56
+ * identifier isn't a default import of a `.stator` module.
57
+ */
58
+ export function componentImportSpecifier(
59
+ frontmatter: string,
60
+ componentName: string,
61
+ ): string | null {
62
+ const sf = ts.createSourceFile(
63
+ 'fm.ts',
64
+ frontmatter,
65
+ ts.ScriptTarget.Latest,
66
+ true,
67
+ ts.ScriptKind.TS,
68
+ )
69
+ for (const stmt of sf.statements) {
70
+ if (!ts.isImportDeclaration(stmt)) continue
71
+ const clause = stmt.importClause
72
+ if (!clause || clause.isTypeOnly) continue
73
+ if (clause.name && clause.name.text === componentName) {
74
+ const spec = stmt.moduleSpecifier
75
+ if (ts.isStringLiteral(spec) && spec.text.endsWith('.stator')) return spec.text
76
+ }
77
+ }
78
+ return null
79
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Stage 1 of the `.stator` compiler: split a source file into its regions.
3
+ *
4
+ * A `.stator` file has up to four regions:
5
+ * - `---` frontmatter fence (server: type-only machine imports, props)
6
+ * - the JSX-flavored template body (server-rendered)
7
+ * - one or more `<style>` blocks (scoped styles)
8
+ * - one or more bare `<script>` blocks (client code; processed in 3b)
9
+ *
10
+ * Region detection is intentionally string-level here — the JSX *inside* the
11
+ * template is handed to the TypeScript parser in a later stage. Splitting is the
12
+ * only job of this module.
13
+ *
14
+ * Script disambiguation: an **inline** `<script>` is the component's client
15
+ * code — it's pulled out as a region and (in a later stage) compiled to a
16
+ * `StatorElement`. Two explicit forms opt out and stay in the template as
17
+ * literal document markup, emitted verbatim:
18
+ * - `<script src="...">` — an external reference is never an inline component.
19
+ * - `<script is:inline>` — opt-out directive for a verbatim inline script.
20
+ * Note the rule keys off *presence* of `src` / `is:inline`, not "has any
21
+ * attribute": `<script type="module">` or `<script lang="ts">` are still
22
+ * compiled as components, so an incidental attribute can't silently demote a
23
+ * component to dead markup. (`<style>` still uses the older bare-vs-attributed
24
+ * rule; aligning it on `is:inline` is a follow-up.)
25
+ */
26
+
27
+ export interface ParsedStator {
28
+ /** TS/JS between the `---` fences. Empty string when there's no fence. */
29
+ frontmatter: string
30
+ /** The template body with `<style>` and bare `<script>` regions removed. */
31
+ template: string
32
+ /** Contents of each bare `<style>` region, in source order. */
33
+ styles: string[]
34
+ /** Contents of each inline `<script>` region (client code), in source order. */
35
+ scripts: string[]
36
+ /** Character offset in the original source of each captured `<script>` region's
37
+ * opening tag, parallel to `scripts` — used to locate diagnostics. */
38
+ scriptOffsets: number[]
39
+ /** Character offset in the original source where the (trimmed) template body
40
+ * begins — used to map template diagnostics back to original positions.
41
+ * Assumes `<style>`/`<script>` regions follow the template body (the
42
+ * convention), so leading offset is unaffected by their removal. */
43
+ templateOffset: number
44
+ }
45
+
46
+ const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/
47
+ const STYLE_RE = /<style>([\s\S]*?)<\/style>/g
48
+ const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/g
49
+
50
+ /** True if `attrs` (the text between `<script` and `>`) carries `name` as a
51
+ * whole attribute token — so `src` matches `src="x"` but not `data-src`. */
52
+ function hasAttr(attrs: string, name: string): boolean {
53
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
54
+ return new RegExp(`(?:^|\\s)${escaped}(?=[\\s=/]|$)`, 'i').test(attrs)
55
+ }
56
+
57
+ export function splitStator(source: string): ParsedStator {
58
+ let rest = source
59
+ let frontmatter = ''
60
+ let frontmatterLen = 0
61
+
62
+ const fm = rest.match(FRONTMATTER_RE)
63
+ if (fm) {
64
+ frontmatter = (fm[1] ?? '').trim()
65
+ frontmatterLen = fm[0].length
66
+ rest = rest.slice(frontmatterLen)
67
+ }
68
+
69
+ // Leading whitespace before the template body (after the frontmatter).
70
+ const leadingWs = rest.length - rest.trimStart().length
71
+ const templateOffset = frontmatterLen + leadingWs
72
+
73
+ // Scripts before styles so the captured offsets are relative to the
74
+ // post-frontmatter text (style removal hasn't shifted anything yet).
75
+ const scripts: string[] = []
76
+ const scriptOffsets: number[] = []
77
+ rest = rest.replace(SCRIPT_RE, (match, attrs: string, js: string, offset: number) => {
78
+ // `src` / `is:inline` mark a literal script — leave it in the template.
79
+ if (hasAttr(attrs, 'src') || hasAttr(attrs, 'is:inline')) return match
80
+ scripts.push(js.trim())
81
+ scriptOffsets.push(frontmatterLen + offset)
82
+ return ''
83
+ })
84
+
85
+ const styles: string[] = []
86
+ rest = rest.replace(STYLE_RE, (_m, css: string) => {
87
+ styles.push(css.trim())
88
+ return ''
89
+ })
90
+
91
+ return {
92
+ frontmatter,
93
+ template: rest.trim(),
94
+ styles,
95
+ scripts,
96
+ scriptOffsets,
97
+ templateOffset,
98
+ }
99
+ }
100
+
101
+ /** A region's inner text plus the absolute source offset where that text begins.
102
+ * Unlike `splitStator` (which trims and drops offsets), this keeps exact
103
+ * positions so the language-server emit can map generated code back to source. */
104
+ export interface SourceRegion {
105
+ contentOffset: number
106
+ content: string
107
+ }
108
+
109
+ export interface ScannedRegions {
110
+ /** The `---` frontmatter content (server TS), or null when there's no fence. */
111
+ frontmatter: SourceRegion | null
112
+ /** The contiguous template body — everything from the body start up to the
113
+ * first trailing `<style>` / component `<script>` block. Assumes the
114
+ * conventional layout (template first, blocks after). */
115
+ template: SourceRegion
116
+ /** Each bare `<style>` block's CSS content, in source order. */
117
+ styles: SourceRegion[]
118
+ /** Each component (non-literal) `<script>` block's TS content, in source order.
119
+ * `src` / `is:inline` scripts are excluded — same classification as `splitStator`. */
120
+ scripts: SourceRegion[]
121
+ }
122
+
123
+ /**
124
+ * Scan a `.stator` source into regions with exact source offsets — the
125
+ * position-preserving companion to `splitStator`, used by the language-server
126
+ * emit. Shares this module's patterns + `hasAttr` classification so the LS and
127
+ * the runtime compiler never disagree about what's a region.
128
+ */
129
+ export function scanRegions(source: string): ScannedRegions {
130
+ let bodyStart = 0
131
+ let frontmatter: SourceRegion | null = null
132
+ const fm = FRONTMATTER_RE.exec(source)
133
+ if (fm) {
134
+ const prefix = /^---[ \t]*\r?\n/.exec(fm[0])![0]
135
+ frontmatter = { contentOffset: prefix.length, content: fm[1] ?? '' }
136
+ bodyStart = fm[0].length
137
+ }
138
+
139
+ let firstBlock = source.length
140
+ const styles: SourceRegion[] = []
141
+ for (const m of source.matchAll(STYLE_RE)) {
142
+ const start = m.index
143
+ styles.push({
144
+ contentOffset: start + '<style>'.length,
145
+ content: m[1] ?? '',
146
+ })
147
+ if (start < firstBlock) firstBlock = start
148
+ }
149
+ const scripts: SourceRegion[] = []
150
+ for (const m of source.matchAll(SCRIPT_RE)) {
151
+ if (hasAttr(m[1] ?? '', 'src') || hasAttr(m[1] ?? '', 'is:inline')) continue
152
+ const start = m.index
153
+ const openLen = m[0].indexOf('>') + 1
154
+ scripts.push({ contentOffset: start + openLen, content: m[2] ?? '' })
155
+ if (start < firstBlock) firstBlock = start
156
+ }
157
+
158
+ const templateEnd = Math.max(bodyStart, firstBlock)
159
+ return {
160
+ frontmatter,
161
+ template: {
162
+ contentOffset: bodyStart,
163
+ content: source.slice(bodyStart, templateEnd),
164
+ },
165
+ styles,
166
+ scripts,
167
+ }
168
+ }
@@ -0,0 +1,200 @@
1
+ import postcss from 'postcss'
2
+ import selectorParser from 'postcss-selector-parser'
3
+
4
+ /**
5
+ * Scope a component's `<style>` content with the attribute approach.
6
+ *
7
+ * - Each rule's subject compound gets `[data-s-<hash>]` appended, so it only
8
+ * matches this component's elements (which the compiler marks with the same
9
+ * attribute). Subject-only scoping (Vue-style) keeps descendant selectors
10
+ * working without over-constraining ancestors.
11
+ * - `:global(...)` is unwrapped; if the *subject* is global, that selector is
12
+ * left unscoped (the escape hatch for reaching outside the component).
13
+ * - `@keyframes` names are renamed per-hash (and `animation`/`animation-name`
14
+ * references rewritten) so animations don't leak or collide.
15
+ *
16
+ * Pure function — the Vite plugin calls it, then Vite's own CSS pipeline handles
17
+ * url()/nesting/minify on the result.
18
+ */
19
+ export interface ScopeCssOptions {
20
+ /**
21
+ * Client components scope by DESCENDANT of their custom-element root
22
+ * (`[data-s-h] .swatch`) instead of per-element attributes — an island has
23
+ * exactly one root carrying the attr, and its class may create DOM at
24
+ * runtime that per-element stamping could never reach. Selectors whose
25
+ * first compound is the root tag itself get the attr on that compound
26
+ * (`variant-picker[data-s-h] …`).
27
+ */
28
+ strategy?: 'per-element' | 'descendant'
29
+ /** The custom-element tag, required for 'descendant'. */
30
+ rootTag?: string
31
+ }
32
+
33
+ export function scopeCss(css: string, hash: string, opts: ScopeCssOptions = {}): string {
34
+ const attr = `data-s-${hash}`
35
+ const root = postcss.parse(css)
36
+
37
+ // 1. Rename @keyframes and rewrite references.
38
+ const renamed = new Map<string, string>()
39
+ root.walkAtRules((at) => {
40
+ if (/^(-\w+-)?keyframes$/i.test(at.name)) {
41
+ const from = at.params.trim()
42
+ const to = `${from}-${hash}`
43
+ renamed.set(from, to)
44
+ at.params = to
45
+ }
46
+ })
47
+ if (renamed.size > 0) {
48
+ root.walkDecls((decl) => {
49
+ if (decl.prop === 'animation-name') {
50
+ decl.value = decl.value
51
+ .split(',')
52
+ .map((n) => renamed.get(n.trim()) ?? n.trim())
53
+ .join(', ')
54
+ } else if (decl.prop === 'animation') {
55
+ decl.value = decl.value
56
+ .split(',')
57
+ .map((part) =>
58
+ part
59
+ .trim()
60
+ .split(/\s+/)
61
+ .map((tok) => renamed.get(tok) ?? tok)
62
+ .join(' '),
63
+ )
64
+ .join(', ')
65
+ }
66
+ })
67
+ }
68
+
69
+ // 2. Scope each rule's selectors (skip keyframe step selectors).
70
+ root.walkRules((rule) => {
71
+ const parent = rule.parent
72
+ if (parent && parent.type === 'atrule' && /keyframes$/i.test((parent as postcss.AtRule).name)) {
73
+ return
74
+ }
75
+ rule.selector =
76
+ opts.strategy === 'descendant'
77
+ ? scopeSelectorListDescendant(rule.selector, attr, opts.rootTag ?? '')
78
+ : scopeSelectorList(rule.selector, attr)
79
+ })
80
+
81
+ return root.toString()
82
+ }
83
+
84
+ function scopeSelectorListDescendant(selector: string, attr: string, rootTag: string): string {
85
+ const transform = selectorParser((selectors) => {
86
+ selectors.each((sel) => {
87
+ // Descendant mode: `:global` anywhere means the author owns the whole
88
+ // selector — unwrap it and scope nothing. (Per-element mode has finer
89
+ // subject rules; descendant prefixing would mangle global ancestors.)
90
+ let hasGlobal = false
91
+ sel.walkPseudos((pseudo) => {
92
+ if (pseudo.value === ':global') {
93
+ hasGlobal = true
94
+ const inner = pseudo.nodes[0]
95
+ if (inner && inner.nodes.length > 0) {
96
+ pseudo.replaceWith(...inner.nodes.map((n) => n.clone()))
97
+ } else {
98
+ pseudo.remove()
99
+ }
100
+ }
101
+ })
102
+ if (hasGlobal) return
103
+
104
+ const attrNode = selectorParser.attribute({
105
+ attribute: attr,
106
+ value: undefined,
107
+ raws: {},
108
+ quoteMark: null,
109
+ } as never)
110
+
111
+ // Root-targeting selector (`variant-picker`, `variant-picker:hover`):
112
+ // the attr belongs ON the root compound, not on an ancestor.
113
+ const first = sel.nodes[0]
114
+ if (first && first.type === 'tag' && first.value === rootTag) {
115
+ // insert after the first compound's last simple selector
116
+ let insertAfter: selectorParser.Node = first
117
+ for (const node of sel.nodes.slice(1)) {
118
+ if (node.type === 'combinator') break
119
+ if (node.type === 'pseudo' && node.value.startsWith('::')) break
120
+ insertAfter = node
121
+ }
122
+ sel.insertAfter(insertAfter as never, attrNode)
123
+ return
124
+ }
125
+
126
+ // Everything else: descendant of the scoped root.
127
+ const combinator = selectorParser.combinator({ value: ' ' } as never)
128
+ if (sel.nodes[0]) {
129
+ sel.insertBefore(sel.nodes[0] as never, combinator)
130
+ sel.insertBefore(sel.nodes[0] as never, attrNode)
131
+ } else {
132
+ sel.append(attrNode)
133
+ }
134
+ })
135
+ })
136
+ return transform.processSync(selector)
137
+ }
138
+
139
+ function scopeSelectorList(selector: string, attr: string): string {
140
+ const transform = selectorParser((selectors) => {
141
+ selectors.each((sel) => {
142
+ // Is the subject (last compound) inside :global()? Compute before unwrap.
143
+ const subjectIsGlobal = isSubjectGlobal(sel)
144
+
145
+ // Unwrap every :global(...) to its inner nodes.
146
+ sel.walkPseudos((pseudo) => {
147
+ if (pseudo.value === ':global') {
148
+ const inner = pseudo.nodes[0]
149
+ if (inner && inner.nodes.length > 0) {
150
+ pseudo.replaceWith(...inner.nodes.map((n) => n.clone()))
151
+ } else {
152
+ pseudo.remove()
153
+ }
154
+ }
155
+ })
156
+
157
+ if (subjectIsGlobal) return
158
+
159
+ // Insert the scope attribute after the subject's last simple selector,
160
+ // before any trailing pseudo-elements (::before, ::after).
161
+ const nodes = sel.nodes
162
+ let insertAfter: selectorParser.Node | undefined
163
+ for (const node of nodes) {
164
+ if (node.type === 'combinator') {
165
+ insertAfter = undefined // reset at each combinator; track the subject only
166
+ continue
167
+ }
168
+ if (node.type === 'pseudo' && node.value.startsWith('::')) continue // pseudo-element stays last
169
+ insertAfter = node
170
+ }
171
+ const attrNode = selectorParser.attribute({
172
+ attribute: attr,
173
+ value: undefined,
174
+ raws: {},
175
+ quoteMark: null,
176
+ } as never)
177
+ if (insertAfter) sel.insertAfter(insertAfter as never, attrNode)
178
+ else sel.append(attrNode)
179
+ })
180
+ })
181
+ return transform.processSync(selector)
182
+ }
183
+
184
+ /** True when the selector's subject compound (after the last combinator) is
185
+ * inside a `:global(...)`. */
186
+ function isSubjectGlobal(sel: selectorParser.Selector): boolean {
187
+ const nodes = sel.nodes
188
+ let lastCombinator = -1
189
+ for (let i = nodes.length - 1; i >= 0; i--) {
190
+ if (nodes[i]!.type === 'combinator') {
191
+ lastCombinator = i
192
+ break
193
+ }
194
+ }
195
+ for (let i = lastCombinator + 1; i < nodes.length; i++) {
196
+ const n = nodes[i]!
197
+ if (n.type === 'pseudo' && n.value === ':global') return true
198
+ }
199
+ return false
200
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Language-server emit: turn a `.stator` file into **mapped virtual code** the
3
+ * editor tooling can hand to real language services (TS for the script/template,
4
+ * CSS for styles), with every generated position mapped back to the source so
5
+ * completions, hover, and diagnostics land in the right place.
6
+ *
7
+ * This is a *second* emit target, distinct from the runtime `compile()`:
8
+ * - `compile()` rewrites the template to `html\`…\`` for execution.
9
+ * - `toVirtualCode()` preserves the template as JSX so TS's own JSX service
10
+ * gives intelligence, and preserves source offsets for mapping.
11
+ *
12
+ * It shares the front end (`scanRegions`) with the compiler so the two never
13
+ * disagree about `.stator` syntax — the drift trap in embedded-language tooling.
14
+ *
15
+ * v1 scope: correct region mappings + a useful TSX shell. The ambient typing
16
+ * (`Stator`, JSX intrinsics — see `STATOR_AMBIENT`) is intentionally permissive
17
+ * on JSX and refined against real in-editor feedback; the mappings are the
18
+ * load-bearing part.
19
+ */
20
+
21
+ import { analyzeScriptClasses } from './client-script.ts'
22
+ import { componentPropsType, extractFrontmatterTypes } from './dts.ts'
23
+ import { type ScannedRegions, scanRegions } from './split.ts'
24
+
25
+ /** A contiguous run mapping generated code back to source, 1:1 over its length. */
26
+ export interface VirtualMapping {
27
+ sourceOffset: number
28
+ generatedOffset: number
29
+ length: number
30
+ }
31
+
32
+ export interface VirtualFile {
33
+ lang: 'tsx' | 'css'
34
+ code: string
35
+ mappings: VirtualMapping[]
36
+ }
37
+
38
+ export interface VirtualCodeResult {
39
+ /** The TSX shell: frontmatter + template (server component) or the client
40
+ * `<script>` module (client component). */
41
+ tsx: VirtualFile
42
+ /** One CSS virtual file per `<style>` block. */
43
+ styles: VirtualFile[]
44
+ }
45
+
46
+ const TEMPLATE_IMPORTS =
47
+ "import { read, each, when, match, on, classList, styleList, raw } from '@statorjs/stator/template';\n"
48
+ const CLIENT_IMPORTS =
49
+ "import { StatorElement, use, machine, defineElement, bind, effect, dispatch } from '@statorjs/stator/client';\n"
50
+ // Aliased so they never collide with a component's own `InstanceOf` import.
51
+ // NOTE: `InstanceOf` comes from /template, not /machine — the template flavor
52
+ // includes `send`/`state`/`snapshot` (what a route-level binding actually is,
53
+ // and what authors type props with). The engine flavor is selectors-only (the
54
+ // read-only view actions get via `helpers.reads`) and would make every
55
+ // `Stator.reads` binding unassignable to component props.
56
+ const AMBIENT_TYPE_IMPORTS =
57
+ "import type { AnyMachineDef as __SMachineDef } from '@statorjs/stator/machine';\n" +
58
+ "import type { InstanceOf as __SInstanceOf } from '@statorjs/stator/template';\n"
59
+
60
+ /**
61
+ * Per-file ambient scaffold prepended to every virtual TSX. `Stator` (the macro
62
+ * surface, module-scoped) makes `Stator.props`/`reads`/… resolve — `reads` typed
63
+ * through `InstanceOf` so a route's bindings infer their machine instance types.
64
+ * The **global** JSX namespace is permissive (`extends Record<string, any>`), so
65
+ * directives + custom elements don't flood false errors; it must be `declare
66
+ * global` because TS resolves `JSX.IntrinsicElements` globally, and `extends
67
+ * Record` (rather than an own index signature) lets every file's copy merge
68
+ * without a duplicate-index-signature clash. Real per-element JSX typing — the
69
+ * Astro `astro-jsx.d.ts` approach — is a Phase 2 refinement.
70
+ */
71
+ const STATOR_AMBIENT = `declare const Stator: {
72
+ props<P>(): P;
73
+ reads<const T extends readonly __SMachineDef[]>(defs: T): { -readonly [K in keyof T]: __SInstanceOf<T[K]> };
74
+ request: any;
75
+ response: { status: number; headers: Record<string, string>; cookies: any };
76
+ };
77
+ declare global {
78
+ namespace JSX {
79
+ interface IntrinsicElements extends Record<string, any> {}
80
+ }
81
+ }
82
+ `
83
+
84
+ const DOCTYPE_RE = /^\s*<!doctype[^>]*>/i
85
+
86
+ export function toVirtualCode(source: string): VirtualCodeResult {
87
+ const regions = scanRegions(source)
88
+ const isClient =
89
+ regions.scripts.length > 0 &&
90
+ analyzeScriptClasses(regions.scripts.map((s) => s.content).join('\n')).length > 0
91
+
92
+ return {
93
+ tsx: isClient ? buildClientTsx(regions) : buildServerTsx(regions),
94
+ styles: regions.styles.map(buildCssFile),
95
+ }
96
+ }
97
+
98
+ /** Server component: frontmatter (module scope) + template as a JSX fragment, so
99
+ * template expressions see the frontmatter's bindings. */
100
+ function buildServerTsx(regions: ScannedRegions): VirtualFile {
101
+ const mappings: VirtualMapping[] = []
102
+ let code = TEMPLATE_IMPORTS + AMBIENT_TYPE_IMPORTS + STATOR_AMBIENT
103
+
104
+ if (regions.frontmatter?.content.trim()) {
105
+ push(
106
+ mappings,
107
+ regions.frontmatter.contentOffset,
108
+ code.length,
109
+ regions.frontmatter.content.length,
110
+ )
111
+ code += `${regions.frontmatter.content}\n`
112
+ }
113
+
114
+ // A leading <!doctype> is static HTML, not JSX — drop it from the shell (no
115
+ // intelligence lost) and advance the template mapping past it.
116
+ let tplOffset = regions.template.contentOffset
117
+ let tpl = regions.template.content
118
+ const doctype = DOCTYPE_RE.exec(tpl)
119
+ if (doctype) {
120
+ tplOffset += doctype[0].length
121
+ tpl = tpl.slice(doctype[0].length)
122
+ }
123
+
124
+ // `export default function` gives importers a default export (`import X from
125
+ // './x.stator'`) and puts the template in a scope that closes over the
126
+ // frontmatter bindings. The param is typed from `Stator.props<P>()` (same
127
+ // extraction as the .d.ts generator), so `<Component bad={...}/>` in OTHER
128
+ // .stator files is checked in-editor — TS validates value-based JSX against
129
+ // the component function's first parameter. Named prop types resolve
130
+ // because the frontmatter is emitted into this same module above.
131
+ const propsT = componentPropsType(
132
+ extractFrontmatterTypes(regions.frontmatter?.content ?? '').propsType,
133
+ regions.template.content,
134
+ )
135
+ code += `export default function (_props: ${propsT}) {\n return (<>`
136
+ push(mappings, tplOffset, code.length, tpl.length)
137
+ code += tpl
138
+ code += '</>);\n}\n'
139
+
140
+ return { lang: 'tsx', code, mappings }
141
+ }
142
+
143
+ /** Client component: the `<script>` is the module. Emit it as TS so the class,
144
+ * `use()`/`machine()`, and imports get full intelligence. (Template-member
145
+ * resolution — `bind:text={theme.label}` → the class field — is a later phase.) */
146
+ function buildClientTsx(regions: ScannedRegions): VirtualFile {
147
+ const mappings: VirtualMapping[] = []
148
+ let code = TEMPLATE_IMPORTS + CLIENT_IMPORTS + AMBIENT_TYPE_IMPORTS + STATOR_AMBIENT
149
+
150
+ for (const script of regions.scripts) {
151
+ push(mappings, script.contentOffset, code.length, script.content.length)
152
+ code += `${script.content}\n`
153
+ }
154
+
155
+ // A client component is also used as `<Tag/>` elsewhere, so its importers need
156
+ // a default export too. The named class export above still gets full TS
157
+ // intelligence; this stub just satisfies the import.
158
+ code += '\nexport default function (_props: any) { return null as any; }\n'
159
+
160
+ return { lang: 'tsx', code, mappings }
161
+ }
162
+
163
+ function buildCssFile(style: { contentOffset: number; content: string }): VirtualFile {
164
+ return {
165
+ lang: 'css',
166
+ code: style.content,
167
+ mappings: [
168
+ {
169
+ sourceOffset: style.contentOffset,
170
+ generatedOffset: 0,
171
+ length: style.content.length,
172
+ },
173
+ ],
174
+ }
175
+ }
176
+
177
+ function push(
178
+ mappings: VirtualMapping[],
179
+ sourceOffset: number,
180
+ generatedOffset: number,
181
+ length: number,
182
+ ): void {
183
+ if (length > 0) mappings.push({ sourceOffset, generatedOffset, length })
184
+ }
@@ -0,0 +1,2 @@
1
+ export type { JsonLdProps } from './json-ld.ts'
2
+ export { JsonLd, ldToString } from './json-ld.ts'