@statorjs/stator 1.2.1 → 1.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.2.1"
91
+ "@statorjs/stator": "1.2.2"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -18,6 +18,7 @@
18
18
  * load-bearing part.
19
19
  */
20
20
 
21
+ import ts from 'typescript'
21
22
  import { analyzeScriptClasses } from './client-script.ts'
22
23
  import { componentPropsType, extractFrontmatterTypes } from './dts.ts'
23
24
  import { type ScannedRegions, scanRegions } from './split.ts'
@@ -131,24 +132,29 @@ export function toVirtualCode(source: string): VirtualCodeResult {
131
132
  }
132
133
  }
133
134
 
134
- /** Server component: frontmatter (module scope) + template as a JSX fragment, so
135
- * template expressions see the frontmatter's bindings. */
135
+ /** Server component: import/type/interface declarations hoist to module scope;
136
+ * the executable frontmatter body + the template (a JSX fragment) live inside
137
+ * the render function, so template expressions see the frontmatter's bindings.
138
+ *
139
+ * The body is deliberately NOT at module scope: it runs as a synchronous
140
+ * function body at runtime (compile.ts wraps it the same way), so modelling it
141
+ * as one here is what makes top-level `await` / `return` the TS errors they
142
+ * should be. Emitting the body at module scope (the previous shape) silently
143
+ * made them legal in-editor while diverging from runtime semantics. */
136
144
  function buildServerTsx(regions: ScannedRegions): VirtualFile {
137
145
  const mappings: VirtualMapping[] = []
138
- const userCode = (regions.frontmatter?.content ?? '') + regions.template.content
146
+ const fm = regions.frontmatter?.content ?? ''
147
+ const fmOffset = regions.frontmatter?.contentOffset ?? 0
148
+ const userCode = fm + regions.template.content
139
149
  let code =
140
150
  injectImports(TEMPLATE_GLOBALS, '@statorjs/stator/template', userCode) +
141
151
  AMBIENT_TYPE_IMPORTS +
142
152
  STATOR_AMBIENT
143
153
 
144
- if (regions.frontmatter?.content.trim()) {
145
- push(
146
- mappings,
147
- regions.frontmatter.contentOffset,
148
- code.length,
149
- regions.frontmatter.content.length,
150
- )
151
- code += `${regions.frontmatter.content}\n`
154
+ const { hoisted, body } = splitFrontmatter(fm, fmOffset)
155
+ for (const seg of hoisted) {
156
+ push(mappings, seg.sourceOffset, code.length, seg.text.length)
157
+ code += `${seg.text}\n`
152
158
  }
153
159
 
154
160
  // A leading <!doctype> is static HTML, not JSX — drop it from the shell (no
@@ -162,17 +168,18 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
162
168
  }
163
169
 
164
170
  // `export default function` gives importers a default export (`import X from
165
- // './x.stator'`) and puts the template in a scope that closes over the
166
- // frontmatter bindings. The param is typed from `Stator.props<P>()` (same
167
- // extraction as the .d.ts generator), so `<Component bad={...}/>` in OTHER
168
- // .stator files is checked in-editor TS validates value-based JSX against
169
- // the component function's first parameter. Named prop types resolve
170
- // because the frontmatter is emitted into this same module above.
171
- const propsT = componentPropsType(
172
- extractFrontmatterTypes(regions.frontmatter?.content ?? '').propsType,
173
- regions.template.content,
174
- )
175
- code += `export default function (_props: ${propsT}) {\n return (<>`
171
+ // './x.stator'`) and a scope that closes over the frontmatter body. The param
172
+ // is typed from `Stator.props<P>()` (same extraction as the .d.ts generator),
173
+ // so `<Component bad={...}/>` in OTHER .stator files is checked in-editor.
174
+ // Named prop types resolve because their type/interface decls hoisted above.
175
+ const propsT = componentPropsType(extractFrontmatterTypes(fm).propsType, regions.template.content)
176
+ code += `export default function (_props: ${propsT}) {\n`
177
+ for (const seg of body) {
178
+ code += ' '
179
+ push(mappings, seg.sourceOffset, code.length, seg.text.length)
180
+ code += `${seg.text}\n`
181
+ }
182
+ code += ' return (<>'
176
183
  push(mappings, tplOffset, code.length, tpl.length)
177
184
  code += tpl
178
185
  code += '</>);\n}\n'
@@ -180,6 +187,41 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
180
187
  return { lang: 'tsx', code, mappings }
181
188
  }
182
189
 
190
+ interface FmSegment {
191
+ sourceOffset: number
192
+ text: string
193
+ }
194
+
195
+ /**
196
+ * Split frontmatter into hoisted declarations (import/type/interface — must be
197
+ * module scope) and body statements (everything else — the render function).
198
+ * Mirrors the runtime classification in `compile.ts` `processFrontmatter`, so
199
+ * the editor models the same scoping. Each segment keeps its exact source range
200
+ * (`getStart`..`getText`) so the mapping stays a verbatim 1:1 run.
201
+ */
202
+ function splitFrontmatter(
203
+ fm: string,
204
+ fmOffset: number,
205
+ ): { hoisted: FmSegment[]; body: FmSegment[] } {
206
+ const hoisted: FmSegment[] = []
207
+ const body: FmSegment[] = []
208
+ if (!fm.trim()) return { hoisted, body }
209
+ const sf = ts.createSourceFile('fm.ts', fm, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
210
+ for (const stmt of sf.statements) {
211
+ const seg: FmSegment = { sourceOffset: fmOffset + stmt.getStart(sf), text: stmt.getText(sf) }
212
+ if (
213
+ ts.isImportDeclaration(stmt) ||
214
+ ts.isTypeAliasDeclaration(stmt) ||
215
+ ts.isInterfaceDeclaration(stmt)
216
+ ) {
217
+ hoisted.push(seg)
218
+ } else {
219
+ body.push(seg)
220
+ }
221
+ }
222
+ return { hoisted, body }
223
+ }
224
+
183
225
  /** Client component: the `<script>` is the module. Emit it as TS so the class,
184
226
  * `use()`/`machine()`, and imports get full intelligence. (Template-member
185
227
  * resolution — `bind:text={theme.label}` → the class field — is a later phase.) */