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