also looking at this
feat(stx): generate component declarations
#3105
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.
| @@ -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 | }) |