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
54 changes: 21 additions & 33 deletions packages/css/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RE_CSS,
RE_CSS_INLINE,
RE_INLINE,
shouldSkipTransform,
toCssFileName,
} from './utils.ts'
import type { CSSModulesConfig } from 'lightningcss'
Expand All @@ -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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions packages/css/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
13 changes: 13 additions & 0 deletions packages/css/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand All @@ -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)
}
35 changes: 34 additions & 1 deletion tests/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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': `<template><button class="btn">Click</button></template>\n<style src="${btn}" scoped />`,
'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,
Expand Down
Loading