@statorjs/stator 2.0.1 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
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": "2.0.1"
91
+ "@statorjs/stator": "2.1.0"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -1,6 +1,7 @@
1
1
  import ts from 'typescript'
2
2
  import { elementMarkerSelector } from '../wire/island-markers.ts'
3
3
  import type { ClientDirective, ClientElement } from './client-script.ts'
4
+ import { CLIENT_AUTHOR_GLOBALS, CLIENT_LOWERING_TARGETS, importLine } from './emit-names.ts'
4
5
 
5
6
  /**
6
7
  * Phase 3b stage 5 — emit the client entry module for a client component: the
@@ -13,8 +14,10 @@ import type { ClientDirective, ClientElement } from './client-script.ts'
13
14
  * class as written; the subclass overrides `setup()`.
14
15
  */
15
16
 
16
- const PRIMITIVES =
17
- "import { StatorElement, defineElement, use, machine, bind, bindSlot, effect, dispatch, attrValue, setAttr } from '@statorjs/stator/client'"
17
+ const PRIMITIVES = importLine(
18
+ [...CLIENT_AUTHOR_GLOBALS, ...CLIENT_LOWERING_TARGETS],
19
+ '@statorjs/stator/client',
20
+ )
18
21
 
19
22
  export interface EmitClientInput {
20
23
  /** The author's `<script>` source. */
@@ -8,6 +8,12 @@ import {
8
8
  pascalToKebab,
9
9
  } from './client-script.ts'
10
10
  import { CompileError, type DiagnosticLocation, locAt } from './diagnostics.ts'
11
+ import {
12
+ ISLAND_SHELL_EXTRAS,
13
+ importLine,
14
+ TEMPLATE_AUTHOR_GLOBALS,
15
+ TEMPLATE_LOWERING_TARGETS,
16
+ } from './emit-names.ts'
11
17
  import { scopeHash } from './hash.ts'
12
18
  import { type LowerMeta, lowerTemplate } from './lower.ts'
13
19
  import { splitStator } from './split.ts'
@@ -47,8 +53,19 @@ export interface CompileResult {
47
53
  clientTag?: string
48
54
  }
49
55
 
50
- const PRIMITIVES_IMPORT =
51
- "import { html, read, each, itemBind, when, match, defer, on, classList, styleList, spreadAttrs } from '@statorjs/stator/template'"
56
+ const PRIMITIVES_IMPORT = importLine(
57
+ [...TEMPLATE_AUTHOR_GLOBALS, ...TEMPLATE_LOWERING_TARGETS],
58
+ '@statorjs/stator/template',
59
+ )
60
+
61
+ const SHELL_PRIMITIVES_IMPORT = importLine(
62
+ [
63
+ ...TEMPLATE_AUTHOR_GLOBALS,
64
+ ...TEMPLATE_LOWERING_TARGETS.filter((n) => n !== 'itemBind'),
65
+ ...ISLAND_SHELL_EXTRAS,
66
+ ],
67
+ '@statorjs/stator/template',
68
+ )
52
69
 
53
70
  export interface CompileOptions {
54
71
  /** Stable id for the component (file path). Used for the scope hash so the
@@ -81,11 +98,17 @@ export function compile(source: string, opts: CompileOptions = {}): CompileResul
81
98
  const script = scripts.join('\n')
82
99
  if (script.trim()) {
83
100
  if (analyzeScriptClasses(script).length > 0) {
101
+ // The island's SERVER FENCE (2.1): runs per shell render, on the
102
+ // server only, with its bindings in template scope. The <script>
103
+ // never sees it, in either direction.
104
+ const fm = processFrontmatter(frontmatter, 'island', source, opts.id)
84
105
  return compileClient(template, script, {
85
106
  hash,
86
107
  scopeAttr,
87
108
  styles,
88
109
  file: opts.id,
110
+ fm,
111
+ source,
89
112
  })
90
113
  }
91
114
  throw new CompileError(
@@ -139,7 +162,16 @@ export function compile(source: string, opts: CompileOptions = {}): CompileResul
139
162
  function compileClient(
140
163
  template: string,
141
164
  script: string,
142
- ctx: { hash: string; scopeAttr?: string; styles: string[]; file?: string },
165
+ ctx: {
166
+ hash: string
167
+ scopeAttr?: string
168
+ styles: string[]
169
+ file?: string
170
+ /** The island's processed server fence (2.1) — empty parts when absent. */
171
+ fm?: FrontmatterParts
172
+ /** Original source, for located collision diagnostics. */
173
+ source?: string
174
+ },
143
175
  ): CompileResult {
144
176
  const classes = analyzeScriptClasses(script)
145
177
  const root = extractClientRoot(template, ctx.file)
@@ -149,6 +181,22 @@ function compileClient(
149
181
  analyzeClient(script, tags, { file: ctx.file })
150
182
 
151
183
  const cls = classes.find((c) => pascalToKebab(c.name) === root.tag)
184
+
185
+ // A fence binding sharing a name with a `use()` field is ambiguous by
186
+ // construction (client wiring keys template identifiers on the use-field
187
+ // set) — a located error, not a precedence rule.
188
+ if (ctx.fm && cls) {
189
+ const fenceNames = fenceBindingNames(ctx.fm)
190
+ for (const field of cls.useFields.keys()) {
191
+ if (fenceNames.has(field)) {
192
+ throw new CompileError(
193
+ `stator: "${field}" is both a fence binding and a use() field — the template ` +
194
+ `couldn't tell the server value from the client machine. Rename one.`,
195
+ ctx.source ? locAt(ctx.source, 0, ctx.file) : undefined,
196
+ )
197
+ }
198
+ }
199
+ }
152
200
  if (!cls) {
153
201
  // analyzeClient validated the tag↔class match just above; reaching here is a bug.
154
202
  throw new CompileError(
@@ -186,9 +234,13 @@ function compileClient(
186
234
  const attrDecl = `{ ${[...cls.staticAttrs].map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`
187
235
  const rootScope = ctx.scopeAttr ? ` data-s-${ctx.hash}` : ''
188
236
  const serverCode = [
189
- "import { html, read, each, when, match, defer, on, classList, styleList, spreadAttrs, createHtmlFragment, clientShellAttrs } from '@statorjs/stator/template'",
237
+ SHELL_PRIMITIVES_IMPORT,
238
+ ...(ctx.fm?.hoisted ? [ctx.fm.hoisted] : []),
190
239
  '',
191
240
  `export default function (props = {}) {`,
241
+ // The fence body runs here — per shell render, server only, ahead of
242
+ // the template so its bindings are in scope for the interpolations.
243
+ ...(ctx.fm?.body ? [ctx.fm.body] : []),
192
244
  ` const __inner = ${innerExpr}`,
193
245
  ` const __attrs = clientShellAttrs(props, ${attrDecl}, ${JSON.stringify(root.rootAttrs)})`,
194
246
  ` return createHtmlFragment(\`<${root.tag}\${__attrs}${rootScope}>\` + __inner.html + \`</${root.tag}>\`)`,
@@ -311,7 +363,7 @@ const VALID_PRAGMAS = new Set(['live'])
311
363
  */
312
364
  function processFrontmatter(
313
365
  fm: string,
314
- kind: 'route' | 'component',
366
+ kind: 'route' | 'component' | 'island',
315
367
  source: string,
316
368
  file?: string,
317
369
  ): FrontmatterParts {
@@ -342,6 +394,14 @@ function processFrontmatter(
342
394
  const repls: Array<[start: number, end: number, replacement: string]> = []
343
395
  const visit = (n: ts.Node): void => {
344
396
  if (ts.isCallExpression(n) && isStatorCall(n, 'props')) {
397
+ if (kind === 'island') {
398
+ throw new CompileError(
399
+ "stator: Stator.props() is not available in an island's fence — island props are " +
400
+ 'declared by `static attrs` on the element class, and richer values arrive ' +
401
+ 'through the hydrate pattern (the shell renders with the props of its use site).',
402
+ locInFm(n),
403
+ )
404
+ }
345
405
  if (kind === 'route') {
346
406
  throw new CompileError(
347
407
  'stator: Stator.props() is not available in a route page (a route has no parent props). ' +
@@ -374,6 +434,12 @@ function processFrontmatter(
374
434
  return
375
435
  }
376
436
  if (ts.isCallExpression(n) && isStatorCall(n, 'forwarded')) {
437
+ if (kind === 'island') {
438
+ throw new CompileError(
439
+ "stator: Stator.forwarded() is not available in an island's fence.",
440
+ locInFm(n),
441
+ )
442
+ }
377
443
  if (kind === 'route') {
378
444
  throw new CompileError(
379
445
  'stator: Stator.forwarded() is only available in a component — it reads a directive ' +
@@ -422,6 +488,41 @@ function processFrontmatter(
422
488
  }
423
489
  }
424
490
 
491
+ /** Top-level names a fence declares — imports (default/named/namespace),
492
+ * consts/lets, functions, classes — for the fence-vs-use()-field collision
493
+ * check. Parsed from the processed parts (hoisted + body). */
494
+ function fenceBindingNames(fm: FrontmatterParts): Set<string> {
495
+ const names = new Set<string>()
496
+ const sf = ts.createSourceFile(
497
+ 'fence.ts',
498
+ `${fm.hoisted}\n${fm.body}`,
499
+ ts.ScriptTarget.Latest,
500
+ true,
501
+ ts.ScriptKind.TS,
502
+ )
503
+ const bind = (n: ts.BindingName): void => {
504
+ if (ts.isIdentifier(n)) names.add(n.text)
505
+ else for (const el of n.elements) if (ts.isBindingElement(el)) bind(el.name)
506
+ }
507
+ for (const stmt of sf.statements) {
508
+ if (ts.isImportDeclaration(stmt) && stmt.importClause) {
509
+ const c = stmt.importClause
510
+ if (c.name) names.add(c.name.text)
511
+ if (c.namedBindings) {
512
+ if (ts.isNamespaceImport(c.namedBindings)) names.add(c.namedBindings.name.text)
513
+ else for (const s of c.namedBindings.elements) names.add(s.name.text)
514
+ }
515
+ } else if (ts.isVariableStatement(stmt)) {
516
+ for (const d of stmt.declarationList.declarations) bind(d.name)
517
+ } else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
518
+ names.add(stmt.name.text)
519
+ } else if (ts.isClassDeclaration(stmt) && stmt.name) {
520
+ names.add(stmt.name.text)
521
+ }
522
+ }
523
+ return names
524
+ }
525
+
425
526
  function emitComponent(fm: FrontmatterParts, htmlExpr: string, meta: LowerMeta): string {
426
527
  let param = ''
427
528
  if (fm.propsType && meta.usesChildren) param = `props: ${fm.propsType} & { children?: any }`
@@ -456,7 +557,7 @@ function emitRoute(fm: FrontmatterParts, htmlExpr: string, _meta: LowerMeta): st
456
557
  /** Parse `// @stator <flag>` comment pragmas from the frontmatter text. */
457
558
  function parsePragmas(
458
559
  fm: string,
459
- kind: 'route' | 'component',
560
+ kind: 'route' | 'component' | 'island',
460
561
  source: string,
461
562
  file?: string,
462
563
  ): Set<string> {
@@ -481,7 +582,11 @@ function parsePragmas(
481
582
  return out
482
583
  }
483
584
 
484
- function requireRoute(what: string, kind: 'route' | 'component', loc: DiagnosticLocation): void {
585
+ function requireRoute(
586
+ what: string,
587
+ kind: 'route' | 'component' | 'island',
588
+ loc: DiagnosticLocation,
589
+ ): void {
485
590
  if (kind !== 'route') {
486
591
  throw new CompileError(
487
592
  `stator: ${what} is only available in a route page, not a component. ` +
@@ -0,0 +1,48 @@
1
+ /** The names the compiler auto-injects into emitted modules — single source
2
+ * of truth for the emitters (compile.ts, client-emit.ts) AND the language
3
+ * server (virtual-code.ts), which must inject the same globals in-editor: a
4
+ * name injected in one place but not the other hides a missing-import bug,
5
+ * and a name injected that authors also legitimately import collides with
6
+ * their import (`raw` is NOT a global — authors import it — which is why it
7
+ * appears in no list here).
8
+ *
9
+ * Author globals are names template/script authors may write directly;
10
+ * lowering targets are names only the compiler's output references. */
11
+
12
+ /** Template names authors write: control flow + display + directive values. */
13
+ export const TEMPLATE_AUTHOR_GLOBALS = [
14
+ 'read',
15
+ 'each',
16
+ 'when',
17
+ 'match',
18
+ 'defer',
19
+ 'on',
20
+ 'classList',
21
+ 'styleList',
22
+ ] as const
23
+
24
+ /** Template names only lowered code references. `itemBind` needs server
25
+ * render state, so it is a SERVER-module target only — client-island shells
26
+ * never emit it (see lower.ts, keyed-row lowering). */
27
+ export const TEMPLATE_LOWERING_TARGETS = ['html', 'itemBind', 'spreadAttrs'] as const
28
+
29
+ /** Extra lowering targets for a client island's server SHELL module. */
30
+ export const ISLAND_SHELL_EXTRAS = ['createHtmlFragment', 'clientShellAttrs'] as const
31
+
32
+ /** Client-module names island authors write in a `<script>`. */
33
+ export const CLIENT_AUTHOR_GLOBALS = [
34
+ 'StatorElement',
35
+ 'use',
36
+ 'machine',
37
+ 'defineElement',
38
+ 'bind',
39
+ 'effect',
40
+ 'dispatch',
41
+ ] as const
42
+
43
+ /** Client-module names only lowered code references. */
44
+ export const CLIENT_LOWERING_TARGETS = ['bindSlot', 'attrValue', 'setAttr'] as const
45
+
46
+ export function importLine(names: readonly string[], from: string): string {
47
+ return `import { ${names.join(', ')} } from '${from}'`
48
+ }
@@ -21,6 +21,7 @@
21
21
  import ts from 'typescript'
22
22
  import { analyzeScriptClasses } from './client-script.ts'
23
23
  import { statorPropsType } from './dts.ts'
24
+ import { CLIENT_AUTHOR_GLOBALS, TEMPLATE_AUTHOR_GLOBALS } from './emit-names.ts'
24
25
  import { type ScannedRegions, scanRegions } from './split.ts'
25
26
 
26
27
  /** A contiguous run mapping generated code back to source, 1:1 over its length. */
@@ -44,21 +45,11 @@ export interface VirtualCodeResult {
44
45
  styles: VirtualFile[]
45
46
  }
46
47
 
47
- // Must mirror the RUNTIME's auto-injected globals exactly (compile.ts
48
- // PRIMITIVES_IMPORT / client-emit.ts): a name injected here but not at
49
- // runtime hides a missing-import bug; a name in both collides with the
50
- // author's legitimate import (`raw` is NOT a runtime global — authors
51
- // import it — which is why it must not be in this list).
52
- const TEMPLATE_GLOBALS = ['read', 'each', 'when', 'match', 'defer', 'on', 'classList', 'styleList']
53
- const CLIENT_GLOBALS = [
54
- 'StatorElement',
55
- 'use',
56
- 'machine',
57
- 'defineElement',
58
- 'bind',
59
- 'effect',
60
- 'dispatch',
61
- ]
48
+ // The editor injects the AUTHOR-visible subsets only — lowering targets are
49
+ // names authors never write, so injecting them here would shadow nothing and
50
+ // pollute completions. Shared with the emitters via emit-names.ts.
51
+ const TEMPLATE_GLOBALS = TEMPLATE_AUTHOR_GLOBALS
52
+ const CLIENT_GLOBALS = CLIENT_AUTHOR_GLOBALS
62
53
 
63
54
  /** Local names the user's code already binds from `modulePath` imports —
64
55
  * the runtime strips such habit-imports; the virtual emit (which must keep
@@ -79,7 +70,7 @@ function userImportedLocals(code: string, modulePath: string): Set<string> {
79
70
  return locals
80
71
  }
81
72
 
82
- function injectImports(globals: string[], modulePath: string, userCode: string): string {
73
+ function injectImports(globals: readonly string[], modulePath: string, userCode: string): string {
83
74
  const bound = userImportedLocals(userCode, modulePath)
84
75
  const missing = globals.filter((g) => !bound.has(g))
85
76
  return missing.length > 0 ? `import { ${missing.join(', ')} } from '${modulePath}';\n` : ''
@@ -367,6 +358,28 @@ function buildClientTsx(regions: ScannedRegions): VirtualFile {
367
358
  AMBIENT_TYPE_IMPORTS +
368
359
  STATOR_AMBIENT
369
360
 
361
+ // The island's SERVER FENCE (2.1): typed here so the editor checks it, but
362
+ // wrapped so its bindings don't leak into the <script>'s scope (at runtime
363
+ // the two never meet). Hoisted imports that also appear verbatim in the
364
+ // script are skipped — same module, one binding.
365
+ const fm = regions.frontmatter?.content ?? ''
366
+ if (fm.trim()) {
367
+ const fmOffset = regions.frontmatter?.contentOffset ?? 0
368
+ const { hoisted, body } = splitFrontmatter(fm, fmOffset)
369
+ for (const seg of hoisted) {
370
+ if (userScript.includes(seg.text.trim())) continue
371
+ push(mappings, seg.sourceOffset, code.length, seg.text.length)
372
+ code += `${seg.text}\n`
373
+ }
374
+ code += 'function __statorFence(props: Record<string, unknown>) {\n'
375
+ for (const seg of body) {
376
+ code += ' '
377
+ push(mappings, seg.sourceOffset, code.length, seg.text.length)
378
+ code += `${seg.text}\n`
379
+ }
380
+ code += '}\nvoid __statorFence\n'
381
+ }
382
+
370
383
  for (const script of regions.scripts) {
371
384
  push(mappings, script.contentOffset, code.length, script.content.length)
372
385
  code += `${script.content}\n`