diff --git a/packages/css/src/plugin.ts b/packages/css/src/plugin.ts
index 8b1390f4c..2b3fc5652 100644
--- a/packages/css/src/plugin.ts
+++ b/packages/css/src/plugin.ts
@@ -24,6 +24,7 @@ import {
RE_CSS,
RE_CSS_INLINE,
RE_INLINE,
+ shouldSkipTransform,
toCssFileName,
} from './utils.ts'
import type { CSSModulesConfig } from 'lightningcss'
@@ -45,14 +46,6 @@ interface CssPluginResult {
post: Plugin[]
}
-function shouldSkipTransform(id: string, cleanId: string): boolean {
- const isInline = RE_INLINE.test(id)
- // Skip CSS files with non-inline queries (e.g. ?raw handled by other plugins),
- // but allow through virtual CSS from other plugins (e.g. Vue SFC `lang.css`)
- // where the clean path itself is not a CSS file.
- return id !== cleanId && !isInline && CSS_LANGS_RE.test(cleanId)
-}
-
export function CssPlugin(
config: ResolvedConfig,
{ logger }: { logger: Logger },
@@ -413,9 +406,8 @@ async function processWithLightningCSS(
})
}
- // Virtual modules (with query strings) can't use file-based bundling;
- // ?inline is excluded because the underlying file is real.
- if (id !== cleanId && !RE_INLINE.test(id)) {
+ // Virtual CSS (e.g. `Comp.vue?vue&type=style&lang.css`) has no real file to bundle
+ if (!RE_CSS.test(cleanId)) {
return transformWithLightningCSS(code, cleanId, {
target: config.css.target,
lightningcss: config.css.lightningcss,
@@ -425,29 +417,25 @@ async function processWithLightningCSS(
})
}
- if (RE_CSS.test(cleanId)) {
- const bundleResult = await bundleWithLightningCSS(
- cleanId,
- {
- target: config.css.target,
- lightningcss: config.css.lightningcss,
- minify: config.css.minify,
- cssModules,
- preprocessorOptions: config.css.preprocessorOptions,
- sourceMap: config.sourceMap,
- logger,
- },
- code,
- )
- deps.push(...bundleResult.deps)
- return {
- code: bundleResult.code,
- map: bundleResult.map,
- modules: bundleResult.modules,
- }
+ const bundleResult = await bundleWithLightningCSS(
+ cleanId,
+ {
+ target: config.css.target,
+ lightningcss: config.css.lightningcss,
+ minify: config.css.minify,
+ cssModules,
+ preprocessorOptions: config.css.preprocessorOptions,
+ sourceMap: config.sourceMap,
+ logger,
+ },
+ code,
+ )
+ deps.push(...bundleResult.deps)
+ return {
+ code: bundleResult.code,
+ map: bundleResult.map,
+ modules: bundleResult.modules,
}
-
- return { code: '' }
}
async function processWithPostCSS(
diff --git a/packages/css/src/utils.test.ts b/packages/css/src/utils.test.ts
new file mode 100644
index 000000000..6471e7ac4
--- /dev/null
+++ b/packages/css/src/utils.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from 'vitest'
+import { getCleanId, shouldSkipTransform } from './utils.ts'
+
+describe('shouldSkipTransform', () => {
+ test.each([
+ '/a/foo.css',
+ '/a/foo.css?inline',
+ '/a/foo.scss?inline',
+ '/a/Comp.vue?vue&type=style&index=0&lang.css',
+ '/a/Comp.vue?vue&type=style&index=0&lang.scss',
+ '/a/Button.css?vue&type=style&index=0&src=abc&scoped=abc&lang.css',
+ '/a/Button.scss?vue&type=style&index=0&src=true&lang.scss',
+ '/a/B.module.css?vue&type=style&index=0&src=true&lang.module.css',
+ ])('processes %s', (id) => {
+ expect(shouldSkipTransform(id, getCleanId(id))).toBe(false)
+ })
+
+ test.each([
+ '/a/foo.css?raw',
+ '/a/foo.css?url',
+ '/a/foo.css?bytes',
+ '/a/foo.css?worker',
+ '/a/foo.css?v=123',
+ '/a/foo.css?raw&lang.css',
+ ])('skips %s', (id) => {
+ expect(shouldSkipTransform(id, getCleanId(id))).toBe(true)
+ })
+})
diff --git a/packages/css/src/utils.ts b/packages/css/src/utils.ts
index 84d3d0847..e70f3ce7b 100644
--- a/packages/css/src/utils.ts
+++ b/packages/css/src/utils.ts
@@ -7,6 +7,11 @@ export const RE_CSS_INLINE: RegExp =
export const CSS_MODULE_RE: RegExp =
/\.module\.(?:css|less|sass|scss|styl|stylus)(?:$|\?)/
+export const RE_CSS_LANG_TAIL: RegExp = /\.(?:css|less|sass|scss|styl|stylus)$/
+// Vite's `SPECIAL_QUERY_RE` plus `?bytes` from unplugin-raw
+export const RE_SPECIAL_QUERY: RegExp =
+ /[?&](?:raw|url|bytes|worker|sharedworker)\b/
+
export function toCssFileName(jsFileName: string): string {
return jsFileName.replace(/(?:\.module)?(\.[cm]?js)$/, '.css')
}
@@ -15,3 +20,11 @@ export function getCleanId(id: string): string {
const queryIndex = id.indexOf('?')
return queryIndex === -1 ? id : id.slice(0, queryIndex)
}
+
+export function shouldSkipTransform(id: string, cleanId: string): boolean {
+ if (id === cleanId || RE_INLINE.test(id)) return false
+ if (!CSS_LANGS_RE.test(cleanId)) return false
+ if (RE_SPECIAL_QUERY.test(id)) return true
+ // Without a CSS tail (e.g. `&lang.css`) Rolldown doesn't treat the id as CSS either
+ return !RE_CSS_LANG_TAIL.test(id)
+}
diff --git a/tests/issues.test.ts b/tests/issues.test.ts
index 7fc8882b5..bfa8d3ee5 100644
--- a/tests/issues.test.ts
+++ b/tests/issues.test.ts
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { exec } from 'tinyexec'
import { describe, expect, test } from 'vitest'
-import { testBuild } from './utils.ts'
+import { getTestDir, testBuild } from './utils.ts'
describe('issues', () => {
test('#61', async (context) => {
@@ -273,6 +273,39 @@ button {
expect(fileMap['index.mjs']).toContain('mod_btn')
})
+ test.for(['lightningcss', 'postcss'] as const)(
+ '#1095 (%s)',
+ async (transformer, context) => {
+ const Vue = (await import('unplugin-vue/rolldown')).default
+ // A relative `src` gets an empty scope id in unplugin-vue 8.0.0 (unplugin/unplugin-vue#202)
+ const btn = path.join(getTestDir(context.task), 'Button.css')
+ const { outputDir } = await testBuild({
+ context,
+ files: {
+ 'index.ts': `export { default as MyButton } from './Button.vue'`,
+ 'Button.vue': `\n`,
+ 'Button.css': `@import './base.css';\n.btn { color: red; }`,
+ 'base.css': `.base { margin: 0; }`,
+ },
+ options: {
+ plugins: [Vue({ isProduction: true })],
+ deps: { neverBundle: true },
+ css: { transformer, inject: true, splitting: true },
+ },
+ snapshot: false,
+ })
+ const js = await readFile(path.join(outputDir, 'index.mjs'), 'utf8')
+ const css = await readFile(path.join(outputDir, 'index.css'), 'utf8')
+ const scopeId = /__scopeId", "(data-v-[\da-f]+)/.exec(js)?.[1]
+ expect(scopeId).toBeDefined()
+ expect(css).toContain(`.btn[${scopeId}]`)
+ // `@import` is inlined before Vue's scoped transform, so imported rules are scoped too
+ expect(css).toContain(`.base[${scopeId}]`)
+ expect(css).not.toContain('@import')
+ expect(js).toContain(`import './index.css'`)
+ },
+ )
+
test('#772', async (context) => {
const { fileMap, outputFiles } = await testBuild({
context,