Skip to content

Commit c421fa5

Browse files
committed
fix(css): handle virtual module IDs from framework plugins
Move CSS processing from `load` to `transform` hook so framework plugins (e.g. Vue SFC) can load virtual modules first. This prevents ENOENT errors when the CSS plugin tried to read virtual paths like `MyButton.vue?vue&type=style&index=0&lang.css` from disk. closes #800
1 parent 5145496 commit c421fa5

File tree

10 files changed

+167
-48
lines changed

10 files changed

+167
-48
lines changed

dts.snapshot.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"css.d.mts": {
1717
"createCssPostHooks": "declare function createCssPostHooks(_: { css: { splitting: boolean; fileName: string } }, _: CssStyles): Pick<Required<Plugin>, 'renderChunk' | 'generateBundle'>",
1818
"CssStyles": "type CssStyles = Map<string, string>",
19+
"getCleanId": "declare function getCleanId(_: string): string",
1920
"getEmptyChunkReplacer": "declare function getEmptyChunkReplacer(_: string[]): (_: string) => string",
2021
"RE_CSS": "RegExp",
2122
"removePureCssChunks": "declare function removePureCssChunks(_: Record<string, OutputChunk | OutputAsset>, _: CssStyles): void",
@@ -35,6 +36,7 @@
3536
"StylusPreprocessorOptions",
3637
"createCssPostHooks",
3738
"defaultCssBundleName",
39+
"getCleanId",
3840
"getEmptyChunkReplacer",
3941
"removePureCssChunks",
4042
"resolveCssOptions"

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,10 @@
175175
"unocss": "catalog:docs",
176176
"unplugin-ast": "catalog:dev",
177177
"unplugin-unused": "catalog:peer",
178+
"unplugin-vue": "catalog:dev",
178179
"vite": "catalog:docs",
179-
"vitest": "catalog:dev"
180+
"vitest": "catalog:dev",
181+
"vue": "catalog:docs"
180182
},
181183
"prettier": "@sxzz/prettier-config"
182184
}

