Skip to content
Merged
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
66 changes: 54 additions & 12 deletions packages/css/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,42 @@ interface CssPluginConfig {
target?: string[]
}

interface CssPluginResult {
/** Plugins that run BEFORE user plugins (CSS compilation) */
pre: Plugin[]
/** Plugins that run AFTER user plugins (CSS collection & emission) */
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 },
): Plugin[] {
): CssPluginResult {
const cssConfig: CssPluginConfig = {
css: resolveCssOptions(config.css, config.target, config.unbundle),
cwd: config.cwd,
target: config.target,
}
const styles: CssStyles = new Map()
const modulesMap = new Map<string, Record<string, string>>()

const transformPlugin: Plugin = {
// Pre-user plugin: compiles CSS (preprocessors, LightningCSS/PostCSS)
// but does NOT convert to JS — allows user plugins (e.g. Vue scoped CSS)
// to transform the compiled CSS before collection.
const cssCompilePlugin: Plugin = {
name: '@tsdown/css',

buildStart() {
styles.clear()
modulesMap.clear()
},

resolveId: {
Expand Down Expand Up @@ -91,13 +111,9 @@ export function CssPlugin(
filter: { id: CSS_LANGS_RE },
async handler(code, id) {
const cleanId = getCleanId(id)
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.
if (id !== cleanId && !isInline && CSS_LANGS_RE.test(cleanId)) return
if (shouldSkipTransform(id, cleanId)) return

const isInline = RE_INLINE.test(id)
const isModule =
!isInline &&
cssConfig.css.modules !== false &&
Expand Down Expand Up @@ -151,8 +167,30 @@ export function CssPlugin(
)
}
modulesConfig?.getJSON?.(cleanId, modules, cleanId)
modulesMap.set(id, modules)
}

// Return compiled CSS without converting to JS.
// User plugins can still transform this CSS (e.g. Vue scoped styles).
return { code }
},
},
}

// Post-user plugin: collects final CSS (after user plugin transforms)
// and converts CSS modules to JS exports.
const cssCollectPlugin: Plugin = {
name: '@tsdown/css:collect',

transform: {
filter: { id: CSS_LANGS_RE },
handler(code, id) {
const cleanId = getCleanId(id)
if (shouldSkipTransform(id, cleanId)) return

const isInline = RE_INLINE.test(id)
const modules = modulesMap.get(id)

if (isInline) {
return {
code: `export default ${JSON.stringify(code)};`,
Expand Down Expand Up @@ -182,7 +220,7 @@ export function CssPlugin(
},
}

const plugins: Plugin[] = [transformPlugin]
const postPlugins: Plugin[] = [cssCollectPlugin]

if (cssConfig.css.inject) {
// Inject plugin runs BEFORE CssPostPlugin so it can see pure CSS chunks
Expand Down Expand Up @@ -273,11 +311,15 @@ export function CssPlugin(
}
},
}
plugins.push(injectPlugin)
postPlugins.push(injectPlugin)
}

plugins.push(CssPostPlugin(cssConfig.css, styles))
return plugins
postPlugins.push(CssPostPlugin(cssConfig.css, styles))

return {
pre: [cssCompilePlugin],
post: postPlugins,
}
}

interface ProcessResult {
Expand Down
11 changes: 10 additions & 1 deletion src/features/rolldown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ async function resolveInputOptions(
)
}
}
let cssPostPlugins: Plugin[] | undefined
if (!cjsDts) {
if (unused) {
const { Unused } =
Expand All @@ -150,7 +151,9 @@ async function resolveInputOptions(

if (pkgExists('@tsdown/css')) {
const { CssPlugin } = await import('@tsdown/css')
plugins.push(CssPlugin(config, { logger }))
const cssPlugins = CssPlugin(config, { logger })
plugins.push(...cssPlugins.pre)
cssPostPlugins = cssPlugins.post
} else {
plugins.push(CssGuardPlugin())
}
Expand All @@ -173,6 +176,12 @@ async function resolveInputOptions(
plugins.push(userPlugins)
}

// CSS post plugins must run AFTER user plugins so that user transforms
// (e.g. Vue scoped CSS) are applied before CSS is collected and emitted.
if (cssPostPlugins) {
plugins.push(...cssPostPlugins)
}

const define = {
...config.define,
...Object.keys(env).reduce((acc, key) => {
Expand Down
3 changes: 2 additions & 1 deletion tests/__snapshots__/issues/800.snap.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { MyButton_default as MyButton };

```css

.btn { color: red; }
.btn { color: red;
}

```
31 changes: 31 additions & 0 deletions tests/__snapshots__/issues/837.snap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## index.mjs

```mjs
import { createElementBlock, openBlock } from "vue";
//#region \0/plugin-vue/export-helper
var export_helper_default = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) target[key] = val;
return target;
};
//#endregion
//#region MyButton.vue
const _sfc_main = {};
const _hoisted_1 = { class: "btn" };
function _sfc_render(_ctx, _cache) {
return openBlock(), createElementBlock("button", _hoisted_1, "Click");
}
var MyButton_default = /* @__PURE__ */ export_helper_default(_sfc_main, [["render", _sfc_render], ["__scopeId", "data-v-a840afd1"]]);
//#endregion
export { MyButton_default as MyButton };

```

## style.css

```css

.btn[data-v-a840afd1] { color: red;
}

```
23 changes: 23 additions & 0 deletions tests/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,29 @@ button {
expect(css).not.toContain('@include')
})

test('#837', async (context) => {
const Vue = (await import('unplugin-vue/rolldown')).default
const { outputFiles, fileMap } = await testBuild({
context,
files: {
'index.ts': `export { default as MyButton } from './MyButton.vue'`,
'MyButton.vue': `<template><button class="btn">Click</button></template>
<style scoped>
.btn { color: red; }
</style>`,
},
options: {
plugins: [Vue({ isProduction: true })],
deps: { skipNodeModulesBundle: true },
},
})
expect(outputFiles).toContain('index.mjs')
expect(outputFiles).toContain('style.css')
const css = fileMap['style.css']
expect(css).toContain('.btn')
expect(css).toMatch(/\[data-v-[\da-f]+\]/)
})

test('#772', async (context) => {
const { fileMap, outputFiles } = await testBuild({
context,
Expand Down
Loading