Skip to content

Commit 2a88a67

Browse files
feat(css): add CSS modules support (#834)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent a4d4e1d commit 2a88a67

19 files changed

+848
-36
lines changed

docs/options/css.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,94 @@ export function greet() {
328328

329329
This is useful for component libraries where you want CSS to be automatically included when users import your components.
330330

331+
## CSS Modules
332+
333+
Files with the `.module.css` extension (and preprocessor variants like `.module.scss`, `.module.less`, etc.) are treated as [CSS modules](https://github.com/css-modules/css-modules). Class names are automatically scoped and exported as a JavaScript object:
334+
335+
```ts
336+
// src/index.ts
337+
import styles from './app.module.css'
338+
339+
console.log(styles.title) // "scoped_title_hash"
340+
```
341+
342+
```css
343+
/* app.module.css */
344+
.title {
345+
color: red;
346+
}
347+
.content {
348+
font-size: 14px;
349+
}
350+
```
351+
352+
The CSS is emitted with scoped class names, and the JS output exports the mapping from original to scoped names.
353+
354+
### Configuration
355+
356+
Configure CSS modules behavior via `css.modules`:
357+
358+
```ts
359+
export default defineConfig({
360+
css: {
361+
modules: {
362+
// Scoping behavior: 'local' (default) or 'global'
363+
scopeBehaviour: 'local',
364+
365+
// Pattern for scoped class names (Lightning CSS pattern syntax)
366+
generateScopedName: '[hash]_[local]',
367+
368+
// Transform class name convention in JS exports
369+
localsConvention: 'camelCase',
370+
},
371+
},
372+
})
373+
```
374+
375+
Set `css.modules: false` to disable CSS modules entirely — `.module.css` files will be treated as regular CSS.
376+
377+
### `localsConvention`
378+
379+
Controls how class names are exported in JavaScript:
380+
381+
| Value | Input | Exports |
382+
| ----------------- | --------- | ------------------- |
383+
| _(not set)_ | `foo-bar` | `foo-bar` |
384+
| `'camelCase'` | `foo-bar` | `foo-bar`, `fooBar` |
385+
| `'camelCaseOnly'` | `foo-bar` | `fooBar` |
386+
| `'dashes'` | `foo-bar` | `foo-bar`, `fooBar` |
387+
| `'dashesOnly'` | `foo-bar` | `fooBar` |
388+
389+
### `generateScopedName`
390+
391+
When using `transformer: 'lightningcss'` (default), this accepts a Lightning CSS [pattern string](https://lightningcss.dev/css-modules.html#custom-naming-conventions) (e.g., `'[hash]_[local]'`).
392+
393+
When using `transformer: 'postcss'`, this also accepts a function:
394+
395+
```ts
396+
export default defineConfig({
397+
css: {
398+
transformer: 'postcss',
399+
modules: {
400+
generateScopedName: (name, filename, css) => {
401+
return `my-lib_${name}`
402+
},
403+
},
404+
},
405+
})
406+
```
407+
408+
> [!NOTE]
409+
> Function-form `generateScopedName` is only supported with `transformer: 'postcss'`. The Lightning CSS transformer only supports string patterns.
410+
411+
### Optional Dependencies
412+
413+
When using `transformer: 'postcss'` with CSS modules, install [`postcss-modules`](https://github.com/css-modules/postcss-modules):
414+
415+
```bash
416+
npm install -D postcss postcss-modules
417+
```
418+
331419
## CSS Code Splitting
332420

333421
### Merged Mode (Default)
@@ -372,6 +460,22 @@ dist/
372460
async-abc123.css ← CSS from async chunk
373461
```
374462

463+
## PostCSS Optional Peer Dependencies
464+
465+
When using `transformer: 'postcss'`, the following packages may need to be installed depending on the features you use:
466+
467+
| Package | Purpose | Required When |
468+
| ------------------------------------------------------------------- | ---------------------------------------- | -------------------------------------- |
469+
| [`postcss`](https://github.com/postcss/postcss) | Core PostCSS engine | Always (with `transformer: 'postcss'`) |
470+
| [`postcss-import`](https://github.com/postcss/postcss-import) | Resolve and inline `@import` statements | CSS files use `@import` |
471+
| [`postcss-modules`](https://github.com/css-modules/postcss-modules) | CSS modules support (scoped class names) | Using `.module.css` files |
472+
473+
```bash
474+
npm install -D postcss postcss-import postcss-modules
475+
```
476+
477+
All three are declared as optional peer dependencies of `@tsdown/css` and only loaded when needed.
478+
375479
## Options Reference
376480

377481
| Option | Type | Default | Description |
@@ -380,6 +484,7 @@ dist/
380484
| `css.splitting` | `boolean` | `false` | Enable CSS code splitting per chunk |
381485
| `css.fileName` | `string` | `'style.css'` | File name for the merged CSS file (when `splitting: false`) |
382486
| `css.minify` | `boolean` | `false` | Enable CSS minification |
487+
| `css.modules` | `object \| false` | `{}` | CSS modules configuration, or `false` to disable |
383488
| `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific syntax lowering target |
384489
| `css.postcss` | `string \| object` || PostCSS config path or inline options |
385490
| `css.preprocessorOptions` | `object` || Options for CSS preprocessors |

docs/zh-CN/options/css.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,94 @@ export function greet() {
328328

329329
这对于组件库非常有用,可以确保用户导入组件时自动包含对应的 CSS。
330330

331+
## CSS Modules
332+
333+
扩展名为 `.module.css` 的文件(以及预处理器变体如 `.module.scss``.module.less` 等)会被视为 [CSS Modules](https://github.com/css-modules/css-modules)。类名会自动添加作用域,并作为 JavaScript 对象导出:
334+
335+
```ts
336+
// src/index.ts
337+
import styles from './app.module.css'
338+
339+
console.log(styles.title) // "scoped_title_hash"
340+
```
341+
342+
```css
343+
/* app.module.css */
344+
.title {
345+
color: red;
346+
}
347+
.content {
348+
font-size: 14px;
349+
}
350+
```
351+
352+
CSS 会以作用域化的类名输出,JS 输出导出原始类名到作用域化类名的映射。
353+
354+
### 配置
355+
356+
通过 `css.modules` 配置 CSS modules 行为:
357+
358+
```ts
359+
export default defineConfig({
360+
css: {
361+
modules: {
362+
// 作用域行为:'local'(默认)或 'global'
363+
scopeBehaviour: 'local',
364+
365+
// 作用域类名模式(Lightning CSS 模式语法)
366+
generateScopedName: '[hash]_[local]',
367+
368+
// JS 导出中的类名转换约定
369+
localsConvention: 'camelCase',
370+
},
371+
},
372+
})
373+
```
374+
375+
设置 `css.modules: false` 可完全禁用 CSS modules——`.module.css` 文件将被视为普通 CSS。
376+
377+
### `localsConvention`
378+
379+
控制类名在 JavaScript 中的导出方式:
380+
381+
|| 输入 | 导出 |
382+
| ----------------- | --------- | ------------------- |
383+
| _(未设置)_ | `foo-bar` | `foo-bar` |
384+
| `'camelCase'` | `foo-bar` | `foo-bar``fooBar` |
385+
| `'camelCaseOnly'` | `foo-bar` | `fooBar` |
386+
| `'dashes'` | `foo-bar` | `foo-bar``fooBar` |
387+
| `'dashesOnly'` | `foo-bar` | `fooBar` |
388+
389+
### `generateScopedName`
390+
391+
使用 `transformer: 'lightningcss'`(默认)时,接受 Lightning CSS [模式字符串](https://lightningcss.dev/css-modules.html#custom-naming-conventions)(如 `'[hash]_[local]'`)。
392+
393+
使用 `transformer: 'postcss'` 时,还支持函数形式:
394+
395+
```ts
396+
export default defineConfig({
397+
css: {
398+
transformer: 'postcss',
399+
modules: {
400+
generateScopedName: (name, filename, css) => {
401+
return `my-lib_${name}`
402+
},
403+
},
404+
},
405+
})
406+
```
407+
408+
> [!NOTE]
409+
> 函数形式的 `generateScopedName` 仅在 `transformer: 'postcss'` 时支持。Lightning CSS 转换器仅支持字符串模式。
410+
411+
### 可选依赖
412+
413+
使用 `transformer: 'postcss'` 配合 CSS modules 时,需安装 [`postcss-modules`](https://github.com/css-modules/postcss-modules)
414+
415+
```bash
416+
npm install -D postcss postcss-modules
417+
```
418+
331419
## CSS 代码分割
332420

333421
### 合并模式(默认)
@@ -372,6 +460,22 @@ dist/
372460
async-abc123.css ← 异步 chunk 的 CSS
373461
```
374462

463+
## PostCSS 可选依赖
464+
465+
使用 `transformer: 'postcss'` 时,根据使用的功能可能需要安装以下包:
466+
467+
|| 用途 | 何时需要 |
468+
| ------------------------------------------------------------------- | ------------------------------ | -------------------------------------------- |
469+
| [`postcss`](https://github.com/postcss/postcss) | PostCSS 核心引擎 | 始终需要(使用 `transformer: 'postcss'` 时) |
470+
| [`postcss-import`](https://github.com/postcss/postcss-import) | 解析和内联 `@import` 语句 | CSS 文件使用 `@import`|
471+
| [`postcss-modules`](https://github.com/css-modules/postcss-modules) | CSS modules 支持(作用域类名) | 使用 `.module.css` 文件时 |
472+
473+
```bash
474+
npm install -D postcss postcss-import postcss-modules
475+
```
476+
477+
这三个包都声明为 `@tsdown/css` 的可选 peer dependencies,仅在需要时加载。
478+
375479
## 选项参考
376480

377481
| 选项 | 类型 | 默认值 | 描述 |
@@ -380,6 +484,7 @@ dist/
380484
| `css.splitting` | `boolean` | `false` | 启用按 chunk 的 CSS 代码分割 |
381485
| `css.fileName` | `string` | `'style.css'` | 合并 CSS 的文件名(当 `splitting: false` 时) |
382486
| `css.minify` | `boolean` | `false` | 启用 CSS 压缩 |
487+
| `css.modules` | `object \| false` | `{}` | CSS modules 配置,或 `false` 禁用 |
383488
| `css.target` | `string \| string[] \| false` | _继承 `target`_ | CSS 专用语法降级目标 |
384489
| `css.postcss` | `string \| object` || PostCSS 配置路径或内联选项 |
385490
| `css.preprocessorOptions` | `object` || CSS 预处理器选项 |

packages/css/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"peerDependencies": {
5151
"postcss": "^8.4.0",
5252
"postcss-import": "^16.0.0",
53+
"postcss-modules": "^6.0.0",
5354
"sass": "*",
5455
"sass-embedded": "*",
5556
"tsdown": "workspace:*"
@@ -61,6 +62,9 @@
6162
"postcss-import": {
6263
"optional": true
6364
},
65+
"postcss-modules": {
66+
"optional": true
67+
},
6468
"sass": {
6569
"optional": true
6670
},

packages/css/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { resolveCssOptions } from './options.ts'
22
export { CssPlugin } from './plugin.ts'
33
export type {
4+
CSSModulesOptions,
45
CssOptions,
56
LessPreprocessorOptions,
67
LightningCSSOptions,

packages/css/src/lightningcss.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { readFileSync } from 'node:fs'
22
import path from 'node:path'
3+
import { extractLightningCssModuleExports } from './modules.ts'
34
import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts'
45
import { getCssResolver, resolveWithResolver } from './resolve.ts'
56
import type { LightningCSSOptions, PreprocessorOptions } from './options.ts'
6-
import type { Targets } from 'lightningcss'
7+
import type { CSSModulesConfig, Targets } from 'lightningcss'
78
import type { Logger } from 'tsdown/internal'
89

910
const encoder = new TextEncoder()
@@ -13,31 +14,44 @@ export interface TransformCssOptions {
1314
target?: string[]
1415
lightningcss?: LightningCSSOptions
1516
minify?: boolean
17+
cssModules?: boolean | CSSModulesConfig
18+
}
19+
20+
export interface TransformCssResult {
21+
code: string
22+
modules?: Record<string, string>
1623
}
1724

1825
export interface BundleCssOptions {
1926
target?: string[]
2027
lightningcss?: LightningCSSOptions
2128
minify?: boolean
29+
cssModules?: boolean | CSSModulesConfig
2230
preprocessorOptions?: PreprocessorOptions
2331
logger: Logger
2432
}
2533

2634
export interface BundleCssResult {
2735
code: string
2836
deps: string[]
37+
modules?: Record<string, string>
2938
}
3039

3140
export async function transformWithLightningCSS(
3241
code: string,
3342
filename: string,
3443
options: TransformCssOptions,
35-
): Promise<string> {
44+
): Promise<TransformCssResult> {
3645
const targets =
3746
options.lightningcss?.targets ??
3847
(options.target ? esbuildTargetToLightningCSS(options.target) : undefined)
39-
if (!targets && !options.lightningcss && !options.minify) {
40-
return code
48+
if (
49+
!targets &&
50+
!options.lightningcss &&
51+
!options.minify &&
52+
!options.cssModules
53+
) {
54+
return { code }
4155
}
4256

4357
const { transform } = await import('lightningcss')
@@ -47,9 +61,15 @@ export async function transformWithLightningCSS(
4761
...options.lightningcss,
4862
targets,
4963
minify: options.minify,
64+
cssModules: options.cssModules,
5065
})
5166

52-
return decoder.decode(result.code)
67+
return {
68+
code: decoder.decode(result.code),
69+
modules: result.exports
70+
? extractLightningCssModuleExports(result.exports)
71+
: undefined,
72+
}
5373
}
5474

5575
export async function bundleWithLightningCSS(
@@ -69,6 +89,7 @@ export async function bundleWithLightningCSS(
6989
...options.lightningcss,
7090
targets,
7191
minify: options.minify,
92+
cssModules: options.cssModules,
7293
resolver: {
7394
async read(filePath: string) {
7495
let fileCode: string
@@ -108,6 +129,9 @@ export async function bundleWithLightningCSS(
108129
return {
109130
code: new TextDecoder().decode(result.code),
110131
deps,
132+
modules: result.exports
133+
? extractLightningCssModuleExports(result.exports)
134+
: undefined,
111135
}
112136
}
113137

0 commit comments

Comments
 (0)