From bef6a5bf2f945e8a4681418793dfb5b78e436057 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 03:42:17 +0800 Subject: [PATCH 1/4] feat(css): support `?inline` query for CSS imports Aligns with Vite's `?inline` behavior: `import css from './foo.css?inline'` returns the fully processed CSS as a JS string export instead of emitting a separate CSS file. - Add `resolveId`/`load` hooks scoped to CSS `?inline` (won't intercept non-CSS `?inline` like `foo.svg?inline`) - Process inline CSS through the full pipeline (preprocessors, Lightning CSS, PostCSS, @import bundling, minification) - Return `export default "..."` with `moduleSideEffects: false` (tree-shakeable) - Fix `getPreprocessorLang` to use clean ID (without query) so SCSS/Less/Stylus compile correctly with `?inline` - Allow `?inline` CSS through `@import` bundling path (real files, not virtual) - Move `CSS_LANGS_RE` to shared utils --- client.d.ts | 5 ++ packages/css/src/plugin.ts | 66 +++++++++++++++++-- packages/css/src/utils.ts | 5 ++ ...nline-alongside-regular-css-import.snap.md | 19 ++++++ .../css/handle-css-inline.snap.md | 10 +++ .../css/handle-scss-inline.snap.md | 10 +++ tests/css.test.ts | 43 ++++++++++++ 7 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 tests/__snapshots__/css/css-inline-alongside-regular-css-import.snap.md create mode 100644 tests/__snapshots__/css/handle-css-inline.snap.md create mode 100644 tests/__snapshots__/css/handle-scss-inline.snap.md diff --git a/client.d.ts b/client.d.ts index 122a33990..3367afa20 100644 --- a/client.d.ts +++ b/client.d.ts @@ -56,3 +56,8 @@ interface ImportGlobFunction { interface ImportMeta { glob: ImportGlobFunction } + +declare module '*?inline' { + const src: string + export default src +} diff --git a/packages/css/src/plugin.ts b/packages/css/src/plugin.ts index 9282d5874..f1f641259 100644 --- a/packages/css/src/plugin.ts +++ b/packages/css/src/plugin.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import path from 'node:path' import { bundleWithLightningCSS, @@ -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 @@ -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') { @@ -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: '', @@ -181,7 +232,7 @@ async function processWithLightningCSS( config: CssPluginConfig, logger: Logger, ): Promise { - const lang = getPreprocessorLang(id) + const lang = getPreprocessorLang(cleanId) if (lang) { const preResult = await compilePreprocessor( @@ -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, @@ -234,7 +286,7 @@ async function processWithPostCSS( deps: string[], config: CssPluginConfig, ): Promise { - const lang = getPreprocessorLang(id) + const lang = getPreprocessorLang(cleanId) if (lang) { const preResult = await compilePreprocessor( diff --git a/packages/css/src/utils.ts b/packages/css/src/utils.ts index 6b902abae..e90a7d041 100644 --- a/packages/css/src/utils.ts +++ b/packages/css/src/utils.ts @@ -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('?') diff --git a/tests/__snapshots__/css/css-inline-alongside-regular-css-import.snap.md b/tests/__snapshots__/css/css-inline-alongside-regular-css-import.snap.md new file mode 100644 index 000000000..ff11cb93a --- /dev/null +++ b/tests/__snapshots__/css/css-inline-alongside-regular-css-import.snap.md @@ -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; +} + +``` diff --git a/tests/__snapshots__/css/handle-css-inline.snap.md b/tests/__snapshots__/css/handle-css-inline.snap.md new file mode 100644 index 000000000..e52d07837 --- /dev/null +++ b/tests/__snapshots__/css/handle-css-inline.snap.md @@ -0,0 +1,10 @@ +## index.mjs + +```mjs +//#endregion +//#region index.ts +console.log(".foo {\n color: red;\n}\n"); +//#endregion +export {}; + +``` diff --git a/tests/__snapshots__/css/handle-scss-inline.snap.md b/tests/__snapshots__/css/handle-scss-inline.snap.md new file mode 100644 index 000000000..e52d07837 --- /dev/null +++ b/tests/__snapshots__/css/handle-scss-inline.snap.md @@ -0,0 +1,10 @@ +## index.mjs + +```mjs +//#endregion +//#region index.ts +console.log(".foo {\n color: red;\n}\n"); +//#endregion +export {}; + +``` diff --git a/tests/css.test.ts b/tests/css.test.ts index 1e39cbc28..87da57f1e 100644 --- a/tests/css.test.ts +++ b/tests/css.test.ts @@ -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 From ae2ab109bda5fbb60442e312faee532a32c209f7 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 03:43:44 +0800 Subject: [PATCH 2/4] fix(css): match `?inline` in any query position `RE_CSS_INLINE` now matches `foo.css?x=1&inline` in addition to `foo.css?inline`, aligning with Vite's `[?&]inline` pattern. --- packages/css/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/css/src/utils.ts b/packages/css/src/utils.ts index e90a7d041..ffe874441 100644 --- a/packages/css/src/utils.ts +++ b/packages/css/src/utils.ts @@ -3,7 +3,7 @@ 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/ + /\.(?:css|less|sass|scss|styl|stylus)\?(?:.*&)?inline\b/ export function getCleanId(id: string): string { const queryIndex = id.indexOf('?') From 18ca32623f847d29083e73baa80ead76ca27b2fb Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Sun, 8 Mar 2026 03:44:39 +0800 Subject: [PATCH 3/4] docs(css): add `?inline` query documentation --- docs/options/css.md | 20 ++++++++++++++++++++ docs/zh-CN/options/css.md | 20 ++++++++++++++++++++ skills/tsdown/references/option-css.md | 11 +++++++++++ 3 files changed, 51 insertions(+) diff --git a/docs/options/css.md b/docs/options/css.md index 6512da876..2f5ad5442 100644 --- a/docs/options/css.md +++ b/docs/options/css.md @@ -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 './style.css' // Extracted to a .css file +import css from './theme.css?inline' // Returns processed CSS as a string +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: diff --git a/docs/zh-CN/options/css.md b/docs/zh-CN/options/css.md index 88c9a6065..0ff4f0e18 100644 --- a/docs/zh-CN/options/css.md +++ b/docs/zh-CN/options/css.md @@ -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 './style.css' // 提取为 .css 文件 +import css from './theme.css?inline' // 返回处理后的 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` 文件。需要安装对应的预处理器作为开发依赖: diff --git a/skills/tsdown/references/option-css.md b/skills/tsdown/references/option-css.md index d70d4c385..a64a45cdd 100644 --- a/skills/tsdown/references/option-css.md +++ b/skills/tsdown/references/option-css.md @@ -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: From 4c3f932e35cf55fbeebf12265f196bafef730ae9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:45:56 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- docs/options/css.md | 2 +- docs/zh-CN/options/css.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/options/css.md b/docs/options/css.md index 2f5ad5442..dc8868f13 100644 --- a/docs/options/css.md +++ b/docs/options/css.md @@ -51,8 +51,8 @@ All imported CSS is bundled into a single output file with `@import` statements 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 './style.css' // Extracted to a .css file 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" ``` diff --git a/docs/zh-CN/options/css.md b/docs/zh-CN/options/css.md index 0ff4f0e18..c71261124 100644 --- a/docs/zh-CN/options/css.md +++ b/docs/zh-CN/options/css.md @@ -51,8 +51,8 @@ export function greet() { 在 CSS 导入路径后添加 `?inline` 查询参数,可以将完全处理后的 CSS 作为 JavaScript 字符串返回,而不是输出为独立的 `.css` 文件。此行为与 [Vite 的 `?inline` 行为](https://vite.dev/guide/features#disabling-css-injection-into-the-page)保持一致: ```ts -import './style.css' // 提取为 .css 文件 import css from './theme.css?inline' // 返回处理后的 CSS 字符串 +import './style.css' // 提取为 .css 文件 console.log(css) // ".theme { color: red; }\n" ```