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