@statorjs/stator 2.0.2 → 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 +2 -2
- package/src/compiler/client-emit.ts +5 -2
- package/src/compiler/compile.ts +112 -20
- package/src/compiler/emit-names.ts +48 -0
- package/src/compiler/virtual-code.ts +29 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.0
|
|
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
|
|
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
|
-
|
|
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. */
|
package/src/compiler/compile.ts
CHANGED
|
@@ -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
|
-
|
|
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,24 +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) {
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
if (frontmatter.trim()) {
|
|
89
|
-
throw new CompileError(
|
|
90
|
-
`stator: an island file (a template with a StatorElement <script>) can't have ` +
|
|
91
|
-
`frontmatter — the fence would never run: the island's shell renders from props. ` +
|
|
92
|
-
`Move the server work into the route or component that renders this island and ` +
|
|
93
|
-
`pass the results as props.`,
|
|
94
|
-
locAt(source, 0, opts.id),
|
|
95
|
-
)
|
|
96
|
-
}
|
|
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)
|
|
97
105
|
return compileClient(template, script, {
|
|
98
106
|
hash,
|
|
99
107
|
scopeAttr,
|
|
100
108
|
styles,
|
|
101
109
|
file: opts.id,
|
|
110
|
+
fm,
|
|
111
|
+
source,
|
|
102
112
|
})
|
|
103
113
|
}
|
|
104
114
|
throw new CompileError(
|
|
@@ -152,7 +162,16 @@ export function compile(source: string, opts: CompileOptions = {}): CompileResul
|
|
|
152
162
|
function compileClient(
|
|
153
163
|
template: string,
|
|
154
164
|
script: string,
|
|
155
|
-
ctx: {
|
|
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
|
+
},
|
|
156
175
|
): CompileResult {
|
|
157
176
|
const classes = analyzeScriptClasses(script)
|
|
158
177
|
const root = extractClientRoot(template, ctx.file)
|
|
@@ -162,6 +181,22 @@ function compileClient(
|
|
|
162
181
|
analyzeClient(script, tags, { file: ctx.file })
|
|
163
182
|
|
|
164
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
|
+
}
|
|
165
200
|
if (!cls) {
|
|
166
201
|
// analyzeClient validated the tag↔class match just above; reaching here is a bug.
|
|
167
202
|
throw new CompileError(
|
|
@@ -199,9 +234,13 @@ function compileClient(
|
|
|
199
234
|
const attrDecl = `{ ${[...cls.staticAttrs].map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')} }`
|
|
200
235
|
const rootScope = ctx.scopeAttr ? ` data-s-${ctx.hash}` : ''
|
|
201
236
|
const serverCode = [
|
|
202
|
-
|
|
237
|
+
SHELL_PRIMITIVES_IMPORT,
|
|
238
|
+
...(ctx.fm?.hoisted ? [ctx.fm.hoisted] : []),
|
|
203
239
|
'',
|
|
204
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] : []),
|
|
205
244
|
` const __inner = ${innerExpr}`,
|
|
206
245
|
` const __attrs = clientShellAttrs(props, ${attrDecl}, ${JSON.stringify(root.rootAttrs)})`,
|
|
207
246
|
` return createHtmlFragment(\`<${root.tag}\${__attrs}${rootScope}>\` + __inner.html + \`</${root.tag}>\`)`,
|
|
@@ -324,7 +363,7 @@ const VALID_PRAGMAS = new Set(['live'])
|
|
|
324
363
|
*/
|
|
325
364
|
function processFrontmatter(
|
|
326
365
|
fm: string,
|
|
327
|
-
kind: 'route' | 'component',
|
|
366
|
+
kind: 'route' | 'component' | 'island',
|
|
328
367
|
source: string,
|
|
329
368
|
file?: string,
|
|
330
369
|
): FrontmatterParts {
|
|
@@ -355,6 +394,14 @@ function processFrontmatter(
|
|
|
355
394
|
const repls: Array<[start: number, end: number, replacement: string]> = []
|
|
356
395
|
const visit = (n: ts.Node): void => {
|
|
357
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
|
+
}
|
|
358
405
|
if (kind === 'route') {
|
|
359
406
|
throw new CompileError(
|
|
360
407
|
'stator: Stator.props() is not available in a route page (a route has no parent props). ' +
|
|
@@ -387,6 +434,12 @@ function processFrontmatter(
|
|
|
387
434
|
return
|
|
388
435
|
}
|
|
389
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
|
+
}
|
|
390
443
|
if (kind === 'route') {
|
|
391
444
|
throw new CompileError(
|
|
392
445
|
'stator: Stator.forwarded() is only available in a component — it reads a directive ' +
|
|
@@ -435,6 +488,41 @@ function processFrontmatter(
|
|
|
435
488
|
}
|
|
436
489
|
}
|
|
437
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
|
+
|
|
438
526
|
function emitComponent(fm: FrontmatterParts, htmlExpr: string, meta: LowerMeta): string {
|
|
439
527
|
let param = ''
|
|
440
528
|
if (fm.propsType && meta.usesChildren) param = `props: ${fm.propsType} & { children?: any }`
|
|
@@ -469,7 +557,7 @@ function emitRoute(fm: FrontmatterParts, htmlExpr: string, _meta: LowerMeta): st
|
|
|
469
557
|
/** Parse `// @stator <flag>` comment pragmas from the frontmatter text. */
|
|
470
558
|
function parsePragmas(
|
|
471
559
|
fm: string,
|
|
472
|
-
kind: 'route' | 'component',
|
|
560
|
+
kind: 'route' | 'component' | 'island',
|
|
473
561
|
source: string,
|
|
474
562
|
file?: string,
|
|
475
563
|
): Set<string> {
|
|
@@ -494,7 +582,11 @@ function parsePragmas(
|
|
|
494
582
|
return out
|
|
495
583
|
}
|
|
496
584
|
|
|
497
|
-
function requireRoute(
|
|
585
|
+
function requireRoute(
|
|
586
|
+
what: string,
|
|
587
|
+
kind: 'route' | 'component' | 'island',
|
|
588
|
+
loc: DiagnosticLocation,
|
|
589
|
+
): void {
|
|
498
590
|
if (kind !== 'route') {
|
|
499
591
|
throw new CompileError(
|
|
500
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
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`
|