Skip to content

Commit b737941

Browse files
feat(css): support ?inline query for CSS imports (#810)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 1b1a172 commit b737941

File tree

10 files changed

+202
-7
lines changed

10 files changed

+202
-7
lines changed

client.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,8 @@ interface ImportGlobFunction {
5656
interface ImportMeta {
5757
glob: ImportGlobFunction
5858
}
59+
60+
declare module '*?inline' {
61+
const src: string
62+
export default src
63+
}

docs/options/css.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ CSS `@import` statements are automatically resolved and inlined into the output.
4646

4747
All imported CSS is bundled into a single output file with `@import` statements removed.
4848

49+
### Inline CSS (`?inline`)
50+
51+
Appending `?inline` to a CSS import returns the fully processed CSS as a JavaScript string instead of emitting a separate `.css` file. This aligns with [Vite's `?inline` behavior](https://vite.dev/guide/features#disabling-css-injection-into-the-page):
52+
53+
```ts
54+
import css from './theme.css?inline' // Returns processed CSS as a string
55+
import './style.css' // Extracted to a .css file
56+
console.log(css) // ".theme { color: red; }\n"
57+
```
58+
59+
The `?inline` CSS goes through the full processing pipeline — preprocessors, `@import` inlining, syntax lowering, and minification — just like regular CSS. The only difference is the output format: a JavaScript string export instead of a CSS asset file.
60+
61+
This also works with preprocessors:
62+
63+
```ts
64+
import css from './theme.scss?inline'
65+
```
66+
67+
When `?inline` is used, the CSS is not included in the emitted `.css` files and the import is tree-shakeable (`moduleSideEffects: false`).
68+
4969
## CSS Pre-processors
5070

5171
`tsdown` provides built-in support for `.scss`, `.sass`, `.less`, `.styl`, and `.stylus` files. The corresponding pre-processor must be installed as a dev dependency:

docs/zh-CN/options/css.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ export function greet() {
4646

4747
所有被导入的 CSS 会被打包到单个输出文件中,`@import` 语句会被移除。
4848

49+
### 内联 CSS(`?inline`
50+
51+
在 CSS 导入路径后添加 `?inline` 查询参数,可以将完全处理后的 CSS 作为 JavaScript 字符串返回,而不是输出为独立的 `.css` 文件。此行为与 [Vite 的 `?inline` 行为](https://vite.dev/guide/features#disabling-css-injection-into-the-page)保持一致:
52+
53+
```ts
54+
import css from './theme.css?inline' // 返回处理后的 CSS 字符串
55+
import './style.css' // 提取为 .css 文件
56+
console.log(css) // ".theme { color: red; }\n"
57+
```
58+
59+
`?inline` CSS 会经过完整的处理管线——预处理器、`@import` 内联、语法降级和压缩——与普通 CSS 完全一致。唯一的区别是输出格式:JavaScript 字符串导出而非 CSS 资源文件。
60+
61+
也支持预处理器文件:
62+
63+
```ts
64+
import css from './theme.scss?inline'
65+
```
66+
67+
使用 `?inline` 时,CSS 不会包含在输出的 `.css` 文件中,且该导入是可摇树的(`moduleSideEffects: false`)。
68+
4969
## CSS 预处理器
5070

5171
`tsdown` 内置支持 `.scss``.sass``.less``.styl``.stylus` 文件。需要安装对应的预处理器作为开发依赖:

packages/css/src/plugin.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFile } from 'node:fs/promises'
12
import path from 'node:path'
23
import {
34
bundleWithLightningCSS,
@@ -7,13 +8,17 @@ import { resolveCssOptions, type ResolvedCssOptions } from './options.ts'
78
import { CssPostPlugin, type CssStyles } from './post.ts'
89
import { processWithPostCSS as runPostCSS } from './postcss.ts'
910
import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts'
10-
import { getCleanId, RE_CSS } from './utils.ts'
11+
import {
12+
CSS_LANGS_RE,
13+
getCleanId,
14+
RE_CSS,
15+
RE_CSS_INLINE,
16+
RE_INLINE,
17+
} from './utils.ts'
1118
import type { Plugin } from 'rolldown'
1219
import type { ResolvedConfig } from 'tsdown'
1320
import type { Logger } from 'tsdown/internal'
1421

15-
const CSS_LANGS_RE = /\.(?:css|less|sass|scss|styl|stylus)$/
16-
1722
interface CssPluginConfig {
1823
css: ResolvedCssOptions
1924
cwd: string
@@ -38,10 +43,48 @@ export function CssPlugin(
3843
styles.clear()
3944
},
4045

46+
resolveId: {
47+
filter: { id: RE_CSS_INLINE },
48+
async handler(source, ...args) {
49+
const cleanSource = getCleanId(source)
50+
const resolved = await this.resolve(cleanSource, ...args)
51+
if (resolved) {
52+
return {
53+
...resolved,
54+
id: `${resolved.id}?inline`,
55+
}
56+
}
57+
},
58+
},
59+
60+
load: {
61+
filter: { id: RE_CSS_INLINE },
62+
async handler(id) {
63+
const cleanId = getCleanId(id)
64+
// Only handle real files; virtual CSS modules are loaded by their own plugins
65+
if (styles.has(id)) return
66+
67+
const code = await readFile(cleanId, 'utf8').catch(() => null)
68+
if (code == null) return
69+
70+
return {
71+
code,
72+
moduleType: 'js',
73+
}
74+
},
75+
},
76+
4177
transform: {
4278
filter: { id: CSS_LANGS_RE },
4379
async handler(code, id) {
4480
const cleanId = getCleanId(id)
81+
const isInline = RE_INLINE.test(id)
82+
83+
// Skip CSS files with non-inline queries (e.g. ?raw handled by other plugins),
84+
// but allow through virtual CSS from other plugins (e.g. Vue SFC `lang.css`)
85+
// where the clean path itself is not a CSS file.
86+
if (id !== cleanId && !isInline && CSS_LANGS_RE.test(cleanId)) return
87+
4588
const deps: string[] = []
4689

4790
if (cssConfig.css.transformer === 'lightningcss') {
@@ -65,6 +108,14 @@ export function CssPlugin(
65108
code += '\n'
66109
}
67110

111+
if (isInline) {
112+
return {
113+
code: `export default ${JSON.stringify(code)};`,
114+
moduleSideEffects: false,
115+
moduleType: 'js',
116+
}
117+
}
118+
68119
styles.set(id, code)
69120
return {
70121
code: '',
@@ -181,7 +232,7 @@ async function processWithLightningCSS(
181232
config: CssPluginConfig,
182233
logger: Logger,
183234
): Promise<string> {
184-
const lang = getPreprocessorLang(id)
235+
const lang = getPreprocessorLang(cleanId)
185236

186237
if (lang) {
187238
const preResult = await compilePreprocessor(
@@ -199,8 +250,9 @@ async function processWithLightningCSS(
199250
})
200251
}
201252

202-
// Virtual modules (with query strings) can't use file-based bundling
203-
if (id !== cleanId) {
253+
// Virtual modules (with query strings) can't use file-based bundling;
254+
// ?inline is excluded because the underlying file is real.
255+
if (id !== cleanId && !RE_INLINE.test(id)) {
204256
return transformWithLightningCSS(code, cleanId, {
205257
target: config.css.target,
206258
lightningcss: config.css.lightningcss,
@@ -234,7 +286,7 @@ async function processWithPostCSS(
234286
deps: string[],
235287
config: CssPluginConfig,
236288
): Promise<string> {
237-
const lang = getPreprocessorLang(id)
289+
const lang = getPreprocessorLang(cleanId)
238290

239291
if (lang) {
240292
const preResult = await compilePreprocessor(

packages/css/src/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
export const RE_CSS: RegExp = /\.css$/
2+
export const RE_INLINE: RegExp = /[?&]inline\b/
3+
export const CSS_LANGS_RE: RegExp =
4+
/\.(?:css|less|sass|scss|styl|stylus)(?:$|\?)/
5+
export const RE_CSS_INLINE: RegExp =
6+
/\.(?:css|less|sass|scss|styl|stylus)\?(?:.*&)?inline\b/
27

38
export function getCleanId(id: string): string {
49
const queryIndex = id.indexOf('?')

skills/tsdown/references/option-css.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ Output: `index.mjs` + `index.css`
3030

3131
CSS `@import` statements are resolved and inlined automatically. No separate output files produced.
3232

33+
### Inline CSS (`?inline`)
34+
35+
Append `?inline` to return processed CSS as a JS string instead of emitting a `.css` file:
36+
37+
```ts
38+
import './style.css' // → .css file
39+
import css from './theme.css?inline' // → JS string
40+
```
41+
42+
Works with preprocessors too (`./foo.scss?inline`). Goes through full pipeline (preprocessors, @import inlining, lowering, minification). Tree-shakeable (`moduleSideEffects: false`).
43+
3344
## CSS Pre-processors
3445
3546
Built-in support for Sass, Less, and Stylus. Install the preprocessor:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
## index.mjs
2+
3+
```mjs
4+
//#endregion
5+
//#region index.ts
6+
console.log(".foo {\n color: red;\n}\n");
7+
//#endregion
8+
export {};
9+
10+
```
11+
12+
## style.css
13+
14+
```css
15+
.bar {
16+
color: #00f;
17+
}
18+
19+
```
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## index.mjs
2+
3+
```mjs
4+
//#endregion
5+
//#region index.ts
6+
console.log(".foo {\n color: red;\n}\n");
7+
//#endregion
8+
export {};
9+
10+
```
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## index.mjs
2+
3+
```mjs
4+
//#endregion
5+
//#region index.ts
6+
console.log(".foo {\n color: red;\n}\n");
7+
//#endregion
8+
export {};
9+
10+
```

tests/css.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,49 @@ describe('css', () => {
369369
expect(fileMap['index.mjs']).toContain(`.foo{color:red;}`)
370370
})
371371

372+
test('handle .css?inline', async (context) => {
373+
const { fileMap, outputFiles } = await testBuild({
374+
context,
375+
files: {
376+
'index.ts': `import css from './foo.css?inline'; console.log(css);`,
377+
'foo.css': `.foo { color: red; }`,
378+
},
379+
})
380+
expect(outputFiles).toEqual(['index.mjs'])
381+
expect(fileMap['index.mjs']).toContain('.foo')
382+
expect(fileMap['index.mjs']).toContain('color')
383+
})
384+
385+
test('handle .scss?inline', async (context) => {
386+
const { fileMap, outputFiles } = await testBuild({
387+
context,
388+
files: {
389+
'index.ts': `import css from './foo.scss?inline'; console.log(css);`,
390+
'foo.scss': `$color: red; .foo { color: $color; }`,
391+
},
392+
})
393+
expect(outputFiles).toEqual(['index.mjs'])
394+
// Verify SCSS was actually compiled (no $color variable in output)
395+
expect(fileMap['index.mjs']).not.toContain('$color')
396+
expect(fileMap['index.mjs']).toContain('color: red')
397+
})
398+
399+
test('css?inline alongside regular css import', async (context) => {
400+
const { fileMap, outputFiles } = await testBuild({
401+
context,
402+
files: {
403+
'index.ts': `import './bar.css'; import css from './foo.css?inline'; console.log(css);`,
404+
'foo.css': `.foo { color: red; }`,
405+
'bar.css': `.bar { color: blue; }`,
406+
},
407+
})
408+
expect(outputFiles).toContain('style.css')
409+
expect(outputFiles).toContain('index.mjs')
410+
expect(fileMap['style.css']).toContain('.bar')
411+
expect(fileMap['style.css']).not.toContain('.foo')
412+
expect(fileMap['index.mjs']).toContain('.foo')
413+
})
414+
372415
describe('@import bundling', () => {
373416
test('diamond dependency graph', async (context) => {
374417
// From esbuild TestCSSAtImport

0 commit comments

Comments
 (0)