Skip to content

Commit a0fedd4

Browse files
author
祖士洋
committed
fix(cli): use vue-sfc-transformer for SFC transpilation
Replace detypes with vue-sfc-transformer and esbuild. Resolve script setup macro types declared in normal script blocks. Preserve JS/TS file handling and format output with Prettier. Add regression coverage for cross-block props resolution.
1 parent 24999cd commit a0fedd4

4 files changed

Lines changed: 133 additions & 30 deletions

File tree

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,14 @@
8181
"dependencies": {
8282
"@dotenvx/dotenvx": "^2.6.0",
8383
"@modelcontextprotocol/sdk": "^1.29.0",
84-
"@unovue/detypes": "^0.8.5",
8584
"@vue/compiler-sfc": "^3.5",
8685
"c12": "^3.3.4",
8786
"commander": "^15.0.0",
8887
"consola": "^3.4.2",
8988
"dedent": "^1.7.2",
9089
"deepmerge": "^4.3.1",
9190
"diff": "^9.0.0",
91+
"esbuild": "0.28.1",
9292
"fs-extra": "^11.3.6",
9393
"fuzzysort": "^3.1.0",
9494
"get-tsconfig": "^4.14.0",
@@ -101,6 +101,7 @@
101101
"pathe": "catalog:",
102102
"postcss": "^8.5.19",
103103
"postcss-selector-parser": "^7.1.4",
104+
"prettier": "^3.9.6",
104105
"prompts": "^2.4.2",
105106
"reka-ui": "catalog:",
106107
"semver": "^7.8.5",
@@ -112,6 +113,7 @@
112113
"undici": "^8.9.0",
113114
"validate-npm-package-name": "catalog:",
114115
"vue-metamorph": "3.3.4",
116+
"vue-sfc-transformer": "^0.1.17",
115117
"zod": "catalog:",
116118
"zod-to-json-schema": "^3.25.2"
117119
},
Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,114 @@
1+
import type { SFCBlock } from '@vue/compiler-sfc'
12
import type { TransformOpts } from '.'
2-
import { transform } from '@unovue/detypes'
3+
import { parse } from '@vue/compiler-sfc'
4+
import { transform } from 'esbuild'
5+
import MagicString from 'magic-string'
6+
import { format } from 'prettier'
7+
import { preTranspileScriptSetup, transpileVueTemplate } from 'vue-sfc-transformer'
38

49
export async function transformSFC(opts: TransformOpts) {
510
if (opts.config?.typescript)
611
return opts.raw
712

8-
return await transformByDetype(opts.raw, opts.filename).then(res => res as string)
13+
return opts.filename.endsWith('.vue')
14+
? await transformVueSFC(opts.raw, opts.filename)
15+
: await transformScript(opts.raw, opts.filename)
916
}
1017

