')
56 | })
57 |
58 | it('should handle type detection from file extension', async () => {
59 | const tsxCode = `
60 | export function App() {
61 | return
Hello
;
62 | }
63 | `
64 |
65 | const result = await transformCode(tsxCode, {
66 | filepath: 'src/App.tsx',
67 | debug: true
68 | })
69 |
70 | // TSX files should be processed by transformJsx
71 | expect(result).toContain('className')
72 | // Even if no transformation occurs, the file should at least be processed through the correct path
73 | expect(typeof result).toBe('string')
74 | })
75 |
76 | it('should validate input types', async () => {
77 | // Test with non-string input
78 | const result = await transformCode(null as any, {
79 | filepath: 'test.js',
80 | debug: true
81 | })
82 |
83 | expect(result).toBe('')
84 | })
85 | })
86 |
--------------------------------------------------------------------------------
/src/transformJsx.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import path from 'node:path'
3 | import { parse as babelParse, traverse as babelTraverse } from '@babel/core'
4 | import vueJsxPlugin from '@vue/babel-plugin-jsx'
5 | import { transformVue } from './transformVue'
6 |
7 | interface Options {
8 | filepath?: string
9 | isRem?: boolean
10 | globalCss?: any
11 | debug?: boolean
12 | resolveAlias?: any
13 | }
14 |
15 | export async function transformJsx(code: string, options?: Options) {
16 | const {
17 | filepath,
18 | isRem,
19 | globalCss,
20 | debug = false,
21 | resolveAlias,
22 | } = options || {}
23 | try {
24 | const ast = babelParse(code, {
25 | babelrc: false,
26 | comments: true,
27 | plugins: [[vueJsxPlugin]],
28 | })
29 |
30 | let container: any = null
31 | let css = ''
32 | let cssPath = ''
33 | babelTraverse(ast as any, {
34 | enter({ node }: any) {
35 | if (node.type === 'JSXElement') {
36 | if (container)
37 | return
38 | container = node
39 | }
40 | if (node.type === 'ImportDeclaration') {
41 | const value = node.source.value
42 | if (value.endsWith('.css')) {
43 | css += fs.readFileSync(
44 | (cssPath = path.resolve(filepath!, '../', value)),
45 | 'utf-8',
46 | )
47 | }
48 | }
49 | },
50 | })
51 | const jsxCode = code.slice(container.start, container.end)
52 | const isJsx = jsxCode.includes('className')
53 |
54 | const wrapperVue = `
${jsxCode}
55 | `
58 | const vueTransfer = await transformVue(wrapperVue, {
59 | isJsx,
60 | isRem,
61 | globalCss,
62 | filepath,
63 | debug,
64 | resolveAlias,
65 | })
66 | // vueTransfer = vueTransfer.replace(/class/g, 'className')
67 | if (cssPath) {
68 | const cssTransfer = vueTransfer.match(/
99 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { CssType } from './type'
2 | import { createGenerator } from '@unocss/core'
3 | import presetUno from '@unocss/preset-uno'
4 |
5 | export const TRANSFER_FLAG = '.__unocss_transfer__'
6 |
7 | export function transformUnocssBack(code: string[]) {
8 | const result: string[] = []
9 | return new Promise((resolve) => {
10 | createGenerator(
11 | {},
12 | {
13 | presets: [presetUno()],
14 | },
15 | )
16 | .generate(code || '')
17 | .then((res: any) => {
18 | const css = res.getLayers()
19 | code.forEach((item) => {
20 | try {
21 | const reg = new RegExp(
22 | `${item.replace(/([!()[\]*])/g, '\\\\$1')}{(.*)}`,
23 | )
24 | const match = css.match(reg)
25 | if (!match)
26 | return
27 | const matcher = match[1]
28 |
29 | matcher
30 | .split(';')
31 | .filter(Boolean)
32 | .forEach((item: string) => {
33 | const [key, v] = item.split(':')
34 | result.push(key.trim())
35 | })
36 | }
37 | catch (error) {}
38 | })
39 |
40 | resolve(result)
41 | })
42 | })
43 | }
44 |
45 | export function diffTemplateStyle(before: string, after: string) {
46 | const s1 = before.match(/
43 | "
44 | `;
45 |
46 | exports[`test-vue-2 > test-vue-2.vue 1`] = `
47 | "
50 |
51 | Track & field champions:
52 |
53 | - Adhemar da Silva
54 | - Wang Junxia
55 | - Wilma Rudolph
56 | - Babe Didrikson-Zaharias
57 | - Betty Cuthbert
58 | - Fanny Blankers-Koen
59 | - Florence Griffith-Joyner
60 | - Irena Szewinska
61 | - Jackie Joyner-Kersee
62 | - Shirley Strickland
63 | - Carl Lewis
64 | - Emil Zatopek
65 | - Haile Gebrselassie
66 | - Jesse Owens
67 | - Jim Thorpe
68 | - Paavo Nurmi
69 | - Sergei Bubka
70 | - Usain Bolt
71 |
72 |
73 |
74 | "
75 | `;
76 |
--------------------------------------------------------------------------------
/playground/src/utils.ts:
--------------------------------------------------------------------------------
1 | export const cssSuggestions = [
2 | // 文本属性
3 | 'text-align',
4 | 'text-align-last',
5 | 'text-transform',
6 | 'text-indent',
7 | 'text-decoration',
8 | 'text-shadow',
9 | 'vertical-align',
10 | 'white-space',
11 | 'word-spacing',
12 | 'word-break',
13 | 'line-height',
14 | 'letter-spacing',
15 |
16 | // 字体属性
17 | 'font',
18 | 'font-family',
19 | 'font-size',
20 | 'font-style',
21 | 'font-variant',
22 | 'font-weight',
23 | 'font-stretch',
24 |
25 | // 颜色和背景
26 | 'color',
27 | 'opacity',
28 | 'background',
29 | 'background-color',
30 | 'background-image',
31 | 'background-repeat',
32 | 'background-position',
33 | 'background-size',
34 | 'background-clip',
35 | 'background-origin',
36 | 'background-attachment',
37 | 'background-blend-mode',
38 |
39 | // 边框和轮廓
40 | 'border',
41 | 'border-width',
42 | 'border-style',
43 | 'border-color',
44 | 'border-radius',
45 | 'border-top',
46 | 'border-right',
47 | 'border-bottom',
48 | 'border-left',
49 | 'outline',
50 | 'outline-width',
51 | 'outline-style',
52 | 'outline-color',
53 | 'outline-offset',
54 |
55 | // 尺寸和盒模型
56 | 'width',
57 | 'min-width',
58 | 'max-width',
59 | 'height',
60 | 'min-height',
61 | 'max-height',
62 | 'box-sizing',
63 | 'margin',
64 | 'margin-top',
65 | 'margin-right',
66 | 'margin-bottom',
67 | 'margin-left',
68 | 'padding',
69 | 'padding-top',
70 | 'padding-right',
71 | 'padding-bottom',
72 | 'padding-left',
73 |
74 | // 显示和定位
75 | 'display',
76 | 'visibility',
77 | 'position',
78 | 'top',
79 | 'right',
80 | 'bottom',
81 | 'left',
82 | 'z-index',
83 | 'float',
84 | 'clear',
85 |
86 | // 柔性盒模型
87 | 'flex',
88 | 'flex-grow',
89 | 'flex-shrink',
90 | 'flex-basis',
91 | 'flex-flow',
92 | 'flex-direction',
93 | 'flex-wrap',
94 | 'justify-content',
95 | 'align-items',
96 | 'align-content',
97 | 'order',
98 | 'align-self',
99 |
100 | // 网格布局
101 | 'grid',
102 | 'grid-template-columns',
103 | 'grid-template-rows',
104 | 'grid-template-areas',
105 | 'grid-auto-columns',
106 | 'grid-auto-rows',
107 | 'grid-auto-flow',
108 | 'grid-column-gap',
109 | 'grid-row-gap',
110 | 'grid-gap',
111 | 'grid-column-start',
112 | 'grid-column-end',
113 | 'grid-row-start',
114 | 'grid-row-end',
115 | 'grid-area',
116 |
117 | // 过渡和动画
118 | 'transition',
119 | 'transition-property',
120 | 'transition-duration',
121 | 'transition-timing-function',
122 | 'transition-delay',
123 | 'animation',
124 | 'animation-name',
125 | 'animation-duration',
126 | 'animation-timing-function',
127 | 'animation-delay',
128 | 'animation-iteration-count',
129 | 'animation-direction',
130 | 'animation-fill-mode',
131 | 'animation-play-state',
132 |
133 | // 变换
134 | 'transform',
135 | 'transform-origin',
136 | 'transform-style',
137 | 'perspective',
138 | 'perspective-origin',
139 | 'backface-visibility',
140 |
141 | // 滤镜
142 | 'filter',
143 |
144 | // 其他
145 | 'cursor',
146 | 'list-style',
147 | 'user-select',
148 | 'resize',
149 | 'overflow',
150 | 'overflow-x',
151 | 'overflow-y',
152 | 'box-shadow',
153 | 'isolation',
154 | 'mix-blend-mode',
155 | 'content',
156 | 'counter-reset',
157 | 'counter-increment',
158 | ]
159 |
--------------------------------------------------------------------------------
/src/transformInlineStyle.ts:
--------------------------------------------------------------------------------
1 | import { toUnocssClass, transformStyleToUnocss } from 'transform-to-unocss-core'
2 |
3 | const styleReg = /<([\w\-]+)[^/>]*[^:]style="([^"]+)"[^>]*>/g
4 | const removeStyleReg = / style=("{1})(.*?)\1/
5 | const templateReg = /^
(.*)<\/template>$/ms
6 | const commentReg = //gs
7 | export function transformInlineStyle(
8 | code: string,
9 | isJsx?: boolean,
10 | isRem?: boolean,
11 | debug = false,
12 | ): string {
13 | // code中提取template
14 | const match = code.match(templateReg)
15 | if (!match)
16 | return code
17 | let templateMatch = match[1]
18 | const commentMap: Record = {}
19 | let count = 0
20 | const commentPrefix = '__commentMap__'
21 | templateMatch = templateMatch.replace(commentReg, (comment: string) => {
22 | count++
23 | commentMap[count] = comment
24 | return `${commentPrefix}${count}`
25 | })
26 |
27 | templateMatch.replace(styleReg, (target, tag, inlineStyle) => {
28 | const [after, noMap] = isJsx
29 | ? toUnocssClass(inlineStyle, isRem)
30 | : transformStyleToUnocss(inlineStyle, isRem, debug)
31 | // transform inline-style
32 |
33 | if (debug) {
34 | console.log(
35 | '[DEBUG] transformInlineStyle processing:',
36 | JSON.stringify(
37 | {
38 | tag,
39 | inlineStyle,
40 | after,
41 | noMapLength: noMap?.length || 0,
42 | },
43 | null,
44 | 2,
45 | ),
46 | )
47 | }
48 |
49 | if (isJsx) {
50 | // (["]{1})(.*?)\1
51 | const newReg = new RegExp(`<${tag}.*\\sclass=(["']{1})(.*?)\\1`, 's')
52 | const matcher = target.match(newReg)
53 |
54 | if (matcher) {
55 | return (templateMatch = templateMatch.replace(
56 | target,
57 | target
58 | .replace(removeStyleReg, '')
59 | .replace(
60 | `class="${matcher[2]}"`,
61 | noMap.length
62 | ? `class="${matcher[2]} ${after}" style="${noMap.map(item => item && item.trim()).join(';')}"`
63 | : `class="${matcher[2]} ${after}"`,
64 | ),
65 | ))
66 | }
67 |
68 | return (templateMatch = templateMatch.replace(
69 | target,
70 | target
71 | .replace(removeStyleReg, '')
72 | .replace(
73 | `<${tag}`,
74 | noMap.length
75 | ? `<${tag} class="${after}" style="${noMap.map(item => item && item.trim()).join(';')}`
76 | : `<${tag} class="${after}"`,
77 | ),
78 | ))
79 | }
80 |
81 | return (templateMatch = templateMatch.replace(
82 | target,
83 | target
84 | .replace(removeStyleReg, '')
85 | .replace(
86 | `<${tag}`,
87 | noMap.length
88 | ? `<${tag} ${after} style="${noMap.map(item => item && item.trim()).join(';')}"`
89 | : `<${tag} ${after}`,
90 | ),
91 | ))
92 | })
93 |
94 | // 还原注释
95 | Object.keys(commentMap).forEach((key) => {
96 | const commentKey = `${commentPrefix}${key}`
97 | const value = commentMap[key]
98 | templateMatch = templateMatch.replace(commentKey, value)
99 | })
100 |
101 | return code.replace(templateReg, `${templateMatch}`)
102 | }
103 |
--------------------------------------------------------------------------------
/src/transformHtml.ts:
--------------------------------------------------------------------------------
1 | import fsp from 'node:fs/promises'
2 | import path from 'node:path'
3 | import { prettierCode } from './prettierCode'
4 | import { transformVue } from './transformVue'
5 | import { diffTemplateStyle } from './utils'
6 | import { wrapperVueTemplate } from './wrapperVueTemplate'
7 |
8 | const linkCssReg = //g
9 | const styleReg = /\s*'))
100 | code = code.replace(styleReg, '')
101 | }
102 | if (css.length) {
103 | for (const c of css) {
104 | const { url, content } = c
105 | const vue = wrapperVueTemplate(template, content)
106 |
107 | const transferCode = await transformVue(vue, {
108 | isJsx: true,
109 | isRem,
110 | globalCss,
111 | debug,
112 | resolveAlias,
113 | })
114 |
115 | if (diffTemplateStyle(template, transferCode)) {
116 | // 新增的css全部被转换了,这个link可以被移除了
117 | code = code.replace(url, '')
118 | }
119 | else {
120 | // todo:比对已经转换的属性,移除无用的属性
121 | }
122 | template = transferCode
123 | }
124 | }
125 |
126 | return code.replace(originBody, getBody(template))
127 | }
128 |
--------------------------------------------------------------------------------
/test/demo/demo3.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
152 |
--------------------------------------------------------------------------------
/src/transformCode.ts:
--------------------------------------------------------------------------------
1 | import type { SuffixType } from './type'
2 | import { transformAstro } from './transformAstro'
3 | import { transformHtml } from './transformHtml'
4 | import { transformJsx } from './transformJsx'
5 | import { transformSvelte } from './transformSvelte'
6 | import { transformVue } from './transformVue'
7 |
8 | interface Options {
9 | isRem?: boolean
10 | filepath?: string
11 | type?: SuffixType
12 | isJsx?: boolean
13 | globalCss?: any
14 | debug?: boolean
15 | resolveAlias?: any
16 | }
17 |
18 | export async function transformCode(code: string, options: Options) {
19 | const {
20 | filepath,
21 | isRem,
22 | type,
23 | isJsx = true,
24 | globalCss,
25 | debug,
26 | resolveAlias,
27 | } = options || {}
28 |
29 | // 添加输入验证
30 | if (typeof code !== 'string') {
31 | if (debug) {
32 | console.warn(
33 | `[transform-to-unocss] transformCode received non-string code: ${typeof code}, filepath: ${filepath}`,
34 | )
35 | }
36 | return String(code || '')
37 | }
38 |
39 | if (debug) {
40 | console.log(
41 | '[DEBUG] transformCode started:',
42 | JSON.stringify({
43 | filepath,
44 | type,
45 | isJsx,
46 | isRem,
47 | codeLength: code.length,
48 | }),
49 | )
50 | }
51 |
52 | // 如果没有指定类型,尝试从文件路径推断
53 | let detectedType = type
54 | if (!detectedType && filepath) {
55 | if (filepath.endsWith('.tsx') || filepath.endsWith('.jsx')) {
56 | detectedType = 'tsx'
57 | }
58 | else if (filepath.endsWith('.html')) {
59 | detectedType = 'html'
60 | }
61 | else if (filepath.endsWith('.svelte')) {
62 | detectedType = 'svelte'
63 | }
64 | else if (filepath.endsWith('.astro')) {
65 | detectedType = 'astro'
66 | }
67 | else if (filepath.endsWith('.vue')) {
68 | detectedType = 'vue'
69 | }
70 | }
71 |
72 | if (debug) {
73 | console.log(
74 | `[DEBUG] transformCode detected type: ${detectedType}, original type: ${type}, filepath: ${filepath}`,
75 | )
76 | }
77 |
78 | // 如果仍然没有类型且不是Vue文件,直接返回
79 | if (
80 | !detectedType
81 | && filepath
82 | && !filepath.endsWith('.vue')
83 | && !code.includes('')
84 | ) {
85 | if (debug) {
86 | console.warn(
87 | `[transform-to-unocss] transformCode: Unknown file type for ${filepath}, skipping transformation`,
88 | )
89 | }
90 | return code
91 | }
92 |
93 | // 删除代码中的注释部分
94 | // code = code.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
95 | if (detectedType === 'tsx') {
96 | if (debug) {
97 | console.log(`[DEBUG] transformCode: Processing as TSX file`)
98 | }
99 | return transformJsx(code, {
100 | filepath,
101 | isRem,
102 | globalCss,
103 | debug,
104 | resolveAlias,
105 | })
106 | }
107 | if (detectedType === 'html')
108 | return transformHtml(code, { filepath, globalCss, debug, resolveAlias })
109 | if (detectedType === 'svelte') {
110 | return transformSvelte(code, {
111 | filepath,
112 | isRem,
113 | globalCss,
114 | debug,
115 | resolveAlias,
116 | })
117 | }
118 | if (detectedType === 'astro') {
119 | return transformAstro(code, {
120 | filepath,
121 | isRem,
122 | globalCss,
123 | debug,
124 | resolveAlias,
125 | })
126 | }
127 |
128 | // 只有确认是Vue文件或包含Vue语法时才调用transformVue
129 | if (
130 | detectedType === 'vue'
131 | || code.includes('')
132 | || code.includes('
93 |
115 |
--------------------------------------------------------------------------------
/test/__snapshots__/complex5.test.ts.snap:
--------------------------------------------------------------------------------
1 |
2 |
9 |
![avatar]()
17 |
![]()
26 |

32 |
36 | {{ unreadCnt > 99 ? '99+' : unreadCnt }}
37 |
38 |
39 |
40 |
127 |
152 |
--------------------------------------------------------------------------------
/src/stylusCompiler.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import process from 'node:process'
3 |
4 | export async function stylusCompiler(
5 | css: string,
6 | filepath: string,
7 | globalCss?: string,
8 | debug?: boolean,
9 | resolveAlias?: any,
10 | ) {
11 | if (typeof window !== 'undefined')
12 | throw new Error('Stylus is not supported in this browser')
13 |
14 | if (debug) {
15 | console.log(
16 | `[transform-to-tailwindcss] Compiling Stylus file: ${filepath || 'unknown file'}`,
17 | )
18 | }
19 |
20 | let result = globalCss ? `${globalCss}${css}` : css
21 |
22 | // Pre-replace alias imports like @import '@/foo' using resolveAlias map if provided
23 | if (resolveAlias) {
24 | try {
25 | const importRegex = /@import\s+['"](~?@\/[\w\-./]+)['"]/g
26 | result = result.replace(importRegex, (m, imp) => {
27 | const rel = imp.replace(/^~?@\//, '')
28 | // try array/object alias
29 | if (Array.isArray(resolveAlias)) {
30 | for (const a of resolveAlias) {
31 | if (typeof a.find === 'string' && imp.startsWith(a.find)) {
32 | const resolved = path.isAbsolute(a.replacement)
33 | ? path.join(a.replacement, imp.slice(a.find.length))
34 | : path.join(
35 | process.cwd(),
36 | a.replacement,
37 | imp.slice(a.find.length),
38 | )
39 | return `@import '${resolved}'`
40 | }
41 | }
42 | }
43 | else if (typeof resolveAlias === 'object') {
44 | for (const k of Object.keys(resolveAlias)) {
45 | if (imp.startsWith(k)) {
46 | const val = resolveAlias[k]
47 | const resolved = path.isAbsolute(val)
48 | ? path.join(val, imp.slice(k.length))
49 | : path.join(process.cwd(), val, imp.slice(k.length))
50 | return `@import '${resolved}'`
51 | }
52 | }
53 | }
54 | // fallback to src
55 | return `@import '${path.resolve(process.cwd(), 'src', rel)}'`
56 | })
57 | }
58 | catch (e) {
59 | if (debug)
60 | console.warn('[transform-to-unocss] stylus alias replace failed', e)
61 | }
62 | }
63 |
64 | try {
65 | // 使用用户项目中的 stylus 版本(通过 peerDependencies)
66 | const stylus = await import('stylus')
67 |
68 | // collect include paths from resolveAlias
69 | const includePaths: string[] = []
70 | try {
71 | if (resolveAlias) {
72 | if (Array.isArray(resolveAlias)) {
73 | for (const a of resolveAlias) {
74 | if (a && a.replacement) {
75 | includePaths.push(
76 | path.isAbsolute(a.replacement)
77 | ? a.replacement
78 | : path.resolve(process.cwd(), a.replacement),
79 | )
80 | }
81 | }
82 | }
83 | else if (typeof resolveAlias === 'object') {
84 | for (const k of Object.keys(resolveAlias)) {
85 | const v = resolveAlias[k]
86 | includePaths.push(
87 | path.isAbsolute(v) ? v : path.resolve(process.cwd(), v),
88 | )
89 | }
90 | }
91 | }
92 | }
93 | catch (e) {
94 | if (debug) {
95 | console.warn(
96 | '[transform-to-unocss] stylus resolveAlias normalize failed',
97 | e,
98 | )
99 | }
100 | }
101 |
102 | result = stylus.default.render(result, {
103 | filename: filepath,
104 | paths: includePaths.length ? includePaths : undefined,
105 | })
106 | return result
107 | }
108 | catch (error: any) {
109 | if (
110 | error.code === 'MODULE_NOT_FOUND'
111 | || error.message.includes('Cannot resolve module')
112 | ) {
113 | throw new Error(
114 | `Stylus compiler not found. Please install stylus in your project:\n`
115 | + `npm install stylus\n`
116 | + `or\n`
117 | + `yarn add stylus\n`
118 | + `or\n`
119 | + `pnpm add stylus`,
120 | )
121 | }
122 | console.error(
123 | `Error:\n transform-to-unocss(stylusCompiler) ${error.toString()}`,
124 | )
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/test/demo/complex5.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
![avatar]()
17 |
![]()
26 |

32 |
36 | {{ unreadCnt > 99 ? '99+' : unreadCnt }}
37 |
38 |
39 |
40 |
127 |
163 |
--------------------------------------------------------------------------------
/src/transformMedia.ts:
--------------------------------------------------------------------------------
1 | import { getLastName } from 'transform-to-unocss-core'
2 | import { transformCss } from './transformCss'
3 |
4 | const mediaReg = /@media([\s\w]*)\(([\w-]+):\s*(\w+)\)\s*\{([\s\w.{}\-:;]*)\}/g
5 |
6 | const mediaSingleReg
7 | = /@media([\s\w]*)\(([\w-]+):\s*(\w+)\)\s*\{([\s\w.{}\-:;]*)\}/
8 | const emptyMediaReg = /@media([\s\w]*)\(([\w-]+):\s*(\w+)\)\s*\{\s*\}/g
9 | const valMap: any = {
10 | '640px': 'sm',
11 | '768px': 'md',
12 | '1024px': 'lg',
13 | '1280px': 'xl',
14 | '1536px': '2xl',
15 | }
16 |
17 | /**
18 | * Transforms CSS @media queries to UnoCSS responsive utilities
19 | * @param code - The code containing @media queries
20 | * @param isJsx - Whether the code is JSX/TSX format
21 | * @param isRem - Whether to convert px values to rem
22 | * @param filepath - The file path for resolving CSS imports within media queries
23 | * @param debug - Whether to enable debug logging
24 | * @param globalCss - Global CSS configuration for preprocessors
25 | * @returns A tuple of [transformed code, restore function]
26 | */
27 | export async function transformMedia(
28 | code: string,
29 | isJsx?: boolean,
30 | isRem?: boolean,
31 | filepath?: string,
32 | debug = false,
33 | globalCss?: any,
34 | ): Promise<[string, (r: string) => string]> {
35 | const transferBackMap: any = []
36 | let result = code
37 |
38 | const matcher = code.match(mediaReg)
39 | if (!matcher) {
40 | if (debug) {
41 | console.log('[DEBUG] transformMedia: No @media queries found')
42 | }
43 | return returnValue(result)
44 | }
45 |
46 | if (debug) {
47 | console.log(
48 | '[DEBUG] transformMedia started:',
49 | JSON.stringify(
50 | {
51 | filepath,
52 | isJsx,
53 | isRem,
54 | mediaQueriesCount: matcher.length,
55 | },
56 | null,
57 | 2,
58 | ),
59 | )
60 | }
61 |
62 | for await (const item of matcher) {
63 | const [all, pre, key, val, inner] = item.match(mediaSingleReg)!
64 | const tempFlag = `/* __transformMedia${Math.random()}__ */`
65 |
66 | const value = valMap[val]
67 |
68 | if (debug) {
69 | console.log(
70 | '[DEBUG] transformMedia processing query:',
71 | JSON.stringify(
72 | {
73 | all: `${all.substring(0, 100)}...`,
74 | key,
75 | val,
76 | mappedValue: value,
77 | hasPrefix: !!pre.trim(),
78 | },
79 | null,
80 | 2,
81 | ),
82 | )
83 | }
84 |
85 | if (!value) {
86 | result = result.replace(all, tempFlag)
87 | transferBackMap.push((r: string) => r.replace(tempFlag, all))
88 |
89 | continue
90 | }
91 |
92 | if (pre.trim()) {
93 | const transfer = await transformCss(
94 | inner,
95 | result,
96 | `max-${value}`,
97 | isJsx,
98 | filepath,
99 | isRem,
100 | debug,
101 | globalCss,
102 | )
103 |
104 | if (transfer !== result) {
105 | result = transfer.replace(emptyMediaReg, '')
106 | transferBackMap.push((r: string) => r.replace(tempFlag, transfer))
107 |
108 | continue
109 | }
110 | result = result.replace(all, tempFlag)
111 | transferBackMap.push((r: string) => r.replace(tempFlag, all))
112 |
113 | continue
114 | }
115 | let mapValue: string = value
116 | if (key === 'prefers-reduced-motion')
117 | mapValue = `${getLastName(key)}-${val === 'no-preference' ? 'safe' : val}`
118 |
119 | const transfer = (
120 | await transformCss(
121 | inner,
122 | result,
123 | mapValue,
124 | isJsx,
125 | filepath,
126 | isRem,
127 | debug,
128 | globalCss,
129 | )
130 | ).replace(emptyMediaReg, '')
131 | result = transfer.replace(all, tempFlag)
132 | transferBackMap.push((r: string) => r.replace(tempFlag, all))
133 | }
134 |
135 | return returnValue(result)
136 |
137 | function returnValue(result: string): [string, (r: string) => string] {
138 | return [
139 | result,
140 | (r: string) =>
141 | transferBackMap.reduce(
142 | (result: string, fn: (r: string) => string) => fn(result),
143 | r,
144 | ),
145 | ]
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "transform-to-unocss",
3 | "type": "module",
4 | "version": "0.1.23",
5 | "packageManager": "pnpm@10.22.0",
6 | "description": "🚀 Effortlessly transform CSS, inline styles, and preprocessors (Sass/Less/Stylus) to UnoCSS with smart conflict resolution and debug support",
7 | "author": {
8 | "name": "Simon He",
9 | "url": "https://github.com/Simon-He95"
10 | },
11 | "license": "MIT",
12 | "funding": "https://github.com/sponsors/Simon-He95",
13 | "homepage": "https://github.com/Simon-He95/transformToUnocss#readme",
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/Simon-He95/transformToUnocss.git"
17 | },
18 | "bugs": "https://github.com/Simon-He95/transformToUnocss/issues",
19 | "keywords": [
20 | "transform-to-unocss",
21 | "unocss",
22 | "css-migration",
23 | "css-to-unocss",
24 | "tailwindcss",
25 | "atomic-css",
26 | "css-transform",
27 | "build-tool",
28 | "vite-plugin",
29 | "webpack-plugin",
30 | "rollup-plugin",
31 | "vue",
32 | "react",
33 | "svelte",
34 | "astro",
35 | "sass",
36 | "less",
37 | "stylus",
38 | "performance-optimization",
39 | "cli-tool"
40 | ],
41 | "sideEffects": false,
42 | "exports": {
43 | ".": {
44 | "import": "./dist/index.js",
45 | "require": "./dist/index.cjs"
46 | }
47 | },
48 | "main": "./dist/index.js",
49 | "module": "./dist/index.js",
50 | "types": "./dist/index.d.ts",
51 | "typesVersions": {
52 | "*": {
53 | "*": [
54 | "./dist/*",
55 | "./dist/index.d.ts"
56 | ]
57 | }
58 | },
59 | "bin": {
60 | "tounocss": "./cli.mjs"
61 | },
62 | "files": [
63 | "README.md",
64 | "README_zh.md",
65 | "cli.mjs",
66 | "dist",
67 | "license"
68 | ],
69 | "scripts": {
70 | "build": "tsdown ./src/index.ts ./src/cli.ts",
71 | "dev": "npm run build -- --watch src",
72 | "format": "prettier --write --cache .",
73 | "lint": "eslint . --cache",
74 | "lint:fix": "pnpm run lint --fix",
75 | "play": "pnpm run -C playground dev",
76 | "play:build": "pnpm run -C playground build",
77 | "preview": "pnpm run -C playground preview",
78 | "prepublishOnly": "nr build",
79 | "release": "bumpp && npm publish",
80 | "test": "vitest",
81 | "typecheck": "tsc --noEmit"
82 | },
83 | "peerDependencies": {
84 | "less": "^3.0.0 || ^4.0.0",
85 | "less-plugin-module-resolver": "^1.0.0",
86 | "sass": "^1.0.0",
87 | "stylus": "^0.50.0 || ^0.60.0"
88 | },
89 | "peerDependenciesMeta": {
90 | "less": {
91 | "optional": true
92 | },
93 | "less-plugin-module-resolver": {
94 | "optional": true
95 | },
96 | "sass": {
97 | "optional": true
98 | },
99 | "stylus": {
100 | "optional": true
101 | }
102 | },
103 | "dependencies": {
104 | "@babel/core": "^7.28.5",
105 | "@rollup/pluginutils": "^5.3.0",
106 | "@unocss/core": "^0.50.8",
107 | "@unocss/preset-uno": "^0.50.8",
108 | "@vue/babel-plugin-jsx": "^1.5.0",
109 | "@vue/compiler-sfc": "^3.5.24",
110 | "fast-glob": "^3.3.3",
111 | "node-html-parser": "^7.0.1",
112 | "transform-to-unocss-core": "^0.0.70",
113 | "unplugin": "^2.3.10"
114 | },
115 | "devDependencies": {
116 | "@antfu/eslint-config": "^5.4.1",
117 | "@simon_he/clack-prompts": "^0.8.11",
118 | "@simon_he/colorize": "^0.0.1",
119 | "@types/babel__core": "^7.20.5",
120 | "@types/less": "^3.0.8",
121 | "@types/node": "^18.19.130",
122 | "@types/stylus": "^0.48.43",
123 | "bumpp": "^8.2.1",
124 | "eslint": "^9.39.1",
125 | "find-up": "^6.3.0",
126 | "less": "^4.4.2",
127 | "less-plugin-module-resolver": "^1.0.3",
128 | "lint-staged": "^13.3.0",
129 | "magic-string": "^0.30.21",
130 | "picocolors": "^1.1.1",
131 | "prettier": "^3.6.2",
132 | "rimraf": "^6.1.0",
133 | "sass": "^1.94.1",
134 | "simple-git-hooks": "^2.13.1",
135 | "stylus": "^0.63.0",
136 | "transform-to-unocss": "workspace:^",
137 | "tsdown": "^0.9.9",
138 | "tsx": "^3.14.0",
139 | "typescript": "^5.9.3",
140 | "vitest": "^3.2.4"
141 | },
142 | "lint-staged": {
143 | "*": [
144 | "prettier --write --cache --ignore-unknown"
145 | ],
146 | "*.{vue,js,ts,jsx,tsx,md,json}": "eslint --fix"
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/transformVue.ts:
--------------------------------------------------------------------------------
1 | import type { CssType } from './type'
2 | import { compilerCss } from './compilerCss'
3 | import { prettierCode } from './prettierCode'
4 | import { transformCss } from './transformCss'
5 | import { transformInlineStyle } from './transformInlineStyle'
6 | import { transformMedia } from './transformMedia'
7 | import { getVueCompilerSfc } from './utils' // 从utils引入公共函数
8 |
9 | interface Options {
10 | isJsx?: boolean
11 | filepath?: string
12 | isRem?: boolean
13 | globalCss?: any
14 | debug?: boolean
15 | resolveAlias?: any
16 | }
17 |
18 | export async function transformVue(code: string, options?: Options) {
19 | const { isJsx, filepath, isRem, globalCss, debug, resolveAlias }
20 | = options || {}
21 |
22 | // 添加基本的输入验证
23 | if (typeof code !== 'string') {
24 | if (debug) {
25 | console.warn(
26 | `[transform-to-unocss] transformVue received non-string code: ${typeof code}, filepath: ${filepath}`,
27 | )
28 | }
29 | return String(code || '')
30 | }
31 |
32 | // 检查文件路径和内容,避免处理非Vue文件
33 | if (
34 | filepath
35 | && !filepath.endsWith('.vue')
36 | && !code.includes('')
37 | && !code.includes('
93 |
193 |
--------------------------------------------------------------------------------
/src/unplugin.ts:
--------------------------------------------------------------------------------
1 | import type { Options } from './type'
2 | import { createFilter } from '@rollup/pluginutils'
3 | import { createUnplugin } from 'unplugin'
4 | import { transformCode } from './transformCode'
5 |
6 | const unplugin = createUnplugin((options?: Options): any => {
7 | // 默认排除 node_modules,用户可以通过 exclude 选项覆盖
8 | const defaultExclude = ['**/node_modules/**']
9 | const userExclude = options?.exclude
10 | ? Array.isArray(options.exclude)
11 | ? options.exclude
12 | : [options.exclude]
13 | : []
14 | const finalExclude = [...defaultExclude, ...userExclude]
15 |
16 | const filter = createFilter(options?.include, finalExclude)
17 | let globalCss: any = null
18 | // store resolve.alias from Vite/Rollup config so we can pass it down to compilers
19 | let resolveAlias: any = null
20 | return [
21 | {
22 | name: 'unplugin-transform-to-unocss',
23 | enforce: 'pre',
24 | async configResolved(config: any) {
25 | globalCss = config.css?.preprocessorOptions
26 | resolveAlias = config.resolve?.alias ?? null
27 | },
28 | transformInclude(id: string) {
29 | // 额外的安全检查:确保不处理 node_modules 中的文件
30 | if (id.includes('node_modules')) {
31 | return false
32 | }
33 | return filter(id)
34 | },
35 | async transform(code: string, id: string) {
36 | let suffix!: 'vue' | 'tsx'
37 | if (id.endsWith('.vue')) {
38 | suffix = 'vue'
39 | }
40 | else if (id.endsWith('lang.tsx')) {
41 | // skip
42 | }
43 | else if (id.endsWith('.tsx')) {
44 | suffix = 'tsx'
45 | }
46 |
47 | if (!suffix)
48 | return code
49 |
50 | // Attempt to use bundler resolver (this.resolve) to rewrite style imports
51 | // so they match Vite/Rollup resolution (this is the most accurate method).
52 | let transformedCode = code
53 | try {
54 | // only handle .vue single-file components here
55 | if (suffix === 'vue' && typeof this.resolve === 'function') {
56 | const styleBlockRegex = /
94 | ```
95 |
96 | **After:**
97 |
98 | ```vue
99 |
100 |
101 |
Hello World
102 |
This is a paragraph
103 |
104 |
105 | ```
106 |
107 | ✨ **70% smaller bundle size** and **zero runtime overhead**!
108 |
109 | ## 🔧 Build Tool Integration
110 |
111 |
112 | 🔥 Vite (Recommended)
113 |
114 | ```ts
115 | import { viteTransformToUnocss } from 'transform-to-unocss'
116 | // vite.config.ts
117 | import { defineConfig } from 'vite'
118 |
119 | export default defineConfig({
120 | plugins: [
121 | viteTransformToUnocss({
122 | include: ['**/*.vue', '**/*.tsx', '**/*.jsx'],
123 | exclude: ['node_modules/**'],
124 | debug: true, // Enable debug mode
125 | }),
126 | ],
127 | })
128 | ```
129 |
130 |
131 |
132 |
133 | 📦 Rollup
134 |
135 | ```ts
136 | // rollup.config.js
137 | import { rollupTransformToUnocss } from 'transform-to-unocss'
138 |
139 | export default {
140 | plugins: [
141 | rollupTransformToUnocss({
142 | include: ['**/*.vue', '**/*.tsx', '**/*.jsx'],
143 | debug: false,
144 | }),
145 | ],
146 | }
147 | ```
148 |
149 |
150 |
151 |
152 | ⚡ Webpack
153 |
154 | ```ts
155 | // webpack.config.js
156 | const { webpackTransformToUnocss } = require('transform-to-unocss')
157 |
158 | module.exports = {
159 | plugins: [
160 | webpackTransformToUnocss({
161 | include: ['**/*.vue', '**/*.tsx', '**/*.jsx'],
162 | exclude: ['node_modules/**'],
163 | }),
164 | ],
165 | }
166 | ```
167 |
168 |
169 |
170 |
171 | 🎯 Vue CLI
172 |
173 | ```ts
174 | // vue.config.js
175 | const { webpackTransformToUnocss } = require('transform-to-unocss')
176 |
177 | module.exports = {
178 | configureWebpack: {
179 | plugins: [
180 | webpackTransformToUnocss({
181 | include: ['**/*.vue'],
182 | debug: process.env.NODE_ENV === 'development',
183 | }),
184 | ],
185 | },
186 | }
187 | ```
188 |
189 |
190 |
191 |
192 | ⚡ Esbuild
193 |
194 | ```ts
195 | // esbuild.config.js
196 | import { build } from 'esbuild'
197 | import { esbuildTransformToUnocss } from 'transform-to-unocss'
198 |
199 | build({
200 | plugins: [
201 | esbuildTransformToUnocss({
202 | include: ['**/*.tsx', '**/*.jsx'],
203 | }),
204 | ],
205 | })
206 | ```
207 |
208 |
209 |
210 | ## � Core Features
211 |
212 | ### 🎯 Smart Transformation
213 |
214 | - ✅ **CSS class selectors** → UnoCSS utilities
215 | - ✅ **Inline styles** → Atomic class attributes
216 | - ✅ **Preprocessors** (Sass/Less/Stylus) → Pure UnoCSS
217 | - ✅ **Pseudo-classes** (`:hover`, `:focus`, etc.)
218 | - ✅ **Media queries** → Responsive classes
219 | - ✅ **Complex selectors** → Smart parsing
220 |
221 | ### 🔧 Developer Experience
222 |
223 | - 🐛 **Debug mode** - Detailed transformation logs
224 | - � **One-click rollback** - Safe change reversal
225 | - 🎨 **VS Code extension** - [To UnoCSS](https://github.com/Simon-He95/unot)
226 | - 📝 **TypeScript support** - Full type definitions
227 | - 🚀 **Zero configuration** - Works out of the box
228 |
229 | ### 🏗️ Framework Support
230 |
231 | - ⚡ **Vue 3/2** - Full support
232 | - ⚛️ **React** - JSX/TSX support
233 | - 🎭 **Svelte** - Native support
234 | - 🚀 **Astro** - Component support
235 | - 📄 **HTML** - Pure HTML files
236 |
237 | ## 🎨 Advanced Usage
238 |
239 | ### Programmatic API
240 |
241 | ```typescript
242 | import { transfromCode } from 'transform-to-unocss'
243 |
244 | // Transform Vue component
245 | const result = await transfromCode(vueCode, {
246 | type: 'vue',
247 | isRem: true,
248 | debug: true,
249 | })
250 |
251 | // Transform React component
252 | const result = await transfromCode(reactCode, {
253 | type: 'tsx',
254 | isJsx: true,
255 | debug: false,
256 | })
257 | ```
258 |
259 | ### Configuration Options
260 |
261 | ```typescript
262 | interface Options {
263 | type?: 'vue' | 'tsx' | 'jsx' | 'html' | 'svelte' | 'astro'
264 | isJsx?: boolean // Whether to use JSX syntax
265 | isRem?: boolean // Whether to convert to rem units
266 | debug?: boolean // Whether to enable debug mode
267 | include?: string[] // File patterns to include
268 | exclude?: string[] // File patterns to exclude
269 | }
270 | ```
271 |
272 | ## 📊 Performance Comparison
273 |
274 | | Project Type | Before | After | Reduction |
275 | | ------------------ | ------ | ----- | --------- |
276 | | Medium Vue Project | 245KB | 73KB | 70% ↓ |
277 | | React Application | 180KB | 54KB | 68% ↓ |
278 | | Enterprise Project | 890KB | 267KB | 72% ↓ |
279 |
280 | ## �️ Debug Mode
281 |
282 | Use the `--debug` flag for detailed transformation information:
283 |
284 | ```bash
285 | tounocss playground --debug
286 | ```
287 |
288 | Debug output includes:
289 |
290 | - 📝 File processing progress
291 | - 🎯 CSS rule transformation details
292 | - ⚡ Performance statistics
293 | - 🔍 Conflict resolution process
294 |
295 | ## 🚁 Ecosystem
296 |
297 | - [transform-to-unocss-core](https://github.com/Simon-He95/transform-to-unocss-core) - Browser-side CSS transformation core
298 | - [To UnoCSS](https://github.com/Simon-He95/unot) - VS Code extension
299 | - [transformToTailwindcss](https://github.com/Simon-He95/transformToTailwindcss) - Tailwind CSS transformer
300 |
301 | ## 🤝 Contributing
302 |
303 | We welcome all forms of contributions! Please check the [Contributing Guide](./CONTRIBUTING.md) for details.
304 |
305 | ### Development Setup
306 |
307 | ```bash
308 | # Clone the repository
309 | git clone https://github.com/Simon-He95/transform-to-unocss.git
310 |
311 | # Install dependencies
312 | pnpm install
313 |
314 | # Development mode
315 | pnpm dev
316 |
317 | # Run tests
318 | pnpm test
319 |
320 | # Build project
321 | pnpm build
322 | ```
323 |
324 | ## 📸 Visual Transformation
325 |
326 | ### Before Transformation
327 |
328 | 
329 |
330 | ### After Transformation
331 |
332 | 
333 |
334 | **Result**: 70% smaller CSS bundle, better performance, and cleaner code! 🚀
335 |
336 | ## 🤝 Contributing
337 |
338 | We welcome all forms of contributions! Please check the [Contributing Guide](./CONTRIBUTING.md) for details.
339 |
340 | ### Development Setup
341 |
342 | ```bash
343 | # Clone the repository
344 | git clone https://github.com/Simon-He95/transform-to-unocss.git
345 |
346 | # Install dependencies
347 | pnpm install
348 |
349 | # Development mode
350 | pnpm dev
351 |
352 | # Run tests
353 | pnpm test
354 |
355 | # Build project
356 | pnpm build
357 | ```
358 |
359 | We welcome contributions! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
360 |
361 | ## 💖 Support the Project
362 |
363 | If this project helped you, please consider:
364 |
365 | - ⭐ **Star this repository**
366 | - 🐛 **Report issues**
367 | - 🔧 **Contribute code**
368 | - ☕ **[Buy me a coffee](https://github.com/Simon-He95/sponsor)**
369 |
370 | Your support keeps this project alive and improving! 🙏
371 |
372 | ## 📄 License
373 |
374 | [MIT](./LICENSE) © 2023-present [Simon He](https://github.com/Simon-He95)
375 |
376 | ---
377 |
378 |
379 | Made with ❤️ by Simon He
380 |
381 |
--------------------------------------------------------------------------------
/test/transformCode.test.ts:
--------------------------------------------------------------------------------
1 | import fsp from 'node:fs/promises'
2 | import path from 'node:path'
3 | import { describe, expect, it } from 'vitest'
4 | import { transformCode } from '../src'
5 |
6 | describe('transformCode', () => {
7 | it('transformCode: all', async () => {
8 | const demos = await fsp.readdir('./test/demo')
9 | const contents = await Promise.all(
10 | demos.map(async (demo) => {
11 | const url = `./test/demo/${demo}`
12 | const filepath = path.resolve(process.cwd(), url)
13 | const suffix = demo.endsWith('.vue')
14 | ? 'vue'
15 | : demo.endsWith('.tsx')
16 | ? 'tsx'
17 | : ''
18 | if (!suffix) return
19 |
20 | return `\n\n----- ${demo} -------\n\n${await transformCode(
21 | await fsp.readFile(url, 'utf-8'),
22 | {
23 | filepath,
24 | type: suffix,
25 | },
26 | )}`
27 | }),
28 | )
29 |
30 | await expect(contents.filter(Boolean)).toMatchFileSnapshot(
31 | './__snapshots__/all.test.ts.snap',
32 | )
33 | })
34 | })
35 |
36 | describe('single demo classWeight', async () => {
37 | const demo = await fsp.readFile('./test/demo/classWeight.vue', 'utf-8')
38 | const filepath = path.resolve(process.cwd(), './test/demo/classWeight.vue')
39 | it('classWeight.vue', async () => {
40 | await expect(
41 | await transformCode(demo, { filepath, type: 'vue' }),
42 | ).toMatchFileSnapshot('./__snapshots__/classWeight.test.ts.snap')
43 | })
44 | })
45 |
46 | describe('single demo classCombine', () => {
47 | it('classCombine.vue', async () => {
48 | const demo = await fsp.readFile('./test/demo/classCombine.vue', 'utf-8')
49 | const filepath = path.resolve(process.cwd(), './test/demo/classCombine.vue')
50 | await expect(
51 | await transformCode(demo, { filepath, type: 'vue' }),
52 | ).toMatchFileSnapshot('./__snapshots__/classCombine.test.ts.snap')
53 | })
54 | })
55 |
56 | describe('single demo classTail', () => {
57 | it('classTail.vue', async () => {
58 | const demo = await fsp.readFile('./test/demo/classTail.vue', 'utf-8')
59 | const filepath = path.resolve(process.cwd(), './test/demo/classTail.vue')
60 | await expect(
61 | await transformCode(demo, { filepath, type: 'vue' }),
62 | ).toMatchFileSnapshot('./__snapshots__/classTail.test.ts.snap')
63 | })
64 | })
65 |
66 | describe('single demo Media', () => {
67 | it('media.vue', async () => {
68 | const demo = await fsp.readFile('./test/demo/media.vue', 'utf-8')
69 | const filepath = path.resolve(process.cwd(), './test/demo/media.vue')
70 | await expect(
71 | await transformCode(demo, { filepath, type: 'vue' }),
72 | ).toMatchFileSnapshot('./__snapshots__/media.test.ts.snap')
73 | })
74 | })
75 |
76 | describe('classSpace.vue', () => {
77 | it('classSpace.vue', async () => {
78 | const demo = await fsp.readFile('./test/demo/classSpace.vue', 'utf-8')
79 | const filepath = path.resolve(process.cwd(), './test/demo/classSpace.vue')
80 | await expect(
81 | await transformCode(demo, { filepath, type: 'vue' }),
82 | ).toMatchFileSnapshot('./__snapshots__/classSpace.test.ts.snap')
83 | })
84 | })
85 |
86 | describe('single demo styleWeight', () => {
87 | it('styleWeight.vue', async () => {
88 | const demo = await fsp.readFile('./test/demo/styleWeight.vue', 'utf-8')
89 | const filepath = path.resolve(process.cwd(), './test/demo/styleWeight.vue')
90 | await expect(
91 | await transformCode(demo, { filepath, type: 'vue' }),
92 | ).toMatchFileSnapshot('./__snapshots__/styleWeight.test.ts.snap')
93 | })
94 | })
95 |
96 | describe('single test', () => {
97 | it('single.vue', async () => {
98 | const demo = await fsp.readFile('./test/demo/test.vue', 'utf-8')
99 | const filepath = path.resolve(process.cwd(), './test/demo/test.vue')
100 | await expect(
101 | await transformCode(demo, { filepath, type: 'vue' }),
102 | ).toMatchFileSnapshot('./__snapshots__/test.test.ts.snap')
103 | })
104 | })
105 |
106 | describe('single demo vue.tsx', () => {
107 | it('vue.tsx', async () => {
108 | const _path = './test/demo/vue.tsx'
109 | const demo = await fsp.readFile(_path, 'utf-8')
110 | const filepath = path.resolve(process.cwd(), _path)
111 | await expect(
112 | await transformCode(demo, { filepath, type: 'tsx' }),
113 | ).toMatchFileSnapshot('./__snapshots__/vue.test.ts.snap')
114 | })
115 | })
116 |
117 | describe('single demo test-1.vue', () => {
118 | it('test-1.vue', async () => {
119 | const _path = './test/demo/test-1.vue'
120 | const demo = await fsp.readFile(_path, 'utf-8')
121 | const filepath = path.resolve(process.cwd(), _path)
122 | await expect(
123 | await transformCode(demo, { filepath, type: 'vue' }),
124 | ).toMatchFileSnapshot('./__snapshots__/test-1.test.ts.snap')
125 | })
126 | })
127 |
128 | describe('single demo complex1.vue', () => {
129 | it('complex.vue', async () => {
130 | const _path = './test/demo/complex1.vue'
131 | const demo = await fsp.readFile(_path, 'utf-8')
132 | const filepath = path.resolve(process.cwd(), _path)
133 | await expect(
134 | await transformCode(demo, { filepath, type: 'vue' }),
135 | ).toMatchFileSnapshot('./__snapshots__/complex1.test.ts.snap')
136 | })
137 | })
138 |
139 | describe('single demo complex2.vue', () => {
140 | it('complex.vue', async () => {
141 | const _path = './test/demo/complex2.vue'
142 | const demo = await fsp.readFile(_path, 'utf-8')
143 | const filepath = path.resolve(process.cwd(), _path)
144 | await expect(
145 | await transformCode(demo, { filepath, type: 'vue' }),
146 | ).toMatchFileSnapshot('./__snapshots__/complex2.test.ts.snap')
147 | })
148 | })
149 |
150 | describe('single demo complex3.vue', () => {
151 | it('complex.vue', async () => {
152 | const _path = './test/demo/complex3.vue'
153 | const demo = await fsp.readFile(_path, 'utf-8')
154 | const filepath = path.resolve(process.cwd(), _path)
155 | await expect(
156 | await transformCode(demo, { filepath, type: 'vue' }),
157 | ).toMatchFileSnapshot('./__snapshots__/complex3.test.ts.snap')
158 | })
159 | })
160 |
161 | describe('single demo complex4.vue', () => {
162 | it('complex.vue', async () => {
163 | const _path = './test/demo/complex4.vue'
164 | const demo = await fsp.readFile(_path, 'utf-8')
165 | const filepath = path.resolve(process.cwd(), _path)
166 | await expect(
167 | await transformCode(demo, { filepath, type: 'vue' }),
168 | ).toMatchFileSnapshot('./__snapshots__/complex4.test.ts.snap')
169 | })
170 | })
171 |
172 | describe('single demo complex5.vue', () => {
173 | it('complex.vue', async () => {
174 | const _path = './test/demo/complex5.vue'
175 | const demo = await fsp.readFile(_path, 'utf-8')
176 | const filepath = path.resolve(process.cwd(), _path)
177 | await expect(
178 | await transformCode(demo, { filepath, type: 'vue' }),
179 | ).toMatchFileSnapshot('./__snapshots__/complex5.test.ts.snap')
180 | })
181 | })
182 |
183 | describe('single demo complex6.vue', async () => {
184 | const _path = './test/demo/complex6.vue'
185 | const demo = await fsp.readFile(_path, 'utf-8')
186 |
187 | it('complex.vue', async () => {
188 | const filepath = path.resolve(process.cwd(), _path)
189 | await expect(
190 | await transformCode(demo, { filepath, type: 'vue' }),
191 | ).toMatchFileSnapshot('./__snapshots__/complex6.test.ts.snap')
192 | })
193 | })
194 |
195 | describe('single demo complex7.vue', async () => {
196 | const _path = './test/demo/complex7.vue'
197 | const demo = await fsp.readFile(_path, 'utf-8')
198 |
199 | it('complex.vue', async () => {
200 | const filepath = path.resolve(process.cwd(), _path)
201 | await expect(
202 | await transformCode(demo, { filepath, type: 'vue' }),
203 | ).toMatchFileSnapshot('./__snapshots__/complex7.test.ts.snap')
204 | })
205 | })
206 |
207 | describe('debug mode', () => {
208 | it('should output debug logs when debug mode is enabled', async () => {
209 | const testVueCode = `
210 |
211 |
Debug Test
212 |
213 |
214 |
215 | `
230 |
231 | // 捕获控制台输出
232 | const originalConsoleLog = console.log
233 | const logs: string[] = []
234 | console.log = (...args) => {
235 | // 将参数转换为字符串
236 | const logString = args.map(arg => {
237 | if (typeof arg === 'string') {
238 | return arg
239 | }
240 | return String(arg)
241 | }).join(' ')
242 | logs.push(logString)
243 | originalConsoleLog(...args)
244 | }
245 |
246 | try {
247 | // 测试 debug 模式
248 | await transformCode(testVueCode, {
249 | type: 'vue',
250 | debug: true
251 | })
252 |
253 | // 验证是否有 debug 日志输出
254 | const debugLogs = logs.filter(log => log.includes('[DEBUG]'))
255 | expect(debugLogs.length).toBeGreaterThan(0)
256 |
257 | // 验证特定的 debug 消息
258 | expect(debugLogs.some(log => log.includes('transformVue started'))).toBe(true)
259 | expect(debugLogs.some(log => log.includes('transformCss started'))).toBe(true)
260 |
261 | } finally {
262 | // 恢复原始的 console.log
263 | console.log = originalConsoleLog
264 | }
265 | })
266 |
267 | it('should output debug logs for inline styles when debug mode is enabled', async () => {
268 | const testVueCodeWithInlineStyles = `
269 |
270 | Test
271 |
272 |
273 |
274 | `
279 |
280 | // 捕获控制台输出
281 | const originalConsoleLog = console.log
282 | const logs: string[] = []
283 | console.log = (...args) => {
284 | // 将参数转换为字符串
285 | const logString = args.map(arg => {
286 | if (typeof arg === 'string') {
287 | return arg
288 | }
289 | return String(arg)
290 | }).join(' ')
291 | logs.push(logString)
292 | originalConsoleLog(...args)
293 | }
294 |
295 | try {
296 | // 测试 debug 模式
297 | await transformCode(testVueCodeWithInlineStyles, {
298 | type: 'vue',
299 | debug: true
300 | })
301 |
302 | // 验证是否有内联样式相关的 debug 日志输出
303 | const inlineStyleDebugLogs = logs.filter(log => log.includes('[DEBUG] transformInlineStyle processing'))
304 | expect(inlineStyleDebugLogs.length).toBeGreaterThan(0)
305 |
306 | // 验证特定的内联样式处理日志
307 | expect(inlineStyleDebugLogs.some(log => log.includes('width: 100px; height: 50px; background-color: red;'))).toBe(true)
308 | expect(inlineStyleDebugLogs.some(log => log.includes('font-size: 14px; color: blue;'))).toBe(true)
309 |
310 | } finally {
311 | // 恢复原始的 console.log
312 | console.log = originalConsoleLog
313 | }
314 | })
315 | })
316 |
--------------------------------------------------------------------------------
/src/sassCompiler.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import process from 'node:process'
3 |
4 | /**
5 | * Sass 编译器 - 处理 SCSS/Sass 文件编译
6 | *
7 | * 解决了以下问题:
8 | * 1. Sass 新版本的 mixed-decls 弃用警告
9 | * 2. legacy-js-api 弃用警告(优先使用新 API)
10 | * 3. @import 规则弃用警告(Dart Sass 3.0.0 将移除)
11 | * 4. 嵌套规则后声明的兼容性问题
12 | * 5. 支持用户项目中的 Sass 版本
13 | * 6. 自动检测并使用最合适的 Sass API
14 | * 7. 多层次的警告抑制机制(console.warn 拦截 + 自定义 logger + silenceDeprecations)
15 | *
16 | * @param css 要编译的 CSS 内容
17 | * @param filepath 文件路径,用于解析 @import 等
18 | * @param globalCss 全局 CSS 内容(字符串)或包含 CSS 的对象(如 {css: string})
19 | * @param debug 是否开启调试模式
20 | * @returns 编译后的 CSS 字符串
21 | */
22 | export async function sassCompiler(
23 | css: string,
24 | filepath: string,
25 | globalCss?: string | any,
26 | debug?: boolean,
27 | resolveAlias?: any,
28 | ) {
29 | if (typeof window !== 'undefined')
30 | throw new Error('sassCompiler is not supported in this browser')
31 |
32 | // 添加输入验证
33 | if (typeof css !== 'string') {
34 | if (debug) {
35 | console.warn(
36 | `[transform-to-unocss] sassCompiler received non-string CSS input: ${typeof css}`,
37 | )
38 | }
39 | return String(css || '')
40 | }
41 |
42 | // 检查文件路径,避免处理不相关的文件
43 | if (filepath) {
44 | const isValidSassFile
45 | = filepath.endsWith('.scss')
46 | || filepath.endsWith('.sass')
47 | || filepath.includes('.vue')
48 | || filepath.includes('.svelte')
49 | || filepath.includes('.astro')
50 | || filepath.includes('.tsx')
51 | || filepath.includes('.jsx')
52 |
53 | if (!isValidSassFile && debug) {
54 | console.warn(
55 | `[transform-to-unocss] sassCompiler called for unexpected file type: ${filepath}`,
56 | )
57 | }
58 | }
59 |
60 | if (debug) {
61 | console.log(
62 | `[transform-to-unocss] Compiling SCSS file: ${filepath || 'unknown file'}`,
63 | )
64 | }
65 |
66 | const baseDir = path.dirname(filepath)
67 |
68 | // 处理 globalCss 和当前 CSS
69 | let result = ''
70 | if (globalCss) {
71 | // 检查 globalCss 的类型,确保它是字符串
72 | if (typeof globalCss === 'string') {
73 | result += globalCss
74 | }
75 | else if (typeof globalCss === 'object' && globalCss !== null) {
76 | // 如果是对象,尝试提取 CSS 内容
77 | const globalCssObj = globalCss as any
78 | if ('css' in globalCssObj && typeof globalCssObj.css === 'string') {
79 | result += globalCssObj.css
80 | }
81 | else if (debug) {
82 | console.warn(
83 | `[transform-to-unocss] Unexpected globalCss object format:`,
84 | globalCss,
85 | )
86 | }
87 | }
88 | else if (debug) {
89 | console.warn(
90 | `[transform-to-unocss] globalCss is not a string or valid object: ${typeof globalCss}`,
91 | globalCss,
92 | )
93 | }
94 | }
95 | result += css
96 |
97 | if (process.env.DEBUG_SASS) {
98 | console.log(
99 | '[transform-to-unocss] [sassCompiler] globalCss type:',
100 | typeof globalCss,
101 | )
102 |
103 | console.log(
104 | '[transform-to-unocss] [sassCompiler] result before replace length:',
105 | result.length,
106 | )
107 |
108 | console.log(
109 | '[transform-to-unocss] [sassCompiler] result before replace snippet:',
110 | result.slice(0, 200),
111 | )
112 | }
113 |
114 | try {
115 | // 使用用户项目中的 sass 版本(通过 peerDependencies)
116 | const sass = await import('sass')
117 |
118 | // 临时抑制 console.warn 来阻止 Sass 弃用警告
119 | const originalWarn = console.warn
120 | const filteredWarn = (message: string, ...args: any[]) => {
121 | const messageStr = String(message)
122 | const deprecationPatterns = [
123 | 'Deprecation Warning',
124 | 'mixed-decls',
125 | 'legacy-js-api',
126 | 'import',
127 | 'Sass @import rules are deprecated',
128 | 'will be removed in Dart Sass',
129 | 'More info and automated migrator',
130 | ]
131 |
132 | const shouldIgnore = deprecationPatterns.some(pattern =>
133 | messageStr.includes(pattern),
134 | )
135 |
136 | if (!shouldIgnore) {
137 | originalWarn(message, ...args)
138 | }
139 | }
140 |
141 | try {
142 | // 检查 Sass 版本以确定使用哪种 API
143 | const sassInfo = sass.info || ''
144 | const isModernSass
145 | = sassInfo.includes('dart-sass') || sassInfo.includes('1.')
146 |
147 | const compileOptions: any = {
148 | // 启用现代 Sass API
149 | syntax: 'scss',
150 | // 支持 @use 和 @forward,让 Sass 使用默认的文件解析
151 | // 同时添加项目 src 目录到 loadPaths,方便解析 '@/...' 别名
152 | loadPaths: [baseDir, path.resolve(process.cwd(), 'src')],
153 | }
154 |
155 | // 为现代版本的 Sass 添加兼容性配置
156 | if (isModernSass) {
157 | // 抑制弃用警告,特别是 mixed-decls 警告
158 | compileOptions.quietDeps = true
159 | // 控制警告级别,避免输出过多的弃用警告
160 | compileOptions.verbose = false
161 | // 静默特定的弃用警告(如果 Sass 版本支持)
162 | try {
163 | compileOptions.silenceDeprecations = [
164 | 'mixed-decls',
165 | 'import',
166 | 'legacy-js-api',
167 | ]
168 | }
169 | catch (e) {
170 | // 某些 Sass 版本可能不支持此选项
171 | }
172 | // 自定义 logger 来过滤特定的弃用警告
173 | compileOptions.logger = {
174 | warn: (message: string, options: any) => {
175 | // 过滤掉常见的弃用警告
176 | const deprecationPatterns = [
177 | 'mixed-decls',
178 | 'legacy-js-api',
179 | 'import',
180 | 'Deprecation Warning',
181 | 'behavior for declarations that appear after nested',
182 | 'will be changing to match the behavior specified by CSS',
183 | 'The legacy JS API is deprecated',
184 | 'Sass @import rules are deprecated',
185 | 'will be removed in Dart Sass 3.0.0',
186 | 'More info and automated migrator',
187 | ]
188 |
189 | const shouldIgnore = deprecationPatterns.some(pattern =>
190 | message.includes(pattern),
191 | )
192 |
193 | if (shouldIgnore) {
194 | return // 忽略这类警告
195 | }
196 |
197 | // 只输出真正需要关注的警告
198 | if (debug) {
199 | console.warn(`[transform-to-unocss] Sass warning: ${message}`)
200 | }
201 | },
202 | }
203 | }
204 |
205 | // 在编译前,尝试将源码中以 '@/...' 或 '~@/...' 开头的别名导入替换为可解析的绝对路径。
206 | // 这能解决在没有构建别名解析器(如 vite/webpack)的环境中,Sass 无法直接解析别名的问题。
207 | const fs = await import('node:fs')
208 |
209 | const replaceAliasImports = (source: string) => {
210 | const importRegex = /@(import|use|forward)\s+(['"])(~?@\/[\w\-./]+)\2/g
211 |
212 | const resolveAliasLocal = (impPath: string) => {
213 | // impPath like '@/styles/foo' or '~@/styles/foo'
214 | const rel = impPath.replace(/^~?@\//, '')
215 | // If we have a resolver map from Vite/Rollup, try to resolve via it
216 | try {
217 | if (resolveAlias) {
218 | // config.resolve.alias can be array or object
219 | if (Array.isArray(resolveAlias)) {
220 | for (const a of resolveAlias) {
221 | // { find, replacement }
222 | if (
223 | typeof a.find === 'string'
224 | && impPath.startsWith(a.find)
225 | ) {
226 | return path.resolve(
227 | a.replacement,
228 | impPath.slice(a.find.length),
229 | )
230 | }
231 | if (a.find instanceof RegExp) {
232 | const m = impPath.match(a.find)
233 | if (m)
234 | return impPath.replace(a.find, a.replacement)
235 | }
236 | }
237 | }
238 | else if (typeof resolveAlias === 'object') {
239 | for (const key of Object.keys(resolveAlias)) {
240 | if (impPath.startsWith(key)) {
241 | return path.resolve(
242 | resolveAlias[key],
243 | impPath.slice(key.length),
244 | )
245 | }
246 | }
247 | }
248 | }
249 | }
250 | catch (e) {
251 | // ignore resolver errors and fallback
252 | if (debug)
253 | console.warn('[transform-to-unocss] resolveAlias failed', e)
254 | }
255 | const candidateSrc = path.resolve(process.cwd(), 'src', rel)
256 | const candidateRoot = path.resolve(process.cwd(), rel)
257 |
258 | const exts = ['', '.scss', '.sass', '.css']
259 | const underscoreVariants = (p: string) => {
260 | const dir = path.dirname(p)
261 | const base = path.basename(p)
262 | return path.join(dir, `_${base}`)
263 | }
264 |
265 | const tryPaths = (base: string) => {
266 | for (const e of exts) {
267 | const p1 = base + e
268 | if (fs.existsSync(p1))
269 | return p1
270 | const p2 = underscoreVariants(base) + e
271 | if (fs.existsSync(p2))
272 | return p2
273 | }
274 | return null
275 | }
276 |
277 | // Prefer src-based resolution
278 | let found = tryPaths(candidateSrc)
279 | if (found)
280 | return found
281 |
282 | found = tryPaths(candidateRoot)
283 | if (found)
284 | return found
285 |
286 | // 最后回退到 src 路径(即使文件可能不存在),让 Sass 去进一步解析
287 | return candidateSrc
288 | }
289 |
290 | return source.replace(importRegex, (match, kw, quote, impPath) => {
291 | try {
292 | const resolved = resolveAliasLocal(impPath)
293 | if (resolved && resolved !== impPath) {
294 | // ensure resolved is absolute-ish; if it's relative, resolve from cwd
295 | let finalPath = resolved
296 | try {
297 | // If resolved looks like a path (contains /) but is not absolute, make it absolute
298 | if (!path.isAbsolute(finalPath)) {
299 | finalPath = path.resolve(process.cwd(), finalPath)
300 | }
301 | }
302 | catch (e) {
303 | // keep original resolved if path ops fail
304 | }
305 |
306 | if (debug) {
307 | console.log(
308 | `[transform-to-unocss] Rewriting ${kw} ${impPath} -> ${finalPath}`,
309 | )
310 | }
311 | return `@${kw} ${quote}${finalPath}${quote}`
312 | }
313 | }
314 | catch (e) {
315 | // Ignore resolution errors and leave original import
316 | if (debug)
317 | console.warn('[transform-to-unocss] alias resolution error', e)
318 | }
319 |
320 | return match
321 | })
322 | }
323 |
324 | // 在编译前把 alias 导入替换并准备要编译的源
325 | const sourceToCompile = replaceAliasImports(result)
326 |
327 | // 可选调试:打印最终传入 Sass 的源码,方便定位 globalCss 是否被包含
328 | if (process.env.DEBUG_SASS) {
329 | console.log(
330 | '[transform-to-unocss] [sassCompiler] sourceToCompile:',
331 | sourceToCompile,
332 | )
333 | }
334 |
335 | // 优先使用新的 compile API(如果可用),否则回退到 compileString
336 | let compiledResult: any
337 |
338 | // 临时替换 console.warn 来过滤弃用警告
339 | console.warn = filteredWarn
340 |
341 | if (sass.compile && typeof sass.compile === 'function') {
342 | // 使用新的 API - 需要写入临时文件
343 | const os = await import('node:os')
344 | const tempFilePath = `${os.tmpdir()}/transform-to-unocss-${Date.now()}.scss`
345 |
346 | try {
347 | fs.writeFileSync(tempFilePath, sourceToCompile)
348 | compiledResult = sass.compile(tempFilePath, compileOptions)
349 | }
350 | finally {
351 | // 清理临时文件
352 | try {
353 | fs.unlinkSync(tempFilePath)
354 | }
355 | catch (e) {
356 | // 忽略清理错误
357 | }
358 | }
359 | }
360 | else {
361 | // 回退到旧的 API
362 | compiledResult = sass.compileString(sourceToCompile, compileOptions)
363 | }
364 |
365 | result = compiledResult.css
366 | return result
367 | }
368 | finally {
369 | // 恢复原始的 console.warn
370 | console.warn = originalWarn
371 | }
372 | }
373 | catch (error: any) {
374 | if (
375 | error.code === 'MODULE_NOT_FOUND'
376 | || error.message.includes('Cannot resolve module')
377 | ) {
378 | throw new Error(
379 | `Sass compiler not found. Please install sass in your project:\n`
380 | + `npm install sass\n`
381 | + `or\n`
382 | + `yarn add sass\n`
383 | + `or\n`
384 | + `pnpm add sass`,
385 | )
386 | }
387 | console.error(
388 | `Error:\n transform-to-unocss(sassCompiler) ${error.toString()}`,
389 | )
390 | // 返回原始 CSS 而不是 undefined,以便测试能够继续
391 | return css
392 | }
393 | }
394 |
--------------------------------------------------------------------------------