diff --git a/packages/css/src/plugin.ts b/packages/css/src/plugin.ts index 3ec4a8868..46c6f2005 100644 --- a/packages/css/src/plugin.ts +++ b/packages/css/src/plugin.ts @@ -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>() - 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: { @@ -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 && @@ -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)};`, @@ -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 @@ -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 { diff --git a/src/features/rolldown.ts b/src/features/rolldown.ts index 05f3fc66d..8d567503d 100644 --- a/src/features/rolldown.ts +++ b/src/features/rolldown.ts @@ -134,6 +134,7 @@ async function resolveInputOptions( ) } } + let cssPostPlugins: Plugin[] | undefined if (!cjsDts) { if (unused) { const { Unused } = @@ -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()) } @@ -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) => { diff --git a/tests/__snapshots__/issues/800.snap.md b/tests/__snapshots__/issues/800.snap.md index 91bf4e7a7..11ae403e5 100644 --- a/tests/__snapshots__/issues/800.snap.md +++ b/tests/__snapshots__/issues/800.snap.md @@ -25,6 +25,7 @@ export { MyButton_default as MyButton }; ```css -.btn { color: red; } +.btn { color: red; +} ``` diff --git a/tests/__snapshots__/issues/837.snap.md b/tests/__snapshots__/issues/837.snap.md new file mode 100644 index 000000000..3a7e0d5cf --- /dev/null +++ b/tests/__snapshots__/issues/837.snap.md @@ -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; +} + +``` diff --git a/tests/issues.test.ts b/tests/issues.test.ts index 73ec19ed5..b82e8455a 100644 --- a/tests/issues.test.ts +++ b/tests/issues.test.ts @@ -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': ` +`, + }, + 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,