11-
export async function transformByDetype(content: string, filename: string) {
12-
return await transform(content, filename, {
13-
removeTsComments: true,
14-
prettierOptions: {
15-
proseWrap: 'never',
16-
},
18+
export async function transformVueSFC(content: string, filename: string) {
19+
const { descriptor, errors } = parse(content, {
20+
filename,
21+
ignoreEmpty: true,
1722
})
23+
24+
if (errors.length) {
25+
throw new Error(errors.map(error => typeof error === 'string' ? error : error.message).join('\n'))
26+
}
27+
28+
const output = new MagicString(content)
29+
const isTypeScriptSFC = [descriptor.script, descriptor.scriptSetup]
30+
.some(block => block?.lang === 'ts' || block?.lang === 'tsx')
31+
32+
if (isTypeScriptSFC && descriptor.template?.ast) {
33+
const template = await transpileVueTemplate(
34+
descriptor.template.content,
35+
descriptor.template.ast as Parameters<typeof transpileVueTemplate>[1],
36+
descriptor.template.loc.start.offset,
37+
async code => await stripTypeScript(code, 'ts'),
38+
)
39+
replaceBlockContent(output, content, descriptor.template, template)
40+
}
41+
42+
if (descriptor.script?.lang === 'ts' || descriptor.script?.lang === 'tsx') {
43+
const script = await stripTypeScript(descriptor.script.content, descriptor.script.lang)
44+
replaceBlockContent(output, content, descriptor.script, script, true)
45+
}
46+
47+
if (descriptor.scriptSetup?.lang === 'ts' || descriptor.scriptSetup?.lang === 'tsx') {
48+
const preTranspiled = await preTranspileScriptSetup(
49+
descriptor as Parameters<typeof preTranspileScriptSetup>[0],
50+
filename,
51+
)
52+
const scriptSetup = await stripTypeScript(preTranspiled.content, descriptor.scriptSetup.lang)
53+
replaceBlockContent(output, content, descriptor.scriptSetup, scriptSetup, true)
54+
}
55+
56+
return await format(output.toString(), {
57+
parser: 'vue',
58+
proseWrap: 'never',
59+
})
60+
}
61+
62+
async function transformScript(content: string, filename: string) {
63+
const loader = getScriptLoader(filename)
64+
65+
if (!loader)
66+
return content
67+
68+
const output = await stripTypeScript(content, loader)
69+
70+
return await format(output, {
71+
parser: loader === 'ts' || loader === 'tsx' ? 'typescript' : 'babel',
72+
proseWrap: 'never',
73+
})
74+
}
75+
76+
function getScriptLoader(filename: string) {
77+
if (filename.endsWith('.tsx'))
78+
return 'tsx' as const
79+
if (filename.endsWith('.jsx'))
80+
return 'jsx' as const
81+
if (['.js', '.cjs', '.mjs'].some(extension => filename.endsWith(extension)))
82+
return 'js' as const
83+
if (['.ts', '.cts', '.mts'].some(extension => filename.endsWith(extension)))
84+
return 'ts' as const
85+
}
86+
87+
async function stripTypeScript(content: string, loader: 'js' | 'jsx' | 'ts' | 'tsx') {
88+
const result = await transform(content, {
89+
loader,
90+
target: 'esnext',
91+
legalComments: 'none',
92+
})
93+
94+
return result.code.trimEnd()
95+
}
96+
97+
function replaceBlockContent(
98+
output: MagicString,
99+
source: string,
100+
block: SFCBlock,
101+
content: string,
102+
removeTypeScriptLang = false,
103+
) {
104+
output.overwrite(block.loc.start.offset, block.loc.end.offset, content)
105+
106+
if (!removeTypeScriptLang)
107+
return
108+
109+
const blockStart = source.lastIndexOf(`<${block.type}`, block.loc.start.offset)
110+
const openTag = source.slice(blockStart, block.loc.start.offset)
111+
const transformedOpenTag = openTag.replace(/\s+lang=(?:"tsx?"|'tsx?'|tsx?)(?=\s|>)/, '')
112+
113+
output.overwrite(blockStart, block.loc.start.offset, transformedOpenTag)
18114
}

packages/cli/test/utils/transform-sfc.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { resolve } from 'pathe'
22
import { describe, expect, it } from 'vitest'
33
import { transform } from '../../src/utils/transformers'
4+
import { transformVueSFC } from '../../src/utils/transformers/transform-sfc'
45

56
describe('transformSFC', () => {
67
it('basic', async () => {
@@ -87,6 +88,22 @@ describe('transformSFC', () => {
8788
expect(result).toMatchSnapshot()
8889
})
8990

91+
it('resolves props declared in a normal script block', async () => {
92+
const result = await transformVueSFC(`<script lang="ts">
93+
export interface Props {
94+
foo: string
95+
}
96+
</script>
97+
98+
<script lang="ts" setup>
99+
const props = defineProps<Props>()
100+
</script>
101+
`, 'app.vue')
102+
103+
expect(result).toContain('foo: { type: String, required: true }')
104+
expect(result).not.toContain('lang="ts"')
105+
})
106+
90107
it('defineProps with withDefaults', async () => {
91108
const result = await transform({
92109
filename: 'app.vue',

pnpm-lock.yaml

Lines changed: 9 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)