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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

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 })