also looking at this
feat(stx): generate component declarations
#3105
19 files
+894
-37
| @@ -52,7 +52,7 @@ import { generate, processSource } from '@stacksjs/dtsx' | ||
| 52 | 52 | const options: DtsGenerationOptions = { |
| 53 | 53 | cwd: './', // default: process.cwd() |
| 54 | 54 | root: './src', // default: './src' |
| 55 | entrypoints: ['**/*.ts'], // default: ['**/*.ts'] | |
| 55 | entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'], // default | |
| 56 | 56 | outdir: './dist', // default: './dist' |
| 57 | 57 | clean: true, // default: false |
| 58 | 58 | verbose: true, // default: false |
| @@ -80,6 +80,31 @@ console.log(dtsContent) | ||
| 80 | 80 | // export declare function greet(name: string): string; |
| 81 | 81 | ``` |
| 82 | 82 | |
| 83 | ### STX Components | |
| 84 | ||
| 85 | `.stx` files are declaration entrypoints alongside TypeScript and Vue files. | |
| 86 | dtsx reads Vue-style `<script server|client>` blocks and Blade-style `@ts` | |
| 87 | blocks, then emits an `@stacksjs/stx` `DefineComponent` declaration. Props are | |
| 88 | derived from typed macros, runtime prop schemas, explicit `props as Props` | |
| 89 | assertions, and legacy `$props.name` access: | |
| 90 | ||
| 91 | ```stx | |
| 92 | <script server lang="ts"> | |
| 93 | interface CardProps { | |
| 94 | title: string | |
| 95 | count?: number | |
| 96 | } | |
| 97 | ||
| 98 | const props = withDefaults(defineProps<CardProps>(), { count: 0 }) | |
| 99 | </script> | |
| 100 | ||
| 101 | <article>{{ props.title }}</article> | |
| 102 | ``` | |
| 103 | ||
| 104 | This emits `Card.d.ts` with `{ title: string; count?: number }` as its `$props` | |
| 105 | contract. The same transform feeds the semantic inference path and the | |
| 106 | annotation-first `isolatedDeclarations` path. | |
| 107 | ||
| 83 | 108 | Library 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. |
| 84 | 109 | |
| 85 | 110 | ```ts |
| @@ -185,7 +210,7 @@ cat src/utils.ts | dtsx stdin > dist/utils.d.ts | ||
| 185 | 210 | |
| 186 | 211 | - `--cwd <path>`: Set the current working directory _(default: current directory)_ |
| 187 | 212 | - `--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}`)_ | |
| 189 | 214 | - `--outdir <path>`: Set the output directory for generated .d.ts files _(default: './dist')_ |
| 190 | 215 | - `--keep-comments`: Keep comments in generated .d.ts files _(default: true)_ |
| 191 | 216 | - `--clean`: Clean output directory before generation _(default: false)_ |
| @@ -25,7 +25,7 @@ import { generate } from '@stacksjs/dtsx' | ||
| 25 | 25 | const config: DtsGenerationConfig = { |
| 26 | 26 | cwd: './', // default: process.cwd() |
| 27 | 27 | root: './src', // default: './src' |
| 28 | entrypoints: ['**/*.ts'], // default: ['**/*.ts'] | |
| 28 | entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'], // default | |
| 29 | 29 | outdir: './dist', // default: './dist' |
| 30 | 30 | keepComments: true, // default: true |
| 31 | 31 | clean: true, // default: true |
| @@ -134,7 +134,7 @@ dtsx generate --output-structure flat | ||
| 134 | 134 | |--------|------|---------|-------------| |
| 135 | 135 | | `--cwd <path>` | `string` | current directory | Set the current working directory | |
| 136 | 136 | | `--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) | | |
| 138 | 138 | | `--outdir <path>` | `string` | `'./dist'` | Set the output directory for generated .d.ts files | |
| 139 | 139 | | `--keep-comments [value]` | `boolean` | `true` | Keep comments in generated .d.ts files | |
| 140 | 140 | | `--clean` | `boolean` | `true` | Clean output directory before generation | |
| @@ -64,7 +64,7 @@ import { generate } from '@stacksjs/dtsx' | ||
| 64 | 64 | const options: DtsGenerationOptions = { |
| 65 | 65 | cwd: './', // default: process.cwd() |
| 66 | 66 | root: './src', // default: './src' |
| 67 | entrypoints: ['**/*.ts'], // default: ['**/*.ts'] | |
| 67 | entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'], // default | |
| 68 | 68 | outdir: './dist', // default: './dist' |
| 69 | 69 | clean: true, // default: false |
| 70 | 70 | verbose: true, // default: false |
| @@ -74,6 +74,13 @@ const options: DtsGenerationOptions = { | ||
| 74 | 74 | await generate(options) |
| 75 | 75 | ``` |
| 76 | 76 | |
| 77 | ### STX Components | |
| 78 | ||
| 79 | `.stx` entrypoints support Vue-style script blocks, Blade-style `@ts` blocks, | |
| 80 | typed and runtime `defineProps`, `props as Props`, and legacy `$props.name` | |
| 81 | contracts. dtsx emits the default component as | |
| 82 | `DefineComponent<Props>` from `@stacksjs/stx` in semantic and isolated modes. | |
| 83 | ||
| 77 | 84 | ### Configuration File |
| 78 | 85 | |
| 79 | 86 | Library 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 | ||
| 269 | 276 | #### Generate Options |
| 270 | 277 | |
| 271 | 278 | - `--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}`)_ | |
| 273 | 280 | - `--outdir <path>`: Set the output directory for generated .d.ts files _(default: './dist')_ |
| 274 | 281 | - `--keep-comments`: Keep comments in generated .d.ts files _(default: true)_ |
| 275 | 282 | - `--clean`: Clean output directory before generation _(default: false)_ |
| @@ -9,7 +9,7 @@ import { logger } from './logger' | ||
| 9 | 9 | export const defaultConfig: DtsGenerationConfig = { |
| 10 | 10 | cwd: process.cwd(), |
| 11 | 11 | root: './src', |
| 12 | entrypoints: ['**/*.{ts,tsx,mts,cts}'], | |
| 12 | entrypoints: ['**/*.{ts,tsx,mts,cts,vue,stx}'], | |
| 13 | 13 | outdir: './dist', |
| 14 | 14 | keepComments: true, |
| 15 | 15 | clean: true, |
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Declaration } from '../types' |
| 8 | import { isStxFile, transformStxToTs } from '../stx' | |
| 8 | 9 | import { isVueFile, transformVueSfcToTs } from '../vue' |
| 9 | 10 | import { hashContent } from './hash' |
| 10 | 11 | import { scanIsolatedDeclarations, scanSemanticDeclarations } from './scanner' |
| @@ -23,10 +24,13 @@ export function extractDeclarations( | ||
| 23 | 24 | keepComments: boolean = true, |
| 24 | 25 | isolatedDeclarations: boolean = false, |
| 25 | 26 | ): Declaration[] { |
| 26 | // Vue SFCs are transformed into a virtual TS module before scanning. | |
| 27 | // Component files are transformed into virtual TS modules before scanning. | |
| 27 | 28 | if (isVueFile(filePath)) { |
| 28 | 29 | sourceCode = transformVueSfcToTs(sourceCode) |
| 29 | 30 | } |
| 31 | else if (isStxFile(filePath)) { | |
| 32 | sourceCode = transformStxToTs(sourceCode) | |
| 33 | } | |
| 30 | 34 | |
| 31 | 35 | const contentHash = hashContent(sourceCode) |
| 32 | 36 | const cacheKey = `${filePath}:${keepComments ? 1 : 0}:${isolatedDeclarations ? 1 : 0}` |
| @@ -792,7 +792,7 @@ function assertUniqueOutputPaths(files: string[], config: DtsGenerationConfig): | ||
| 792 | 792 | } |
| 793 | 793 | |
| 794 | 794 | function 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) | |
| 796 | 796 | } |
| 797 | 797 | |
| 798 | 798 | /** |
| @@ -801,7 +801,7 @@ function isProcessableSourceFile(filePath: string): boolean { | ||
| 801 | 801 | function getOutputPath(inputPath: string, config: DtsGenerationConfig): string { |
| 802 | 802 | const rootPath = resolve(config.cwd, config.root) |
| 803 | 803 | 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) => { | |
| 805 | 805 | if (ext === '.mts' || ext === '.mjs') return '.d.mts' |
| 806 | 806 | if (ext === '.cts' || ext === '.cjs') return '.d.cts' |
| 807 | 807 | return '.d.ts' |
| @@ -1125,7 +1125,7 @@ export async function watch(options?: Partial<DtsGenerationConfig>): Promise<voi | ||
| 1125 | 1125 | const rootPath = '${rootPath}'; |
| 1126 | 1126 | |
| 1127 | 1127 | 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)) { | |
| 1129 | 1129 | console.log(\`CHANGED:\${filename}\`); |
| 1130 | 1130 | } |
| 1131 | 1131 | }); |
| @@ -42,7 +42,7 @@ export interface ResolveResult { | ||
| 42 | 42 | * Extensions tried, in order, when resolving an extensionless specifier. |
| 43 | 43 | * `.d.ts` last so source files win over generated declarations. |
| 44 | 44 | */ |
| 45 | const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts'] as const | |
| 45 | const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.vue', '.stx', '.js', '.jsx', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts'] as const | |
| 46 | 46 | |
| 47 | 47 | const RUNTIME_SOURCE_EXTENSIONS: Readonly<Record<string, readonly string[]>> = { |
| 48 | 48 | '.js': ['.ts', '.tsx', '.d.ts'], |
| @@ -3,6 +3,7 @@ import { extractDeclarations } from './extractor/extract' | ||
| 3 | 3 | import { hashContent } from './extractor/hash' |
| 4 | 4 | import { scanIsolatedDeclarations, scanSemanticDeclarations } from './extractor/scanner' |
| 5 | 5 | import { processDeclarations } from './processor' |
| 6 | import { isStxFile, transformStxToTs } from './stx' | |
| 6 | 7 | import { isVueFile, transformVueSfcToTs } from './vue' |
| 7 | 8 | |
| 8 | 9 | // --------------------------------------------------------------------------- |
| @@ -102,10 +103,13 @@ export function processSourceDirect( | ||
| 102 | 103 | importOrder: string[] = ['bun'], |
| 103 | 104 | isolatedDeclarations: boolean = false, |
| 104 | 105 | ): string { |
| 105 | // Vue SFCs are transformed into a virtual TS module before scanning. | |
| 106 | // Component files are transformed into virtual TS modules before scanning. | |
| 106 | 107 | if (isVueFile(filename)) { |
| 107 | 108 | sourceCode = transformVueSfcToTs(sourceCode) |
| 108 | 109 | } |
| 110 | else if (isStxFile(filename)) { | |
| 111 | sourceCode = transformStxToTs(sourceCode) | |
| 112 | } | |
| 109 | 113 | const declarations = isolatedDeclarations |
| 110 | 114 | ? scanIsolatedDeclarations(sourceCode, filename, keepComments) |
| 111 | 115 | : scanSemanticDeclarations(sourceCode, filename, keepComments) |
| @@ -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 | ||
| 9 | import { | |
| 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 | ||
| 27 | export 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. */ | |
| 43 | export function isStxFile(filePath: string): boolean { | |
| 44 | return filePath.endsWith('.stx') | |
| 45 | } | |
| 46 | ||
| 47 | function 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 | */ | |
| 77 | function 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. */ | |
| 103 | export function parseStxScripts(source: string): StxScriptBlock[] { | |
| 104 | return [...parseScriptBlocks(source), ...parseTsBlocks(source)] | |
| 105 | .sort((left, right) => left.start - right.start) | |
| 106 | } | |
| 107 | ||
| 108 | function 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 | ||
| 116 | function 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 | ||
| 141 | function 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 | ||
| 146 | function 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 | ||
| 157 | function 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. */ | |
| 165 | function 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 | ||
| 185 | function 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 | ||
| 203 | function stripStxCompilerStatements(content: string): string { | |
| 204 | return stripMacroStatements(content) | |
| 205 | } | |
| 206 | ||
| 207 | function 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. */ | |
| 214 | function 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 | */ | |
| 250 | export 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 | } | |
| @@ -125,6 +125,33 @@ function skipComment(text: string, i: number): number { | ||
| 125 | 125 | return i + 1 |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | /** Return the end of a string/template/comment at `i`, or `i` for normal code. */ | |
| 129 | export 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. */ | |
| 138 | export 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 | ||
| 128 | 155 | /** |
| 129 | 156 | * Find the index of the closer matching the opener at `start` |
| 130 | 157 | * (`text[start]` must be the opener). Returns -1 when unbalanced. |
| @@ -303,7 +330,7 @@ function typeKey(key: string): string { | ||
| 303 | 330 | // Compiler-macro extraction (defineProps / defineEmits / defineExpose) |
| 304 | 331 | // --------------------------------------------------------------------------- |
| 305 | 332 | |
| 306 | interface MacroCall { | |
| 333 | export interface MacroCall { | |
| 307 | 334 | /** Generic type argument text (`defineProps<T>`), when present. */ |
| 308 | 335 | generic: string | null |
| 309 | 336 | /** First argument text, when the call has arguments. */ |
| @@ -311,7 +338,7 @@ interface MacroCall { | ||
| 311 | 338 | } |
| 312 | 339 | |
| 313 | 340 | /** Locate the first `name(...)` / `name<T>(...)` call in `text`. */ |
| 314 | function findMacroCall(text: string, name: string): MacroCall | null { | |
| 341 | export function findMacroCall(text: string, name: string): MacroCall | null { | |
| 315 | 342 | const pattern = new RegExp(`\\b${name}\\b`, 'g') |
| 316 | 343 | let match: RegExpExecArray | null |
| 317 | 344 | // eslint-disable-next-line no-cond-assign |
| @@ -599,7 +626,8 @@ function convertEmitsCallSignatures(typeText: string): string | null { | ||
| 599 | 626 | // --------------------------------------------------------------------------- |
| 600 | 627 | |
| 601 | 628 | /** |
| 602 | * Remove `defineProps` / `defineEmits` / `defineExpose` / `defineOptions` | |
| 629 | * Remove `defineProps` / `defineEmits` / `defineExpose` / `defineSlots` / | |
| 630 | * `defineOptions` | |
| 603 | 631 | * statements (including `withDefaults(defineProps(), ...)` wrappers) from |
| 604 | 632 | * `<script setup>` content. Their type information has already been captured |
| 605 | 633 | * in the synthesized component type; keeping the calls would only add noise |
| @@ -608,7 +636,7 @@ function convertEmitsCallSignatures(typeText: string): string | null { | ||
| 608 | 636 | */ |
| 609 | 637 | export function stripMacroStatements(content: string): string { |
| 610 | 638 | let result = content |
| 611 | for (const name of ['defineProps', 'defineEmits', 'defineExpose', 'defineOptions']) { | |
| 639 | for (const name of ['defineProps', 'defineEmits', 'defineExpose', 'defineSlots', 'defineOptions']) { | |
| 612 | 640 | result = stripMacro(result, name) |
| 613 | 641 | } |
| 614 | 642 | return result |
| @@ -668,7 +696,7 @@ function stripMacro(text: string, name: string): string { | ||
| 668 | 696 | // Options API default-export extraction |
| 669 | 697 | // --------------------------------------------------------------------------- |
| 670 | 698 | |
| 671 | interface OptionsExtraction { | |
| 699 | export interface OptionsExtraction { | |
| 672 | 700 | /** The options object literal text, when a component options object was found. */ |
| 673 | 701 | options: string | null |
| 674 | 702 | /** The script with the default export removed (kept only when options were found). */ |
| @@ -679,11 +707,11 @@ interface OptionsExtraction { | ||
| 679 | 707 | |
| 680 | 708 | /** Extract the options object from `export default {...}` / `export default defineComponent({...})`. */ |
| 681 | 709 | export 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 | |
| 687 | 715 | while (i < script.length && /\s/.test(script[i])) i++ |
| 688 | 716 | |
| 689 | 717 | let objectStart = -1 |
| @@ -716,14 +744,47 @@ export function extractDefaultExportOptions(script: string): OptionsExtraction { | ||
| 716 | 744 | |
| 717 | 745 | let end = statementEnd + 1 |
| 718 | 746 | 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() | |
| 720 | 748 | return { options: script.slice(objectStart, objectEnd + 1), script: cleaned, passthrough: false } |
| 721 | 749 | } |
| 722 | 750 | return { options: null, script, passthrough: false } |
| 723 | 751 | } |
| 724 | 752 | |
| 753 | /** Find a top-level `export default` outside strings, templates and comments. */ | |
| 754 | function 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 | ||
| 725 | 786 | /** Pull a named option (`props`, `emits`) out of an options object literal. */ |
| 726 | function getOptionValue(options: string, name: string): string | null { | |
| 787 | export function getOptionValue(options: string, name: string): string | null { | |
| 727 | 788 | const entry = parseObjectEntries(options).find(e => e.key === name) |
| 728 | 789 | return entry && entry.value !== '' ? entry.value : null |
| 729 | 790 | } |
| @@ -741,20 +802,49 @@ function getOptionValue(options: string, name: string): string | null { | ||
| 741 | 802 | * source or in the synthesized component type (e.g. via `typeof sizes`) are |
| 742 | 803 | * kept, as are exported declarations. |
| 743 | 804 | */ |
| 744 | function filterLocalValueStatements(content: string, componentType: string): string { | |
| 805 | export function filterLocalValueStatements(content: string, componentType: string): string { | |
| 745 | 806 | const spans = findLocalValueStatements(content) |
| 746 | 807 | // Remove from the end backwards so earlier spans stay valid. |
| 747 | 808 | let result = content |
| 748 | 809 | for (let i = spans.length - 1; i >= 0; i--) { |
| 749 | 810 | const span = spans[i] |
| 750 | 811 | 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 | |
| 752 | 813 | if (new RegExp(`\\btypeof\\s+${span.name}\\b`).test(componentType)) continue |
| 753 | 814 | result = rest |
| 754 | 815 | } |
| 755 | 816 | return result |
| 756 | 817 | } |
| 757 | 818 | |
| 819 | /** Check for an identifier outside strings and comments. */ | |
| 820 | function 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 | ||
| 758 | 848 | interface LocalValueStatement { |
| 759 | 849 | name: string |
| 760 | 850 | start: number |
| @@ -769,12 +859,14 @@ interface LocalValueStatement { | ||
| 769 | 859 | */ |
| 770 | 860 | function findLocalValueStatements(content: string): LocalValueStatement[] { |
| 771 | 861 | const spans: LocalValueStatement[] = [] |
| 862 | const topLevelLineStarts = findTopLevelLineStarts(content) | |
| 772 | 863 | const pattern = /^[ \t]*(const|let|var|(?:async[ \t]+)?function)[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?::[^=\n]+)?=?/gm |
| 773 | 864 | let match: RegExpExecArray | null |
| 774 | 865 | // eslint-disable-next-line no-cond-assign |
| 775 | 866 | while ((match = pattern.exec(content)) !== null) { |
| 776 | 867 | // Never touch exported declarations. |
| 777 | 868 | const lineStart = content.lastIndexOf('\n', match.index) + 1 |
| 869 | if (!topLevelLineStarts.has(lineStart)) continue | |
| 778 | 870 | if (/\bexport\b/.test(content.slice(lineStart, match.index))) continue |
| 779 | 871 | |
| 780 | 872 | const keyword = match[1] |
| @@ -839,11 +931,38 @@ function findLocalValueStatements(content: string): LocalValueStatement[] { | ||
| 839 | 931 | return spans |
| 840 | 932 | } |
| 841 | 933 | |
| 934 | /** Collect line starts whose code is outside every brace/paren/bracket pair. */ | |
| 935 | function 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 | ||
| 842 | 961 | /** |
| 843 | 962 | * Remove an inlined interface/alias declaration from script content, unless |
| 844 | 963 | * the name is still referenced elsewhere or the declaration is exported. |
| 845 | 964 | */ |
| 846 | function removeInlinedDeclaration(content: string, name: string, declText: string): string { | |
| 965 | export function removeInlinedDeclaration(content: string, name: string, declText: string): string { | |
| 847 | 966 | const index = content.indexOf(declText) |
| 848 | 967 | if (index === -1) return content |
| 849 | 968 | // Never remove exported declarations — they are part of the module API. |
| @@ -855,7 +974,7 @@ function removeInlinedDeclaration(content: string, name: string, declText: strin | ||
| 855 | 974 | return rest |
| 856 | 975 | } |
| 857 | 976 | |
| 858 | interface PropsResolution { | |
| 977 | export interface PropsResolution { | |
| 859 | 978 | type: string |
| 860 | 979 | /** A local interface/alias that was inlined into the props type. */ |
| 861 | 980 | inlinedLocal?: { name: string, declText: string } |
| @@ -866,7 +985,7 @@ interface PropsResolution { | ||
| 866 | 985 | * literal members become `;`, continuation newlines (after an opener, comma, |
| 867 | 986 | * colon, or before a closer, comma, union/intersection bar) become spaces. |
| 868 | 987 | */ |
| 869 | function collapseTypeText(text: string): string { | |
| 988 | export function collapseTypeText(text: string): string { | |
| 870 | 989 | let out = '' |
| 871 | 990 | let i = 0 |
| 872 | 991 | const prevSignificant = (): string => { |
| @@ -962,7 +1081,7 @@ function endsWithLeadingComment(out: string): boolean { | ||
| 962 | 1081 | return true |
| 963 | 1082 | } |
| 964 | 1083 | |
| 965 | function resolvePropsType(scriptSetup: string | null, script: string | null): PropsResolution | null { | |
| 1084 | export function resolvePropsType(scriptSetup: string | null, script: string | null): PropsResolution | null { | |
| 966 | 1085 | const sources = [scriptSetup, script].filter((s): s is string => s !== null) |
| 967 | 1086 | for (const source of sources) { |
| 968 | 1087 | const call = findMacroCall(source, 'defineProps') |
| @@ -993,7 +1112,7 @@ function resolvePropsType(scriptSetup: string | null, script: string | null): Pr | ||
| 993 | 1112 | return null |
| 994 | 1113 | } |
| 995 | 1114 | |
| 996 | function resolveEmitsType(scriptSetup: string | null, script: string | null): string | null { | |
| 1115 | export function resolveEmitsType(scriptSetup: string | null, script: string | null): string | null { | |
| 997 | 1116 | const sources = [scriptSetup, script].filter((s): s is string => s !== null) |
| 998 | 1117 | for (const source of sources) { |
| 999 | 1118 | const call = findMacroCall(source, 'defineEmits') |
| @@ -1012,7 +1131,7 @@ function resolveEmitsType(scriptSetup: string | null, script: string | null): st | ||
| 1012 | 1131 | return null |
| 1013 | 1132 | } |
| 1014 | 1133 | |
| 1015 | function resolveExposedType(scriptSetup: string | null, script: string | null): string | null { | |
| 1134 | export function resolveExposedType(scriptSetup: string | null, script: string | null): string | null { | |
| 1016 | 1135 | const sources = [scriptSetup, script].filter((s): s is string => s !== null) |
| 1017 | 1136 | for (const source of sources) { |
| 1018 | 1137 | const call = findMacroCall(source, 'defineExpose') |
| @@ -1020,6 +1139,9 @@ function resolveExposedType(scriptSetup: string | null, script: string | null): | ||
| 1020 | 1139 | const entries = parseObjectEntries(call.arg) |
| 1021 | 1140 | if (entries.length === 0) return null |
| 1022 | 1141 | 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 | } | |
| 1023 | 1145 | const isFunction = entry.isMethod || /=>|^\s*function\b/.test(entry.value) |
| 1024 | 1146 | return `${typeKey(entry.key)}: ${isFunction ? '(...args: any[]) => any' : 'unknown'}` |
| 1025 | 1147 | }) |
| @@ -19,7 +19,7 @@ export interface WatchConfig { | ||
| 19 | 19 | |
| 20 | 20 | /** |
| 21 | 21 | * File patterns to watch (glob-like) |
| 22 | * @default ['**\/*.ts', '**\/*.tsx'] | |
| 22 | * @default ['**\/*.ts', '**\/*.tsx', '**\/*.vue', '**\/*.stx'] | |
| 23 | 23 | */ |
| 24 | 24 | include?: string[] |
| 25 | 25 | |
| @@ -126,7 +126,7 @@ export function createWatcher( | ||
| 126 | 126 | ): Watcher { |
| 127 | 127 | const { |
| 128 | 128 | root, |
| 129 | include = ['**/*.ts', '**/*.tsx'], | |
| 129 | include = ['**/*.ts', '**/*.tsx', '**/*.vue', '**/*.stx'], | |
| 130 | 130 | exclude = ['**/node_modules/**', '**/*.d.ts', '**/dist/**'], |
| 131 | 131 | debounce = 100, |
| 132 | 132 | initialBuild = true, |
| @@ -20,8 +20,8 @@ async function createProject(name: string): Promise<string> { | ||
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | describe('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}']) | |
| 25 | 25 | }) |
| 26 | 26 | |
| 27 | 27 | it('isolates cached configuration by project directory', async () => { |
| @@ -0,0 +1,18 @@ | ||
| 1 | {{-- This is the same typed-props convention used by STX's Blade examples. --}} | |
| 2 | @ts | |
| 3 | export interface AlertProps { | |
| 4 | type: 'success' | 'info' | 'warning' | 'error' | |
| 5 | message: string | |
| 6 | dismissible?: boolean | |
| 7 | } | |
| 8 | ||
| 9 | const { type, message, dismissible = false } = props as AlertProps | |
| 10 | const 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> | |
| @@ -0,0 +1,16 @@ | ||
| 1 | @ts | |
| 2 | export type ServerStatus = 'ready' | 'busy' | |
| 3 | export const STATUS_ATTRIBUTE = 'data-status' as const | |
| 4 | @endts | |
| 5 | ||
| 6 | <script client lang="ts"> | |
| 7 | interface Props { | |
| 8 | status: ServerStatus | |
| 9 | retries?: number | |
| 10 | } | |
| 11 | ||
| 12 | const props = defineProps<Props>() | |
| 13 | const retries = state(props.retries ?? 0) | |
| 14 | </script> | |
| 15 | ||
| 16 | <div data-status="">{{ retries }}</div> | |
| @@ -0,0 +1,14 @@ | ||
| 1 | <script lang="ts"> | |
| 2 | import type { BadgeMeta } from './types' | |
| 3 | ||
| 4 | export 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> | |
| @@ -0,0 +1,36 @@ | ||
| 1 | <script server lang="ts"> | |
| 2 | import type { PanelTone } from './types' | |
| 3 | ||
| 4 | export const PANEL_VERSION = 2 as const | |
| 5 | ||
| 6 | interface PanelProps { | |
| 7 | /** Visible heading. */ | |
| 8 | title: string | |
| 9 | tone?: PanelTone | |
| 10 | count?: number | |
| 11 | } | |
| 12 | ||
| 13 | interface PanelEmits { | |
| 14 | (event: 'close'): void | |
| 15 | (event: 'select', id: number): void | |
| 16 | } | |
| 17 | ||
| 18 | interface PanelSlots { | |
| 19 | default: () => unknown | |
| 20 | header?: (props: { title: string }) => unknown | |
| 21 | } | |
| 22 | ||
| 23 | const props = withDefaults(defineProps<PanelProps>(), { | |
| 24 | tone: 'neutral', | |
| 25 | count: 0, | |
| 26 | }) | |
| 27 | const emit = defineEmits<PanelEmits>() | |
| 28 | const slots = defineSlots<PanelSlots>() | |
| 29 | const close = () => emit('close') | |
| 30 | defineExpose({ close }) | |
| 31 | </script> | |
| 32 | ||
| 33 | <section data-tone=""> | |
| 34 | <slot name="header" :title="props.title"></slot> | |
| 35 | <slot></slot> | |
| 36 | </section> | |
| @@ -0,0 +1,305 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { generate } from '../src/generator' | |
| 6 | import { processSource } from '../src/process-source' | |
| 7 | import { isStxFile, parseStxScripts, transformStxToTs } from '../src/stx' | |
| 8 | ||
| 9 | const fixturesDir = join(import.meta.dir, 'fixtures', 'stx') | |
| 10 | const tempDirs: string[] = [] | |
| 11 | ||
| 12 | async function createTempDir(): Promise<string> { | |
| 13 | const tempDir = await mkdtemp(join(tmpdir(), 'dtsx-stx-')) | |
| 14 | tempDirs.push(tempDir) | |
| 15 | return tempDir | |
| 16 | } | |
| 17 | ||
| 18 | afterEach(async () => { | |
| 19 | await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) | |
| 20 | }) | |
| 21 | ||
| 22 | const 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 | ||
| 32 | async 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 | ||
| 43 | async 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 | ||
| 59 | describe('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 | ||
| 110 | describe('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> | |
| 180 | export const title = $props.title || 'Untitled' | |
| 181 | export const count = $props.count ?? 0 | |
| 182 | export const enabled = $props.enabled !== false | |
| 183 | export const items = $props.items || [] | |
| 184 | export const metadata = $props['metadata'] || {} | |
| 185 | export 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> | |
| 199 | const emoji = '🔥 $props.fake' | |
| 200 | // $props.commented | |
| 201 | export 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"> | |
| 218 | defineProps<{ | |
| 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 | ||
| 249 | describe('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 | }) | |