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
5 changes: 5 additions & 0 deletions client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,8 @@ interface ImportGlobFunction {
interface ImportMeta {
glob: ImportGlobFunction
}

declare module '*?inline' {
const src: string
export default src
}
20 changes: 20 additions & 0 deletions docs/options/css.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ CSS `@import` statements are automatically resolved and inlined into the output.

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

### Inline CSS (`?inline`)

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):

```ts
import css from './theme.css?inline' // Returns processed CSS as a string
import './style.css' // Extracted to a .css file
console.log(css) // ".theme { color: red; }\n"
```

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.

This also works with preprocessors:

```ts
import css from './theme.scss?inline'
```

When `?inline` is used, the CSS is not included in the emitted `.css` files and the import is tree-shakeable (`moduleSideEffects: false`).

## CSS Pre-processors

`tsdown` provides built-in support for `.scss`, `.sass`, `.less`, `.styl`, and `.stylus` files. The corresponding pre-processor must be installed as a dev dependency:
Expand Down
20 changes: 20 additions & 0 deletions docs/zh-CN/options/css.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ export function greet() {

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

### 内联 CSS(`?inline`)

在 CSS 导入路径后添加 `?inline` 查询参数,可以将完全处理后的 CSS 作为 JavaScript 字符串返回,而不是输出为独立的 `.css` 文件。此行为与 [Vite 的 `?inline` 行为](https://vite.dev/guide/features#disabling-css-injection-into-the-page)保持一致:

```ts
import css from './theme.css?inline' // 返回处理后的 CSS 字符串
import './style.css' // 提取为 .css 文件
console.log(css) // ".theme { color: red; }\n"
```

`?inline` CSS 会经过完整的处理管线——预处理器、`@import` 内联、语法降级和压缩——与普通 CSS 完全一致。唯一的区别是输出格式:JavaScript 字符串导出而非 CSS 资源文件。

也支持预处理器文件:

```ts
import css from './theme.scss?inline'
```

使用 `?inline` 时,CSS 不会包含在输出的 `.css` 文件中,且该导入是可摇树的(`moduleSideEffects: false`)。

## CSS 预处理器

`tsdown` 内置支持 `.scss`、`.sass`、`.less`、`.styl` 和 `.stylus` 文件。需要安装对应的预处理器作为开发依赖:
Expand Down
66 changes: 59 additions & 7 deletions packages/css/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import {
bundleWithLightningCSS,
Expand All @@ -7,13 +8,17 @@ import { resolveCssOptions, type ResolvedCssOptions } from './options.ts'
import { CssPostPlugin, type CssStyles } from './post.ts'
import { processWithPostCSS as runPostCSS } from './postcss.ts'
import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts'
import { getCleanId, RE_CSS } from './utils.ts'
import {
CSS_LANGS_RE,
getCleanId,
RE_CSS,
RE_CSS_INLINE,
RE_INLINE,
} from './utils.ts'
import type { Plugin } from 'rolldown'
import type { ResolvedConfig } from 'tsdown'
import type { Logger } from 'tsdown/internal'

const CSS_LANGS_RE = /\.(?:css|less|sass|scss|styl|stylus)$/

interface CssPluginConfig {
css: ResolvedCssOptions
cwd: string
Expand All @@ -38,10 +43,48 @@ export function CssPlugin(
styles.clear()
},

resolveId: {
filter: { id: RE_CSS_INLINE },
async handler(source, ...args) {
const cleanSource = getCleanId(source)
const resolved = await this.resolve(cleanSource, ...args)
if (resolved) {
return {
...resolved,
id: `${resolved.id}?inline`,
}
}
},
},

load: {
filter: { id: RE_CSS_INLINE },
async handler(id) {
const cleanId = getCleanId(id)
// Only handle real files; virtual CSS modules are loaded by their own plugins
if (styles.has(id)) return

const code = await readFile(cleanId, 'utf8').catch(() => null)
if (code == null) return

return {
code,
moduleType: 'js',
}
},
},

transform: {
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

const deps: string[] = []

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

if (isInline) {
return {
code: `export default ${JSON.stringify(code)};`,
moduleSideEffects: false,
moduleType: 'js',
}
}

styles.set(id, code)
return {
code: '',
Expand Down Expand Up @@ -181,7 +232,7 @@ async function processWithLightningCSS(
config: CssPluginConfig,
logger: Logger,
): Promise<string> {
const lang = getPreprocessorLang(id)
const lang = getPreprocessorLang(cleanId)

if (lang) {
const preResult = await compilePreprocessor(
Expand All @@ -199,8 +250,9 @@ async function processWithLightningCSS(
})
}

// Virtual modules (with query strings) can't use file-based bundling
if (id !== cleanId) {
// 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)) {
return transformWithLightningCSS(code, cleanId, {
target: config.css.target,
lightningcss: config.css.lightningcss,
Expand Down Expand Up @@ -234,7 +286,7 @@ async function processWithPostCSS(
deps: string[],
config: CssPluginConfig,
): Promise<string> {
const lang = getPreprocessorLang(id)
const lang = getPreprocessorLang(cleanId)

if (lang) {
const preResult = await compilePreprocessor(
Expand Down
5 changes: 5 additions & 0 deletions packages/css/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export const RE_CSS: RegExp = /\.css$/
export const RE_INLINE: RegExp = /[?&]inline\b/
export const CSS_LANGS_RE: RegExp =
/\.(?:css|less|sass|scss|styl|stylus)(?:$|\?)/
export const RE_CSS_INLINE: RegExp =
/\.(?:css|less|sass|scss|styl|stylus)\?(?:.*&)?inline\b/

export function getCleanId(id: string): string {
const queryIndex = id.indexOf('?')
Expand Down
11 changes: 11 additions & 0 deletions skills/tsdown/references/option-css.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ Output: `index.mjs` + `index.css`

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

### Inline CSS (`?inline`)

Append `?inline` to return processed CSS as a JS string instead of emitting a `.css` file:

```ts
import './style.css' // → .css file
import css from './theme.css?inline' // → JS string
```

Works with preprocessors too (`./foo.scss?inline`). Goes through full pipeline (preprocessors, @import inlining, lowering, minification). Tree-shakeable (`moduleSideEffects: false`).

## CSS Pre-processors

Built-in support for Sass, Less, and Stylus. Install the preprocessor:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## index.mjs

```mjs
//#endregion
//#region index.ts
console.log(".foo {\n color: red;\n}\n");
//#endregion
export {};

```

## style.css

```css
.bar {
color: #00f;
}

```
10 changes: 10 additions & 0 deletions tests/__snapshots__/css/handle-css-inline.snap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## index.mjs

```mjs
//#endregion
//#region index.ts
console.log(".foo {\n color: red;\n}\n");
//#endregion
export {};

```
10 changes: 10 additions & 0 deletions tests/__snapshots__/css/handle-scss-inline.snap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## index.mjs

```mjs
//#endregion
//#region index.ts
console.log(".foo {\n color: red;\n}\n");
//#endregion
export {};

```
43 changes: 43 additions & 0 deletions tests/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,49 @@ describe('css', () => {
expect(fileMap['index.mjs']).toContain(`.foo{color:red;}`)
})

test('handle .css?inline', async (context) => {
const { fileMap, outputFiles } = await testBuild({
context,
files: {
'index.ts': `import css from './foo.css?inline'; console.log(css);`,
'foo.css': `.foo { color: red; }`,
},
})
expect(outputFiles).toEqual(['index.mjs'])
expect(fileMap['index.mjs']).toContain('.foo')
expect(fileMap['index.mjs']).toContain('color')
})

test('handle .scss?inline', async (context) => {
const { fileMap, outputFiles } = await testBuild({
context,
files: {
'index.ts': `import css from './foo.scss?inline'; console.log(css);`,
'foo.scss': `$color: red; .foo { color: $color; }`,
},
})
expect(outputFiles).toEqual(['index.mjs'])
// Verify SCSS was actually compiled (no $color variable in output)
expect(fileMap['index.mjs']).not.toContain('$color')
expect(fileMap['index.mjs']).toContain('color: red')
})

test('css?inline alongside regular css import', async (context) => {
const { fileMap, outputFiles } = await testBuild({
context,
files: {
'index.ts': `import './bar.css'; import css from './foo.css?inline'; console.log(css);`,
'foo.css': `.foo { color: red; }`,
'bar.css': `.bar { color: blue; }`,
},
})
expect(outputFiles).toContain('style.css')
expect(outputFiles).toContain('index.mjs')
expect(fileMap['style.css']).toContain('.bar')
expect(fileMap['style.css']).not.toContain('.foo')
expect(fileMap['index.mjs']).toContain('.foo')
})

describe('@import bundling', () => {
test('diamond dependency graph', async (context) => {
// From esbuild TestCSSAtImport
Expand Down
Loading