ReviewOS

also looking at this

stacks/dtsx

feat(stx): generate component declarations

#3105
Merged chris wants to merge codex/stx-component-declarations into main
19 files +894 -37
README.mdmodified+27-2
Changes to README.md
@@ -52,7 +52,7 @@ import { generate, processSource } from '@stacksjs/dtsx'
5252const options: DtsGenerationOptions = {
5353 cwd: './', // default: process.cwd()
5454 root: './src', // default: './src'
55 entrypoints: ['**/*.ts'], // default: ['**/*.ts']
55 entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'], // default
5656 outdir: './dist', // default: './dist'
5757 clean: true, // default: false
5858 verbose: true, // default: false
@@ -80,6 +80,31 @@ console.log(dtsContent)
8080// export declare function greet(name: string): string;
8181```
8282
83### STX Components
84
85`.stx` files are declaration entrypoints alongside TypeScript and Vue files.
86dtsx reads Vue-style `<script server|client>` blocks and Blade-style `@ts`
87blocks, then emits an `@stacksjs/stx` `DefineComponent` declaration. Props are
88derived from typed macros, runtime prop schemas, explicit `props as Props`
89assertions, and legacy `$props.name` access:
90
91```stx
92<script server lang="ts">
93interface CardProps {
94 title: string
95 count?: number
96}
97
98const props = withDefaults(defineProps<CardProps>(), { count: 0 })
99</script>
100
101<article>{{ props.title }}</article>
102```
103
104This emits `Card.d.ts` with `{ title: string; count?: number }` as its `$props`
105contract. The same transform feeds the semantic inference path and the
106annotation-first `isolatedDeclarations` path.
107
83108Library usage can also be configured using a `dts.config.ts` _(or `dts.config.js`)_ file, automatically loaded when running `./dtsx` _(or `bunx dtsx`)_ and when calling `generate()` unless custom options are provided.
84109
85110```ts
@@ -185,7 +210,7 @@ cat src/utils.ts | dtsx stdin > dist/utils.d.ts
185210
186211- `--cwd <path>`: Set the current working directory _(default: current directory)_
187212- `--root <path>`: Specify the root directory of the project _(default: './src')_
188- `--entrypoints <files>`: Define entry point files _(comma-separated, default: '**/*.ts')_
213- `--entrypoints <files>`: Define entry point files _(comma-separated, default: `**/*.{ts,tsx,mts,cts,vue,stx}`)_
189214- `--outdir <path>`: Set the output directory for generated .d.ts files _(default: './dist')_
190215- `--keep-comments`: Keep comments in generated .d.ts files _(default: true)_
191216- `--clean`: Clean output directory before generation _(default: false)_
docs/usage.mdmodified+2-2
Changes to docs/usage.md
@@ -25,7 +25,7 @@ import { generate } from '@stacksjs/dtsx'
2525const config: DtsGenerationConfig = {
2626 cwd: './', // default: process.cwd()
2727 root: './src', // default: './src'
28 entrypoints: ['**/*.ts'], // default: ['**/*.ts']
28 entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'], // default
2929 outdir: './dist', // default: './dist'
3030 keepComments: true, // default: true
3131 clean: true, // default: true
@@ -134,7 +134,7 @@ dtsx generate --output-structure flat
134134|--------|------|---------|-------------|
135135| `--cwd <path>` | `string` | current directory | Set the current working directory |
136136| `--root <path>` | `string` | `'./src'` | Specify the root directory of the project |
137| `--entrypoints <files>` | `string` | `'**/*.ts'` | Define entry point files (comma-separated) |
137| `--entrypoints <files>` | `string` | `'**/*.{ts,tsx,mts,cts,vue,stx}'` | Define entry point files (comma-separated) |
138138| `--outdir <path>` | `string` | `'./dist'` | Set the output directory for generated .d.ts files |
139139| `--keep-comments [value]` | `boolean` | `true` | Keep comments in generated .d.ts files |
140140| `--clean` | `boolean` | `true` | Clean output directory before generation |
packages/dtsx/README.mdmodified+9-2
Changes to packages/dtsx/README.md
@@ -64,7 +64,7 @@ import { generate } from '@stacksjs/dtsx'
6464const options: DtsGenerationOptions = {
6565 cwd: './', // default: process.cwd()
6666 root: './src', // default: './src'
67 entrypoints: ['**/*.ts'], // default: ['**/*.ts']
67 entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'], // default
6868 outdir: './dist', // default: './dist'
6969 clean: true, // default: false
7070 verbose: true, // default: false
@@ -74,6 +74,13 @@ const options: DtsGenerationOptions = {
7474await generate(options)
7575```
7676
77### STX Components
78
79`.stx` entrypoints support Vue-style script blocks, Blade-style `@ts` blocks,
80typed and runtime `defineProps`, `props as Props`, and legacy `$props.name`
81contracts. dtsx emits the default component as
82`DefineComponent<Props>` from `@stacksjs/stx` in semantic and isolated modes.
83
7784### Configuration File
7885
7986Library usage can also be configured using a `dts.config.ts` _(or `dts.config.js`)_ file which is automatically loaded when running the `./dtsx` _(or `bunx dtsx`)_ command. It is also loaded when the `generate` function is called, unless custom options are provided.
@@ -269,7 +276,7 @@ echo "export function foo(): string { return 'bar' }" | dtsx stdin
269276#### Generate Options
270277
271278- `--root <path>`: Specify the root directory of the project _(default: './src')_
272- `--entrypoints <files>`: Define entry point files _(comma-separated, default: '**/*.ts')_
279- `--entrypoints <files>`: Define entry point files _(comma-separated, default: `**/*.{ts,tsx,mts,cts,vue,stx}`)_
273280- `--outdir <path>`: Set the output directory for generated .d.ts files _(default: './dist')_
274281- `--keep-comments`: Keep comments in generated .d.ts files _(default: true)_
275282- `--clean`: Clean output directory before generation _(default: false)_
packages/dtsx/src/config.tsmodified+1-1
Changes to packages/dtsx/src/config.ts
@@ -9,7 +9,7 @@ import { logger } from './logger'
99export const defaultConfig: DtsGenerationConfig = {
1010 cwd: process.cwd(),
1111 root: './src',
12 entrypoints: ['**/*.{ts,tsx,mts,cts}'],
12 entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'],
1313 outdir: './dist',
1414 keepComments: true,
1515 clean: true,
packages/dtsx/src/extractor/extract.tsmodified+5-1
Changes to packages/dtsx/src/extractor/extract.ts
@@ -5,6 +5,7 @@
55 */
66
77import type { Declaration } from '../types'
8import { isStxFile, transformStxToTs } from '../stx'
89import { isVueFile, transformVueSfcToTs } from '../vue'
910import { hashContent } from './hash'
1011import { scanIsolatedDeclarations, scanSemanticDeclarations } from './scanner'
@@ -23,10 +24,13 @@ export function extractDeclarations(
2324 keepComments: boolean = true,
2425 isolatedDeclarations: boolean = false,
2526): Declaration[] {
26 // Vue SFCs are transformed into a virtual TS module before scanning.
27 // Component files are transformed into virtual TS modules before scanning.
2728 if (isVueFile(filePath)) {
2829 sourceCode = transformVueSfcToTs(sourceCode)
2930 }
31 else if (isStxFile(filePath)) {
32 sourceCode = transformStxToTs(sourceCode)
33 }
3034
3135 const contentHash = hashContent(sourceCode)
3236 const cacheKey = `${filePath}:${keepComments ? 1 : 0}:${isolatedDeclarations ? 1 : 0}`
packages/dtsx/src/generator.tsmodified+3-3
Changes to packages/dtsx/src/generator.ts
@@ -792,7 +792,7 @@ function assertUniqueOutputPaths(files: string[], config: DtsGenerationConfig):
792792}
793793
794794function isProcessableSourceFile(filePath: string): boolean {
795 return /\.(m?tsx?|cts|jsx?|mjs|cjs|vue)$/.test(filePath) && !/\.d\.(?:ts|mts|cts)$/.test(filePath)
795 return /\.(m?tsx?|cts|jsx?|mjs|cjs|vue|stx)$/.test(filePath) && !/\.d\.(?:ts|mts|cts)$/.test(filePath)
796796}
797797
798798/**
@@ -801,7 +801,7 @@ function isProcessableSourceFile(filePath: string): boolean {
801801function getOutputPath(inputPath: string, config: DtsGenerationConfig): string {
802802 const rootPath = resolve(config.cwd, config.root)
803803 const relativePath = relative(rootPath, inputPath)
804 const dtsPath = relativePath.replace(/\.(m?tsx?|cts|jsx?|mjs|cjs|vue)$/, (ext) => {
804 const dtsPath = relativePath.replace(/\.(m?tsx?|cts|jsx?|mjs|cjs|vue|stx)$/, (ext) => {
805805 if (ext === '.mts' || ext === '.mjs') return '.d.mts'
806806 if (ext === '.cts' || ext === '.cjs') return '.d.cts'
807807 return '.d.ts'
@@ -1125,7 +1125,7 @@ export async function watch(options?: Partial<DtsGenerationConfig>): Promise<voi
11251125 const rootPath = '${rootPath}';
11261126
11271127 fs.watch(rootPath, { recursive: true }, (eventType, filename) => {
1128 if (filename && filename.endsWith('.ts') && !filename.endsWith('.d.ts')) {
1128 if (filename && /\.(?:[cm]?[jt]sx?|vue|stx)$/.test(filename) && !/\.d\.(?:ts|mts|cts)$/.test(filename)) {
11291129 console.log(\`CHANGED:\${filename}\`);
11301130 }
11311131 });
packages/dtsx/src/module-graph.tsmodified+1-1
Changes to packages/dtsx/src/module-graph.ts
@@ -42,7 +42,7 @@ export interface ResolveResult {
4242 * Extensions tried, in order, when resolving an extensionless specifier.
4343 * `.d.ts` last so source files win over generated declarations.
4444 */
45const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts'] as const
45const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.vue', '.stx', '.js', '.jsx', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts'] as const
4646
4747const RUNTIME_SOURCE_EXTENSIONS: Readonly<Record<string, readonly string[]>> = {
4848 '.js': ['.ts', '.tsx', '.d.ts'],
packages/dtsx/src/process-source.tsmodified+5-1
Changes to packages/dtsx/src/process-source.ts
@@ -3,6 +3,7 @@ import { extractDeclarations } from './extractor/extract'
33import { hashContent } from './extractor/hash'
44import { scanIsolatedDeclarations, scanSemanticDeclarations } from './extractor/scanner'
55import { processDeclarations } from './processor'
6import { isStxFile, transformStxToTs } from './stx'
67import { isVueFile, transformVueSfcToTs } from './vue'
78
89// ---------------------------------------------------------------------------
@@ -102,10 +103,13 @@ export function processSourceDirect(
102103 importOrder: string[] = ['bun'],
103104 isolatedDeclarations: boolean = false,
104105): string {
105 // Vue SFCs are transformed into a virtual TS module before scanning.
106 // Component files are transformed into virtual TS modules before scanning.
106107 if (isVueFile(filename)) {
107108 sourceCode = transformVueSfcToTs(sourceCode)
108109 }
110 else if (isStxFile(filename)) {
111 sourceCode = transformStxToTs(sourceCode)
112 }
109113 const declarations = isolatedDeclarations
110114 ? scanIsolatedDeclarations(sourceCode, filename, keepComments)
111115 : scanSemanticDeclarations(sourceCode, filename, keepComments)
packages/dtsx/src/stx.tsadded+297-0
Changes to packages/dtsx/src/stx.ts
@@ -0,0 +1,297 @@
1/**
2 * STX single-file component declaration support.
3 *
4 * STX accepts Vue-style `<script>` blocks and Laravel Blade-style `@ts`
5 * blocks. Both forms are collected into one virtual TypeScript module before
6 * the normal semantic or isolated declaration scanner runs.
7 */
8
9import {
10 collapseTypeText,
11 extractDefaultExportOptions,
12 filterLocalValueStatements,
13 findMacroCall,
14 findMatching,
15 getOptionValue,
16 mapRuntimeProps,
17 maskNonCode,
18 removeInlinedDeclaration,
19 resolveEmitsType,
20 resolveExposedType,
21 resolvePropsType,
22 skipNonCode,
23 stripMacroStatements,
24 type PropsResolution,
25} from './vue'
26
27export interface StxScriptBlock {
28 /** TypeScript or JavaScript source contained by the block. */
29 content: string
30 /** Source syntax used to declare the block. */
31 kind: 'script' | 'ts'
32 /** Raw attributes for `<script>` blocks. */
33 attrs: string
34 /** Whether the block runs on the server, client, or in either context. */
35 context: 'server' | 'client' | 'universal'
36 /** Declared script language, lowercased. */
37 lang: string
38 /** Byte offset of the block's opening delimiter. */
39 start: number
40}
41
42/** Check whether a file path points at an STX component. */
43export function isStxFile(filePath: string): boolean {
44 return filePath.endsWith('.stx')
45}
46
47function parseScriptBlocks(source: string): StxScriptBlock[] {
48 const blocks: StxScriptBlock[] = []
49 const scriptTag = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi
50 let match: RegExpExecArray | null
51 // eslint-disable-next-line no-cond-assign
52 while ((match = scriptTag.exec(source)) !== null) {
53 const attrs = match[1] ?? ''
54 const lang = /\blang\s*=\s*["']([^"']+)["']/i.exec(attrs)?.[1]?.toLowerCase()
55 ?? (/\b(?:type\s*=\s*["'](?:text\/)?typescript["']|typescript)\b/i.test(attrs) ? 'ts' : 'js')
56 const context = /\bserver\b/i.test(attrs)
57 ? 'server'
58 : /\bclient\b/i.test(attrs) ? 'client' : 'universal'
59 blocks.push({
60 content: match[2] ?? '',
61 kind: 'script',
62 attrs,
63 context,
64 lang,
65 start: match.index,
66 })
67 }
68 return blocks
69}
70
71/**
72 * Parse Blade-style `@ts` blocks. Delimiters are recognized only when they
73 * occupy a line, preventing `"@endts"` inside TypeScript strings and comments
74 * from truncating the block. An unclosed block extends to end-of-file so a
75 * partially edited component still yields the declarations written so far.
76 */
77function parseTsBlocks(source: string): StxScriptBlock[] {
78 const blocks: StxScriptBlock[] = []
79 const opener = /^[ \t]*@ts[ \t]*(?:\r?\n|$)/gm
80 let match: RegExpExecArray | null
81 // eslint-disable-next-line no-cond-assign
82 while ((match = opener.exec(source)) !== null) {
83 const contentStart = opener.lastIndex
84 const closer = /^[ \t]*@endts[ \t]*(?:\r?\n|$)/gm
85 closer.lastIndex = contentStart
86 const close = closer.exec(source)
87 const contentEnd = close?.index ?? source.length
88 blocks.push({
89 content: source.slice(contentStart, contentEnd),
90 kind: 'ts',
91 attrs: '',
92 context: 'universal',
93 lang: 'ts',
94 start: match.index,
95 })
96 if (!close) break
97 opener.lastIndex = closer.lastIndex
98 }
99 return blocks
100}
101
102/** Collect all declaration-bearing STX blocks in document order. */
103export function parseStxScripts(source: string): StxScriptBlock[] {
104 return [...parseScriptBlocks(source), ...parseTsBlocks(source)]
105 .sort((left, right) => left.start - right.start)
106}
107
108function isDeclarationBearingBlock(block: StxScriptBlock): boolean {
109 if (block.kind === 'ts' || block.context !== 'universal' || block.lang === 'ts') return true
110 // A plain HTML `<script>` commonly contains page bootstrap code rather than
111 // component authoring state. Keep it only when it carries a public or typed
112 // declaration signal; template-only runtime code cannot affect a `.d.ts`.
113 return /\b(?:defineProps|defineEmits|defineExpose|defineSlots)\b|\bexport\s|\b(?:interface|type)\s+[A-Za-z_$]/.test(block.content)
114}
115
116function findPropsAssertion(script: string): string | null {
117 const assertion = /(?:\$?props)\s+as\s+/g
118 let match: RegExpExecArray | null
119 // eslint-disable-next-line no-cond-assign
120 while ((match = assertion.exec(script)) !== null) {
121 let start = assertion.lastIndex
122 while (start < script.length && /\s/.test(script[start])) start++
123 if (script[start] === '{') {
124 const end = findMatching(script, start, '{', '}')
125 if (end !== -1) return script.slice(start, end + 1).trim()
126 continue
127 }
128 const name = /^[A-Za-z_$][\w$]*/.exec(script.slice(start))?.[0]
129 if (!name) continue
130 let end = start + name.length
131 while (end < script.length && /\s/.test(script[end])) end++
132 if (script[end] === '<') {
133 const genericEnd = findMatching(script, end, '<', '>')
134 if (genericEnd !== -1) end = genericEnd + 1
135 }
136 return script.slice(start, end).trim()
137 }
138 return null
139}
140
141function findPropsAnnotation(script: string): string | null {
142 const match = /\b(?:const|let|var)\s+\$?props\s*:\s*([^=;\n]+)/.exec(script)
143 return match?.[1]?.trim() ?? null
144}
145
146function inferAmbientPropType(codeAfterAccess: string): string {
147 const value = /^\s*(?:\|\||\?\?)\s*([^;\n]+)/.exec(codeAfterAccess)?.[1]?.trim()
148 if (/^['"`]/.test(value ?? '')) return 'string'
149 if (/^(?:true|false)\b/.test(value ?? '')) return 'boolean'
150 if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)\b/.test(value ?? '')) return 'number'
151 if (/^\[/.test(value ?? '')) return 'unknown[]'
152 if (/^\{/.test(value ?? '')) return 'Record<string, unknown>'
153 if (/^\s*(?:===?|!==?)\s*(?:true|false)\b/.test(codeAfterAccess)) return 'boolean'
154 return 'unknown'
155}
156
157function mergeAmbientPropType(previous: string | undefined, next: string): string {
158 if (!previous || previous === 'unknown') return next
159 if (next === 'unknown' || previous === next) return previous
160 const members = new Set([...previous.split(' | '), ...next.split(' | ')])
161 return [...members].join(' | ')
162}
163
164/** Infer the legacy `$props.name` STX contract used throughout component libraries. */
165function resolveAmbientProps(script: string): PropsResolution | null {
166 const code = maskNonCode(script)
167 const props = new Map<string, string>()
168 const access = /\$props(?:\.([A-Za-z_$][\w$]*)|\[\s*(['"])([^'"\n]+)\2\s*\])/g
169 let match: RegExpExecArray | null
170 // eslint-disable-next-line no-cond-assign
171 while ((match = access.exec(script)) !== null) {
172 if (code.slice(match.index, match.index + 6) !== '$props') continue
173 const name = match[1] ?? match[3]
174 const inferred = inferAmbientPropType(script.slice(access.lastIndex))
175 props.set(name, mergeAmbientPropType(props.get(name), inferred))
176 }
177 if (props.size === 0) return null
178 const members = [...props].map(([name, type]) => {
179 const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)
180 return `${key}?: ${type}`
181 })
182 return { type: `{ ${members.join(', ')} }` }
183}
184
185function resolveStxProps(script: string): PropsResolution | null {
186 const macroProps = resolvePropsType(script, null)
187 if (macroProps) return macroProps
188
189 const assertedType = findPropsAssertion(script) ?? findPropsAnnotation(script)
190 if (!assertedType) return resolveAmbientProps(script)
191
192 if (/^[A-Za-z_$][\w$]*$/.test(assertedType)) {
193 const escaped = assertedType.replace(/[$]/g, '\\$&')
194 const exportedType = new RegExp(`\\bexport\\s+(?:declare\\s+)?(?:interface|type)\\s+${escaped}\\b`)
195 if (exportedType.test(script)) return { type: assertedType }
196 }
197
198 // Reuse the SFC resolver so local interfaces and aliases are inlined while
199 // imported or compound type references remain intact.
200 return resolvePropsType(`${script}\ndefineProps<${assertedType}>()`, null)
201}
202
203function stripStxCompilerStatements(content: string): string {
204 return stripMacroStatements(content)
205}
206
207function isDeclarationStatement(statement: string): boolean {
208 const code = maskNonCode(statement).trimStart()
209 if (code === '') return true
210 return /^(?:import\b|export\b|declare\b|interface\b|type\b|(?:export\s+)?namespace\b|(?:export\s+)?module\b|(?:abstract\s+)?class\b|const\b|let\b|var\b|(?:async\s+)?function\b)/.test(code)
211}
212
213/** Drop top-level browser/runtime statements that cannot contribute declarations. */
214function filterTopLevelRuntimeStatements(content: string): string {
215 const ranges: Array<[number, number]> = []
216 let start = 0
217 let depth = 0
218 let i = 0
219 while (i < content.length) {
220 const ch = content[i]
221 const nonCodeEnd = skipNonCode(content, i)
222 if (nonCodeEnd > i) {
223 i = nonCodeEnd
224 continue
225 }
226 if (ch === '{' || ch === '[' || ch === '(') depth++
227 else if (ch === '}' || ch === ']' || ch === ')') depth = Math.max(0, depth - 1)
228 if ((ch === ';' || ch === '\n') && depth === 0) {
229 const end = i + 1
230 if (!isDeclarationStatement(content.slice(start, end))) ranges.push([start, end])
231 start = end
232 }
233 i++
234 }
235 if (start < content.length && !isDeclarationStatement(content.slice(start))) ranges.push([start, content.length])
236 let result = content
237 for (let j = ranges.length - 1; j >= 0; j--) {
238 result = result.slice(0, ranges[j][0]) + result.slice(ranges[j][1])
239 }
240 return result
241}
242
243/**
244 * Transform an STX component into a virtual TypeScript module.
245 *
246 * The output deliberately imports STX's own Vue-compatible `DefineComponent`
247 * type. This makes generated declarations consumable without a Vue dependency
248 * and exposes `$props` to STX's component utility types and call-site tooling.
249 */
250export function transformStxToTs(source: string): string {
251 const blocks = parseStxScripts(source).filter(isDeclarationBearingBlock)
252 let scriptContent = blocks.map(block => block.content).join('\n')
253 let optionsProps: string | null = null
254
255 const extraction = extractDefaultExportOptions(scriptContent)
256 if (extraction.passthrough) return scriptContent
257 scriptContent = extraction.script
258 if (extraction.options) {
259 const propsOption = getOptionValue(extraction.options, 'props')
260 if (propsOption) optionsProps = mapRuntimeProps(propsOption)
261 }
262
263 const rawScript = scriptContent
264 let filteredScript = stripStxCompilerStatements(scriptContent)
265 const propsResolution = resolveStxProps(rawScript)
266 const props = propsResolution?.type ?? optionsProps ?? '{}'
267 const emits = resolveEmitsType(rawScript, null)
268 const exposed = resolveExposedType(rawScript, null)
269 const slots = findMacroCall(rawScript, 'defineSlots')?.generic ?? null
270
271 const inlined = propsResolution?.inlinedLocal
272 if (inlined) {
273 filteredScript = removeInlinedDeclaration(filteredScript, inlined.name, inlined.declText)
274 }
275
276 let componentType = `DefineComponent<${props}>`
277 const metadata: string[] = []
278 if (emits) metadata.push(`readonly __stxEmits?: ${emits}`)
279 if (slots) metadata.push(`readonly __stxSlots?: ${slots}`)
280 if (exposed) metadata.push(`readonly __stxExposed?: ${exposed}`)
281 if (metadata.length > 0) componentType += ` & { ${metadata.join(', ')} }`
282 componentType = collapseTypeText(componentType)
283
284 filteredScript = filterTopLevelRuntimeStatements(filterLocalValueStatements(filteredScript, componentType))
285 // Put the synthesized public surface first. Some legal STX client scripts
286 // start with browser-only expression statements (IIFEs, DOM registration),
287 // which intentionally are not declaration syntax and may stop a lightweight
288 // declaration scan before later statements.
289 const parts = [
290 `import type { DefineComponent } from '@stacksjs/stx'`,
291 `type __DtsxStxComponentType = ${componentType}`,
292 'const __dtsx_component__: __DtsxStxComponentType = null as unknown as __DtsxStxComponentType',
293 'export default __dtsx_component__',
294 ]
295 if (filteredScript.trim() !== '') parts.push(filteredScript)
296 return parts.join('\n')
297}
packages/dtsx/src/vue.tsmodified+142-20
Changes to packages/dtsx/src/vue.ts
@@ -125,6 +125,33 @@ function skipComment(text: string, i: number): number {
125125 return i + 1
126126}
127127
128/** Return the end of a string/template/comment at `i`, or `i` for normal code. */
129export function skipNonCode(text: string, i: number): number {
130 const ch = text[i]
131 if (ch === '"' || ch === '\'') return skipString(text, i)
132 if (ch === '`') return skipTemplate(text, i)
133 if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) return skipComment(text, i)
134 return i
135}
136
137/** Replace strings, template literals and comments with spaces, preserving offsets and newlines. */
138export function maskNonCode(text: string): string {
139 const chars = text.split('')
140 let i = 0
141 while (i < text.length) {
142 const end = skipNonCode(text, i)
143 if (end > i) {
144 for (let j = i; j < end; j++) {
145 if (chars[j] !== '\n' && chars[j] !== '\r') chars[j] = ' '
146 }
147 i = end
148 continue
149 }
150 i++
151 }
152 return chars.join('')
153}
154
128155/**
129156 * Find the index of the closer matching the opener at `start`
130157 * (`text[start]` must be the opener). Returns -1 when unbalanced.
@@ -303,7 +330,7 @@ function typeKey(key: string): string {
303330// Compiler-macro extraction (defineProps / defineEmits / defineExpose)
304331// ---------------------------------------------------------------------------
305332
306interface MacroCall {
333export interface MacroCall {
307334 /** Generic type argument text (`defineProps<T>`), when present. */
308335 generic: string | null
309336 /** First argument text, when the call has arguments. */
@@ -311,7 +338,7 @@ interface MacroCall {
311338}
312339
313340/** Locate the first `name(...)` / `name<T>(...)` call in `text`. */
314function findMacroCall(text: string, name: string): MacroCall | null {
341export function findMacroCall(text: string, name: string): MacroCall | null {
315342 const pattern = new RegExp(`\\b${name}\\b`, 'g')
316343 let match: RegExpExecArray | null
317344 // eslint-disable-next-line no-cond-assign
@@ -599,7 +626,8 @@ function convertEmitsCallSignatures(typeText: string): string | null {
599626// ---------------------------------------------------------------------------
600627
601628/**
602 * Remove `defineProps` / `defineEmits` / `defineExpose` / `defineOptions`
629 * Remove `defineProps` / `defineEmits` / `defineExpose` / `defineSlots` /
630 * `defineOptions`
603631 * statements (including `withDefaults(defineProps(), ...)` wrappers) from
604632 * `<script setup>` content. Their type information has already been captured
605633 * in the synthesized component type; keeping the calls would only add noise
@@ -608,7 +636,7 @@ function convertEmitsCallSignatures(typeText: string): string | null {
608636 */
609637export function stripMacroStatements(content: string): string {
610638 let result = content
611 for (const name of ['defineProps', 'defineEmits', 'defineExpose', 'defineOptions']) {
639 for (const name of ['defineProps', 'defineEmits', 'defineExpose', 'defineSlots', 'defineOptions']) {
612640 result = stripMacro(result, name)
613641 }
614642 return result
@@ -668,7 +696,7 @@ function stripMacro(text: string, name: string): string {
668696// Options API default-export extraction
669697// ---------------------------------------------------------------------------
670698
671interface OptionsExtraction {
699export interface OptionsExtraction {
672700 /** The options object literal text, when a component options object was found. */
673701 options: string | null
674702 /** The script with the default export removed (kept only when options were found). */
@@ -679,11 +707,11 @@ interface OptionsExtraction {
679707
680708/** Extract the options object from `export default {...}` / `export default defineComponent({...})`. */
681709export function extractDefaultExportOptions(script: string): OptionsExtraction {
682 const pattern = /\bexport\s+default\b/g
683 let match: RegExpExecArray | null
684 // eslint-disable-next-line no-cond-assign
685 while ((match = pattern.exec(script)) !== null) {
686 let i = match.index + match[0].length
710 const exportIndex = findTopLevelExportDefault(script)
711 if (exportIndex !== -1) {
712 const exportText = /^export\s+default\b/.exec(script.slice(exportIndex))?.[0]
713 if (!exportText) return { options: null, script, passthrough: false }
714 let i = exportIndex + exportText.length
687715 while (i < script.length && /\s/.test(script[i])) i++
688716
689717 let objectStart = -1
@@ -716,14 +744,47 @@ export function extractDefaultExportOptions(script: string): OptionsExtraction {
716744
717745 let end = statementEnd + 1
718746 if (script[end] === ';') end++
719 const cleaned = (script.slice(0, match.index) + script.slice(end)).trim()
747 const cleaned = (script.slice(0, exportIndex) + script.slice(end)).trim()
720748 return { options: script.slice(objectStart, objectEnd + 1), script: cleaned, passthrough: false }
721749 }
722750 return { options: null, script, passthrough: false }
723751}
724752
753/** Find a top-level `export default` outside strings, templates and comments. */
754function findTopLevelExportDefault(script: string): number {
755 let depth = 0
756 let i = 0
757 while (i < script.length) {
758 const ch = script[i]
759 if (ch === '"' || ch === '\'') {
760 i = skipString(script, i)
761 continue
762 }
763 if (ch === '`') {
764 i = skipTemplate(script, i)
765 continue
766 }
767 if (ch === '/' && (script[i + 1] === '/' || script[i + 1] === '*')) {
768 i = skipComment(script, i)
769 continue
770 }
771 if (ch === '{' || ch === '[' || ch === '(') depth++
772 else if (ch === '}' || ch === ']' || ch === ')') depth = Math.max(0, depth - 1)
773 else if (depth === 0 && script.startsWith('export', i)) {
774 const before = i === 0 ? '' : script[i - 1]
775 const after = script[i + 6] ?? ''
776 if (!/[\w$]/.test(before) && !/[\w$]/.test(after)) {
777 const match = /^export\s+default\b/.exec(script.slice(i))
778 if (match) return i
779 }
780 }
781 i++
782 }
783 return -1
784}
785
725786/** Pull a named option (`props`, `emits`) out of an options object literal. */
726function getOptionValue(options: string, name: string): string | null {
787export function getOptionValue(options: string, name: string): string | null {
727788 const entry = parseObjectEntries(options).find(e => e.key === name)
728789 return entry && entry.value !== '' ? entry.value : null
729790}
@@ -741,20 +802,49 @@ function getOptionValue(options: string, name: string): string | null {
741802 * source or in the synthesized component type (e.g. via `typeof sizes`) are
742803 * kept, as are exported declarations.
743804 */
744function filterLocalValueStatements(content: string, componentType: string): string {
805export function filterLocalValueStatements(content: string, componentType: string): string {
745806 const spans = findLocalValueStatements(content)
746807 // Remove from the end backwards so earlier spans stay valid.
747808 let result = content
748809 for (let i = spans.length - 1; i >= 0; i--) {
749810 const span = spans[i]
750811 const rest = result.slice(0, span.start) + result.slice(span.end)
751 if (new RegExp(`\\b${span.name}\\b`).test(rest)) continue
812 if (containsIdentifierReference(rest, span.name)) continue
752813 if (new RegExp(`\\btypeof\\s+${span.name}\\b`).test(componentType)) continue
753814 result = rest
754815 }
755816 return result
756817}
757818
819/** Check for an identifier outside strings and comments. */
820function containsIdentifierReference(content: string, name: string): boolean {
821 let i = 0
822 while (i < content.length) {
823 const ch = content[i]
824 if (ch === '"' || ch === '\'') {
825 i = skipString(content, i)
826 continue
827 }
828 if (ch === '`') {
829 i = skipTemplate(content, i)
830 continue
831 }
832 if (ch === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) {
833 i = skipComment(content, i)
834 continue
835 }
836 if (/[A-Za-z_$]/.test(ch)) {
837 let end = i + 1
838 while (end < content.length && /[\w$]/.test(content[end])) end++
839 if (content.slice(i, end) === name) return true
840 i = end
841 continue
842 }
843 i++
844 }
845 return false
846}
847
758848interface LocalValueStatement {
759849 name: string
760850 start: number
@@ -769,12 +859,14 @@ interface LocalValueStatement {
769859 */
770860function findLocalValueStatements(content: string): LocalValueStatement[] {
771861 const spans: LocalValueStatement[] = []
862 const topLevelLineStarts = findTopLevelLineStarts(content)
772863 const pattern = /^[ \t]*(const|let|var|(?:async[ \t]+)?function)[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=\n]+)?=?/gm
773864 let match: RegExpExecArray | null
774865 // eslint-disable-next-line no-cond-assign
775866 while ((match = pattern.exec(content)) !== null) {
776867 // Never touch exported declarations.
777868 const lineStart = content.lastIndexOf('\n', match.index) + 1
869 if (!topLevelLineStarts.has(lineStart)) continue
778870 if (/\bexport\b/.test(content.slice(lineStart, match.index))) continue
779871
780872 const keyword = match[1]
@@ -839,11 +931,38 @@ function findLocalValueStatements(content: string): LocalValueStatement[] {
839931 return spans
840932}
841933
934/** Collect line starts whose code is outside every brace/paren/bracket pair. */
935function findTopLevelLineStarts(content: string): Set<number> {
936 const starts = new Set<number>([0])
937 let depth = 0
938 let i = 0
939 while (i < content.length) {
940 const ch = content[i]
941 if (ch === '"' || ch === '\'') {
942 i = skipString(content, i)
943 continue
944 }
945 if (ch === '`') {
946 i = skipTemplate(content, i)
947 continue
948 }
949 if (ch === '/' && (content[i + 1] === '/' || content[i + 1] === '*')) {
950 i = skipComment(content, i)
951 continue
952 }
953 if (ch === '{' || ch === '[' || ch === '(') depth++
954 else if (ch === '}' || ch === ']' || ch === ')') depth = Math.max(0, depth - 1)
955 if (ch === '\n' && depth === 0) starts.add(i + 1)
956 i++
957 }
958 return starts
959}
960
842961/**
843962 * Remove an inlined interface/alias declaration from script content, unless
844963 * the name is still referenced elsewhere or the declaration is exported.
845964 */
846function removeInlinedDeclaration(content: string, name: string, declText: string): string {
965export function removeInlinedDeclaration(content: string, name: string, declText: string): string {
847966 const index = content.indexOf(declText)
848967 if (index === -1) return content
849968 // Never remove exported declarations — they are part of the module API.
@@ -855,7 +974,7 @@ function removeInlinedDeclaration(content: string, name: string, declText: strin
855974 return rest
856975}
857976
858interface PropsResolution {
977export interface PropsResolution {
859978 type: string
860979 /** A local interface/alias that was inlined into the props type. */
861980 inlinedLocal?: { name: string, declText: string }
@@ -866,7 +985,7 @@ interface PropsResolution {
866985 * literal members become `;`, continuation newlines (after an opener, comma,
867986 * colon, or before a closer, comma, union/intersection bar) become spaces.
868987 */
869function collapseTypeText(text: string): string {
988export function collapseTypeText(text: string): string {
870989 let out = ''
871990 let i = 0
872991 const prevSignificant = (): string => {
@@ -962,7 +1081,7 @@ function endsWithLeadingComment(out: string): boolean {
9621081 return true
9631082}
9641083
965function resolvePropsType(scriptSetup: string | null, script: string | null): PropsResolution | null {
1084export function resolvePropsType(scriptSetup: string | null, script: string | null): PropsResolution | null {
9661085 const sources = [scriptSetup, script].filter((s): s is string => s !== null)
9671086 for (const source of sources) {
9681087 const call = findMacroCall(source, 'defineProps')
@@ -993,7 +1112,7 @@ function resolvePropsType(scriptSetup: string | null, script: string | null): Pr
9931112 return null
9941113}
9951114
996function resolveEmitsType(scriptSetup: string | null, script: string | null): string | null {
1115export function resolveEmitsType(scriptSetup: string | null, script: string | null): string | null {
9971116 const sources = [scriptSetup, script].filter((s): s is string => s !== null)
9981117 for (const source of sources) {
9991118 const call = findMacroCall(source, 'defineEmits')
@@ -1012,7 +1131,7 @@ function resolveEmitsType(scriptSetup: string | null, script: string | null): st
10121131 return null
10131132}
10141133
1015function resolveExposedType(scriptSetup: string | null, script: string | null): string | null {
1134export function resolveExposedType(scriptSetup: string | null, script: string | null): string | null {
10161135 const sources = [scriptSetup, script].filter((s): s is string => s !== null)
10171136 for (const source of sources) {
10181137 const call = findMacroCall(source, 'defineExpose')
@@ -1020,6 +1139,9 @@ function resolveExposedType(scriptSetup: string | null, script: string | null):
10201139 const entries = parseObjectEntries(call.arg)
10211140 if (entries.length === 0) return null
10221141 const props = entries.map((entry) => {
1142 if (entry.value === '' && /^[A-Za-z_$][\w$]*$/.test(entry.key)) {
1143 return `${typeKey(entry.key)}: typeof ${entry.key}`
1144 }
10231145 const isFunction = entry.isMethod || /=>|^\s*function\b/.test(entry.value)
10241146 return `${typeKey(entry.key)}: ${isFunction ? '(...args: any[]) => any' : 'unknown'}`
10251147 })
packages/dtsx/src/watcher.tsmodified+2-2
Changes to packages/dtsx/src/watcher.ts
@@ -19,7 +19,7 @@ export interface WatchConfig {
1919
2020 /**
2121 * File patterns to watch (glob-like)
22 * @default ['**\/*.ts', '**\/*.tsx']
22 * @default ['**\/*.ts', '**\/*.tsx', '**\/*.vue', '**\/*.stx']
2323 */
2424 include?: string[]
2525
@@ -126,7 +126,7 @@ export function createWatcher(
126126): Watcher {
127127 const {
128128 root,
129 include = ['**/*.ts', '**/*.tsx'],
129 include = ['**/*.ts', '**/*.tsx', '**/*.vue', '**/*.stx'],
130130 exclude = ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
131131 debounce = 100,
132132 initialBuild = true,
packages/dtsx/test/config.test.tsmodified+2-2
Changes to packages/dtsx/test/config.test.ts
@@ -20,8 +20,8 @@ async function createProject(name: string): Promise<string> {
2020}
2121
2222describe('getConfig', () => {
23 it('discovers every TypeScript module source format by default', () => {
24 expect(defaultConfig.entrypoints).toEqual(['**/*.{ts,tsx,mts,cts}'])
23 it('discovers supported module and component source formats by default', () => {
24 expect(defaultConfig.entrypoints).toEqual(['**/*.{ts,tsx,mts,cts,vue,stx}'])
2525 })
2626
2727 it('isolates cached configuration by project directory', async () => {
packages/dtsx/test/fixtures/stx/BladeAlert.stxadded+18-0
Changes to packages/dtsx/test/fixtures/stx/BladeAlert.stx
@@ -0,0 +1,18 @@
1{{-- This is the same typed-props convention used by STX's Blade examples. --}}
2@ts
3export interface AlertProps {
4 type: 'success' | 'info' | 'warning' | 'error'
5 message: string
6 dismissible?: boolean
7}
8
9const { type, message, dismissible = false } = props as AlertProps
10const getAlertClass = () => `alert-${type}`
11@endts
12
13<div class="alert ">
14 <p>{{ message }}</p>
15 @if(dismissible)
16 <button>Close</button>
17 @endif
18</div>
packages/dtsx/test/fixtures/stx/MixedBlocks.stxadded+16-0
Changes to packages/dtsx/test/fixtures/stx/MixedBlocks.stx
@@ -0,0 +1,16 @@
1@ts
2export type ServerStatus = 'ready' | 'busy'
3export const STATUS_ATTRIBUTE = 'data-status' as const
4@endts
5
6<script client lang="ts">
7interface Props {
8 status: ServerStatus
9 retries?: number
10}
11
12const props = defineProps<Props>()
13const retries = state(props.retries ?? 0)
14</script>
15
16<div data-status="">{{ retries }}</div>
packages/dtsx/test/fixtures/stx/OptionsBadge.stxadded+14-0
Changes to packages/dtsx/test/fixtures/stx/OptionsBadge.stx
@@ -0,0 +1,14 @@
1<script lang="ts">
2import type { BadgeMeta } from './types'
3
4export default {
5 props: {
6 label: { type: String, required: true },
7 count: Number,
8 active: Boolean,
9 meta: Object as PropType<BadgeMeta>,
10 },
11}
12</script>
13
14<span>{{ label }}</span>
packages/dtsx/test/fixtures/stx/TemplateOnly.stxadded+3-0
Changes to packages/dtsx/test/fixtures/stx/TemplateOnly.stx
@@ -0,0 +1,3 @@
1<article>
2 <slot></slot>
3</article>
packages/dtsx/test/fixtures/stx/TypedPanel.stxadded+36-0
Changes to packages/dtsx/test/fixtures/stx/TypedPanel.stx
@@ -0,0 +1,36 @@
1<script server lang="ts">
2import type { PanelTone } from './types'
3
4export const PANEL_VERSION = 2 as const
5
6interface PanelProps {
7 /** Visible heading. */
8 title: string
9 tone?: PanelTone
10 count?: number
11}
12
13interface PanelEmits {
14 (event: 'close'): void
15 (event: 'select', id: number): void
16}
17
18interface PanelSlots {
19 default: () => unknown
20 header?: (props: { title: string }) => unknown
21}
22
23const props = withDefaults(defineProps<PanelProps>(), {
24 tone: 'neutral',
25 count: 0,
26})
27const emit = defineEmits<PanelEmits>()
28const slots = defineSlots<PanelSlots>()
29const close = () => emit('close')
30defineExpose({ close })
31</script>
32
33<section data-tone="">
34 <slot name="header" :title="props.title"></slot>
35 <slot></slot>
36</section>
packages/dtsx/test/fixtures/stx/types.tsadded+6-0
Changes to packages/dtsx/test/fixtures/stx/types.ts
@@ -0,0 +1,6 @@
1export type PanelTone = 'neutral' | 'brand' | 'danger'
2
3export interface BadgeMeta {
4 id: string
5 tags: readonly string[]
6}
packages/dtsx/test/stx.test.tsadded+305-0
Changes to packages/dtsx/test/stx.test.ts
@@ -0,0 +1,305 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { generate } from '../src/generator'
6import { processSource } from '../src/process-source'
7import { isStxFile, parseStxScripts, transformStxToTs } from '../src/stx'
8
9const fixturesDir = join(import.meta.dir, 'fixtures', 'stx')
10const tempDirs: string[] = []
11
12async function createTempDir(): Promise<string> {
13 const tempDir = await mkdtemp(join(tmpdir(), 'dtsx-stx-'))
14 tempDirs.push(tempDir)
15 return tempDir
16}
17
18afterEach(async () => {
19 await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
20})
21
22const stxShim = `declare module '@stacksjs/stx' {
23 export type DefineComponent<Props = {}, RawBindings = {}, Data = unknown> = {
24 new (...args: unknown[]): { $props: Props }
25 props?: Props
26 setup?: (props: Props) => RawBindings | void
27 data?: Data
28 }
29}
30`
31
32async function expectParsesUnderTsc(dts: string, dependencies: Record<string, string> = {}): Promise<void> {
33 const tempDir = await createTempDir()
34 await writeFile(join(tempDir, 'out.d.ts'), dts)
35 await writeFile(join(tempDir, 'stx.d.ts'), stxShim)
36 await Promise.all(Object.entries(dependencies).map(([name, content]) => writeFile(join(tempDir, name), content)))
37 const tscBin = join(import.meta.dir, '..', '..', '..', 'node_modules', '.bin', 'tsc')
38 const proc = Bun.spawnSync([tscBin, '--noEmit', '--strict', 'out.d.ts', 'stx.d.ts', ...Object.keys(dependencies)], { cwd: tempDir })
39 const output = `${proc.stdout.toString()}${proc.stderr.toString()}`
40 expect(proc.exitCode === 0 ? '' : output).toBe('')
41}
42
43async function generateFixtures(entrypoints: string[], isolatedDeclarations: boolean): Promise<string> {
44 const tempDir = await createTempDir()
45 const srcDir = join(tempDir, 'src')
46 const outDir = join(tempDir, 'dist')
47 await cp(fixturesDir, srcDir, { recursive: true })
48 await generate({
49 cwd: tempDir,
50 root: './src',
51 outdir: './dist',
52 entrypoints,
53 isolatedDeclarations,
54 clean: true,
55 })
56 return outDir
57}
58
59describe('stx component parsing', () => {
60 it('detects only terminal .stx extensions', () => {
61 expect(isStxFile('/src/Panel.stx')).toBe(true)
62 expect(isStxFile('/src/Panel.ts')).toBe(false)
63 expect(isStxFile('/src/Panel.stx.ts')).toBe(false)
64 expect(isStxFile('/src/Panel.STX')).toBe(false)
65 })
66
67 it('collects server, client, universal and Blade TypeScript blocks in document order', () => {
68 const source = [
69 '@ts',
70 'export type First = string',
71 '@endts',
72 '<script server lang="ts">export const second = 2</script>',
73 '<script client type="text/typescript">export const third = 3</script>',
74 '<script>export const fourth = 4</script>',
75 ].join('\n')
76 const blocks = parseStxScripts(source)
77 expect(blocks.map(block => block.kind)).toEqual(['ts', 'script', 'script', 'script'])
78 expect(blocks.map(block => block.context)).toEqual(['universal', 'server', 'client', 'universal'])
79 expect(blocks.map(block => block.lang)).toEqual(['ts', 'ts', 'ts', 'js'])
80 expect(blocks.map(block => block.content.trim())).toEqual([
81 'export type First = string',
82 'export const second = 2',
83 'export const third = 3',
84 'export const fourth = 4',
85 ])
86 })
87
88 it('does not close a Blade block for @endts text inside its code', () => {
89 const source = `@ts\nconst marker = '@endts'\nexport interface Props { value: string }\nconst props = {} as Props\n@endts\n<div />`
90 const blocks = parseStxScripts(source)
91 expect(blocks).toHaveLength(1)
92 expect(blocks[0].content).toContain(`const marker = '@endts'`)
93 expect(blocks[0].content).toContain('interface Props')
94 })
95
96 it('keeps an unfinished Blade block usable while a file is being edited', () => {
97 const blocks = parseStxScripts('@ts\nexport interface Props { value: string }')
98 expect(blocks).toHaveLength(1)
99 expect(blocks[0].content).toContain('interface Props')
100 })
101
102 it('ignores directive-looking text in plain runtime script strings', () => {
103 const source = `<script>\nconst docs = \`export default { props: ['fake'] }\`\n</script>`
104 const dts = processSource(source, 'RuntimeDocs.stx')
105 expect(dts).toContain('DefineComponent<{}>')
106 expect(dts).not.toContain('fake?: unknown')
107 })
108})
109
110describe('stx component declaration transform', () => {
111 it('declares modern STX props, emits, slots, exposed values and named exports', async () => {
112 const source = await readFile(join(fixturesDir, 'TypedPanel.stx'), 'utf8')
113 const dts = processSource(source, 'TypedPanel.stx')
114 expect(dts).toContain(`import type { DefineComponent } from '@stacksjs/stx';`)
115 expect(dts).toContain(`import type { PanelTone } from './types';`)
116 expect(dts).toContain('title: string')
117 expect(dts).toContain('tone?: PanelTone')
118 expect(dts).toContain('count?: number')
119 expect(dts).toContain('readonly __stxEmits?')
120 expect(dts).toContain(`(event: 'select', id: number): void`)
121 expect(dts).toContain('readonly __stxSlots?: PanelSlots')
122 expect(dts).toContain('readonly __stxExposed?')
123 expect(dts).toContain('PANEL_VERSION')
124 expect(dts).not.toContain('defineProps')
125 expect(dts).not.toContain('withDefaults')
126 expect(dts).not.toContain('declare const props')
127 expect(dts).toContain('declare const close: () => unknown')
128 })
129
130 it('declares Blade-style props assertions without leaking render helpers', async () => {
131 const source = await readFile(join(fixturesDir, 'BladeAlert.stx'), 'utf8')
132 const dts = processSource(source, 'BladeAlert.stx')
133 expect(dts).toContain(`export declare interface AlertProps`)
134 expect(dts).toContain(`DefineComponent<AlertProps>`)
135 expect(dts).not.toContain('getAlertClass')
136 expect(dts).not.toContain('declare const type')
137 await expectParsesUnderTsc(dts)
138 })
139
140 it('maps STX options-style runtime props', async () => {
141 const source = await readFile(join(fixturesDir, 'OptionsBadge.stx'), 'utf8')
142 const dts = processSource(source, 'OptionsBadge.stx')
143 expect(dts).toContain('label: string')
144 expect(dts).toContain('count?: number')
145 expect(dts).toContain('active?: boolean')
146 expect(dts).toContain('meta?: BadgeMeta')
147 expect(dts).not.toContain('PropType')
148 })
149
150 it('merges Blade, client and server declarations in source order', async () => {
151 const source = await readFile(join(fixturesDir, 'MixedBlocks.stx'), 'utf8')
152 const dts = processSource(source, 'MixedBlocks.stx')
153 expect(dts).toContain(`export type ServerStatus = 'ready' | 'busy';`)
154 expect(dts).toContain(`STATUS_ATTRIBUTE`)
155 expect(dts).toContain('status: ServerStatus')
156 expect(dts).toContain('retries?: number')
157 expect(dts).not.toContain('declare const retries')
158 })
159
160 it('emits an empty STX component for template-only files', async () => {
161 const source = await readFile(join(fixturesDir, 'TemplateOnly.stx'), 'utf8')
162 const dts = processSource(source, 'TemplateOnly.stx')
163 expect(dts).toContain('DefineComponent<{}>')
164 expect(dts).toContain('export default __dtsx_component__;')
165 await expectParsesUnderTsc(dts)
166 })
167
168 it('supports inline object props and typed $props annotations', () => {
169 const inline = processSource(`@ts\nconst local = props as { id: string; nested?: { count: number } }\n@endts`, 'Inline.stx')
170 expect(inline).toContain('id: string')
171 expect(inline).toContain('nested?: { count: number }')
172
173 const annotation = processSource(`@ts\nimport type { ExternalProps } from './external'\ndeclare const $props: ExternalProps\n@endts`, 'Annotated.stx')
174 expect(annotation).toContain('DefineComponent<ExternalProps>')
175 expect(annotation).toContain(`from './external'`)
176 })
177
178 it('infers legacy Blade-style $props access with useful default-value types', async () => {
179 const source = `<script server>
180export const title = $props.title || 'Untitled'
181export const count = $props.count ?? 0
182export const enabled = $props.enabled !== false
183export const items = $props.items || []
184export const metadata = $props['metadata'] || {}
185export const opaque = $props.value
186</script>`
187 const dts = processSource(source, 'LegacyProps.stx')
188 expect(dts).toContain('title?: string')
189 expect(dts).toContain('count?: number')
190 expect(dts).toContain('enabled?: boolean')
191 expect(dts).toContain('items?: unknown[]')
192 expect(dts).toContain('metadata?: Record<string, unknown>')
193 expect(dts).toContain('value?: unknown')
194 await expectParsesUnderTsc(dts)
195 })
196
197 it('ignores $props examples in comments and strings while preserving Unicode offsets', () => {
198 const source = `<script server>
199const emoji = '🔥 $props.fake'
200// $props.commented
201export const label = $props.label || 'Label'
202</script>`
203 const dts = processSource(source, 'UnicodeProps.stx')
204 expect(dts).toContain('label?: string')
205 expect(dts).not.toContain('fake?:')
206 expect(dts).not.toContain('commented?:')
207 })
208
209 it('always declares components whose client scripts begin with runtime expressions', () => {
210 const iife = processSource(`<script client>\n(() => { const ready = true })()\n</script>`, 'Iife.stx')
211 const browserCall = processSource(`<script client>\nwindow.addEventListener('ready', () => {})\n</script>`, 'BrowserCall.stx')
212 expect(iife).toContain('export default __dtsx_component__;')
213 expect(browserCall).toContain('export default __dtsx_component__;')
214 })
215
216 it('handles comments and nested generic types without corrupting declarations', async () => {
217 const source = `<script server lang="ts">
218defineProps<{
219 /** Values grouped by key. */
220 groups: ReadonlyMap<string, Array<{ id: string; run: () => Promise<void> }>>
221 label?: string // accessible label
222}>()
223</script>`
224 const dts = processSource(source, 'Nested.stx')
225 expect(dts).toContain('ReadonlyMap<string, Array<{ id: string; run: () => Promise<void> }>>')
226 expect(dts).toContain('/* accessible label */')
227 expect(dts).not.toMatch(/\*\/;/)
228 await expectParsesUnderTsc(dts)
229 })
230
231 it('produces equivalent public contracts in semantic and isolated modes', async () => {
232 const source = await readFile(join(fixturesDir, 'TypedPanel.stx'), 'utf8')
233 const semantic = processSource(source, 'TypedPanel.stx', true, ['bun'], false)
234 const isolated = processSource(source, 'TypedPanel.stx', true, ['bun'], true)
235 for (const expected of ['title: string', 'tone?: PanelTone', '__stxEmits', '__stxSlots', 'PANEL_VERSION']) {
236 expect(semantic).toContain(expected)
237 expect(isolated).toContain(expected)
238 }
239 expect(isolated).toContain('export default __dtsx_component__;')
240 })
241
242 it('returns a plain default-export script unchanged', () => {
243 const virtual = transformStxToTs(`<script>\nconst component = class Card {}\nexport default component\n</script>`)
244 expect(virtual).toContain('export default component')
245 expect(virtual).not.toContain('__dtsx_component__')
246 })
247})
248
249describe('stx project generation', () => {
250 for (const isolatedDeclarations of [false, true]) {
251 it(`emits and typechecks STX entrypoints in ${isolatedDeclarations ? 'isolated' : 'semantic'} mode`, async () => {
252 const outDir = await generateFixtures(['**/*.{stx,ts}'], isolatedDeclarations)
253 const panel = await readFile(join(outDir, 'TypedPanel.d.ts'), 'utf8')
254 const blade = await readFile(join(outDir, 'BladeAlert.d.ts'), 'utf8')
255 const options = await readFile(join(outDir, 'OptionsBadge.d.ts'), 'utf8')
256 const mixed = await readFile(join(outDir, 'MixedBlocks.d.ts'), 'utf8')
257 const template = await readFile(join(outDir, 'TemplateOnly.d.ts'), 'utf8')
258 const types = await readFile(join(outDir, 'types.d.ts'), 'utf8')
259
260 expect(panel).toContain('title: string')
261 expect(blade).toContain('DefineComponent<AlertProps>')
262 expect(options).toContain('meta?: BadgeMeta')
263 expect(mixed).toContain('status: ServerStatus')
264 expect(template).toContain('DefineComponent<{}>')
265 expect(types).toContain('PanelTone')
266
267 await expectParsesUnderTsc(panel, {
268 'types.d.ts': types,
269 })
270 })
271 }
272
273 it('discovers .stx files through default entrypoints', async () => {
274 const tempDir = await createTempDir()
275 await cp(fixturesDir, join(tempDir, 'src'), { recursive: true })
276 await generate({ cwd: tempDir, root: './src', outdir: './dist', clean: true })
277 expect(await readFile(join(tempDir, 'dist', 'TypedPanel.d.ts'), 'utf8')).toContain('title: string')
278 })
279
280 it('auto-includes and rewrites explicitly imported .stx components', async () => {
281 const tempDir = await createTempDir()
282 const srcDir = join(tempDir, 'src')
283 const outDir = join(tempDir, 'dist')
284 await cp(fixturesDir, srcDir, { recursive: true })
285 await writeFile(join(srcDir, 'index.ts'), `import TypedPanel from './TypedPanel.stx'\nexport { TypedPanel }\n`)
286 await generate({ cwd: tempDir, root: './src', outdir: './dist', entrypoints: ['index.ts'], clean: true })
287
288 const index = await readFile(join(outDir, 'index.d.ts'), 'utf8')
289 expect(index).toContain(`from './TypedPanel'`)
290 expect(index).not.toContain('.stx')
291 expect(await readFile(join(outDir, 'TypedPanel.d.ts'), 'utf8')).toContain('title: string')
292 })
293
294 it('resolves extensionless imports to STX components', async () => {
295 const tempDir = await createTempDir()
296 const srcDir = join(tempDir, 'src')
297 const outDir = join(tempDir, 'dist')
298 await cp(fixturesDir, srcDir, { recursive: true })
299 await writeFile(join(srcDir, 'index.ts'), `export { default as TemplateOnly } from './TemplateOnly'\n`)
300 await generate({ cwd: tempDir, root: './src', outdir: './dist', entrypoints: ['index.ts'], clean: true })
301
302 expect(await readFile(join(outDir, 'index.d.ts'), 'utf8')).toContain(`from './TemplateOnly'`)
303 expect(await readFile(join(outDir, 'TemplateOnly.d.ts'), 'utf8')).toContain('DefineComponent<{}>')
304 })
305})