diff --git a/docs/options/css.md b/docs/options/css.md index 3c6890a29..53b765a60 100644 --- a/docs/options/css.md +++ b/docs/options/css.md @@ -328,6 +328,94 @@ export function greet() { This is useful for component libraries where you want CSS to be automatically included when users import your components. +## CSS Modules + +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: + +```ts +// src/index.ts +import styles from './app.module.css' + +console.log(styles.title) // "scoped_title_hash" +``` + +```css +/* app.module.css */ +.title { + color: red; +} +.content { + font-size: 14px; +} +``` + +The CSS is emitted with scoped class names, and the JS output exports the mapping from original to scoped names. + +### Configuration + +Configure CSS modules behavior via `css.modules`: + +```ts +export default defineConfig({ + css: { + modules: { + // Scoping behavior: 'local' (default) or 'global' + scopeBehaviour: 'local', + + // Pattern for scoped class names (Lightning CSS pattern syntax) + generateScopedName: '[hash]_[local]', + + // Transform class name convention in JS exports + localsConvention: 'camelCase', + }, + }, +}) +``` + +Set `css.modules: false` to disable CSS modules entirely — `.module.css` files will be treated as regular CSS. + +### `localsConvention` + +Controls how class names are exported in JavaScript: + +| Value | Input | Exports | +| ----------------- | --------- | ------------------- | +| _(not set)_ | `foo-bar` | `foo-bar` | +| `'camelCase'` | `foo-bar` | `foo-bar`, `fooBar` | +| `'camelCaseOnly'` | `foo-bar` | `fooBar` | +| `'dashes'` | `foo-bar` | `foo-bar`, `fooBar` | +| `'dashesOnly'` | `foo-bar` | `fooBar` | + +### `generateScopedName` + +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]'`). + +When using `transformer: 'postcss'`, this also accepts a function: + +```ts +export default defineConfig({ + css: { + transformer: 'postcss', + modules: { + generateScopedName: (name, filename, css) => { + return `my-lib_${name}` + }, + }, + }, +}) +``` + +> [!NOTE] +> Function-form `generateScopedName` is only supported with `transformer: 'postcss'`. The Lightning CSS transformer only supports string patterns. + +### Optional Dependencies + +When using `transformer: 'postcss'` with CSS modules, install [`postcss-modules`](https://github.com/css-modules/postcss-modules): + +```bash +npm install -D postcss postcss-modules +``` + ## CSS Code Splitting ### Merged Mode (Default) @@ -372,6 +460,22 @@ dist/ async-abc123.css ← CSS from async chunk ``` +## PostCSS Optional Peer Dependencies + +When using `transformer: 'postcss'`, the following packages may need to be installed depending on the features you use: + +| Package | Purpose | Required When | +| ------------------------------------------------------------------- | ---------------------------------------- | -------------------------------------- | +| [`postcss`](https://github.com/postcss/postcss) | Core PostCSS engine | Always (with `transformer: 'postcss'`) | +| [`postcss-import`](https://github.com/postcss/postcss-import) | Resolve and inline `@import` statements | CSS files use `@import` | +| [`postcss-modules`](https://github.com/css-modules/postcss-modules) | CSS modules support (scoped class names) | Using `.module.css` files | + +```bash +npm install -D postcss postcss-import postcss-modules +``` + +All three are declared as optional peer dependencies of `@tsdown/css` and only loaded when needed. + ## Options Reference | Option | Type | Default | Description | @@ -380,6 +484,7 @@ dist/ | `css.splitting` | `boolean` | `false` | Enable CSS code splitting per chunk | | `css.fileName` | `string` | `'style.css'` | File name for the merged CSS file (when `splitting: false`) | | `css.minify` | `boolean` | `false` | Enable CSS minification | +| `css.modules` | `object \| false` | `{}` | CSS modules configuration, or `false` to disable | | `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific syntax lowering target | | `css.postcss` | `string \| object` | — | PostCSS config path or inline options | | `css.preprocessorOptions` | `object` | — | Options for CSS preprocessors | diff --git a/docs/zh-CN/options/css.md b/docs/zh-CN/options/css.md index 7c23d2e23..1d6f9d84d 100644 --- a/docs/zh-CN/options/css.md +++ b/docs/zh-CN/options/css.md @@ -328,6 +328,94 @@ export function greet() { 这对于组件库非常有用,可以确保用户导入组件时自动包含对应的 CSS。 +## CSS Modules + +扩展名为 `.module.css` 的文件(以及预处理器变体如 `.module.scss`、`.module.less` 等)会被视为 [CSS Modules](https://github.com/css-modules/css-modules)。类名会自动添加作用域,并作为 JavaScript 对象导出: + +```ts +// src/index.ts +import styles from './app.module.css' + +console.log(styles.title) // "scoped_title_hash" +``` + +```css +/* app.module.css */ +.title { + color: red; +} +.content { + font-size: 14px; +} +``` + +CSS 会以作用域化的类名输出,JS 输出导出原始类名到作用域化类名的映射。 + +### 配置 + +通过 `css.modules` 配置 CSS modules 行为: + +```ts +export default defineConfig({ + css: { + modules: { + // 作用域行为:'local'(默认)或 'global' + scopeBehaviour: 'local', + + // 作用域类名模式(Lightning CSS 模式语法) + generateScopedName: '[hash]_[local]', + + // JS 导出中的类名转换约定 + localsConvention: 'camelCase', + }, + }, +}) +``` + +设置 `css.modules: false` 可完全禁用 CSS modules——`.module.css` 文件将被视为普通 CSS。 + +### `localsConvention` + +控制类名在 JavaScript 中的导出方式: + +| 值 | 输入 | 导出 | +| ----------------- | --------- | ------------------- | +| _(未设置)_ | `foo-bar` | `foo-bar` | +| `'camelCase'` | `foo-bar` | `foo-bar`、`fooBar` | +| `'camelCaseOnly'` | `foo-bar` | `fooBar` | +| `'dashes'` | `foo-bar` | `foo-bar`、`fooBar` | +| `'dashesOnly'` | `foo-bar` | `fooBar` | + +### `generateScopedName` + +使用 `transformer: 'lightningcss'`(默认)时,接受 Lightning CSS [模式字符串](https://lightningcss.dev/css-modules.html#custom-naming-conventions)(如 `'[hash]_[local]'`)。 + +使用 `transformer: 'postcss'` 时,还支持函数形式: + +```ts +export default defineConfig({ + css: { + transformer: 'postcss', + modules: { + generateScopedName: (name, filename, css) => { + return `my-lib_${name}` + }, + }, + }, +}) +``` + +> [!NOTE] +> 函数形式的 `generateScopedName` 仅在 `transformer: 'postcss'` 时支持。Lightning CSS 转换器仅支持字符串模式。 + +### 可选依赖 + +使用 `transformer: 'postcss'` 配合 CSS modules 时,需安装 [`postcss-modules`](https://github.com/css-modules/postcss-modules): + +```bash +npm install -D postcss postcss-modules +``` + ## CSS 代码分割 ### 合并模式(默认) @@ -372,6 +460,22 @@ dist/ async-abc123.css ← 异步 chunk 的 CSS ``` +## PostCSS 可选依赖 + +使用 `transformer: 'postcss'` 时,根据使用的功能可能需要安装以下包: + +| 包 | 用途 | 何时需要 | +| ------------------------------------------------------------------- | ------------------------------ | -------------------------------------------- | +| [`postcss`](https://github.com/postcss/postcss) | PostCSS 核心引擎 | 始终需要(使用 `transformer: 'postcss'` 时) | +| [`postcss-import`](https://github.com/postcss/postcss-import) | 解析和内联 `@import` 语句 | CSS 文件使用 `@import` 时 | +| [`postcss-modules`](https://github.com/css-modules/postcss-modules) | CSS modules 支持(作用域类名) | 使用 `.module.css` 文件时 | + +```bash +npm install -D postcss postcss-import postcss-modules +``` + +这三个包都声明为 `@tsdown/css` 的可选 peer dependencies,仅在需要时加载。 + ## 选项参考 | 选项 | 类型 | 默认值 | 描述 | @@ -380,6 +484,7 @@ dist/ | `css.splitting` | `boolean` | `false` | 启用按 chunk 的 CSS 代码分割 | | `css.fileName` | `string` | `'style.css'` | 合并 CSS 的文件名(当 `splitting: false` 时) | | `css.minify` | `boolean` | `false` | 启用 CSS 压缩 | +| `css.modules` | `object \| false` | `{}` | CSS modules 配置,或 `false` 禁用 | | `css.target` | `string \| string[] \| false` | _继承 `target`_ | CSS 专用语法降级目标 | | `css.postcss` | `string \| object` | — | PostCSS 配置路径或内联选项 | | `css.preprocessorOptions` | `object` | — | CSS 预处理器选项 | diff --git a/packages/css/package.json b/packages/css/package.json index 7ed85811d..54391f282 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -50,6 +50,7 @@ "peerDependencies": { "postcss": "^8.4.0", "postcss-import": "^16.0.0", + "postcss-modules": "^6.0.0", "sass": "*", "sass-embedded": "*", "tsdown": "workspace:*" @@ -61,6 +62,9 @@ "postcss-import": { "optional": true }, + "postcss-modules": { + "optional": true + }, "sass": { "optional": true }, diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index 73db5a107..5417c970e 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -1,6 +1,7 @@ export { resolveCssOptions } from './options.ts' export { CssPlugin } from './plugin.ts' export type { + CSSModulesOptions, CssOptions, LessPreprocessorOptions, LightningCSSOptions, diff --git a/packages/css/src/lightningcss.ts b/packages/css/src/lightningcss.ts index 1f6426b84..d223ec5a5 100644 --- a/packages/css/src/lightningcss.ts +++ b/packages/css/src/lightningcss.ts @@ -1,9 +1,10 @@ import { readFileSync } from 'node:fs' import path from 'node:path' +import { extractLightningCssModuleExports } from './modules.ts' import { compilePreprocessor, getPreprocessorLang } from './preprocessors.ts' import { getCssResolver, resolveWithResolver } from './resolve.ts' import type { LightningCSSOptions, PreprocessorOptions } from './options.ts' -import type { Targets } from 'lightningcss' +import type { CSSModulesConfig, Targets } from 'lightningcss' import type { Logger } from 'tsdown/internal' const encoder = new TextEncoder() @@ -13,12 +14,19 @@ export interface TransformCssOptions { target?: string[] lightningcss?: LightningCSSOptions minify?: boolean + cssModules?: boolean | CSSModulesConfig +} + +export interface TransformCssResult { + code: string + modules?: Record } export interface BundleCssOptions { target?: string[] lightningcss?: LightningCSSOptions minify?: boolean + cssModules?: boolean | CSSModulesConfig preprocessorOptions?: PreprocessorOptions logger: Logger } @@ -26,18 +34,24 @@ export interface BundleCssOptions { export interface BundleCssResult { code: string deps: string[] + modules?: Record } export async function transformWithLightningCSS( code: string, filename: string, options: TransformCssOptions, -): Promise { +): Promise { const targets = options.lightningcss?.targets ?? (options.target ? esbuildTargetToLightningCSS(options.target) : undefined) - if (!targets && !options.lightningcss && !options.minify) { - return code + if ( + !targets && + !options.lightningcss && + !options.minify && + !options.cssModules + ) { + return { code } } const { transform } = await import('lightningcss') @@ -47,9 +61,15 @@ export async function transformWithLightningCSS( ...options.lightningcss, targets, minify: options.minify, + cssModules: options.cssModules, }) - return decoder.decode(result.code) + return { + code: decoder.decode(result.code), + modules: result.exports + ? extractLightningCssModuleExports(result.exports) + : undefined, + } } export async function bundleWithLightningCSS( @@ -69,6 +89,7 @@ export async function bundleWithLightningCSS( ...options.lightningcss, targets, minify: options.minify, + cssModules: options.cssModules, resolver: { async read(filePath: string) { let fileCode: string @@ -108,6 +129,9 @@ export async function bundleWithLightningCSS( return { code: new TextDecoder().decode(result.code), deps, + modules: result.exports + ? extractLightningCssModuleExports(result.exports) + : undefined, } } diff --git a/packages/css/src/modules.ts b/packages/css/src/modules.ts new file mode 100644 index 000000000..48a9283f7 --- /dev/null +++ b/packages/css/src/modules.ts @@ -0,0 +1,70 @@ +import type { CSSModuleExports } from 'lightningcss' + +const VALID_ID_RE = /^[$_a-z][$\w]*$/i + +export function modulesToEsm(modules: Record): string { + const lines: string[] = [] + for (const [key, value] of Object.entries(modules)) { + if (VALID_ID_RE.test(key)) { + lines.push(`export const ${key} = ${JSON.stringify(value)};`) + } + } + lines.push(`export default ${JSON.stringify(modules)};`) + return lines.join('\n') +} + +function dashToCamel(str: string): string { + return str.replaceAll(/-([a-z])/g, (_, c: string) => c.toUpperCase()) +} + +export function applyLocalsConvention( + modules: Record, + convention: 'camelCase' | 'camelCaseOnly' | 'dashes' | 'dashesOnly', +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(modules)) { + const camelized = dashToCamel(key) + switch (convention) { + case 'camelCase': + result[key] = value + if (camelized !== key) { + result[camelized] = value + } + break + case 'camelCaseOnly': + result[camelized] = value + break + case 'dashes': + result[key] = value + if (key.includes('-')) { + result[camelized] = value + } + break + case 'dashesOnly': + if (key.includes('-')) { + result[camelized] = value + } else { + result[key] = value + } + break + } + } + return result +} + +export function extractLightningCssModuleExports( + exports: CSSModuleExports, +): Record { + const modules: Record = {} + const sortedEntries = Object.entries(exports).toSorted(([a], [b]) => + a.localeCompare(b), + ) + for (const [key, value] of sortedEntries) { + let name = value.name + for (const c of value.composes) { + name += ` ${c.name}` + } + modules[key] = name + } + return modules +} diff --git a/packages/css/src/options.test.ts b/packages/css/src/options.test.ts index 761a96358..765b88e57 100644 --- a/packages/css/src/options.test.ts +++ b/packages/css/src/options.test.ts @@ -10,6 +10,7 @@ describe('resolveCssOptions', () => { fileName: defaultCssBundleName, minify: false, inject: false, + modules: {}, target: undefined, preprocessorOptions: undefined, lightningcss: undefined, @@ -42,6 +43,26 @@ describe('resolveCssOptions', () => { expect(result.target).toBeUndefined() }) + test('modules defaults to empty object', () => { + const result = resolveCssOptions() + expect(result.modules).toEqual({}) + }) + + test('modules=false disables CSS modules', () => { + const result = resolveCssOptions({ modules: false }) + expect(result.modules).toBe(false) + }) + + test('modules config is passed through', () => { + const result = resolveCssOptions({ + modules: { localsConvention: 'camelCase', hashPrefix: 'app' }, + }) + expect(result.modules).toEqual({ + localsConvention: 'camelCase', + hashPrefix: 'app', + }) + }) + test('custom options are passed through', () => { const result = resolveCssOptions({ transformer: 'postcss', @@ -59,6 +80,7 @@ describe('resolveCssOptions', () => { fileName: 'custom.css', minify: true, inject: true, + modules: {}, target: undefined, preprocessorOptions: { scss: { additionalData: '$x: 1;' } }, lightningcss: { drafts: { customMedia: true } }, diff --git a/packages/css/src/options.ts b/packages/css/src/options.ts index 4388d0319..99a02f34d 100644 --- a/packages/css/src/options.ts +++ b/packages/css/src/options.ts @@ -11,6 +11,51 @@ export type LightningCSSOptions = Omit< 'filename' | 'code' > +export interface CSSModulesOptions { + /** + * Controls the scoping behavior. + * @default 'local' + */ + scopeBehaviour?: 'global' | 'local' + + /** + * File paths matching these patterns will use global scoping. + */ + globalModulePaths?: RegExp[] + + /** + * Pattern or function to generate scoped class names. + * When using `transformer: 'lightningcss'`, only string patterns are supported. + */ + generateScopedName?: + | string + | ((name: string, filename: string, css: string) => string) + + /** + * Prefix added to hashes when generating scoped names. + */ + hashPrefix?: string + + /** + * Transform convention for exported class names. + */ + localsConvention?: 'camelCase' | 'camelCaseOnly' | 'dashes' | 'dashesOnly' + + /** + * Whether to include global class names in the export. + */ + exportGlobals?: boolean + + /** + * Callback to receive the generated class name mappings. + */ + getJSON?: ( + cssFileName: string, + json: Record, + outputFileName: string, + ) => void +} + export interface CssOptions { /** * Enable/disable CSS code splitting. @@ -77,6 +122,15 @@ export interface CssOptions { */ inject?: boolean + /** + * CSS modules configuration. + * When not `false`, `.module.css` files (and preprocessor variants) are + * treated as CSS modules with scoped class names. + * + * @see https://github.com/css-modules/css-modules + */ + modules?: CSSModulesOptions | false + /** * CSS transformer to use. Controls how CSS is processed: * @@ -140,7 +194,7 @@ export interface StylusPreprocessorOptions { export type ResolvedCssOptions = Overwrite< MarkPartial< Required, - 'preprocessorOptions' | 'lightningcss' | 'postcss' + 'preprocessorOptions' | 'lightningcss' | 'postcss' | 'modules' >, { target?: string[] } > @@ -166,6 +220,7 @@ export function resolveCssOptions( fileName: options.fileName ?? defaultCssBundleName, minify: options.minify ?? false, inject: options.inject ?? false, + modules: options.modules === false ? false : (options.modules ?? {}), target: cssTarget, preprocessorOptions: options.preprocessorOptions, lightningcss: options.lightningcss, diff --git a/packages/css/src/plugin.ts b/packages/css/src/plugin.ts index f5a6823e7..7cf316bb6 100644 --- a/packages/css/src/plugin.ts +++ b/packages/css/src/plugin.ts @@ -4,17 +4,24 @@ import { bundleWithLightningCSS, transformWithLightningCSS, } from './lightningcss.ts' -import { resolveCssOptions, type ResolvedCssOptions } from './options.ts' +import { applyLocalsConvention, modulesToEsm } from './modules.ts' +import { + resolveCssOptions, + type CSSModulesOptions, + 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 { CSS_LANGS_RE, + CSS_MODULE_RE, getCleanId, RE_CSS, RE_CSS_INLINE, RE_INLINE, } from './utils.ts' +import type { CSSModulesConfig } from 'lightningcss' import type { Plugin } from 'rolldown' import type { ResolvedConfig } from 'tsdown' import type { Logger } from 'tsdown/internal' @@ -87,19 +94,37 @@ export function CssPlugin( // where the clean path itself is not a CSS file. if (id !== cleanId && !isInline && CSS_LANGS_RE.test(cleanId)) return + const isModule = + !isInline && + cssConfig.css.modules !== false && + CSS_MODULE_RE.test(cleanId) + const deps: string[] = [] + let modules: Record | undefined if (cssConfig.css.transformer === 'lightningcss') { - code = await processWithLightningCSS( + const result = await processWithLightningCSS( code, id, cleanId, deps, cssConfig, logger, + isModule, ) + code = result.code + modules = result.modules } else { - code = await processWithPostCSS(code, id, cleanId, deps, cssConfig) + const result = await processWithPostCSS( + code, + id, + cleanId, + deps, + cssConfig, + isModule, + ) + code = result.code + modules = result.modules } for (const dep of deps) { @@ -110,6 +135,20 @@ export function CssPlugin( code += '\n' } + if (modules) { + const modulesConfig = + typeof cssConfig.css.modules === 'object' + ? cssConfig.css.modules + : undefined + if (modulesConfig?.localsConvention) { + modules = applyLocalsConvention( + modules, + modulesConfig.localsConvention, + ) + } + modulesConfig?.getJSON?.(cleanId, modules, cleanId) + } + if (isInline) { return { code: `export default ${JSON.stringify(code)};`, @@ -118,7 +157,18 @@ export function CssPlugin( } } - styles.set(id, code) + if (code.length) { + styles.set(id, code) + } + + if (modules) { + return { + code: modulesToEsm(modules), + moduleSideEffects: false, + moduleType: 'js', + } + } + return { code: '', moduleSideEffects: 'no-treeshake', @@ -226,6 +276,36 @@ export function CssPlugin( return plugins } +interface ProcessResult { + code: string + modules?: Record +} + +function resolveCssModulesConfig( + modulesOptions: CSSModulesOptions | false | undefined, + isModule: boolean, + logger: Logger, +): boolean | CSSModulesConfig | undefined { + if (!isModule) return undefined + + const config = typeof modulesOptions === 'object' ? modulesOptions : undefined + if (!config) return true + + const cssModulesConfig: CSSModulesConfig = {} + if (typeof config.generateScopedName === 'string') { + cssModulesConfig.pattern = config.generateScopedName + } else if (typeof config.generateScopedName === 'function') { + logger.warn( + '[@tsdown/css] `generateScopedName` as a function is not supported with `transformer: "lightningcss"`. Use a string pattern or switch to `transformer: "postcss"`.', + ) + } + if (config.scopeBehaviour === 'global') { + cssModulesConfig.pattern = '[local]' + } + + return Object.keys(cssModulesConfig).length > 0 ? cssModulesConfig : true +} + async function processWithLightningCSS( code: string, id: string, @@ -233,8 +313,14 @@ async function processWithLightningCSS( deps: string[], config: CssPluginConfig, logger: Logger, -): Promise { + isModule: boolean, +): Promise { const lang = getPreprocessorLang(cleanId) + const cssModules = resolveCssModulesConfig( + config.css.modules, + isModule, + logger, + ) if (lang) { const preResult = await compilePreprocessor( @@ -249,6 +335,7 @@ async function processWithLightningCSS( target: config.css.target, lightningcss: config.css.lightningcss, minify: config.css.minify, + cssModules, }) } @@ -259,6 +346,7 @@ async function processWithLightningCSS( target: config.css.target, lightningcss: config.css.lightningcss, minify: config.css.minify, + cssModules, }) } @@ -269,16 +357,17 @@ async function processWithLightningCSS( target: config.css.target, lightningcss: config.css.lightningcss, minify: config.css.minify, + cssModules, preprocessorOptions: config.css.preprocessorOptions, logger, }, code, ) deps.push(...bundleResult.deps) - return bundleResult.code + return { code: bundleResult.code, modules: bundleResult.modules } } - return '' + return { code: '' } } async function processWithPostCSS( @@ -287,7 +376,8 @@ async function processWithPostCSS( cleanId: string, deps: string[], config: CssPluginConfig, -): Promise { + isModule: boolean, +): Promise { const lang = getPreprocessorLang(cleanId) if (lang) { @@ -301,6 +391,9 @@ async function processWithPostCSS( deps.push(...preResult.deps) } + const modulesConfig = + typeof config.css.modules === 'object' ? config.css.modules : undefined + const needInlineImport = code.includes('@import') const postcssResult = await runPostCSS( code, @@ -308,15 +401,18 @@ async function processWithPostCSS( config.css.postcss, config.cwd, needInlineImport, + isModule ? { isModule: true, config: modulesConfig } : undefined, ) code = postcssResult.code deps.push(...postcssResult.deps) - return transformWithLightningCSS(code, cleanId, { + const transformResult = await transformWithLightningCSS(code, cleanId, { target: config.css.target, lightningcss: config.css.lightningcss, minify: config.css.minify, }) + + return { code: transformResult.code, modules: postcssResult.modules } } function isEmptyChunkCode(code: string): boolean { diff --git a/packages/css/src/postcss.ts b/packages/css/src/postcss.ts index 7c138d01b..c01824db5 100644 --- a/packages/css/src/postcss.ts +++ b/packages/css/src/postcss.ts @@ -1,5 +1,5 @@ import { importWithError } from 'tsdown/internal' -import type { PostCSSOptions } from './options.ts' +import type { CSSModulesOptions, PostCSSOptions } from './options.ts' interface PostCSSConfigResult { options: Record @@ -9,6 +9,12 @@ interface PostCSSConfigResult { interface PostCSSProcessResult { code: string deps: string[] + modules?: Record +} + +export interface PostCSSModulesOptions { + isModule: boolean + config?: CSSModulesOptions } const fileConfigCache = new Map< @@ -63,11 +69,36 @@ export async function processWithPostCSS( postcssOption: PostCSSOptions | undefined, cwd: string, injectImport?: boolean, + modulesOptions?: PostCSSModulesOptions, ): Promise { const config = await resolvePostCSSConfig(postcssOption, cwd) const plugins: any[] = [] + let modules: Record | undefined + + if (modulesOptions?.isModule) { + const postcssModules: any = await importWithError('postcss-modules') + const { + localsConvention: _, + getJSON: userGetJSON, + ...rest + } = modulesOptions.config ?? {} + plugins.push( + (postcssModules.default ?? postcssModules)({ + ...rest, + getJSON( + cssFileName: string, + json: Record, + outputFileName: string, + ) { + modules = json + userGetJSON?.(cssFileName, json, outputFileName) + }, + }), + ) + } + if (injectImport) { const postcssImport: any = await importWithError('postcss-import') plugins.push((postcssImport.default ?? postcssImport)()) @@ -100,5 +131,5 @@ export async function processWithPostCSS( } } - return { code: result.css, deps } + return { code: result.css, deps, modules } } diff --git a/packages/css/src/utils.ts b/packages/css/src/utils.ts index ffe874441..85cbdbf07 100644 --- a/packages/css/src/utils.ts +++ b/packages/css/src/utils.ts @@ -5,6 +5,9 @@ export const CSS_LANGS_RE: RegExp = export const RE_CSS_INLINE: RegExp = /\.(?:css|less|sass|scss|styl|stylus)\?(?:.*&)?inline\b/ +export const CSS_MODULE_RE: RegExp = + /\.module\.(?:css|less|sass|scss|styl|stylus)(?:$|\?)/ + export function getCleanId(id: string): string { const queryIndex = id.indexOf('?') return queryIndex === -1 ? id : id.slice(0, queryIndex) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 691bd9112..90747f2f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,10 +384,10 @@ importers: version: 0.5.7 unplugin-vue: specifier: catalog:dev - version: 7.1.1(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2) + version: 7.1.1(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2) vite: specifier: ^8.0.0 - version: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + version: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) vitest: specifier: catalog:dev version: 4.1.0(@types/node@25.5.0)(@vitest/ui@4.1.0)(vite@8.0.0) @@ -424,13 +424,13 @@ importers: version: 1.1.2(typedoc-plugin-markdown@4.10.0(typedoc@0.28.17(typescript@5.9.3))) vite: specifier: ^8.0.0 - version: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + version: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) vitepress: specifier: catalog:docs version: 2.0.0-alpha.16(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(change-case@5.4.4)(jiti@2.6.1)(oxc-minify@0.119.0)(postcss@8.5.8)(sass-embedded@1.98.0)(sass@1.98.0)(typescript@5.9.3)(yaml@2.8.2) vitepress-plugin-group-icons: specifier: catalog:docs - version: 1.7.1(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) + version: 1.7.1(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)) vitepress-plugin-llms: specifier: catalog:docs version: 1.11.0 @@ -467,6 +467,9 @@ importers: postcss-load-config: specifier: catalog:prod version: 6.0.1(jiti@2.6.1)(postcss@8.5.8)(yaml@2.8.2) + postcss-modules: + specifier: ^6.0.0 + version: 6.0.1(postcss@8.5.8) rolldown: specifier: 1.0.0-rc.9 version: 1.0.0-rc.9 @@ -2768,6 +2771,9 @@ packages: fzf@0.5.2: resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + generic-names@4.0.0: + resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -2857,6 +2863,12 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3114,10 +3126,17 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + loader-utils@3.3.1: + resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} + engines: {node: '>= 12.13.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3512,6 +3531,35 @@ packages: yaml: optional: true + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.2.0: + resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules@6.0.1: + resolution: {integrity: sha512-zyo2sAkVvuZFFy0gc2+4O+xar5dYlaVy/ebO24KT0ftk/iJevSNyPyQellsBLlnccwh7f6V6Y4GvuKRYToNgpQ==} + peerDependencies: + postcss: ^8.0.0 + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -3878,6 +3926,9 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + string-hash@1.1.3: + resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5788,14 +5839,14 @@ snapshots: pathe: 2.0.3 tinyglobby: 0.2.15 unplugin-utils: 0.3.1 - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) '@vitejs/devtools-kit@0.1.0(typescript@5.9.3)(vite@8.0.0)(ws@8.19.0)': dependencies: '@vitejs/devtools-rpc': 0.1.0(typescript@5.9.3)(ws@8.19.0) birpc: 4.0.0 immer: 11.1.4 - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) transitivePeerDependencies: - typescript - ws @@ -5884,7 +5935,7 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 tinyexec: 1.0.4 - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - '@azure/app-configuration' @@ -5912,10 +5963,10 @@ snapshots: - utf-8-validate - vue - '@vitejs/plugin-vue@6.0.5(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.5(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) vue: 3.5.30(typescript@5.9.3) '@vitest/coverage-v8@4.1.0(vitest@4.1.0)': @@ -5947,7 +5998,7 @@ snapshots: estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) '@vitest/pretty-format@4.1.0': dependencies: @@ -6724,6 +6775,10 @@ snapshots: fzf@0.5.2: {} + generic-names@4.0.0: + dependencies: + loader-utils: 3.3.1 + get-caller-file@2.0.5: {} get-port-please@3.2.0: {} @@ -6813,6 +6868,10 @@ snapshots: hyperdyperid@1.2.0: {} + icss-utils@5.1.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -6996,10 +7055,14 @@ snapshots: dependencies: uc.micro: 2.1.0 + loader-utils@3.3.1: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} longest-streak@3.1.0: {} @@ -7642,6 +7705,39 @@ snapshots: postcss: 8.5.8 yaml: 2.8.2 + postcss-modules-extract-imports@3.1.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-modules-local-by-default@4.2.0(postcss@8.5.8): + dependencies: + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 7.1.1 + + postcss-modules-values@4.0.0(postcss@8.5.8): + dependencies: + icss-utils: 5.1.0(postcss@8.5.8) + postcss: 8.5.8 + + postcss-modules@6.0.1(postcss@8.5.8): + dependencies: + generic-names: 4.0.0 + icss-utils: 5.1.0(postcss@8.5.8) + lodash.camelcase: 4.3.0 + postcss: 8.5.8 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.8) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.8) + postcss-modules-scope: 3.2.1(postcss@8.5.8) + postcss-modules-values: 4.0.0(postcss@8.5.8) + string-hash: 1.1.3 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -8006,6 +8102,8 @@ snapshots: std-env@4.0.0: {} + string-hash@1.1.3: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -8265,14 +8363,14 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.3 - unplugin-vue@7.1.1(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2): + unplugin-vue@7.1.1(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(vue@3.5.30(typescript@5.9.3))(yaml@2.8.2): dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@vue/reactivity': 3.5.30 obug: 2.1.1 unplugin: 3.0.0 - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) vue: 3.5.30(typescript@5.9.3) transitivePeerDependencies: - '@types/node' @@ -8348,7 +8446,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2): + vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2): dependencies: '@oxc-project/runtime': 0.115.0 lightningcss: 1.32.0 @@ -8365,13 +8463,13 @@ snapshots: sass-embedded: 1.98.0 yaml: 2.8.2 - vitepress-plugin-group-icons@1.7.1(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)): + vitepress-plugin-group-icons@1.7.1(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2)): dependencies: '@iconify-json/logos': 1.2.10 '@iconify-json/vscode-icons': 1.2.45 '@iconify/utils': 3.1.0 optionalDependencies: - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) vitepress-plugin-llms@1.11.0: dependencies: @@ -8402,7 +8500,7 @@ snapshots: '@shikijs/transformers': 3.23.0 '@shikijs/types': 3.23.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 6.0.5(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.5(vite@8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) '@vue/devtools-api': 8.1.0 '@vue/shared': 3.5.30 '@vueuse/core': 14.2.1(vue@3.5.30(typescript@5.9.3)) @@ -8411,7 +8509,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.2.0 shiki: 3.23.0 - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) vue: 3.5.30(typescript@5.9.3) optionalDependencies: oxc-minify: 0.119.0 @@ -8462,7 +8560,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.5.0)(@vitejs/devtools@0.1.0(@pnpm/logger@1001.0.1)(typescript@5.9.3)(vite@8.0.0)(vue@3.5.30(typescript@5.9.3)))(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 diff --git a/skills/tsdown/SKILL.md b/skills/tsdown/SKILL.md index 356130a8e..f58fa1e26 100644 --- a/skills/tsdown/SKILL.md +++ b/skills/tsdown/SKILL.md @@ -96,7 +96,8 @@ export default defineConfig({ | Shims | `shims: true` - Add ESM/CJS compatibility | [option-shims](references/option-shims.md) | | CJS default | `cjsDefault: true` (default) / `false` | [option-cjs-default](references/option-cjs-default.md) | | Package exports | `exports: true` - Auto-generate exports field | [option-package-exports](references/option-package-exports.md) | -| CSS handling | **[experimental]** `css: { ... }` — full pipeline with preprocessors, Lightning CSS, PostCSS, code splitting; requires `@tsdown/css` | [option-css](references/option-css.md) | +| CSS handling | **[experimental]** `css: { ... }` — full pipeline with preprocessors, Lightning CSS, PostCSS, CSS modules, code splitting; requires `@tsdown/css` | [option-css](references/option-css.md) | +| CSS modules | `css: { modules: { localsConvention: 'camelCase' } }` — scoped class names for `.module.css` files | [option-css](references/option-css.md) | | CSS inject | `css: { inject: true }` — preserve CSS imports in JS output | [option-css](references/option-css.md) | | Unbundle mode | `unbundle: true` - Preserve directory structure | [option-unbundle](references/option-unbundle.md) | | Root directory | `root: 'src'` - Control output directory mapping | [option-root](references/option-root.md) | diff --git a/skills/tsdown/references/option-css.md b/skills/tsdown/references/option-css.md index 72799e176..4a0c692db 100644 --- a/skills/tsdown/references/option-css.md +++ b/skills/tsdown/references/option-css.md @@ -197,6 +197,37 @@ export default defineConfig({ `css.lightningcss.targets` takes precedence over both `target` and `css.target` for CSS. +## CSS Modules + +Files with `.module.css` (and `.module.scss`, `.module.less`, etc.) are treated as CSS modules — class names are scoped and exported as JS: + +```ts +import styles from './app.module.css' +console.log(styles.title) // "scoped_title_hash" +``` + +### Configuration + +```ts +export default defineConfig({ + css: { + modules: { + scopeBehaviour: 'local', // 'local' (default) | 'global' + generateScopedName: '[hash]_[local]', // Lightning CSS pattern string + localsConvention: 'camelCase', // 'camelCase' | 'camelCaseOnly' | 'dashes' | 'dashesOnly' + }, + }, +}) +``` + +Set `css.modules: false` to disable. Function-form `generateScopedName` requires `transformer: 'postcss'`. + +### Optional Dependencies (PostCSS path) + +```bash +npm install -D postcss postcss-modules +``` + ## Code Splitting ### Merged (Default) @@ -233,6 +264,22 @@ export default defineConfig({ }) ``` +## PostCSS Optional Peer Dependencies + +When using `transformer: 'postcss'`, install these as needed: + +| Package | Purpose | Required When | +|---------|---------|---------------| +| `postcss` | Core PostCSS engine | Always (with `transformer: 'postcss'`) | +| `postcss-import` | Resolve/inline `@import` | CSS uses `@import` | +| `postcss-modules` | CSS modules (scoped classes) | Using `.module.css` files | + +```bash +npm install -D postcss postcss-import postcss-modules +``` + +All declared as optional peer dependencies of `@tsdown/css`. + ## Options Reference | Option | Type | Default | Description | @@ -241,6 +288,7 @@ export default defineConfig({ | `css.splitting` | `boolean` | `false` | Per-chunk CSS splitting | | `css.fileName` | `string` | `'style.css'` | Merged CSS file name | | `css.minify` | `boolean` | `false` | CSS minification | +| `css.modules` | `object \| false` | `{}` | CSS modules config, or `false` to disable | | `css.inject` | `boolean` | `false` | Preserve CSS imports in JS output | | `css.target` | `string \| string[] \| false` | _from `target`_ | CSS-specific lowering target | | `css.postcss` | `string \| object` | — | PostCSS config path or inline options | diff --git a/tests/__snapshots__/css-modules/basic-css-module-exports-scoped-class-names.snap.md b/tests/__snapshots__/css-modules/basic-css-module-exports-scoped-class-names.snap.md new file mode 100644 index 000000000..7d9281c09 --- /dev/null +++ b/tests/__snapshots__/css-modules/basic-css-module-exports-scoped-class-names.snap.md @@ -0,0 +1,25 @@ +## index.mjs + +```mjs +//#region app.module.css +var app_module_default = { + "content": "mod_content", + "title": "mod_title" +}; +//#endregion +export { app_module_default as styles }; + +``` + +## style.css + +```css +.mod_title { + color: red; +} + +.mod_content { + font-size: 14px; +} + +``` diff --git a/tests/__snapshots__/css-modules/css-module-with-modules-false-disables-scoping.snap.md b/tests/__snapshots__/css-modules/css-module-with-modules-false-disables-scoping.snap.md new file mode 100644 index 000000000..1845f3339 --- /dev/null +++ b/tests/__snapshots__/css-modules/css-module-with-modules-false-disables-scoping.snap.md @@ -0,0 +1,15 @@ +## index.mjs + +```mjs +export {}; + +``` + +## style.css + +```css +.title { + color: red; +} + +``` diff --git a/tests/__snapshots__/css-modules/css-module-with-splitting.snap.md b/tests/__snapshots__/css-modules/css-module-with-splitting.snap.md new file mode 100644 index 000000000..f0cfb93d2 --- /dev/null +++ b/tests/__snapshots__/css-modules/css-module-with-splitting.snap.md @@ -0,0 +1,18 @@ +## index.css + +```css +.mod_title { + color: red; +} + +``` + +## index.mjs + +```mjs +//#region app.module.css +var app_module_default = { "title": "mod_title" }; +//#endregion +export { app_module_default as styles }; + +``` diff --git a/tests/__snapshots__/css-modules/non-module-css-is-not-affected.snap.md b/tests/__snapshots__/css-modules/non-module-css-is-not-affected.snap.md new file mode 100644 index 000000000..1845f3339 --- /dev/null +++ b/tests/__snapshots__/css-modules/non-module-css-is-not-affected.snap.md @@ -0,0 +1,15 @@ +## index.mjs + +```mjs +export {}; + +``` + +## style.css + +```css +.title { + color: red; +} + +``` diff --git a/tests/css.test.ts b/tests/css.test.ts index 294d20038..62ba2037f 100644 --- a/tests/css.test.ts +++ b/tests/css.test.ts @@ -1572,4 +1572,80 @@ describe('css', () => { expect(asyncBCode).not.toContain('.mjs"') }) }) + + describe('css modules', () => { + test('basic css module exports scoped class names', async (context) => { + const { fileMap, outputFiles } = await testBuild({ + context, + files: { + 'index.ts': `export { default as styles } from './app.module.css'`, + 'app.module.css': `.title { color: red }\n.content { font-size: 14px }`, + }, + options: { + css: { modules: { generateScopedName: 'mod_[local]' } }, + }, + }) + expect(outputFiles).toContain('style.css') + expect(outputFiles).toContain('index.mjs') + + const js = fileMap['index.mjs'] + expect(js).toContain('export') + expect(js).toContain('mod_title') + expect(js).toContain('mod_content') + + const css = fileMap['style.css'] + expect(css).toContain('.mod_title') + expect(css).toContain('.mod_content') + expect(css).not.toMatch(/(? { + const { fileMap } = await testBuild({ + context, + files: { + 'index.ts': `import './app.module.css'`, + 'app.module.css': `.title { color: red }`, + }, + options: { + css: { modules: false }, + }, + }) + const css = fileMap['style.css'] + expect(css).toContain('.title') + }) + + test('non-module css is not affected', async (context) => { + const { fileMap } = await testBuild({ + context, + files: { + 'index.ts': `import './app.css'`, + 'app.css': `.title { color: red }`, + }, + }) + const css = fileMap['style.css'] + expect(css).toContain('.title') + }) + + test('css module with splitting', async (context) => { + const { fileMap, outputFiles } = await testBuild({ + context, + files: { + 'index.ts': `export { default as styles } from './app.module.css'`, + 'app.module.css': `.title { color: red }`, + }, + options: { + css: { + splitting: true, + modules: { generateScopedName: 'mod_[local]' }, + }, + }, + }) + expect(outputFiles).toContain('index.css') + expect(outputFiles).toContain('index.mjs') + + const js = fileMap['index.mjs'] + expect(js).toContain('mod_title') + }) + }) })