Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,14 @@
"dependencies": {
"@dotenvx/dotenvx": "^2.6.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@unovue/detypes": "^0.8.5",
"@vue/compiler-sfc": "^3.5",
"c12": "^3.3.4",
"commander": "^15.0.0",
"consola": "^3.4.2",
"dedent": "^1.7.2",
"deepmerge": "^4.3.1",
"diff": "^9.0.0",
"esbuild": "0.28.1",
"fs-extra": "^11.3.6",
"fuzzysort": "^3.1.0",
"get-tsconfig": "^4.14.0",
Expand All @@ -101,6 +101,7 @@
"pathe": "catalog:",
"postcss": "^8.5.19",
"postcss-selector-parser": "^7.1.4",
"prettier": "^3.9.6",
"prompts": "^2.4.2",
"reka-ui": "catalog:",
"semver": "^7.8.5",
Expand All @@ -112,6 +113,7 @@
"undici": "^8.9.0",
"validate-npm-package-name": "catalog:",
"vue-metamorph": "3.3.4",
"vue-sfc-transformer": "^0.1.17",
"zod": "catalog:",
"zod-to-json-schema": "^3.25.2"
},
Expand Down
129 changes: 122 additions & 7 deletions packages/cli/src/utils/transformers/transform-sfc.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,133 @@
import type { SFCBlock } from '@vue/compiler-sfc'
import type { TransformOpts } from '.'
import { transform } from '@unovue/detypes'
import { parse } from '@vue/compiler-sfc'
import { transform } from 'esbuild'
import MagicString from 'magic-string'
import { format } from 'prettier'
import { preTranspileScriptSetup, transpileVueTemplate } from 'vue-sfc-transformer'

// @vue/compiler-sfc does not export compiler-core's ErrorCodes enum.
// Code 2 is X_DUPLICATE_ATTRIBUTE, which still yields a usable SFC descriptor.
const DUPLICATE_ATTRIBUTE_ERROR_CODE = 2

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

return await transformByDetype(opts.raw, opts.filename).then(res => res as string)
return opts.filename.endsWith('.vue')
? await transformVueSFC(opts.raw, opts.filename)
: await transformScript(opts.raw, opts.filename)
}

export async function transformVueSFC(content: string, filename: string) {
const { descriptor, errors } = parse(content, {
filename,
ignoreEmpty: true,
})

const fatalErrors = errors.filter(error =>
typeof error === 'string'
|| !('code' in error)
|| error.code !== DUPLICATE_ATTRIBUTE_ERROR_CODE,
)

if (fatalErrors.length) {
throw new Error(fatalErrors.map(error => typeof error === 'string' ? error : error.message).join('\n'))
}

const output = new MagicString(content)
const isTypeScriptSFC = [descriptor.script, descriptor.scriptSetup]
.some(block => block?.lang === 'ts' || block?.lang === 'tsx')

if (isTypeScriptSFC && descriptor.template?.ast) {
const template = await transpileVueTemplate(
descriptor.template.content,
descriptor.template.ast as Parameters<typeof transpileVueTemplate>[1],
descriptor.template.loc.start.offset,
async code => await stripTypeScript(code, 'ts'),
)
replaceBlockContent(output, content, descriptor.template, template)
}

if (
!descriptor.script?.src
&& (descriptor.script?.lang === 'ts' || descriptor.script?.lang === 'tsx')
) {
const script = await stripTypeScript(descriptor.script.content, descriptor.script.lang)
replaceBlockContent(output, content, descriptor.script, script, true)
}

if (descriptor.scriptSetup?.lang === 'ts' || descriptor.scriptSetup?.lang === 'tsx') {
const preTranspiled = await preTranspileScriptSetup(
descriptor as Parameters<typeof preTranspileScriptSetup>[0],
filename,
)
const scriptSetup = await stripTypeScript(preTranspiled.content, descriptor.scriptSetup.lang)
replaceBlockContent(output, content, descriptor.scriptSetup, scriptSetup, true)
}

return await format(output.toString(), {
parser: 'vue',
proseWrap: 'never',
})
}

async function transformScript(content: string, filename: string) {
const loader = getScriptLoader(filename)

if (!loader)
return content

const output = await stripTypeScript(content, loader)

return await format(output, {
parser: loader === 'ts' || loader === 'tsx' ? 'typescript' : 'babel',
proseWrap: 'never',
})
}

function getScriptLoader(filename: string) {
if (filename.endsWith('.tsx'))
return 'tsx' as const
if (filename.endsWith('.jsx'))
return 'jsx' as const
if (['.js', '.cjs', '.mjs'].some(extension => filename.endsWith(extension)))
return 'js' as const
if (['.ts', '.cts', '.mts'].some(extension => filename.endsWith(extension)))
return 'ts' as const
}