packages/css/src/index.ts

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { readFile } from 'node:fs/promises'
2-
import { createCssPostHooks, RE_CSS, type CssStyles } from 'tsdown/css'
1+
import {
2+
createCssPostHooks,
3+
getCleanId,
4+
RE_CSS,
5+
type CssStyles,
6+
} from 'tsdown/css'
37
import {
48
bundleWithLightningCSS,
59
transformWithLightningCSS,
610
} from './lightningcss.ts'
7-
import { processWithPostCSS } from './postcss.ts'
11+
import { processWithPostCSS as runPostCSS } from './postcss.ts'
812
import {
913
compilePreprocessor,
1014
getPreprocessorLang,
@@ -28,16 +32,23 @@ export function CssPlugin(
2832
styles.clear()
2933
},
3034

31-
async load(id) {
35+
async transform(code, id) {
3236
if (!isCssOrPreprocessor(id)) return
3337

34-
let code: string
38+
const cleanId = getCleanId(id)
3539
const deps: string[] = []
3640

3741
if (config.css.transformer === 'lightningcss') {
38-
code = await loadWithLightningCSS(id, deps, config, logger)
42+
code = await processWithLightningCSS(
43+
code,
44+
id,
45+
cleanId,
46+
deps,
47+
config,
48+
logger,
49+
)
3950
} else {
40-
code = await loadWithPostCSS(id, deps, config)
51+
code = await processWithPostCSS(code, id, cleanId, deps, config)
4152
}
4253

4354
for (const dep of deps) {
@@ -60,80 +71,92 @@ export function CssPlugin(
6071
}
6172
}
6273

63-
async function loadWithLightningCSS(
74+
async function processWithLightningCSS(
75+
code: string,
6476
id: string,
77+
cleanId: string,
6578
deps: string[],
6679
config: ResolvedConfig,
6780
logger: MinimalLogger,
6881
): Promise<string> {
6982
const lang = getPreprocessorLang(id)
7083

7184
if (lang) {
72-
const rawCode = await readFile(id, 'utf8')
7385
const preResult = await compilePreprocessor(
7486
lang,
75-
rawCode,
76-
id,
87+
code,
88+
cleanId,
7789
config.css.preprocessorOptions,
7890
)
7991
deps.push(...preResult.deps)
8092

81-
return transformWithLightningCSS(preResult.code, id, {
93+
return transformWithLightningCSS(preResult.code, cleanId, {
8294
target: config.css.target,
8395
lightningcss: config.css.lightningcss,
8496
minify: config.css.minify,
8597
})
86-
} else if (RE_CSS.test(id)) {
87-
const bundleResult = await bundleWithLightningCSS(id, {
98+
}
99+
100+
// Virtual modules (with query strings) can't use file-based bundling
101+
if (id !== cleanId) {
102+
return transformWithLightningCSS(code, cleanId, {
88103
target: config.css.target,
89104
lightningcss: config.css.lightningcss,
90105
minify: config.css.minify,
91-
preprocessorOptions: config.css.preprocessorOptions,
92-
logger,
93106
})
107+
}
108+
109+
if (RE_CSS.test(cleanId)) {
110+
const bundleResult = await bundleWithLightningCSS(
111+
cleanId,
112+
{
113+
target: config.css.target,
114+
lightningcss: config.css.lightningcss,
115+
minify: config.css.minify,
116+
preprocessorOptions: config.css.preprocessorOptions,
117+
logger,
118+
},
119+
code,
120+
)
94121
deps.push(...bundleResult.deps)
95122
return bundleResult.code
96123
}
97124

98125
return ''
99126
}
100127

101-
async function loadWithPostCSS(
128+
async function processWithPostCSS(
129+
code: string,
102130
id: string,
131+
cleanId: string,
103132
deps: string[],
104133
config: ResolvedConfig,
105134
): Promise<string> {
106135
const lang = getPreprocessorLang(id)
107-
let code: string
108136

109137
if (lang) {
110-
const rawCode = await readFile(id, 'utf8')
111138
const preResult = await compilePreprocessor(
112139
lang,
113-
rawCode,
114-
id,
140+
code,
141+
cleanId,
115142
config.css.preprocessorOptions,
116143
)
117144
code = preResult.code
118145
deps.push(...preResult.deps)
119-
} else if (RE_CSS.test(id)) {
120-
code = await readFile(id, 'utf8')
121-
} else {
122-
return ''
123146
}
124147

125148
const needInlineImport = code.includes('@import')
126-
const postcssResult = await processWithPostCSS(
149+
const postcssResult = await runPostCSS(
127150
code,
128-
id,
151+
cleanId,
129152
config.css.postcss,
130153
config.cwd,
131154
needInlineImport,
132155
)
133156
code = postcssResult.code
134157
deps.push(...postcssResult.deps)
135158

136-
return transformWithLightningCSS(code, id, {
159+
return transformWithLightningCSS(code, cleanId, {
137160
target: config.css.target,
138161
lightningcss: config.css.lightningcss,
139162
minify: config.css.minify,

packages/css/src/lightningcss.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export async function transformWithLightningCSS(
6262
export async function bundleWithLightningCSS(
6363
filename: string,
6464
options: BundleCssOptions,
65+
code?: string,
6566
): Promise<BundleCssResult> {
6667
const targets =
6768
options.lightningcss?.targets ??
@@ -77,21 +78,26 @@ export async function bundleWithLightningCSS(
7778
minify: options.minify,
7879
resolver: {
7980
async read(filePath: string) {
80-
// Note: LightningCSS explicitly recommends using `readFileSync` instead
81-
// of `readFile` for better performance.
82-
const code = readFileSync(filePath, 'utf8')
81+
let fileCode: string
82+
if (code != null && filePath === filename) {
83+
fileCode = code
84+
} else {
85+
// Note: LightningCSS explicitly recommends using `readFileSync` instead
86+
// of `readFile` for better performance.
87+
fileCode = readFileSync(filePath, 'utf8')
88+
}
8389
const lang = getPreprocessorLang(filePath)
8490
if (lang) {
8591
const preprocessed = await compilePreprocessor(
8692
lang,
87-
code,
93+
fileCode,
8894
filePath,
8995
options.preprocessorOptions,
9096
)
9197
deps.push(...preprocessed.deps)
9298
return preprocessed.code
9399
}
94-
return code
100+
return fileCode
95101
},
96102
resolve(specifier: string, from: string) {
97103
const dir = path.dirname(from)

pnpm-lock.yaml

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ catalogs:
3434
rolldown-plugin-require-cjs: ^0.3.3
3535
typescript: ~5.9.3
3636
unplugin-ast: ^0.16.0
37+
unplugin-vue: ^7.1.1
3738
vitest: ^4.0.18
3839
docs:
3940
'@iconify-json/logos': ^1.2.10

src/css.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export {
22
defaultCssBundleName,
33
resolveCssOptions,
44
} from './features/css/index.ts'
5-
export { RE_CSS } from './features/css/plugin.ts'
5+
export { getCleanId, RE_CSS } from './features/css/plugin.ts'
66
export { createCssPostHooks } from './features/css/post.ts'
77
export {
88
getEmptyChunkReplacer,

src/features/css/plugin.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { readFile } from 'node:fs/promises'
21
import { createCssPostHooks, type CssStyles } from './post.ts'
32
import type { ResolvedConfig } from '../../config/index.ts'
43
import type { Plugin } from 'rolldown'
54

65
export const RE_CSS: RegExp = /\.css(?:$|\?)/
76

7+
export function getCleanId(id: string): string {
8+
const queryIndex = id.indexOf('?')
9+
return queryIndex === -1 ? id : id.slice(0, queryIndex)
10+
}
11+
812
export function CssPlugin(config: ResolvedConfig): Plugin {
913
const styles: CssStyles = new Map()
1014
const postHooks = createCssPostHooks(config, styles)
@@ -16,18 +20,13 @@ export function CssPlugin(config: ResolvedConfig): Plugin {
1620
styles.clear()
1721
},
1822

19-
load: {
20-
filter: {
21-
id: RE_CSS,
22-
},
23-
async handler(id) {
24-
let code = await readFile(id, 'utf8')
25-
if (code.length > 0 && !code.endsWith('\n')) {
26-
code += '\n'
27-
}
28-
styles.set(id, code)
29-
return { code: '', moduleSideEffects: 'no-treeshake', moduleType: 'js' }
30-
},
23+
transform(code, id) {
24+
if (!RE_CSS.test(id)) return
25+
if (code.length > 0 && !code.endsWith('\n')) {
26+
code += '\n'
27+
}
28+
styles.set(id, code)
29+
return { code: '', moduleSideEffects: 'no-treeshake', moduleType: 'js' }
3130
},
3231

3332
...postHooks,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## index.mjs
2+
3+
```mjs
4+
import { createElementBlock, openBlock } from "vue";
5+
//#region \0/plugin-vue/export-helper
6+
var export_helper_default = (sfc, props) => {
7+
const target = sfc.__vccOpts || sfc;
8+
for (const [key, val] of props) target[key] = val;
9+
return target;
10+
};
11+
//#endregion
12+
//#region MyButton.vue
13+
const _sfc_main = {};
14+
const _hoisted_1 = { class: "btn" };
15+
function _sfc_render(_ctx, _cache) {
16+
return openBlock(), createElementBlock("button", _hoisted_1, "Click");
17+
}
18+
var MyButton_default = /* @__PURE__ */ export_helper_default(_sfc_main, [["render", _sfc_render]]);
19+
//#endregion
20+
export { MyButton_default as MyButton };
21+
22+
```
23+
24+
## style.css
25+
26+
```css
27+
28+
.btn { color: red; }
29+
30+
```

0 commit comments

Comments
 (0)