export async function transformByDetype(content: string, filename: string) {
return await transform(content, filename, {
removeTsComments: true,
prettierOptions: {
proseWrap: 'never',
async function stripTypeScript(content: string, loader: 'js' | 'jsx' | 'ts' | 'tsx') {
const result = await transform(content, {
loader,
target: 'esnext',
legalComments: 'inline',
tsconfigRaw: {
compilerOptions: {
verbatimModuleSyntax: true,
},
},
})

return result.code.trimEnd()
}

function replaceBlockContent(
output: MagicString,
source: string,
block: SFCBlock,
content: string,
removeTypeScriptLang = false,
) {
if (block.loc.start.offset !== block.loc.end.offset)
output.overwrite(block.loc.start.offset, block.loc.end.offset, content)

if (!removeTypeScriptLang)
return

const blockStart = source.lastIndexOf(`<${block.type}`, block.loc.start.offset)
const openTag = source.slice(blockStart, block.loc.start.offset)
const transformedOpenTag = openTag.replace(/\s+lang\s*=\s*(?:"tsx?"|'tsx?'|tsx?)(?=\s|\/?>)/, '')

output.overwrite(blockStart, block.loc.start.offset, transformedOpenTag)
Comment thread
Zoushen6 marked this conversation as resolved.
}
95 changes: 95 additions & 0 deletions packages/cli/test/utils/transform-sfc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolve } from 'pathe'
import { describe, expect, it } from 'vitest'
import { transform } from '../../src/utils/transformers'
import { transformVueSFC } from '../../src/utils/transformers/transform-sfc'

describe('transformSFC', () => {
it('basic', async () => {
Expand Down Expand Up @@ -87,6 +88,100 @@
expect(result).toMatchSnapshot()
})

it('resolves props declared in a normal script block', async () => {
const result = await transformVueSFC(`<script lang="ts">
export interface Props {
foo: string
}
</script>

<script lang="ts" setup>
const props = defineProps<Props>()
</script>
`, 'app.vue')

expect(result).toContain('foo: { type: String, required: true }')
expect(result).not.toContain('lang="ts"')
})

it('preserves external TypeScript script blocks', async () => {
const result = await transformVueSFC(
'<script src="./component.ts" lang="ts"></script>',
'app.vue',
)

expect(result).toContain('src="./component.ts"')
expect(result).toContain('lang="ts"')
})

it('removes TypeScript lang attributes with surrounding whitespace', async () => {
const result = await transformVueSFC(
'<script lang = "ts">const value: string = "test"</script>',
'app.vue',
)

expect(result).not.toContain('lang')
expect(result).toContain('const value = "test"')
})

it('preserves imports referenced only in the template', async () => {
const result = await transformVueSFC(`<script lang="ts" setup>
import { Check } from '@lucide/vue'
</script>

<template>
<Check />
</template>
`, 'app.vue')

expect(result).toContain('import { Check } from "@lucide/vue"')
expect(result).toContain('<Check />')
})

it('preserves legal comments while stripping TypeScript', async () => {
const result = await transformVueSFC(`<script lang="ts">
/*! @license MIT */
const value: number = 1
</script>
`, 'app.vue')

expect(result).toContain('/*! @license MIT */')
expect(result).toContain('const value = 1')
})

it('tolerates duplicate template attributes', async () => {
const result = await transformVueSFC(`<template>
<div :class="first" :class="second" />
</template>
`, 'app.vue')

expect(result).toContain(':class="first"')
expect(result).toContain(':class="second"')
})

it.each([
['empty', '<template></template>'],
['whitespace-only', '<template>\n \n</template>'],
])('handles %s templates in TypeScript SFCs', async (_, template) => {
const result = await transformVueSFC(`<script lang="ts">
const value: string = 'ok'
</script>
${template}
`, 'app.vue')

expect(result).not.toContain('lang="ts"')
expect(result).toContain('const value = "ok"')
})

it('handles empty TypeScript script blocks', async () => {
const result = await transformVueSFC(`<script lang="ts"></script>
<template><div /></template>
`, 'app.vue')

expect(result).toContain('<script lang="ts"></script>')
expect(result).toContain('<template><div /></template>')
})

it('defineProps with withDefaults', async () => {
const result = await transform({
filename: 'app.vue',
Expand All @@ -113,7 +208,7 @@
`,
config: {},
})
expect(result).toMatchSnapshot()

Check failure on line 211 in packages/cli/test/utils/transform-sfc.test.ts

View workflow job for this annotation

GitHub Actions / test

test/utils/transform-sfc.test.ts > transformSFC > defineProps with external props

Error: Snapshot `transformSFC > defineProps with external props 1` mismatched - Expected + Received @@ -1,6 +1,7 @@ "<script setup> + import {} from "./__fixtures__/props"; const props = defineProps({ foo: { type: String, required: false, default: "bar" }, a: { type: String, required: true }, b: { type: Number, required: true }, }); ❯ test/utils/transform-sfc.test.ts:211:20
})

it('defineProps with package props', async () => {
Expand All @@ -129,7 +224,7 @@
config: {},
})
// TODO: We need to improve this. https://github.com/unovue/shadcn-vue/issues/187
expect(result).toMatchSnapshot()

Check failure on line 227 in packages/cli/test/utils/transform-sfc.test.ts

View workflow job for this annotation

GitHub Actions / test

test/utils/transform-sfc.test.ts > transformSFC > defineProps with package props

Error: Snapshot `transformSFC > defineProps with package props 1` mismatched - Expected + Received @@ -1,6 +1,7 @@ "<script setup> + import {} from "reka-ui"; const props = defineProps({ foo: { type: String, required: false, default: "bar" }, for: { type: String, required: false }, asChild: { type: Boolean, required: false }, as: { type: null, required: false }, ❯ test/utils/transform-sfc.test.ts:227:20
})

it('returns raw content unchanged for .md files', async () => {
Expand Down
30 changes: 9 additions & 21 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading