├── .nvmrc ├── src ├── index.ts ├── plugin │ ├── url-utils.ts │ ├── types.ts │ ├── transformers │ │ ├── postcss │ │ │ ├── types.ts │ │ │ ├── postcss-extract-icss.ts │ │ │ └── index.ts │ │ └── lightningcss.ts │ ├── locals-convention.ts │ ├── supports-arbitrary-module-namespace.ts │ ├── generate-types.ts │ ├── generate-esm.ts │ └── index.ts ├── @types │ └── postcss-plugins.d.ts └── patch.ts ├── renovate.json ├── pnpm-workspace.yaml ├── tests ├── utils │ ├── base64-module.ts │ ├── get-css-source-maps.ts │ └── vite.ts ├── index.ts ├── specs │ ├── patched │ │ ├── index.ts │ │ ├── lightningcss.spec.ts │ │ └── postcss.spec.ts │ └── reproductions.spec.ts └── fixtures.ts ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 24.11.1 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { pluginName, cssModules } from './plugin/index.js'; 2 | export { patchCssModules } from './patch.js'; 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>privatenumber/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | ignoredBuiltDependencies: 2 | - "@parcel/watcher" 3 | - esbuild 4 | - unrs-resolver 5 | 6 | onlyBuiltDependencies: 7 | - playwright-chromium 8 | -------------------------------------------------------------------------------- /tests/utils/base64-module.ts: -------------------------------------------------------------------------------- 1 | export const base64Module = ( 2 | code: string, 3 | ) => `data:text/javascript;base64,${ 4 | Buffer.from(code).toString('base64') 5 | }`; 6 | -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | import { version } from 'vite'; 2 | import { describe } from 'manten'; 3 | 4 | describe(`vite ${version}`, ({ runTestSuite }) => { 5 | runTestSuite(import('./specs/reproductions.spec.js')); 6 | runTestSuite(import('./specs/patched/index.js')); 7 | }); 8 | -------------------------------------------------------------------------------- /tests/specs/patched/index.ts: -------------------------------------------------------------------------------- 1 | import { testSuite } from 'manten'; 2 | 3 | export default testSuite(({ describe }) => { 4 | describe('Patched', ({ runTestSuite }) => { 5 | runTestSuite(import('./postcss.spec.js')); 6 | runTestSuite(import('./lightningcss.spec.js')); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Dependency directories 13 | /node_modules/ 14 | 15 | # Output of 'npm pack' 16 | *.tgz 17 | 18 | # dotenv environment variables file 19 | .env 20 | .env.test 21 | 22 | # Cache 23 | .eslintcache 24 | 25 | # Distribution 26 | dist 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "ESNext", 6 | ], 7 | "moduleDetection": "force", 8 | "module": "Preserve", 9 | "strict": true, 10 | "noUncheckedIndexedAccess": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "verbatimModuleSyntax": true, 14 | "skipLibCheck": true, 15 | }, 16 | "exclude": [ 17 | "dist", 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /tests/utils/get-css-source-maps.ts: -------------------------------------------------------------------------------- 1 | import type { SourceMap } from 'rollup'; 2 | 3 | export const getCssSourceMaps = ( 4 | code: string, 5 | ) => { 6 | const cssSourcemaps = Array.from(code.matchAll(/\/*# sourceMappingURL=data:application\/json;base64,(.+?) \*\//g)); 7 | 8 | const maps = cssSourcemaps.map( 9 | ([, base64]) => JSON.parse( 10 | Buffer.from(base64!, 'base64').toString('utf8'), 11 | ) as SourceMap, 12 | ); 13 | 14 | maps.sort((a, b) => a.sources[0]!.localeCompare(b.sources[0]!)); 15 | 16 | return maps; 17 | }; 18 | -------------------------------------------------------------------------------- /src/plugin/url-utils.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/vitejs/vite/blob/37af8a7be417f1fb2cf9a0d5e9ad90b76ff211b4/packages/vite/src/node/plugins/css.ts#L185 2 | export const cssModuleRE = /\.module\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; 3 | 4 | const moduleCssQuery = '?.module.css'; 5 | export const cleanUrl = (url: string) => ( 6 | url.endsWith(moduleCssQuery) 7 | ? url.slice(0, -moduleCssQuery.length) 8 | : url 9 | ); 10 | 11 | export const getCssModuleUrl = (url: string) => { 12 | if (cssModuleRE.test(url)) { 13 | return url; 14 | } 15 | return url + moduleCssQuery; 16 | }; 17 | -------------------------------------------------------------------------------- /src/plugin/types.ts: -------------------------------------------------------------------------------- 1 | import type { CSSModuleReferences } from 'lightningcss'; 2 | import type { ExistingRawSourceMap } from 'rollup'; 3 | import type { CSSModuleExports } from './transformers/postcss/types.js'; 4 | import type { Exports } from './generate-esm.js'; 5 | 6 | export type Transformer = ( 7 | code: string, 8 | id: string, 9 | options: Options, 10 | generateSourceMap?: boolean, 11 | ) => { 12 | code: string; 13 | map?: ExistingRawSourceMap; 14 | exports: CSSModuleExports; 15 | references: CSSModuleReferences; 16 | }; 17 | 18 | export type PluginMeta = { 19 | css: string; 20 | exports: Exports; 21 | }; 22 | 23 | export type ExportMode = 'both' | 'named' | 'default'; 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 21 | with: 22 | node-version-file: .nvmrc 23 | 24 | - name: Setup pnpm 25 | uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 26 | with: 27 | run_install: true 28 | 29 | - name: Release 30 | env: 31 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 32 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | run: pnpm dlx semantic-release 34 | -------------------------------------------------------------------------------- /src/@types/postcss-plugins.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'postcss-modules-scope' { 2 | import type { PluginCreator } from 'postcss'; 3 | import type { ClassName } from 'postcss-selector-parser'; 4 | 5 | type GenerateScopedName = ( 6 | name: string, 7 | path: string, 8 | css: string, 9 | node: ClassName, 10 | ) => string; 11 | 12 | type GenerateExportEntry = ( 13 | name: string, 14 | scopedName: string, 15 | path: string, 16 | css: string, 17 | node: ClassName, 18 | ) => { 19 | key: string; 20 | value: string; 21 | }; 22 | 23 | type Options = { 24 | generateScopedName?: GenerateScopedName; 25 | generateExportEntry?: GenerateExportEntry; 26 | exportGlobals?: boolean | undefined; 27 | }; 28 | 29 | type PostcssModulesScope = { 30 | generateScopedName: GenerateScopedName; 31 | } & PluginCreator; 32 | 33 | declare const plugin: PostcssModulesScope; 34 | export default plugin; 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | pull_request: 7 | branches: [master, develop] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 18 | 19 | - name: Use Node.js 20 | uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 21 | with: 22 | node-version-file: .nvmrc 23 | 24 | - name: Setup pnpm 25 | uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 26 | with: 27 | run_install: true 28 | 29 | - name: Lint 30 | run: pnpm lint 31 | 32 | - name: Typecheck 33 | run: pnpm type-check 34 | 35 | - name: Build 36 | run: pnpm build 37 | 38 | - name: Test 39 | run: pnpm test 40 | -------------------------------------------------------------------------------- /src/plugin/transformers/postcss/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is designed for parity with LightningCSS 3 | * so it they can be used as a drop-in alternative 4 | * https://github.com/parcel-bundler/lightningcss/blob/0c05ba8620f427e4a68bff05cfebe77bd35eef6f/node/index.d.ts#L310 5 | */ 6 | 7 | type GlobalReference = { 8 | type: 'global'; 9 | name: string; 10 | }; 11 | 12 | type LocalReference = { 13 | type: 'local'; 14 | name: string; 15 | }; 16 | 17 | export type DependencyReference = { 18 | type: 'dependency'; 19 | specifier: string; 20 | name: string; 21 | }; 22 | 23 | export type CSSModuleReferences = { 24 | [name: string]: DependencyReference; 25 | }; 26 | 27 | type ClassExport = { 28 | name: string; 29 | composes: (LocalReference | GlobalReference | DependencyReference)[]; 30 | }; 31 | 32 | export type CSSModuleExports = Record; 33 | 34 | export type Extracted = { 35 | exports: CSSModuleExports; 36 | references: CSSModuleReferences; 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Hiroki Osame 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/plugin/transformers/lightningcss.ts: -------------------------------------------------------------------------------- 1 | import { transform as lightningcssTransform } from 'lightningcss'; 2 | import type { LightningCSSOptions } from 'vite'; 3 | import type { ExistingRawSourceMap } from 'rollup'; 4 | import type { Transformer } from '../types.js'; 5 | 6 | export const transform: Transformer = ( 7 | code, 8 | id, 9 | options, 10 | generateSourceMap, 11 | ) => { 12 | const transformed = lightningcssTransform({ 13 | ...options, 14 | filename: id, 15 | code: Buffer.from(code), 16 | cssModules: options.cssModules || true, 17 | sourceMap: generateSourceMap, 18 | }); 19 | 20 | /** 21 | * From Vite: 22 | * https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/plugins/css.ts#L2328 23 | * 24 | * Addresses non-deterministic exports order: 25 | * https://github.com/parcel-bundler/lightningcss/issues/291 26 | */ 27 | const exports = Object.fromEntries( 28 | Object.entries( 29 | // `exports` is defined if cssModules is true 30 | transformed.exports!, 31 | ).sort( 32 | // Cheap alphabetical sort (localCompare is expensive) 33 | ([a], [b]) => (a < b ? -1 : (a > b ? 1 : 0)), 34 | ), 35 | ); 36 | 37 | const map = ( 38 | transformed.map 39 | ? JSON.parse(Buffer.from(transformed.map).toString()) as ExistingRawSourceMap 40 | : undefined 41 | ); 42 | 43 | return { 44 | code: transformed.code.toString(), 45 | 46 | map, 47 | 48 | exports, 49 | 50 | // If `dashedIdents` is enabled 51 | // https://github.com/parcel-bundler/lightningcss/blob/v1.23.0/node/index.d.ts#L288-L289 52 | references: transformed.references, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/plugin/locals-convention.ts: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash.camelcase'; 2 | import type { CSSModulesOptions } from 'vite'; 3 | import type { CSSModulesConfig } from 'lightningcss'; 4 | 5 | type Config = CSSModulesOptions | CSSModulesConfig; 6 | 7 | export type LocalsConventionFunction = ( 8 | originalClassName: string, 9 | generatedClassName: string, 10 | inputFile: string, 11 | ) => string; 12 | 13 | export const shouldKeepOriginalExport = ( 14 | cssModuleConfig: Config, 15 | ) => !( 16 | 'localsConvention' in cssModuleConfig 17 | && ( 18 | typeof cssModuleConfig.localsConvention === 'function' 19 | || cssModuleConfig.localsConvention === 'camelCaseOnly' 20 | || cssModuleConfig.localsConvention === 'dashesOnly' 21 | ) 22 | ); 23 | 24 | // From: 25 | // https://github.com/madyankin/postcss-modules/blob/325f0b33f1b746eae7aa827504a5efd0949022ef/src/localsConvention.js#L3-L5 26 | const dashesCamelCase = ( 27 | string: string, 28 | ) => string.replaceAll(/-+(\w)/g, (_, firstLetter) => firstLetter.toUpperCase()); 29 | 30 | export const getLocalesConventionFunction = ( 31 | config: Config, 32 | ): LocalsConventionFunction | undefined => { 33 | if (!('localsConvention' in config)) { 34 | return; 35 | } 36 | 37 | const { localsConvention } = config; 38 | if ( 39 | !localsConvention 40 | || typeof localsConvention === 'function' 41 | ) { 42 | return localsConvention; 43 | } 44 | 45 | if ( 46 | localsConvention === 'camelCase' 47 | || localsConvention === 'camelCaseOnly' 48 | ) { 49 | return camelCase; 50 | } 51 | 52 | if ( 53 | localsConvention === 'dashes' 54 | || localsConvention === 'dashesOnly' 55 | ) { 56 | return dashesCamelCase; 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/plugin/supports-arbitrary-module-namespace.ts: -------------------------------------------------------------------------------- 1 | import type { ResolvedConfig } from 'vite'; 2 | 3 | type Version = [number, number?, number?]; 4 | 5 | /** 6 | * Env versions where arbitrary module namespaces were introduced 7 | * https://github.com/evanw/esbuild/blob/c809af050a74f022d9cf61c66e13365434542420/compat-table/src/index.ts#L464-L476 8 | */ 9 | const arbitraryModuleNamespaceNames = { 10 | // https://github.com/evanw/esbuild/blob/c809af050a74f022d9cf61c66e13365434542420/compat-table/src/index.ts#L392 11 | es: [2022], 12 | chrome: [90], 13 | node: [16], 14 | firefox: [87], 15 | safari: [14, 1], 16 | ios: [14, 5], 17 | } satisfies Record; 18 | 19 | const targetPattern = /^(chrome|deno|edge|firefox|hermes|ie|ios|node|opera|rhino|safari|es)(\w+)/i; 20 | const parseTarget = ( 21 | target: string, 22 | ) => { 23 | const hasType = target.match(targetPattern); 24 | if (!hasType) { 25 | return; 26 | } 27 | 28 | const [, type, version] = hasType; 29 | return [ 30 | type!.toLowerCase(), 31 | version!.split('.').map(Number), 32 | ] as [ 33 | keyof typeof arbitraryModuleNamespaceNames, 34 | Version, 35 | ]; 36 | }; 37 | 38 | const compareSemver = ( 39 | semverA: Version, 40 | semverB: Version, 41 | ) => ( 42 | semverA[0] - semverB[0] 43 | || (semverA[1] || 0) - (semverB[1] || 0) 44 | || (semverA[2] || 0) - (semverB[2] || 0) 45 | || 0 46 | ); 47 | 48 | export const supportsArbitraryModuleNamespace = ( 49 | { build: { target: targets } }: ResolvedConfig, 50 | ) => Boolean( 51 | targets 52 | && (Array.isArray(targets) ? targets : [targets]).every((target) => { 53 | if (target === 'esnext') { 54 | return true; 55 | } 56 | 57 | const hasType = parseTarget(target); 58 | if (!hasType) { 59 | return false; 60 | } 61 | 62 | const [type, version] = hasType; 63 | const addedInVersion = arbitraryModuleNamespaceNames[type]; 64 | if (!addedInVersion) { 65 | return false; 66 | } 67 | 68 | return compareSemver(addedInVersion, version) <= 0; 69 | }), 70 | ); 71 | -------------------------------------------------------------------------------- /src/plugin/transformers/postcss/postcss-extract-icss.ts: -------------------------------------------------------------------------------- 1 | import type { PluginCreator } from 'postcss'; 2 | import { extractICSS, type CSSExports } from 'icss-utils'; 3 | import type { 4 | CSSModuleExports, 5 | DependencyReference, 6 | CSSModuleReferences, 7 | Extracted, 8 | } from './types.js'; 9 | 10 | type onModuleExports = ( 11 | moduleExports: Extracted, 12 | ) => void; 13 | 14 | type Options = { 15 | onModuleExports: onModuleExports; 16 | localClasses: string[]; 17 | }; 18 | 19 | const processExracted = ( 20 | icssExports: CSSExports, 21 | dependencies: Map, 22 | localClasses: string[], 23 | ): Extracted => { 24 | const exports: CSSModuleExports = {}; 25 | const references: CSSModuleReferences = {}; 26 | 27 | for (const [exportedAs, value] of Object.entries(icssExports)) { 28 | // TODO: This should be a stricter check using \b 29 | const hasLocalClass = localClasses.some(localClass => value.includes(localClass)); 30 | if (hasLocalClass) { 31 | const [firstClass, ...composed] = value.split(' '); 32 | exports[exportedAs] = { 33 | name: firstClass!, 34 | composes: composed.map((className) => { 35 | if (localClasses.includes(className)) { 36 | return { 37 | type: 'local', 38 | name: className, 39 | }; 40 | } 41 | 42 | if (dependencies.has(className)) { 43 | return dependencies.get(className)!; 44 | } 45 | 46 | return { 47 | type: 'global', 48 | name: className, 49 | }; 50 | }), 51 | }; 52 | } else if (dependencies.has(value)) { 53 | references[value] = dependencies.get(value)!; 54 | } else { 55 | exports[exportedAs] = value; 56 | } 57 | } 58 | 59 | return { 60 | exports, 61 | references, 62 | }; 63 | }; 64 | 65 | export const postcssExtractIcss: PluginCreator = options => ({ 66 | postcssPlugin: 'extract-icss', 67 | OnceExit: (root) => { 68 | const { icssImports, icssExports } = extractICSS(root); 69 | const dependencies = new Map( 70 | Object.entries(icssImports).flatMap( 71 | ([filePath, fileImports]) => Object 72 | .entries(fileImports) 73 | .map(([hash, name]): [string, DependencyReference] => [ 74 | hash, 75 | Object.freeze({ 76 | type: 'dependency', 77 | name, 78 | specifier: filePath, 79 | }), 80 | ]), 81 | ), 82 | ); 83 | 84 | const extracted = processExracted( 85 | icssExports, 86 | dependencies, 87 | options!.localClasses, 88 | ); 89 | 90 | options!.onModuleExports(extracted); 91 | }, 92 | }); 93 | 94 | postcssExtractIcss.postcss = true; 95 | -------------------------------------------------------------------------------- /src/plugin/generate-types.ts: -------------------------------------------------------------------------------- 1 | import { makeLegalIdentifier } from '@rollup/pluginutils'; 2 | import type { Exports } from './generate-esm.js'; 3 | import type { ExportMode } from './types.js'; 4 | 5 | const dtsTemplate = (code?: string) => `/* eslint-disable */ 6 | /* prettier-ignore */ 7 | // @ts-nocheck 8 | /** 9 | * Generated by vite-css-modules 10 | * https://npmjs.com/vite-css-modules 11 | */ 12 | ${code ? `\n${code}\n` : ''}`; 13 | 14 | type ExportedVariable = [variableName: string, exportedAs: string]; 15 | 16 | const genereateNamedExports = ( 17 | exportedVariables: ExportedVariable[], 18 | exportMode: ExportMode, 19 | allowArbitraryNamedExports: boolean, 20 | ) => { 21 | const prepareNamedExports = exportedVariables.map( 22 | ([jsVariable, exportName]) => { 23 | if (exportMode === 'both' && exportName === '"default"') { 24 | return; 25 | } 26 | 27 | if (jsVariable === exportName) { 28 | return `\t${jsVariable}`; 29 | } 30 | 31 | if (exportName[0] !== '"' || allowArbitraryNamedExports) { 32 | return `\t${jsVariable} as ${exportName}`; 33 | } 34 | 35 | return ''; 36 | }, 37 | ).filter(Boolean); 38 | 39 | if (prepareNamedExports.length === 0) { 40 | return ''; 41 | } 42 | 43 | return `export {\n${prepareNamedExports.join(',\n')}\n};`; 44 | }; 45 | 46 | const generateDefaultExport = ( 47 | exportedVariables: ExportedVariable[], 48 | ) => { 49 | // Generate type-safe default export compatible with rollup-plugin-dts 50 | const properties = exportedVariables.map( 51 | ([jsVariable, exportName]) => { 52 | const key = jsVariable === exportName 53 | ? jsVariable 54 | : exportName; 55 | return `\t${key}: typeof ${jsVariable};`; 56 | }, 57 | ).join('\n'); 58 | 59 | return `declare const __default_export__: {\n${properties}\n};\nexport default __default_export__;`; 60 | }; 61 | 62 | export const generateTypes = ( 63 | exports: Exports, 64 | exportMode: ExportMode, 65 | allowArbitraryNamedExports = false, 66 | ) => { 67 | const variables = new Set(); 68 | const exportedVariables = Object.entries(exports).flatMap( 69 | ([exportName, { exportAs }]) => { 70 | const jsVariable = makeLegalIdentifier(exportName); 71 | variables.add(`declare const ${jsVariable}: string;`); 72 | 73 | return Array.from(exportAs).map((exportAsName) => { 74 | const exportNameSafe = makeLegalIdentifier(exportAsName); 75 | if (exportAsName !== exportNameSafe) { 76 | exportAsName = JSON.stringify(exportAsName); 77 | } 78 | return [jsVariable, exportAsName] as ExportedVariable; 79 | }); 80 | }, 81 | ); 82 | 83 | if (exportedVariables.length === 0) { 84 | return dtsTemplate(); 85 | } 86 | 87 | return dtsTemplate([ 88 | Array.from(variables).join('\n'), 89 | (exportMode === 'both' || exportMode === 'named') 90 | ? genereateNamedExports(exportedVariables, exportMode, allowArbitraryNamedExports) 91 | : '', 92 | (exportMode === 'both' || exportMode === 'default') 93 | ? generateDefaultExport(exportedVariables) 94 | : '', 95 | ].filter(Boolean).join('\n\n')); 96 | }; 97 | -------------------------------------------------------------------------------- /src/plugin/transformers/postcss/index.ts: -------------------------------------------------------------------------------- 1 | import type { CSSModulesOptions } from 'vite'; 2 | import postcssModulesValues from 'postcss-modules-values'; 3 | import postcssModulesLocalByDefault from 'postcss-modules-local-by-default'; 4 | import postcssModulesExtractImports from 'postcss-modules-extract-imports'; 5 | import postcssModulesScope from 'postcss-modules-scope'; 6 | import genericNames from 'generic-names'; 7 | import postcss from 'postcss'; 8 | import type { ExistingRawSourceMap } from 'rollup'; 9 | import type { Transformer } from '../../types.js'; 10 | import { postcssExtractIcss } from './postcss-extract-icss.js'; 11 | import type { Extracted } from './types.js'; 12 | 13 | /** 14 | * For reference, postcss-modules's default: 15 | * https://github.com/madyankin/postcss-modules/blob/v6.0.0/src/scoping.js#L41 16 | * 17 | * I didn't add the line number because it seemed needless. 18 | * I increased the hash to 7 to follow Git's default for short SHA: 19 | * https://stackoverflow.com/a/18134919/911407 20 | * 21 | * FYI LightningCSS recommends hash first for grid compatibility, 22 | * https://github.com/parcel-bundler/lightningcss/blob/v1.23.0/website/pages/css-modules.md?plain=1#L237-L238 23 | * 24 | * but PostCSS CSS Modules doesn't seem to transform Grid names 25 | */ 26 | const defaultScopedName = '_[local]_[hash:7]'; 27 | 28 | export const transform: Transformer = ( 29 | code, 30 | id, 31 | options, 32 | generateSourceMap, 33 | ) => { 34 | const generateScopedName = ( 35 | typeof options.generateScopedName === 'function' 36 | ? options.generateScopedName 37 | : genericNames(options.generateScopedName ?? defaultScopedName, { 38 | hashPrefix: options.hashPrefix, 39 | }) 40 | ); 41 | 42 | const isGlobal = options.globalModulePaths?.some(pattern => pattern.test(id)); 43 | const localClasses: string[] = []; 44 | let extracted: Extracted; 45 | const processed = postcss([ 46 | postcssModulesValues, 47 | 48 | postcssModulesLocalByDefault({ 49 | mode: isGlobal ? 'global' : options.scopeBehaviour, 50 | }), 51 | 52 | // Declares imports from composes 53 | postcssModulesExtractImports(), 54 | 55 | // Resolves & removes composes 56 | postcssModulesScope({ 57 | exportGlobals: options.exportGlobals, 58 | generateScopedName: ( 59 | exportName, 60 | resourceFile, 61 | rawCss, 62 | _node, 63 | ) => { 64 | const scopedName = generateScopedName(exportName, resourceFile, rawCss /* _node */); 65 | localClasses.push(scopedName); 66 | return scopedName; 67 | }, 68 | }), 69 | 70 | postcssExtractIcss({ 71 | localClasses, 72 | onModuleExports: (_extracted) => { 73 | extracted = _extracted; 74 | }, 75 | }), 76 | ]).process(code, { 77 | from: id, 78 | map: ( 79 | generateSourceMap 80 | ? { 81 | inline: false, 82 | annotation: false, 83 | sourcesContent: true, 84 | } 85 | : false 86 | ), 87 | }); 88 | 89 | return { 90 | code: processed.css, 91 | map: processed.map?.toJSON() as unknown as ExistingRawSourceMap, 92 | ...extracted!, 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-css-modules", 3 | "version": "0.0.0-semantic-release", 4 | "description": "Vite plugin for correct CSS Modules behavior", 5 | "keywords": [ 6 | "vite", 7 | "plugin", 8 | "css modules", 9 | "patch" 10 | ], 11 | "license": "MIT", 12 | "repository": "privatenumber/vite-css-modules", 13 | "funding": "https://github.com/privatenumber/vite-css-modules?sponsor=1", 14 | "author": { 15 | "name": "Hiroki Osame", 16 | "email": "hiroki.osame@gmail.com" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "type": "module", 22 | "main": "./dist/index.cjs", 23 | "module": "./dist/index.mjs", 24 | "types": "./dist/index.d.cts", 25 | "exports": { 26 | "require": { 27 | "types": "./dist/index.d.cts", 28 | "default": "./dist/index.cjs" 29 | }, 30 | "import": { 31 | "types": "./dist/index.d.mts", 32 | "default": "./dist/index.mjs" 33 | } 34 | }, 35 | "imports": { 36 | "vite": { 37 | "vite6": "vite6", 38 | "vite5": "vite5", 39 | "default": "vite" 40 | }, 41 | "#vite-css-modules": { 42 | "types": "./src/index.ts", 43 | "development": "./src/index.ts", 44 | "default": "./dist/index.mjs" 45 | } 46 | }, 47 | "packageManager": "pnpm@10.23.0", 48 | "scripts": { 49 | "build": "pkgroll", 50 | "test:vite5": "tsx --import alias-imports -C vite5 tests", 51 | "test:vite6": "tsx --import alias-imports -C vite6 tests", 52 | "test:vite7": "tsx tests", 53 | "test": "pnpm test:vite5 && pnpm test:vite6 && pnpm test:vite7", 54 | "dev": "tsx watch --conditions=development --ignore='/private/**' tests", 55 | "type-check": "tsc", 56 | "lint": "lintroll . --ignore-pattern README.md", 57 | "prepack": "pnpm build && clean-pkg-json" 58 | }, 59 | "dependencies": { 60 | "@jridgewell/remapping": "^2.3.5", 61 | "@rollup/pluginutils": "^5.3.0", 62 | "generic-names": "^4.0.0", 63 | "icss-utils": "^5.1.0", 64 | "magic-string": "^0.30.21", 65 | "postcss-modules-extract-imports": "^3.1.0", 66 | "postcss-modules-local-by-default": "^4.2.0", 67 | "postcss-modules-scope": "^3.2.1", 68 | "postcss-modules-values": "^4.0.0" 69 | }, 70 | "peerDependencies": { 71 | "lightningcss": "^1.23.0", 72 | "postcss": "^8.4.49", 73 | "vite": "^5.0.12 || ^6.0.0 || ^7.0.0" 74 | }, 75 | "peerDependenciesMeta": { 76 | "lightningcss": { 77 | "optional": true 78 | } 79 | }, 80 | "devDependencies": { 81 | "@types/icss-utils": "^5.1.2", 82 | "@types/lodash.camelcase": "^4.3.9", 83 | "@types/node": "^24.10.4", 84 | "@types/postcss-modules-extract-imports": "^3.0.5", 85 | "@types/postcss-modules-local-by-default": "^4.0.2", 86 | "@types/postcss-modules-values": "^4.0.2", 87 | "@vitejs/plugin-vue": "^5.2.4", 88 | "alias-imports": "^1.1.0", 89 | "clean-pkg-json": "^1.3.0", 90 | "fs-fixture": "^2.11.0", 91 | "lightningcss": "^1.30.2", 92 | "lintroll": "^1.25.0", 93 | "lodash.camelcase": "^4.3.0", 94 | "manten": "^1.9.0", 95 | "outdent": "^0.8.0", 96 | "pkgroll": "^2.21.4", 97 | "playwright-chromium": "^1.57.0", 98 | "postcss": "^8.5.6", 99 | "postcss-selector-parser": "^7.1.1", 100 | "rollup": "^4.53.5", 101 | "sass": "^1.94.3", 102 | "tsx": "^4.20.6", 103 | "typescript": "^5.9.3", 104 | "vite": "^7.2.7", 105 | "vite5": "npm:vite@5.4.21", 106 | "vite6": "npm:vite@6.4.1", 107 | "vue": "^3.5.25" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/plugin/generate-esm.ts: -------------------------------------------------------------------------------- 1 | import { makeLegalIdentifier } from '@rollup/pluginutils'; 2 | import { getCssModuleUrl } from './url-utils.js'; 3 | import type { ExportMode } from './types.js'; 4 | 5 | type ImportSpecifiers = Record; 6 | export type Imports = Map; 7 | export type Exports = Record; 11 | }>; 12 | 13 | const importStatement = ( 14 | specifier: string | string[], 15 | source: string, 16 | ) => `import ${ 17 | Array.isArray(specifier) ? `{${specifier.join(',')}}` : specifier 18 | } from${JSON.stringify(source)};`; 19 | 20 | const importsToCode = ( 21 | imports: Imports, 22 | exportMode: ExportMode, 23 | allowArbitraryNamedExports = false, 24 | ) => Array.from(imports) 25 | .map( 26 | ([file, importedAs], index) => { 27 | const importFrom = getCssModuleUrl(file); 28 | if (!allowArbitraryNamedExports || exportMode !== 'named') { 29 | const importDefault = `cssModule${index}`; 30 | return `${importStatement(importDefault, importFrom)}const {${Object.entries(importedAs).map( 31 | ([exportName, importAs]) => `${JSON.stringify(exportName)}: ${importAs}`, 32 | ).join(',')}} = ${importDefault};`; 33 | } 34 | 35 | return importStatement( 36 | Object.entries(importedAs).map( 37 | ([exportName, importAs]) => `${JSON.stringify(exportName)} as ${importAs}`, 38 | ), 39 | importFrom, 40 | ); 41 | }, 42 | ) 43 | .join(''); 44 | 45 | const exportsToCode = ( 46 | exports: Exports, 47 | exportMode: ExportMode, 48 | allowArbitraryNamedExports = false, 49 | ) => { 50 | let code = ''; 51 | 52 | const variables = new Set(); 53 | const exportedVariables = Object.entries(exports).flatMap( 54 | ([exportName, { exportAs, code: value }]) => { 55 | const jsVariable = makeLegalIdentifier(exportName); 56 | variables.add(`const ${jsVariable} = \`${value}\`;`); 57 | 58 | return Array.from(exportAs).map((exportAsName) => { 59 | const exportNameSafe = makeLegalIdentifier(exportAsName); 60 | if (exportAsName !== exportNameSafe) { 61 | exportAsName = JSON.stringify(exportAsName); 62 | } 63 | return [jsVariable, exportAsName] as const; 64 | }); 65 | }, 66 | ); 67 | 68 | code += Array.from(variables).join(''); 69 | 70 | if (exportMode === 'both' || exportMode === 'named') { 71 | const namedExports = `export {${ 72 | exportedVariables 73 | .map( 74 | ([jsVariable, exportName]) => { 75 | if ( 76 | exportName === '"default"' 77 | && exportMode === 'both' 78 | ) { 79 | return; 80 | } 81 | 82 | return ( 83 | jsVariable === exportName 84 | ? jsVariable 85 | : ( 86 | exportName[0] !== '"' || allowArbitraryNamedExports 87 | ? `${jsVariable} as ${exportName}` 88 | : '' 89 | ) 90 | ); 91 | }, 92 | ) 93 | .filter(Boolean) 94 | .join(',') 95 | }};`; 96 | code += namedExports; 97 | } 98 | 99 | if (exportMode === 'both' || exportMode === 'default') { 100 | const defaultExports = `export default{${ 101 | exportedVariables.map( 102 | ([jsVariable, exportName]) => ( 103 | jsVariable === exportName 104 | ? jsVariable 105 | : `${exportName}: ${jsVariable}` 106 | ), 107 | ).join(',') 108 | }}`; 109 | 110 | code += defaultExports; 111 | } 112 | 113 | return code; 114 | }; 115 | 116 | export const generateEsm = ( 117 | imports: Imports, 118 | exports: Exports, 119 | exportMode: ExportMode, 120 | allowArbitraryNamedExports = false, 121 | ) => ( 122 | importsToCode(imports, exportMode, allowArbitraryNamedExports) 123 | + exportsToCode(exports, exportMode, allowArbitraryNamedExports) 124 | ); 125 | -------------------------------------------------------------------------------- /tests/utils/vite.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs/promises'; 3 | import { 4 | build, createServer, type InlineConfig, type ViteDevServer, 5 | } from 'vite'; 6 | import { rollup } from 'rollup'; 7 | import { chromium, type Page } from 'playwright-chromium'; 8 | 9 | export const viteBuild = async ( 10 | fixturePath: string, 11 | config?: InlineConfig, 12 | ) => { 13 | try { 14 | await fs.symlink( 15 | path.resolve('node_modules'), 16 | path.join(fixturePath, 'node_modules'), 17 | ); 18 | } catch {} 19 | 20 | const warnings: string[] = []; 21 | const built = await build({ 22 | root: fixturePath, 23 | configFile: false, 24 | envFile: false, 25 | logLevel: 'warn', 26 | ...config, 27 | 28 | build: { 29 | /** 30 | * Prevents CSS minification from handling the de-duplication of classes 31 | * This is a module bundling concern and should be handled by Rollup 32 | * (which this plugin aims to accomplish) 33 | */ 34 | minify: false, 35 | outDir: 'dist', 36 | lib: { 37 | entry: 'index.js', 38 | formats: ['es'], 39 | cssFileName: 'style.css', 40 | }, 41 | rollupOptions: { 42 | onwarn: ({ message }) => { 43 | warnings.push(message); 44 | }, 45 | }, 46 | ...config?.build, 47 | }, 48 | }); 49 | 50 | if (!Array.isArray(built)) { 51 | throw new TypeError('Build result is not an array'); 52 | } 53 | 54 | const { output } = built[0]!; 55 | const css = output.find(file => file.type === 'asset' && file.fileName.endsWith('.css')); 56 | 57 | if ( 58 | css 59 | && ( 60 | css.type !== 'asset' 61 | || typeof css.source !== 'string' 62 | ) 63 | ) { 64 | throw new Error('Unexpected CSS output'); 65 | } 66 | 67 | return { 68 | js: output[0].code, 69 | css: css?.source.toString(), 70 | warnings, 71 | }; 72 | }; 73 | 74 | const bundleHttpJs = async ( 75 | baseUrl: string, 76 | input: string, 77 | ) => { 78 | const bundle = await rollup({ 79 | input, 80 | logLevel: 'silent', 81 | plugins: [ 82 | { 83 | name: 'vite-dev-server', 84 | resolveId: id => id, 85 | load: async (id) => { 86 | let retry = 5; 87 | while (retry > 0) { 88 | try { 89 | const response = await fetch(path.join(baseUrl, id)); 90 | return await response.text(); 91 | } catch (error) { 92 | if (retry === 0) { 93 | throw error; 94 | } 95 | } 96 | retry -= 1; 97 | } 98 | }, 99 | }, 100 | ], 101 | }); 102 | const generated = await bundle.generate({}); 103 | return generated.output[0].code; 104 | }; 105 | 106 | const viteServe = async ( 107 | fixturePath: string, 108 | viteConfig: InlineConfig | undefined, 109 | callback: (url: string, server: ViteDevServer) => Promise, 110 | ): Promise => { 111 | // This adds a SIGTERM listener to process, which emits a memory leak warning 112 | const server = await createServer({ 113 | root: fixturePath, 114 | configFile: false, 115 | envFile: false, 116 | logLevel: 'error', 117 | server: { 118 | port: 0, 119 | }, 120 | ...viteConfig, 121 | }); 122 | 123 | await server.listen(); 124 | 125 | const url = server.resolvedUrls!.local[0]!; 126 | 127 | try { 128 | return await callback(url, server); 129 | } finally { 130 | await server.close(); 131 | } 132 | }; 133 | 134 | export const getViteDevCode = async ( 135 | fixturePath: string, 136 | config?: InlineConfig, 137 | ) => await viteServe( 138 | fixturePath, 139 | config, 140 | url => bundleHttpJs(url, `@fs${fixturePath}/index.js`), 141 | ); 142 | 143 | export const viteDevBrowser = async ( 144 | fixturePath: string, 145 | viteConfig: InlineConfig, 146 | callback: (page: Page, server: ViteDevServer) => Promise, 147 | ) => { 148 | await viteServe( 149 | fixturePath, 150 | viteConfig, 151 | async (url, server) => { 152 | const browser = await chromium.launch(); 153 | const page = await browser.newPage(); 154 | 155 | try { 156 | await page.goto(url); 157 | await callback(page, server); 158 | } finally { 159 | await browser.close(); 160 | } 161 | }, 162 | ); 163 | }; 164 | -------------------------------------------------------------------------------- /src/patch.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { Plugin, ServerHook } from 'vite'; 3 | import type { 4 | SourceMap, ObjectHook, TransformPluginContext, TransformResult, 5 | } from 'rollup'; 6 | import { cssModules, type PatchConfig } from './plugin/index.js'; 7 | import { cssModuleRE } from './plugin/url-utils.js'; 8 | import type { PluginMeta } from './plugin/types.js'; 9 | 10 | // https://github.com/vitejs/vite/blob/57463fc53fedc8f29e05ef3726f156a6daf65a94/packages/vite/src/node/plugins/css.ts#L185-L195 11 | const directRequestRE = /[?&]direct\b/; 12 | const inlineRE = /[?&]inline\b/; 13 | 14 | const CSS_LANGS_RE = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; 15 | 16 | const isDirectCSSRequest = ( 17 | request: string, 18 | ): boolean => ( 19 | CSS_LANGS_RE.test(request) 20 | && directRequestRE.test(request) 21 | ); 22 | 23 | const appendInlineSoureMap = ( 24 | map: SourceMap | string, 25 | ): string => { 26 | if (typeof map !== 'string') { 27 | map = JSON.stringify(map); 28 | } 29 | 30 | const sourceMapUrl = `data:application/json;base64,${Buffer.from(map).toString('base64')}`; 31 | return `\n/*# sourceMappingURL=${sourceMapUrl} */`; 32 | }; 33 | 34 | type getObjectHook > = ( 35 | T extends ObjectHook 36 | ? NonNullable 37 | : never 38 | ); 39 | type TransformHandler = getObjectHook; 40 | 41 | type NewTransformHandler = ( 42 | this: TransformPluginContext, 43 | originalTransformHandler: TransformHandler, 44 | code: string, 45 | id: string, 46 | options?: { 47 | ssr?: boolean; 48 | }, 49 | ) => Promise | TransformResult; 50 | 51 | const createTransformWrapper = ( 52 | originalTransform: TransformHandler, 53 | newTransform: NewTransformHandler, 54 | ): TransformHandler => function () { 55 | return Reflect.apply(newTransform, this, [originalTransform, ...arguments]); 56 | }; 57 | 58 | const patchTransform = ( 59 | plugin: Plugin, 60 | newTransform: NewTransformHandler, 61 | ) => { 62 | if (!plugin.transform) { 63 | throw new Error('Plugin does not have a transform method'); 64 | } 65 | 66 | // For Vite v6.3.2 67 | if ( 68 | typeof plugin.transform === 'object' 69 | && 'handler' in plugin.transform 70 | ) { 71 | plugin.transform.handler = createTransformWrapper(plugin.transform.handler, newTransform); 72 | } else { 73 | plugin.transform = createTransformWrapper(plugin.transform, newTransform); 74 | } 75 | }; 76 | 77 | const supportNewCssModules = ( 78 | viteCssPostPlugin: Plugin, 79 | config: { 80 | command: string; 81 | base: string; 82 | css?: { 83 | devSourcemap?: boolean; 84 | }; 85 | }, 86 | pluginInstance: Plugin, 87 | ) => { 88 | patchTransform(viteCssPostPlugin, async function (originalTransform, jsCode, id, options) { 89 | if (cssModuleRE.test(id)) { 90 | this.addWatchFile(path.resolve(id)); 91 | const inlined = inlineRE.test(id); 92 | const info = this.getModuleInfo(id)!; 93 | const pluginMeta = info.meta[pluginInstance.name] as PluginMeta | undefined; 94 | if (!pluginMeta) { 95 | // In Vitest, CSS gets disabled 96 | return Reflect.apply(originalTransform, this, [jsCode, id, options]); 97 | } 98 | 99 | let { css } = pluginMeta; 100 | 101 | // https://github.com/vitejs/vite/blob/57463fc53fedc8f29e05ef3726f156a6daf65a94/packages/vite/src/node/plugins/css.ts#L482 102 | if (config.command === 'serve') { 103 | if (isDirectCSSRequest(id)) { 104 | return css; 105 | } 106 | 107 | // server only 108 | if (options?.ssr) { 109 | return jsCode || `export default ${JSON.stringify(css)}`; 110 | } 111 | 112 | if (inlined) { 113 | return `export default ${JSON.stringify(css)}`; 114 | } 115 | 116 | if (config.css?.devSourcemap) { 117 | const map = this.getCombinedSourcemap(); 118 | css += appendInlineSoureMap(map); 119 | } 120 | 121 | // From: https://github.com/vitejs/vite/blob/6c4bf266a0bcae8512f6daf99dff57a73ae7bcf6/packages/vite/src/node/plugins/css.ts#L506 122 | const code = [ 123 | `import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from ${ 124 | JSON.stringify(path.posix.join(config.base, '/@vite/client')) 125 | }`, 126 | `const __vite__id = ${JSON.stringify(id)}`, 127 | `const __vite__css = ${JSON.stringify(css)}`, 128 | '__vite__updateStyle(__vite__id, __vite__css)', 129 | // css modules exports change on edit so it can't self accept 130 | `${jsCode}`, 131 | 'import.meta.hot.prune(() => __vite__removeStyle(__vite__id))', 132 | ].join('\n'); 133 | 134 | return { 135 | code, 136 | map: { mappings: '' }, 137 | }; 138 | } 139 | 140 | /** 141 | * The CSS needs to be stored so the post plugin's renderChunk 142 | * can generate an aggregated style.css file 143 | * https://github.com/vitejs/vite/blob/6c4bf266a0bcae8512f6daf99dff57a73ae7bcf6/packages/vite/src/node/plugins/css.ts#L524C9-L524C15 144 | */ 145 | const result = await Reflect.apply(originalTransform, this, [css, id]); 146 | 147 | // If it's inlined, return the minified CSS 148 | // https://github.com/vitejs/vite/blob/57463fc53fedc8f29e05ef3726f156a6daf65a94/packages/vite/src/node/plugins/css.ts#L530-L536 149 | if (inlined) { 150 | return result; 151 | } 152 | 153 | return { 154 | code: jsCode, 155 | map: { mappings: '' }, 156 | moduleSideEffects: 'no-treeshake', 157 | }; 158 | } 159 | 160 | return Reflect.apply(originalTransform, this, [jsCode, id, options]); 161 | }); 162 | }; 163 | 164 | const supportCssModulesHMR = ( 165 | vitePlugins: readonly Plugin[], 166 | ) => { 167 | const viteCssAnalysisPlugin = vitePlugins.find(plugin => plugin.name === 'vite:css-analysis'); 168 | if (!viteCssAnalysisPlugin) { 169 | return; 170 | } 171 | 172 | const { configureServer } = viteCssAnalysisPlugin; 173 | const tag = '?vite-css-modules?inline'; 174 | viteCssAnalysisPlugin.configureServer = function (server) { 175 | const moduleGraph = server.environments 176 | ? server.environments.client.moduleGraph 177 | : server.moduleGraph; 178 | const { getModuleById } = moduleGraph; 179 | moduleGraph.getModuleById = function (id: string) { 180 | const tagIndex = id.indexOf(tag); 181 | if (tagIndex !== -1) { 182 | id = id.slice(0, tagIndex) + id.slice(tagIndex + tag.length); 183 | } 184 | return Reflect.apply(getModuleById, this, [id]); 185 | }; 186 | 187 | if (configureServer) { 188 | return Reflect.apply(configureServer as ServerHook, this, [server]); 189 | } 190 | }; 191 | 192 | patchTransform(viteCssAnalysisPlugin, async function (originalTransform, css, id, options) { 193 | if (cssModuleRE.test(id)) { 194 | // Disable self-accept by adding `?inline` for: 195 | // https://github.com/vitejs/vite/blob/775bb5026ee1d7e15b75c8829e7f528c1b26c493/packages/vite/src/node/plugins/css.ts#L955-L958 196 | id += tag; 197 | } 198 | 199 | return Reflect.apply(originalTransform, this, [css, id, options]); 200 | }); 201 | }; 202 | 203 | export const patchCssModules = ( 204 | patchConfig?: PatchConfig, 205 | ): Plugin => ({ 206 | name: 'patch-css-modules', 207 | enforce: 'pre', 208 | configResolved: (config) => { 209 | const pluginInstance = cssModules(config, patchConfig); 210 | const cssConfig = config.css; 211 | 212 | const isCssModulesDisabled = ( 213 | cssConfig.transformer === 'lightningcss' 214 | ? cssConfig.lightningcss?.cssModules 215 | : cssConfig.modules 216 | ) === false; 217 | 218 | if (isCssModulesDisabled) { 219 | return; 220 | } 221 | 222 | // Disable CSS Modules in Vite in favor of our plugin 223 | // https://github.com/vitejs/vite/blob/6c4bf266a0bcae8512f6daf99dff57a73ae7bcf6/packages/vite/src/node/plugins/css.ts#L1192 224 | if (cssConfig.transformer === 'lightningcss') { 225 | if (cssConfig.lightningcss) { 226 | // https://github.com/vitejs/vite/blob/997a6951450640fed8cf19e58dce0d7a01b92392/packages/vite/src/node/plugins/css.ts#L2746 227 | cssConfig.lightningcss.cssModules = false; 228 | } 229 | 230 | /** 231 | * When in Lightning mode, Lightning build API is used 232 | * which will trip up on the dashedIdents feature when 233 | * CSS Modules is disabled 234 | * 235 | * So instead we have to revert back to PostCSS, and then 236 | * disable CSS Modules on PostCSS 237 | */ 238 | cssConfig.transformer = 'postcss'; 239 | } 240 | 241 | cssConfig.modules = false; 242 | 243 | const viteCssPostPluginIndex = config.plugins.findIndex(plugin => plugin.name === 'vite:css-post'); 244 | if (viteCssPostPluginIndex === -1) { 245 | throw new Error('vite:css-post plugin not found'); 246 | } 247 | 248 | const viteCssPostPlugin = config.plugins[viteCssPostPluginIndex]!; 249 | 250 | // Insert before 251 | (config.plugins as Plugin[]).splice( 252 | viteCssPostPluginIndex, 253 | 0, 254 | pluginInstance, 255 | ); 256 | 257 | supportNewCssModules( 258 | viteCssPostPlugin, 259 | config, 260 | pluginInstance, 261 | ); 262 | 263 | // Enable HMR by making CSS Modules not self accept 264 | supportCssModulesHMR(config.plugins); 265 | }, 266 | }); 267 | -------------------------------------------------------------------------------- /tests/fixtures.ts: -------------------------------------------------------------------------------- 1 | import outdent from 'outdent'; 2 | 3 | const random255 = () => Math.floor(Math.random() * 256); 4 | 5 | export const newRgb = () => `rgb(${random255()}, ${random255()}, ${random255()})`; 6 | 7 | export const emptyCssModule = Object.freeze({ 8 | 'index.js': outdent` 9 | export * from './style.module.css'; 10 | export { default } from './style.module.css'; 11 | `, 12 | 'style.module.css': '', 13 | }); 14 | 15 | /** 16 | * PostCSS plugin that adds a "--file" CSS variable to indicate PostCSS 17 | * has been successfully applied 18 | */ 19 | const postcssConfig = outdent` 20 | const path = require('path'); 21 | const postcss = require('postcss'); 22 | module.exports = { 23 | plugins: [ 24 | (root) => { 25 | const newRule = postcss.rule({ selector: ':root' }); 26 | newRule.append({ 27 | prop: '--file', 28 | value: JSON.stringify(path.basename(root.source.input.file)), 29 | }); 30 | root.append(newRule); 31 | }, 32 | ], 33 | }; 34 | `; 35 | 36 | export const postcssLogFile = Object.freeze({ 37 | 'postcss.config.js': postcssConfig, 38 | }); 39 | 40 | export const multiCssModules = Object.freeze({ 41 | 'index.js': outdent` 42 | export * as style1 from './style1.module.css'; 43 | export * as style2 from './style2.module.css'; 44 | `, 45 | 46 | 'style1.module.css': outdent` 47 | .className1 { 48 | composes: util-class from './utils1.css'; 49 | color: red; 50 | } 51 | 52 | .class-name2 { 53 | composes: util-class from './utils1.css'; 54 | composes: util-class from './utils2.css'; 55 | composes: class-name2 from './style2.module.css'; 56 | } 57 | `, 58 | 59 | 'style2.module.css': outdent` 60 | .class-name2 { 61 | composes: util-class from './utils1.css'; 62 | color: pink; 63 | } 64 | `, 65 | 66 | 'utils1.css': outdent` 67 | .util-class { 68 | --name: 'foo'; 69 | color: blue; 70 | } 71 | 72 | .unused-class { 73 | color: yellow; 74 | } 75 | `, 76 | 'utils2.css': outdent` 77 | .util-class { 78 | --name: 'bar'; 79 | color: green; 80 | } 81 | `, 82 | }); 83 | 84 | export const reservedKeywords = Object.freeze({ 85 | 'index.js': outdent` 86 | export * as style from './style.module.css'; 87 | `, 88 | 89 | 'style.module.css': outdent` 90 | .import { 91 | composes: if from './utils.css'; 92 | color: red; 93 | } 94 | 95 | .export { 96 | composes: with from './utils.css'; 97 | } 98 | 99 | .default { 100 | color: blue; 101 | } 102 | `, 103 | 104 | 'utils.css': outdent` 105 | .if { 106 | --name: 'foo'; 107 | color: blue; 108 | } 109 | 110 | .with { 111 | color: yellow; 112 | } 113 | `, 114 | }); 115 | 116 | export const exportModeBoth = Object.freeze({ 117 | 'index.js': outdent` 118 | export * as style from './style.module.css'; 119 | `, 120 | 121 | 'style.module.css': outdent` 122 | .class { 123 | composes: util from './utils.css'; 124 | color: red; 125 | } 126 | `, 127 | 128 | 'utils.css': outdent` 129 | .util { 130 | --name: 'foo'; 131 | color: blue; 132 | } 133 | `, 134 | }); 135 | 136 | export const defaultAsComposedName = Object.freeze({ 137 | 'index.js': outdent` 138 | export * as style from './style.module.css'; 139 | `, 140 | 141 | 'style.module.css': outdent` 142 | .typeof { 143 | composes: default from './utils.css'; 144 | color: red; 145 | } 146 | `, 147 | 148 | 'utils.css': outdent` 149 | .default { 150 | --name: 'foo'; 151 | color: blue; 152 | } 153 | `, 154 | }); 155 | 156 | export const defaultAsName = Object.freeze({ 157 | 'index.js': outdent` 158 | export * as style from './style.module.css'; 159 | `, 160 | 161 | 'style.module.css': outdent` 162 | .typeof { 163 | color: red; 164 | } 165 | 166 | .default { 167 | color: blue; 168 | } 169 | `, 170 | }); 171 | 172 | export const cssModulesValues = Object.freeze({ 173 | 'index.js': outdent` 174 | export * from './style.module.css'; 175 | export { default } from './style.module.css'; 176 | `, 177 | 178 | 'style.module.css': outdent` 179 | @value primary as p1, simple-border from './utils1.css'; 180 | @value primary as p2 from './utils2.css'; 181 | 182 | .class-name1 { 183 | color: p1; 184 | border: simple-border; 185 | composes: util-class from './utils1.css'; 186 | composes: util-class from './utils2.css'; 187 | } 188 | .class-name2 { 189 | color: p2; 190 | } 191 | `, 192 | 193 | 'utils1.css': outdent` 194 | @value primary: #fff; 195 | @value simple-border: 1px solid black; 196 | 197 | .util-class { 198 | border: primary; 199 | } 200 | `, 201 | 'utils2.css': outdent` 202 | @value primary: #000; 203 | 204 | .util-class { 205 | border: primary; 206 | } 207 | `, 208 | 209 | ...postcssLogFile, 210 | }); 211 | 212 | export const cssModulesValuesMultipleExports = Object.freeze({ 213 | 'index.js': outdent` 214 | export * from './style.module.css'; 215 | export * from './style2.module.css'; 216 | `, 217 | 218 | 'style.module.css': outdent` 219 | @value primary as p1, simple-border from './style2.module.css'; 220 | 221 | .class-name1 { 222 | color: p1; 223 | border: simple-border; 224 | composes: class-name2 from './style2.module.css'; 225 | } 226 | `, 227 | 228 | 'style2.module.css': outdent` 229 | @value primary: #fff; 230 | @value simple-border: 1px solid black; 231 | 232 | .class-name2 { 233 | border: primary; 234 | } 235 | `, 236 | 237 | ...postcssLogFile, 238 | }); 239 | 240 | export const lightningCustomPropertiesFrom = Object.freeze({ 241 | 'index.js': outdent` 242 | export { default as style1 } from './style1.module.css'; 243 | export { default as style2 } from './style2.module.css'; 244 | `, 245 | 246 | 'style1.module.css': outdent` 247 | .button { 248 | background: var(--accent-color from "./vars.module.css"); 249 | } 250 | `, 251 | 252 | 'style2.module.css': outdent` 253 | .input { 254 | color: var(--accent-color from "./vars.module.css"); 255 | } 256 | `, 257 | 258 | 'vars.module.css': outdent` 259 | :root { 260 | --accent-color: hotpink; 261 | } 262 | `, 263 | }); 264 | 265 | export const lightningFeatures = Object.freeze({ 266 | 'index.js': outdent` 267 | export * from './style.module.css'; 268 | export { default } from './style.module.css'; 269 | `, 270 | 271 | 'style.module.css': outdent` 272 | .button { 273 | &.primary { 274 | color: red; 275 | } 276 | } 277 | `, 278 | }); 279 | 280 | export const scssModules = Object.freeze({ 281 | 'index.js': outdent` 282 | export * from './style.module.scss'; 283 | export { default } from './style.module.scss'; 284 | `, 285 | 286 | 'style.module.scss': outdent` 287 | $primary: #cc0000; 288 | 289 | // comment 290 | 291 | .text-primary { 292 | color: $primary; 293 | } 294 | `, 295 | 296 | ...postcssLogFile, 297 | }); 298 | 299 | export const mixedScssModules = Object.freeze({ 300 | 'index.js': outdent` 301 | export * from './css.module.css'; 302 | export { default } from './css.module.css'; 303 | `, 304 | 305 | 'css.module.css': outdent` 306 | .text-primary { 307 | composes: text-primary from './scss.module.scss'; 308 | } 309 | `, 310 | 311 | 'scss.module.scss': outdent` 312 | $primary: #cc0000; 313 | 314 | // comment 315 | 316 | .text-primary { 317 | color: $primary; 318 | } 319 | `, 320 | 321 | ...postcssLogFile, 322 | }); 323 | 324 | export const inlineCssModules = Object.freeze({ 325 | 'index.js': outdent` 326 | export * from './style.module.css?inline'; 327 | export { default } from './style.module.css?inline'; 328 | `, 329 | 330 | 'style.module.css': outdent` 331 | .class-name1 { 332 | composes: util-class from './utils.css'; 333 | color: red; 334 | } 335 | `, 336 | 337 | 'utils.css': outdent` 338 | .util-class { 339 | --name: 'foo'; 340 | color: blue; 341 | } 342 | 343 | .unused-class { 344 | color: yellow; 345 | } 346 | `, 347 | 348 | ...postcssLogFile, 349 | }); 350 | 351 | export const globalModule = Object.freeze({ 352 | 'index.js': outdent` 353 | export * from './global.module.css'; 354 | export { default } from './global.module.css'; 355 | `, 356 | 357 | 'global.module.css': outdent` 358 | .page { 359 | padding: 20px; 360 | } 361 | :local(.title) { 362 | color: green; 363 | } 364 | `, 365 | }); 366 | 367 | export const missingClassExport = Object.freeze({ 368 | 'index.js': outdent` 369 | export * from './style.module.css'; 370 | export { default } from './style.module.css'; 371 | `, 372 | 373 | 'style.module.css': outdent` 374 | .className1 { 375 | composes: non-existent from './utils.css'; 376 | color: red; 377 | } 378 | `, 379 | 380 | 'utils.css': '', 381 | }); 382 | 383 | export const vue = Object.freeze({ 384 | 'index.js': outdent` 385 | export { default as Comp } from './comp.vue'; 386 | `, 387 | 388 | 'comp.vue': outdent` 389 | 392 | 393 | 399 | `, 400 | 401 | 'utils.css': outdent` 402 | .util-class { 403 | --name: 'foo'; 404 | color: blue; 405 | } 406 | 407 | .unused-class { 408 | color: yellow; 409 | } 410 | `, 411 | 412 | ...postcssLogFile, 413 | }); 414 | 415 | export const moduleNamespace = Object.freeze({ 416 | 'index.js': outdent` 417 | import('./style.module.css'); 418 | `, 419 | 420 | 'style.module.css': outdent` 421 | .class-name { 422 | color: red; 423 | } 424 | `, 425 | }); 426 | 427 | export const requestQuery = Object.freeze({ 428 | 'index.js': outdent` 429 | export * as style from './style.module.css?some-query'; 430 | `, 431 | 432 | 'style.module.css': outdent` 433 | .class-name { 434 | composes: util-class from './utils.css?another-query'; 435 | } 436 | `, 437 | 438 | 'utils.css': outdent` 439 | .util-class { 440 | --name: 'foo'; 441 | color: blue; 442 | } 443 | `, 444 | 445 | ...postcssLogFile, 446 | }); 447 | 448 | export const viteDev = Object.freeze({ 449 | ...multiCssModules, 450 | 'index.html': ` 451 | 452 | 453 | 454 |
455 | 456 | 457 | 458 | `, 459 | 'main.js': ` 460 | import style from './style1.module.css'; 461 | 462 | document.querySelector('#app').innerHTML =\` 463 |
464 | Hello world 465 |
466 | \`; 467 | `, 468 | }); 469 | 470 | export const viteDevOutsideRoot = Object.freeze({ 471 | ...multiCssModules, 472 | 'nested-dir': { 473 | 'index.html': ` 474 | 475 | 476 | 477 |
478 | 479 | 480 | 481 | `, 482 | 'main.js': ` 483 | import style from '../style1.module.css'; 484 | 485 | document.querySelector('#app').innerHTML =\` 486 |
487 | Hello world 488 |
489 | \`; 490 | `, 491 | }, 492 | }); 493 | -------------------------------------------------------------------------------- /src/plugin/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { readFile, writeFile, access } from 'fs/promises'; 3 | import type { Plugin, ResolvedConfig, CSSModulesOptions } from 'vite'; 4 | import type { TransformPluginContext, ExistingRawSourceMap } from 'rollup'; 5 | import { createFilter } from '@rollup/pluginutils'; 6 | import MagicString from 'magic-string'; 7 | import remapping, { type SourceMapInput } from '@jridgewell/remapping'; 8 | import { shouldKeepOriginalExport, getLocalesConventionFunction } from './locals-convention.js'; 9 | import { generateEsm, type Imports, type Exports } from './generate-esm.js'; 10 | import { generateTypes } from './generate-types.js'; 11 | import type { PluginMeta, ExportMode } from './types.js'; 12 | import { supportsArbitraryModuleNamespace } from './supports-arbitrary-module-namespace.js'; 13 | import type { transform as PostcssTransform } from './transformers/postcss/index.js'; 14 | import type { transform as LightningcssTransform } from './transformers/lightningcss.js'; 15 | import { getCssModuleUrl, cleanUrl, cssModuleRE } from './url-utils.js'; 16 | 17 | export const pluginName = 'vite:css-modules'; 18 | 19 | const loadExports = async ( 20 | context: TransformPluginContext, 21 | requestId: string, 22 | fromId: string, 23 | ) => { 24 | const resolved = await context.resolve(requestId, fromId); 25 | if (!resolved) { 26 | throw new Error(`Cannot resolve "${requestId}" from "${fromId}"`); 27 | } 28 | const loaded = await context.load({ 29 | id: resolved.id, 30 | }); 31 | const pluginMeta = loaded.meta[pluginName] as PluginMeta; 32 | return pluginMeta.exports; 33 | }; 34 | 35 | export type PatchConfig = { 36 | 37 | /** 38 | * Specifies the export method for CSS Modules. 39 | * 40 | * - 'both': Export both default and named exports. 41 | * - 'default': Export only the default export. 42 | * - 'named': Export only named exports. 43 | * 44 | * @default 'both' 45 | */ 46 | exportMode?: ExportMode; 47 | 48 | /** 49 | * Generate TypeScript declaration (.d.ts) files for CSS modules 50 | * 51 | * For example, importing `style.module.css` will create a `style.module.css.d.ts` file 52 | * next to it, containing type definitions for the exported CSS class names 53 | */ 54 | generateSourceTypes?: boolean; 55 | }; 56 | 57 | // This plugin is designed to be used by Vite internally 58 | export const cssModules = ( 59 | config: ResolvedConfig, 60 | patchConfig?: PatchConfig, 61 | ): Plugin => { 62 | const filter = createFilter(cssModuleRE); 63 | const allowArbitraryNamedExports = supportsArbitraryModuleNamespace(config); 64 | 65 | const cssConfig = config.css; 66 | const cssModuleConfig: CSSModulesOptions = { ...cssConfig.modules }; 67 | const lightningCssOptions = { ...cssConfig.lightningcss }; 68 | const { devSourcemap } = cssConfig; 69 | 70 | const isLightningCss = cssConfig.transformer === 'lightningcss'; 71 | const loadTransformer = ( 72 | isLightningCss 73 | ? import('./transformers/lightningcss.js') 74 | : import('./transformers/postcss/index.js') 75 | ); 76 | 77 | let transform: typeof PostcssTransform | typeof LightningcssTransform; 78 | 79 | const exportMode = patchConfig?.exportMode ?? 'both'; 80 | 81 | let isVitest = false; 82 | 83 | return { 84 | name: pluginName, 85 | buildStart: async () => { 86 | const transformer = await loadTransformer; 87 | transform = transformer.transform; 88 | }, 89 | load: { 90 | // Fallback load from disk in case it can't be loaded by another plugin (e.g. vue) 91 | order: 'post', 92 | 93 | /** 94 | * Hook filter to reduce JS/Rust communication overhead in Rolldown 95 | * Supported in Vite 6.3.0+ and Rollup 4.38.0+ 96 | * Backwards-compatible: internal filter check remains for older versions 97 | */ 98 | filter: { 99 | id: cssModuleRE, 100 | }, 101 | handler: async (id) => { 102 | if (!filter(id)) { 103 | return; 104 | } 105 | 106 | id = id.split('?', 2)[0]!; 107 | return await readFile(id, 'utf8'); 108 | }, 109 | }, 110 | 111 | transform: { 112 | /** 113 | * Hook filter to reduce JS/Rust communication overhead in Rolldown 114 | * Supported in Vite 6.3.0+ and Rollup 4.38.0+ 115 | * Backwards-compatible: internal filter check remains for older versions 116 | */ 117 | filter: { 118 | id: cssModuleRE, 119 | }, 120 | async handler(inputCss, id) { 121 | if (!filter(id)) { 122 | return; 123 | } 124 | 125 | /** 126 | * Handle Vitest disabling CSS 127 | * https://github.com/vitest-dev/vitest/blob/v2.1.8/packages/vitest/src/node/plugins/cssEnabler.ts#L55-L68 128 | */ 129 | if (inputCss === '') { 130 | if (!isVitest) { 131 | const checkVitest = config.plugins.some(plugin => plugin.name === 'vitest:css-disable'); 132 | if (checkVitest) { 133 | isVitest = true; 134 | } 135 | } 136 | if (isVitest) { 137 | return { 138 | code: 'export default {};', 139 | map: null, 140 | }; 141 | } 142 | } 143 | 144 | const cssModule = transform( 145 | inputCss, 146 | 147 | /** 148 | * Relative path from project root to get stable CSS modules hash 149 | * https://github.com/vitejs/vite/blob/57463fc53fedc8f29e05ef3726f156a6daf65a94/packages/vite/src/node/plugins/css.ts#L2690 150 | */ 151 | cleanUrl(path.relative(config.root, id)), 152 | isLightningCss ? lightningCssOptions : cssModuleConfig, 153 | devSourcemap, 154 | ); 155 | 156 | let outputCss = cssModule.code; 157 | const imports: Imports = new Map(); 158 | let counter = 0; 159 | 160 | const keepOriginalExport = shouldKeepOriginalExport(cssModuleConfig); 161 | const localsConventionFunction = getLocalesConventionFunction(cssModuleConfig); 162 | 163 | const registerImport = ( 164 | fromFile: string, 165 | exportName?: string, 166 | ) => { 167 | let importFrom = imports.get(fromFile); 168 | if (!importFrom) { 169 | importFrom = {}; 170 | imports.set(fromFile, importFrom); 171 | } 172 | 173 | if (!exportName) { 174 | return; 175 | } 176 | 177 | if (!importFrom[exportName]) { 178 | importFrom[exportName] = `_${counter}`; 179 | counter += 1; 180 | } 181 | return importFrom[exportName]; 182 | }; 183 | 184 | /** 185 | * Passes Promise.all result to Object.fromEntries to preserve export order 186 | * This avoids unnecessary git diffs from non-deterministic ordering 187 | * (e.g. generated types) when the CSS module itself hasn't changed 188 | */ 189 | const exportEntries = await Promise.all( 190 | Object.entries(cssModule.exports).map(async ([exportName, exported]) => { 191 | if ( 192 | exportName === 'default' 193 | && exportMode === 'both' 194 | ) { 195 | this.warn('With `exportMode: both`, you cannot use "default" as a class name as it conflicts with the default export. Set `exportMode` to `default` or `named` to use "default" as a class name.'); 196 | } 197 | 198 | const exportAs = new Set(); 199 | if (keepOriginalExport) { 200 | exportAs.add(exportName); 201 | } 202 | 203 | let code: string; 204 | let resolved: string; 205 | if (typeof exported === 'string') { 206 | const transformedExport = localsConventionFunction?.(exportName, exportName, id); 207 | if (transformedExport) { 208 | exportAs.add(transformedExport); 209 | } 210 | code = exported; 211 | resolved = exported; 212 | } else { 213 | const transformedExport = localsConventionFunction?.(exportName, exported.name, id); 214 | if (transformedExport) { 215 | exportAs.add(transformedExport); 216 | } 217 | 218 | // Collect composed classes 219 | const composedClasses = await Promise.all( 220 | exported.composes.map(async (dep) => { 221 | if (dep.type === 'dependency') { 222 | const loaded = await loadExports(this, getCssModuleUrl(dep.specifier), id); 223 | const exportedEntry = loaded[dep.name]!; 224 | if (!exportedEntry) { 225 | throw new Error(`Cannot resolve ${JSON.stringify(dep.name)} from ${JSON.stringify(dep.specifier)}`); 226 | } 227 | const [exportAsName] = Array.from(exportedEntry.exportAs); 228 | const importedAs = registerImport(dep.specifier, exportAsName)!; 229 | return { 230 | resolved: exportedEntry.resolved, 231 | code: `\${${importedAs}}`, 232 | }; 233 | } 234 | 235 | return { 236 | resolved: dep.name, 237 | code: dep.name, 238 | }; 239 | }), 240 | ); 241 | code = [exported.name, ...composedClasses.map(c => c.code)].join(' '); 242 | resolved = [exported.name, ...composedClasses.map(c => c.resolved)].join(' '); 243 | } 244 | 245 | return [ 246 | exportName, 247 | { 248 | code, 249 | resolved, 250 | exportAs, 251 | }, 252 | ] as const; 253 | }), 254 | ); 255 | 256 | const exports: Exports = Object.fromEntries(exportEntries); 257 | 258 | let { map } = cssModule; 259 | 260 | // Inject CSS Modules values 261 | const references = Object.entries(cssModule.references); 262 | if (references.length > 0) { 263 | const ms = new MagicString(outputCss); 264 | await Promise.all( 265 | references.map(async ([placeholder, source]) => { 266 | const loaded = await loadExports(this, getCssModuleUrl(source.specifier), id); 267 | const exported = loaded[source.name]; 268 | if (!exported) { 269 | throw new Error(`Cannot resolve "${source.name}" from "${source.specifier}"`); 270 | } 271 | 272 | registerImport(source.specifier); 273 | ms.replaceAll(placeholder, exported.code); 274 | }), 275 | ); 276 | outputCss = ms.toString(); 277 | 278 | if (map) { 279 | const newMap = remapping( 280 | [ 281 | ms.generateMap({ 282 | source: id, 283 | file: id, 284 | includeContent: true, 285 | }), 286 | map, 287 | ] as SourceMapInput[], 288 | () => null, 289 | ) as ExistingRawSourceMap; 290 | 291 | map = newMap; 292 | } 293 | } 294 | 295 | if ( 296 | 'getJSON' in cssModuleConfig 297 | && typeof cssModuleConfig.getJSON === 'function' 298 | ) { 299 | const json: Record = {}; 300 | for (const exported of Object.values(exports)) { 301 | for (const exportAs of exported.exportAs) { 302 | json[exportAs] = exported.resolved; 303 | } 304 | } 305 | 306 | cssModuleConfig.getJSON(id, json, id); 307 | } 308 | 309 | const jsCode = generateEsm( 310 | imports, 311 | exports, 312 | exportMode, 313 | allowArbitraryNamedExports, 314 | ); 315 | 316 | if (patchConfig?.generateSourceTypes) { 317 | const filePath = id.split('?', 2)[0]; 318 | 319 | // Only generate types for importable module files 320 | if (filePath && cssModuleRE.test(filePath)) { 321 | const fileExists = await access(filePath).then(() => true, () => false); 322 | if (fileExists) { 323 | const dtsPath = `${filePath}.d.ts`; 324 | const newContent = generateTypes(exports, exportMode, allowArbitraryNamedExports); 325 | 326 | // Skip write if content unchanged to avoid triggering file watchers 327 | const existingContent = await readFile(dtsPath, 'utf8').catch(() => null); 328 | if (existingContent !== newContent) { 329 | await writeFile(dtsPath, newContent); 330 | } 331 | } 332 | } 333 | } 334 | 335 | return { 336 | code: jsCode, 337 | map: map ?? { mappings: '' }, 338 | meta: { 339 | [pluginName]: { 340 | css: outputCss, 341 | exports, 342 | } satisfies PluginMeta, 343 | }, 344 | }; 345 | }, 346 | }, 347 | }; 348 | }; 349 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vite-css-modules 2 | 3 | Vite plugin to fix broken CSS Modules handling. 4 | 5 | [→ Play with a demo on StackBlitz](https://stackblitz.com/edit/vitejs-vite-v9jcwo?file=src%2Fstyle.module.css) 6 | 7 | Note: We're working to integrate this fix directly into Vite ([PR #16018](https://github.com/vitejs/vite/pull/16018)). Until then, use this plugin to benefit from these improvements now. 8 | 9 |
10 | 11 |

12 | 13 | 14 |

15 | 16 |
17 | 18 | ## Why use this plugin? 19 | 20 | Have you encountered any of these Vite CSS Module bugs? They're happening because Vite's CSS Modules implementation delegates everything to `postcss-modules`, creating a black box that Vite can't see into. 21 | 22 | This plugin fixes these issues by properly integrating CSS Modules into Vite's build pipeline. 23 | 24 | ### The bugs this fixes 25 | 26 |
27 | 1. PostCSS plugins don't apply to composes dependencies
28 | 29 | When you use `composes` to import classes from another CSS file, Vite's PostCSS plugins never process the imported file. This means your PostCSS transformations, auto-prefixing, or custom plugins are silently skipped for dependencies. 30 | 31 | **What happens in Vite:** 32 | - `style.module.css` gets processed by PostCSS ✓ 33 | - `utils.css` does NOT get processed by PostCSS ✗ 34 | - Your PostCSS plugin never sees `utils.css` because `postcss-modules` bundles it internally 35 | 36 | **With this plugin:** 37 | - Both files go through your PostCSS pipeline correctly 38 | 39 | [Vite issue #10079](https://github.com/vitejs/vite/issues/10079), [#10340](https://github.com/vitejs/vite/issues/10340) | [Test case](https://github.com/privatenumber/vite-css-modules/blob/develop/tests/specs/reproductions.spec.ts#L62-L85) 40 | 41 |
42 | 43 |
44 | 2. Shared classes get duplicated in your bundle
45 | 46 | When multiple CSS Modules compose from the same utility file, the utility's styles get bundled multiple times. This increases bundle size and can cause style conflicts. 47 | 48 | **Example:** 49 | ```css 50 | /* utils.css */ 51 | .button { padding: 10px; } 52 | 53 | /* header.module.css */ 54 | .title { composes: button from './utils.css'; } 55 | 56 | /* footer.module.css */ 57 | .link { composes: button from './utils.css'; } 58 | ``` 59 | 60 | **What happens in Vite:** 61 | ```css 62 | /* Final bundle contains .button styles TWICE */ 63 | .button { padding: 10px; } /* from header.module.css */ 64 | .button { padding: 10px; } /* from footer.module.css - duplicated! */ 65 | ``` 66 | 67 | **With this plugin:** 68 | ```css 69 | /* Final bundle contains .button styles ONCE */ 70 | .button { padding: 10px; } /* deduplicated */ 71 | ``` 72 | 73 | [Vite issue #7504](https://github.com/vitejs/vite/issues/7504), [#15683](https://github.com/vitejs/vite/issues/15683) | [Test case](https://github.com/privatenumber/vite-css-modules/blob/develop/tests/specs/reproductions.spec.ts#L62-L85) 74 | 75 |
76 | 77 |
78 | 3. Missing composes classes fail silently
79 | 80 | If you typo a class name in `composes`, Vite doesn't error. Instead, it outputs `undefined` in your class names, breaking your UI with no warning. 81 | 82 | **Example:** 83 | ```css 84 | .button { 85 | composes: nonexistant from './utils.css'; /* Typo! */ 86 | } 87 | ``` 88 | 89 | **What happens in Vite:** 90 | ```js 91 | import styles from './style.module.css' 92 | 93 | console.log(styles.button) // "_button_abc123 undefined" - no error! 94 | ``` 95 | 96 | **With this plugin:** 97 | ``` 98 | Error: Cannot find class 'nonexistent' in './utils.css' 99 | ``` 100 | 101 | [Vite issue #16075](https://github.com/vitejs/vite/issues/16075) | [Test case](https://github.com/privatenumber/vite-css-modules/blob/develop/tests/specs/reproductions.spec.ts#L314-L326) 102 | 103 |
104 | 105 |
106 | 4. Can't compose between CSS and SCSS
107 | 108 | Trying to compose from SCSS/Sass files causes syntax errors because `postcss-modules` tries to parse SCSS as plain CSS. 109 | 110 | **Example:** 111 | ```scss 112 | /* base.module.scss */ 113 | .container { display: flex; } 114 | ``` 115 | 116 | ```css 117 | /* style.module.css */ 118 | .wrapper { composes: container from './base.module.scss'; } 119 | ``` 120 | 121 | **What happens in Vite:** 122 | ``` 123 | CssSyntaxError: Unexpected '/' 124 | ``` 125 | 126 | **With this plugin:** 127 | - Works correctly because each file goes through its proper preprocessor first 128 | 129 | [Vite issue #10340](https://github.com/vitejs/vite/issues/10340) | [Test case](https://github.com/privatenumber/vite-css-modules/blob/develop/tests/specs/reproductions.spec.ts#L174-L191) 130 | 131 |
132 | 133 |
134 | 5. HMR doesn't work properly
135 | 136 | Changing a CSS Module file causes a full page reload instead of a hot update, losing component state. 137 | 138 | **What happens in Vite:** 139 | - Full page reload on CSS Module changes 140 | 141 | **With this plugin:** 142 | - CSS Module changes update instantly without losing component state 143 | 144 | [Vite issue #16074](https://github.com/vitejs/vite/issues/16074) | [Test case](https://github.com/privatenumber/vite-css-modules/blob/develop/tests/specs/reproductions.spec.ts#L328-L349) 145 | 146 |
147 | 148 |
149 | 6. Reserved JavaScript keywords break exports
150 | 151 | Using JavaScript reserved keywords as class names (like `.import`, `.export`) generates invalid JavaScript code. 152 | 153 | **Example:** 154 | ```css 155 | .import { color: red; } 156 | .export { color: blue; } 157 | ``` 158 | 159 | **What happens in Vite:** 160 | ```js 161 | // Tries to generate invalid JavaScript: 162 | export const import = "..."; // Syntax error "import" is reserved! 163 | export const export = "..."; // Syntax error "export" is reserved! 164 | ``` 165 | 166 | **With this plugin:** 167 | - Properly handles reserved keywords in class names 168 | 169 | [Vite issue #14050](https://github.com/vitejs/vite/issues/14050) | [Test case](https://github.com/privatenumber/vite-css-modules/blob/develop/tests/specs/reproductions.spec.ts#L194-L211) 170 | 171 |
172 | 173 | ## Install 174 | ```sh 175 | npm install -D vite-css-modules 176 | ``` 177 | 178 | ## Setup 179 | 180 | In your Vite config file, add the `patchCssModules()` plugin to patch Vite's CSS Modules behavior: 181 | 182 | ```ts 183 | // vite.config.js 184 | import { patchCssModules } from 'vite-css-modules' 185 | 186 | export default { 187 | plugins: [ 188 | patchCssModules() // ← This is all you need to add! 189 | 190 | // Other plugins... 191 | ], 192 | css: { 193 | // Your existing CSS Modules configuration 194 | modules: { 195 | // ... 196 | }, 197 | // Or if using LightningCSS 198 | lightningcss: { 199 | cssModules: { 200 | // ... 201 | } 202 | } 203 | }, 204 | build: { 205 | // Recommended minimum target (See FAQ for more details) 206 | target: 'es2022' 207 | } 208 | } 209 | ``` 210 | 211 | This patches your Vite to handle CSS Modules in a more predictable way. 212 | 213 | ### Configuration 214 | Configuring the CSS Modules behavior remains the same as before. 215 | 216 | Read the [Vite docs](https://vite.dev/guide/features.html#css-modules) to learn more. 217 | 218 | 219 | ### Strongly typed CSS Modules (Optional) 220 | 221 | As a bonus feature, this plugin can generate type definitions (`.d.ts` files) for CSS Modules. For example, if `style.module.css` is imported, it will create a `style.module.css.d.ts` file next to it with the type definitions for the exported class names: 222 | 223 | ```ts 224 | patchCssModules({ 225 | generateSourceTypes: true 226 | }) 227 | ``` 228 | 229 | ## API 230 | 231 | ### `patchCssModules(options)` 232 | 233 | #### `exportMode` 234 | 235 | - **Type**: `'both' | 'named' | 'default'` 236 | - **Default**: `'both'` 237 | 238 | Specifies how class names are exported from the CSS Module: 239 | 240 | - **`both`**: Exports class names as both named and default exports. 241 | - **`named`**: Exports class names as named exports only. 242 | - **`default`**: Exports class names as a default export only (an object where keys are class names). 243 | 244 | #### `generateSourceTypes` 245 | 246 | - **Type**: `boolean` 247 | - **Default**: `false` 248 | 249 | This option generates a `.d.ts` file next to each CSS module file. 250 | 251 | 252 | ## FAQ 253 | 254 | ### What issues does this plugin address? 255 | 256 | Vite delegates bundling each CSS Module to [`postcss-modules`](https://github.com/madyankin/postcss-modules), leading to significant problems: 257 | 258 | 1. **CSS Modules not integrated into Vite's build** 259 | 260 | Since `postcss-modules` is a black box that only returns the final bundled output, Vite plugins can't hook into the CSS Modules build or process their internal dependencies. This prevents post-processing by plugins like SCSS, PostCSS, or LightningCSS. ([#10079](https://github.com/vitejs/vite/issues/10079), [#10340](https://github.com/vitejs/vite/issues/10340)) 261 | 262 | 2. **Duplicated CSS Module dependencies** 263 | 264 | Bundling CSS Modules separately duplicates shared dependencies, increasing bundle size and causing style overrides. ([#7504](https://github.com/vitejs/vite/issues/7504), [#15683](https://github.com/vitejs/vite/issues/15683)) 265 | 266 | 3. **Silent failures on unresolved dependencies** 267 | 268 | `postcss-modules` fails silently when it can't resolve a `composes` dependency—missing exports don't throw errors, making CSS bugs harder to catch. ([#16075](https://github.com/vitejs/vite/issues/16075)) 269 | 270 | The `vite-css-modules` plugin fixes these issues by seamlessly integrating CSS Modules into Vite's build process. 271 | 272 | ### How does this work? 273 | 274 | The plugin treats CSS Modules as JavaScript modules, fully integrating them into Vite's build pipeline. Here's how: 275 | 276 | - **Transforms CSS into JS modules** 277 | 278 | CSS Modules are compiled into JS files that load the CSS. `composes` rules become JS imports, and class names are exported as named JS exports. 279 | 280 | - **Integrates with Vite's module graph** 281 | 282 | Because they're now JS modules, CSS Modules join Vite's module graph. This enables proper dependency resolution, bundling, and de-duplication. 283 | 284 | - **Unlocks plugin compatibility** 285 | 286 | Other Vite plugins can now access and process CSS Modules—fixing the prior limitation where dependencies inside them were invisible. 287 | 288 | This model is similar to Webpack's `css-loader`, making it familiar to devs transitioning from Webpack. It also reduces overhead and improves performance in larger projects. 289 | 290 | 291 | ### Does it export class names as named exports? 292 | 293 | Yes, but there are a few things to keep in mind: 294 | 295 | - **JavaScript naming restrictions** 296 | 297 | Older JavaScript versions don't allow special characters (like `-`) in variable names. So a class like `.foo-bar` couldn't be imported as `foo-bar` and had to be accessed via the default export. 298 | 299 | - **Using `localsConvention`** 300 | 301 | To work around this, set `css.modules.localsConvention: 'camelCase'` in your Vite config. This converts `foo-bar` → `fooBar`, making it a valid named export. 302 | 303 | - **ES2022 support for arbitrary names** 304 | 305 | With ES2022, you can now export/import names with any characters using quotes. This means `.foo-bar` can be used as a named export directly. 306 | 307 | To enable this, set your build target to `es2022`: 308 | 309 | ```js 310 | // vite.config.js 311 | export default { 312 | build: { 313 | target: 'es2022' 314 | } 315 | } 316 | ``` 317 | 318 | Then import using: 319 | 320 | ```js 321 | import { 'foo-bar' as fooBar } from './styles.module.css' 322 | 323 | // Use it 324 | console.log(fooBar) 325 | ``` 326 | 327 | This gives you full named export access—even for class names with previously invalid characters. 328 | 329 | ## Sponsors 330 | 331 |

332 | 333 | 334 | 335 |

336 | -------------------------------------------------------------------------------- /tests/specs/patched/lightningcss.spec.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { setTimeout } from 'node:timers/promises'; 4 | import { createFixture } from 'fs-fixture'; 5 | import { testSuite, expect } from 'manten'; 6 | import { Features } from 'lightningcss'; 7 | import vitePluginVue from '@vitejs/plugin-vue'; 8 | import { base64Module } from '../../utils/base64-module.js'; 9 | import * as fixtures from '../../fixtures.js'; 10 | import { viteBuild, getViteDevCode, viteDevBrowser } from '../../utils/vite.js'; 11 | import { getCssSourceMaps } from '../../utils/get-css-source-maps.js'; 12 | import { patchCssModules } from '#vite-css-modules'; 13 | 14 | export default testSuite(({ describe }) => { 15 | describe('LightningCSS', ({ test, describe }) => { 16 | test('Configured', async () => { 17 | await using fixture = await createFixture(fixtures.multiCssModules); 18 | 19 | const { js, css } = await viteBuild(fixture.path, { 20 | plugins: [ 21 | patchCssModules(), 22 | ], 23 | css: { 24 | transformer: 'lightningcss', 25 | }, 26 | build: { 27 | target: 'es2022', 28 | }, 29 | }); 30 | 31 | const exported = await import(base64Module(js)); 32 | expect(exported).toMatchObject({ 33 | style1: { 34 | className1: expect.stringMatching(/^[\w-]+_className1 [\w-]+_util-class$/), 35 | default: { 36 | className1: expect.stringMatching(/^[\w-]+_className1 [\w-]+_util-class$/), 37 | 'class-name2': expect.stringMatching(/^[\w-]+_class-name2 [\w-]+_util-class [\w-]+_util-class [\w-]+_class-name2 [\w-]+_util-class$/), 38 | }, 39 | }, 40 | style2: { 41 | 'class-name2': expect.stringMatching(/^[\w-]+_class-name2 [\w-]+_util-class$/), 42 | default: { 43 | 'class-name2': expect.stringMatching(/^[\w-]+_class-name2 [\w-]+_util-class$/), 44 | }, 45 | }, 46 | }); 47 | 48 | // Util is not duplicated 49 | const utilityClass = Array.from(css!.matchAll(/foo/g)); 50 | expect(utilityClass.length).toBe(1); 51 | }); 52 | 53 | test('Empty CSS Module', async () => { 54 | await using fixture = await createFixture(fixtures.emptyCssModule); 55 | 56 | const { js, css } = await viteBuild(fixture.path, { 57 | plugins: [ 58 | patchCssModules(), 59 | ], 60 | css: { 61 | transformer: 'lightningcss', 62 | }, 63 | }); 64 | 65 | const exported = await import(base64Module(js)); 66 | expect(exported).toMatchObject({ 67 | default: {}, 68 | }); 69 | expect(css).toBe('\n'); 70 | }); 71 | 72 | // https://github.com/vitejs/vite/issues/14050 73 | test('reserved keywords', async () => { 74 | await using fixture = await createFixture(fixtures.reservedKeywords); 75 | 76 | const { js } = await viteBuild(fixture.path, { 77 | plugins: [ 78 | patchCssModules(), 79 | ], 80 | build: { 81 | target: 'es2022', 82 | }, 83 | css: { 84 | transformer: 'lightningcss', 85 | }, 86 | }); 87 | const exported = await import(base64Module(js)); 88 | expect(exported).toMatchObject({ 89 | style: { 90 | default: { 91 | export: 'fk9XWG_export V_YH-W_with', 92 | import: 'fk9XWG_import V_YH-W_if', 93 | }, 94 | export: 'fk9XWG_export V_YH-W_with', 95 | import: 'fk9XWG_import V_YH-W_if', 96 | }, 97 | }); 98 | }); 99 | 100 | describe('Custom property dependencies', ({ test }) => { 101 | test('build', async () => { 102 | await using fixture = await createFixture(fixtures.lightningCustomPropertiesFrom); 103 | 104 | const { js, css } = await viteBuild(fixture.path, { 105 | plugins: [ 106 | patchCssModules(), 107 | ], 108 | css: { 109 | transformer: 'lightningcss', 110 | lightningcss: { 111 | cssModules: { 112 | dashedIdents: true, 113 | }, 114 | }, 115 | }, 116 | }); 117 | 118 | const exported = await import(base64Module(js)); 119 | expect(exported).toMatchObject({ 120 | style1: { 121 | button: expect.stringMatching(/^[\w-]+_button$/), 122 | }, 123 | style2: { 124 | input: expect.stringMatching(/^[\w-]+input$/), 125 | }, 126 | }); 127 | 128 | const variableNameMatches = Array.from(css!.matchAll(/(\S+): hotpink/g))!; 129 | expect(variableNameMatches.length).toBe(1); 130 | 131 | const variableName = variableNameMatches[0]![1]; 132 | expect(css).toMatch(`color: var(${variableName})`); 133 | expect(css).toMatch(`background: var(${variableName})`); 134 | }); 135 | 136 | test('serve', async () => { 137 | await using fixture = await createFixture(fixtures.lightningCustomPropertiesFrom); 138 | 139 | const code = await getViteDevCode(fixture.path, { 140 | plugins: [ 141 | patchCssModules(), 142 | ], 143 | css: { 144 | transformer: 'lightningcss', 145 | lightningcss: { 146 | cssModules: { 147 | dashedIdents: true, 148 | }, 149 | }, 150 | }, 151 | }); 152 | 153 | const variableNameMatches = Array.from(code.matchAll(/(\S+): hotpink/g))!; 154 | expect(variableNameMatches.length).toBe(1); 155 | 156 | const variableName = variableNameMatches[0]![1]; 157 | expect(code).toMatch(`color: var(${variableName})`); 158 | expect(code).toMatch(`background: var(${variableName})`); 159 | }); 160 | }); 161 | 162 | describe('Other configs', ({ test }) => { 163 | test('build', async () => { 164 | await using fixture = await createFixture(fixtures.lightningFeatures); 165 | 166 | const { css } = await viteBuild(fixture.path, { 167 | plugins: [ 168 | patchCssModules(), 169 | ], 170 | css: { 171 | transformer: 'lightningcss', 172 | lightningcss: { 173 | include: Features.Nesting, 174 | }, 175 | }, 176 | }); 177 | 178 | expect(css).toMatch(/\.[\w-]+_button\.[\w-]+_primary/); 179 | }); 180 | 181 | test('dev server', async () => { 182 | await using fixture = await createFixture(fixtures.lightningFeatures); 183 | 184 | const code = await getViteDevCode(fixture.path, { 185 | plugins: [ 186 | patchCssModules(), 187 | ], 188 | css: { 189 | transformer: 'lightningcss', 190 | lightningcss: { 191 | include: Features.Nesting, 192 | }, 193 | }, 194 | }); 195 | 196 | const cssSourcemaps = getCssSourceMaps(code); 197 | expect(cssSourcemaps.length).toBe(0); 198 | 199 | expect(code).toMatch(/\.[\w-]+_button\.[\w-]+_primary/); 200 | }); 201 | 202 | test('devSourcemap', async () => { 203 | await using fixture = await createFixture(fixtures.lightningCustomPropertiesFrom); 204 | 205 | const code = await getViteDevCode( 206 | fixture.path, 207 | { 208 | plugins: [ 209 | patchCssModules(), 210 | ], 211 | css: { 212 | devSourcemap: true, 213 | transformer: 'lightningcss', 214 | lightningcss: { 215 | include: Features.Nesting, 216 | cssModules: { 217 | dashedIdents: true, 218 | }, 219 | }, 220 | }, 221 | }, 222 | ); 223 | 224 | const cssSourcemaps = getCssSourceMaps(code); 225 | expect(cssSourcemaps.length).toBe(3); 226 | // I'm skeptical these source maps are correct 227 | // Seems lightningCSS is providing these source maps 228 | expect(cssSourcemaps).toMatchObject([ 229 | { 230 | version: 3, 231 | file: expect.stringMatching(/^style1\.module\.css$/), 232 | mappings: 'AAAA', 233 | names: [], 234 | ignoreList: [], 235 | sources: [expect.stringMatching(/^style1\.module\.css$/)], 236 | sourcesContent: [ 237 | '.button {\n\tbackground: var(--accent-color from "./vars.module.css");\n}', 238 | ], 239 | }, 240 | { 241 | version: 3, 242 | file: expect.stringMatching(/^style2\.module\.css$/), 243 | mappings: 'AAAA', 244 | names: [], 245 | ignoreList: [], 246 | sources: [expect.stringMatching(/^style2\.module\.css$/)], 247 | sourcesContent: [ 248 | '.input {\n\tcolor: var(--accent-color from "./vars.module.css");\n}', 249 | ], 250 | }, 251 | { 252 | version: 3, 253 | sourceRoot: null, 254 | mappings: 'AAAA', 255 | sources: [expect.stringMatching(/^vars\.module\.css$/)], 256 | sourcesContent: [':root {\n\t--accent-color: hotpink;\n}'], 257 | names: [], 258 | }, 259 | ]); 260 | }); 261 | 262 | test('devSourcemap with Vue.js', async () => { 263 | await using fixture = await createFixture({ 264 | ...fixtures.vue, 265 | node_modules: ({ symlink }) => symlink(path.resolve('node_modules')), 266 | }); 267 | 268 | const code = await getViteDevCode(fixture.path, { 269 | plugins: [ 270 | patchCssModules(), 271 | vitePluginVue(), 272 | ], 273 | css: { 274 | devSourcemap: true, 275 | transformer: 'lightningcss', 276 | lightningcss: { 277 | include: Features.Nesting, 278 | }, 279 | }, 280 | }); 281 | 282 | const cssSourcemaps = getCssSourceMaps(code); 283 | expect(cssSourcemaps.length).toBe(2); 284 | expect(cssSourcemaps).toMatchObject([ 285 | { 286 | version: 3, 287 | mappings: 'AAKA;;;;ACLA', 288 | names: [], 289 | sources: [ 290 | expect.stringMatching(/\/comp\.vue$/), 291 | '\u0000', 292 | ], 293 | sourcesContent: [ 294 | '\n' 297 | + '\n' 298 | + '', 304 | null, 305 | ], 306 | file: expect.stringMatching(/\/comp\.vue$/), 307 | }, 308 | { 309 | version: 3, 310 | mappings: 'AAAA;;;;;AAKA;;;;ACLA', 311 | names: [], 312 | sources: [ 313 | expect.stringMatching(/\/utils\.css$/), 314 | '\u0000', 315 | ], 316 | sourcesContent: [ 317 | '.util-class {\n' 318 | + "\t--name: 'foo';\n" 319 | + '\tcolor: blue;\n' 320 | + '}\n' 321 | + '\n' 322 | + '.unused-class {\n' 323 | + '\tcolor: yellow;\n' 324 | + '}', 325 | null, 326 | ], 327 | file: expect.stringMatching(/\/utils\.css$/), 328 | }, 329 | ]); 330 | }); 331 | }); 332 | 333 | test('.d.ts', async () => { 334 | await using fixture = await createFixture(fixtures.reservedKeywords); 335 | 336 | await viteBuild(fixture.path, { 337 | plugins: [ 338 | patchCssModules({ 339 | generateSourceTypes: true, 340 | }), 341 | ], 342 | build: { 343 | target: 'es2022', 344 | }, 345 | css: { 346 | transformer: 'lightningcss', 347 | }, 348 | }); 349 | 350 | const files = await readdir(fixture.path); 351 | expect(files).toStrictEqual([ 352 | 'dist', 353 | 'index.js', 354 | 'node_modules', 355 | 'style.module.css', 356 | 'style.module.css.d.ts', 357 | 'utils.css', 358 | ]); 359 | 360 | const dts = await fixture.readFile('style.module.css.d.ts', 'utf8'); 361 | expect(dts).toMatch('const _import: string'); 362 | expect(dts).toMatch('_import as "import"'); 363 | }); 364 | 365 | describe('exportMode', ({ test }) => { 366 | test('both (default)', async () => { 367 | await using fixture = await createFixture(fixtures.exportModeBoth); 368 | 369 | const { js } = await viteBuild(fixture.path, { 370 | plugins: [ 371 | patchCssModules({ 372 | exportMode: 'both', 373 | }), 374 | ], 375 | build: { 376 | target: 'es2022', 377 | }, 378 | css: { 379 | transformer: 'lightningcss', 380 | }, 381 | }); 382 | const exported = await import(base64Module(js)); 383 | expect(exported).toMatchObject({ 384 | style: { 385 | default: { 386 | class: 'fk9XWG_class V_YH-W_util', 387 | }, 388 | class: 'fk9XWG_class V_YH-W_util', 389 | }, 390 | }); 391 | }); 392 | 393 | test('named', async () => { 394 | await using fixture = await createFixture(fixtures.exportModeBoth); 395 | 396 | const { js } = await viteBuild(fixture.path, { 397 | plugins: [ 398 | patchCssModules({ 399 | exportMode: 'named', 400 | }), 401 | ], 402 | build: { 403 | target: 'es2022', 404 | }, 405 | css: { 406 | transformer: 'lightningcss', 407 | }, 408 | }); 409 | const exported = await import(base64Module(js)); 410 | expect(exported).toMatchObject({ 411 | style: { 412 | class: 'fk9XWG_class V_YH-W_util', 413 | }, 414 | }); 415 | expect(exported.style.default).toBeUndefined(); 416 | }); 417 | 418 | test('default', async () => { 419 | await using fixture = await createFixture(fixtures.exportModeBoth); 420 | 421 | const { js } = await viteBuild(fixture.path, { 422 | plugins: [ 423 | patchCssModules({ 424 | exportMode: 'default', 425 | }), 426 | ], 427 | build: { 428 | target: 'es2022', 429 | }, 430 | css: { 431 | transformer: 'lightningcss', 432 | }, 433 | }); 434 | const exported = await import(base64Module(js)); 435 | expect(exported).toMatchObject({ 436 | style: { 437 | default: { 438 | class: 'fk9XWG_class V_YH-W_util', 439 | }, 440 | }, 441 | }); 442 | 443 | expect( 444 | Object.keys(exported.style).length, 445 | ).toBe(1); 446 | }); 447 | }); 448 | 449 | describe('default as named export', ({ test }) => { 450 | test('should warn & omit `default` from named export', async () => { 451 | await using fixture = await createFixture(fixtures.defaultAsName); 452 | 453 | const { js, warnings } = await viteBuild(fixture.path, { 454 | plugins: [ 455 | patchCssModules(), 456 | ], 457 | build: { 458 | target: 'es2022', 459 | }, 460 | css: { 461 | transformer: 'lightningcss', 462 | }, 463 | }); 464 | const exported = await import(base64Module(js)); 465 | expect(exported).toMatchObject({ 466 | style: { 467 | default: { 468 | typeof: 'fk9XWG_typeof', 469 | default: 'fk9XWG_default', 470 | }, 471 | typeof: 'fk9XWG_typeof', 472 | }, 473 | }); 474 | expect(warnings).toHaveLength(1); 475 | expect(warnings[0]).toMatch('you cannot use "default" as a class name'); 476 | }); 477 | 478 | test('should work with exportMode: \'default\'', async () => { 479 | await using fixture = await createFixture(fixtures.defaultAsName); 480 | 481 | const { js, warnings } = await viteBuild(fixture.path, { 482 | plugins: [ 483 | patchCssModules({ 484 | exportMode: 'default', 485 | }), 486 | ], 487 | build: { 488 | target: 'es2022', 489 | }, 490 | css: { 491 | transformer: 'lightningcss', 492 | }, 493 | }); 494 | const exported = await import(base64Module(js)); 495 | expect(exported).toMatchObject({ 496 | style: { 497 | default: { 498 | default: 'fk9XWG_default', 499 | typeof: 'fk9XWG_typeof', 500 | }, 501 | }, 502 | }); 503 | expect(warnings).toHaveLength(0); 504 | }); 505 | 506 | test('should work with exportMode: \'named\'', async () => { 507 | await using fixture = await createFixture(fixtures.defaultAsName); 508 | 509 | const { js, warnings } = await viteBuild(fixture.path, { 510 | plugins: [ 511 | patchCssModules({ 512 | exportMode: 'named', 513 | }), 514 | ], 515 | build: { 516 | target: 'es2022', 517 | }, 518 | css: { 519 | transformer: 'lightningcss', 520 | }, 521 | }); 522 | const exported = await import(base64Module(js)); 523 | expect(exported).toMatchObject({ 524 | style: { 525 | typeof: 'fk9XWG_typeof', 526 | default: 'fk9XWG_default', 527 | }, 528 | }); 529 | expect(warnings).toHaveLength(0); 530 | }); 531 | 532 | test('composes default (not working)', async () => { 533 | await using fixture = await createFixture(fixtures.defaultAsComposedName); 534 | 535 | const { js } = await viteBuild(fixture.path, { 536 | plugins: [ 537 | patchCssModules(), 538 | ], 539 | build: { 540 | target: 'es2022', 541 | }, 542 | css: { 543 | transformer: 'lightningcss', 544 | }, 545 | }); 546 | const exported = await import(base64Module(js)); 547 | expect(exported).toMatchObject({ 548 | style: { 549 | default: { 550 | typeof: 'fk9XWG_typeof', 551 | }, 552 | 553 | /** 554 | * This should actually compose `default` from `utils.css` (Compare with postcss test) 555 | * LightningCSS has special case to prevent `default` from being imported 556 | * https://github.com/parcel-bundler/lightningcss/issues/908 557 | */ 558 | typeof: 'fk9XWG_typeof', 559 | }, 560 | }); 561 | }); 562 | }); 563 | 564 | test('queries in requests should be preserved', async () => { 565 | await using fixture = await createFixture(fixtures.requestQuery); 566 | const { css } = await viteBuild(fixture.path, { 567 | plugins: [ 568 | patchCssModules(), 569 | ], 570 | build: { 571 | target: 'es2022', 572 | cssMinify: false, 573 | }, 574 | css: { 575 | transformer: 'lightningcss', 576 | }, 577 | }); 578 | expect(css).toMatch('style.module.css?some-query'); 579 | }); 580 | 581 | test('hmr', async () => { 582 | await using fixture = await createFixture(fixtures.viteDev); 583 | 584 | await viteDevBrowser( 585 | fixture.path, 586 | { 587 | plugins: [ 588 | patchCssModules(), 589 | ], 590 | css: { 591 | transformer: 'lightningcss', 592 | }, 593 | }, 594 | async (page) => { 595 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 596 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 597 | 598 | const newColor = fixtures.newRgb(); 599 | const newFile = fixtures.viteDev['style1.module.css'].replace('red', newColor); 600 | await fixture.writeFile('style1.module.css', newFile); 601 | 602 | await setTimeout(1000); 603 | 604 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 605 | expect(textColorAfter).toBe(newColor); 606 | }, 607 | ); 608 | }); 609 | 610 | test('hmr outside root', async () => { 611 | await using fixture = await createFixture(fixtures.viteDevOutsideRoot); 612 | 613 | await viteDevBrowser( 614 | fixture.getPath('nested-dir'), 615 | { 616 | plugins: [ 617 | patchCssModules(), 618 | ], 619 | css: { 620 | transformer: 'lightningcss', 621 | }, 622 | }, 623 | async (page) => { 624 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 625 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 626 | 627 | const newColor = fixtures.newRgb(); 628 | const newFile = fixtures.viteDevOutsideRoot['style1.module.css'].replace('red', newColor); 629 | await fixture.writeFile('style1.module.css', newFile); 630 | 631 | await setTimeout(1000); 632 | 633 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 634 | expect(textColorAfter).toBe(newColor); 635 | }, 636 | ); 637 | }); 638 | 639 | test('enabling sourcemap doesnt emit warning', async () => { 640 | await using fixture = await createFixture(fixtures.multiCssModules); 641 | 642 | const { warnings } = await viteBuild(fixture.path, { 643 | plugins: [ 644 | patchCssModules(), 645 | ], 646 | build: { 647 | sourcemap: true, 648 | }, 649 | css: { 650 | transformer: 'lightningcss', 651 | }, 652 | }); 653 | 654 | expect(warnings).toHaveLength(0); 655 | }); 656 | }); 657 | }); 658 | -------------------------------------------------------------------------------- /tests/specs/reproductions.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { setTimeout } from 'node:timers/promises'; 3 | import { createFixture } from 'fs-fixture'; 4 | import { testSuite, expect } from 'manten'; 5 | import type { CssSyntaxError } from 'postcss'; 6 | import vitePluginVue from '@vitejs/plugin-vue'; 7 | import { base64Module } from '../utils/base64-module.js'; 8 | import * as fixtures from '../fixtures.js'; 9 | import { viteBuild, getViteDevCode, viteDevBrowser } from '../utils/vite.js'; 10 | import { getCssSourceMaps } from '../utils/get-css-source-maps.js'; 11 | 12 | export default testSuite(({ describe }) => { 13 | describe('reproductions', ({ describe }) => { 14 | describe('postcss (no config)', ({ test, describe }) => { 15 | test('build', async () => { 16 | await using fixture = await createFixture({ 17 | ...fixtures.multiCssModules, 18 | ...fixtures.postcssLogFile, 19 | }); 20 | 21 | const { js, css } = await viteBuild(fixture.path); 22 | const exported = await import(base64Module(js)); 23 | expect(exported).toMatchObject({ 24 | style1: { 25 | className1: expect.stringMatching(/^_className1_\w+ _util-class_\w+$/), 26 | default: { 27 | className1: expect.stringMatching(/^_className1_\w+ _util-class_\w+$/), 28 | 'class-name2': expect.stringMatching(/^_class-name2_\w+ _util-class_\w+ _util-class_\w+ _class-name2_\w+ _util-class_\w+$/), 29 | }, 30 | }, 31 | style2: { 32 | default: { 33 | 'class-name2': expect.stringMatching(/^_class-name2_\w+ _util-class_\w+$/), 34 | }, 35 | }, 36 | }); 37 | 38 | expect(css).toMatch('--file: "style1.module.css"'); 39 | expect(css).toMatch('--file: "style2.module.css"'); 40 | 41 | // Without the patch, PostCSS is not applied to composed dependencies 42 | // https://github.com/vitejs/vite/issues/10079 43 | expect(css).not.toMatch('--file: "utils1.css?.module.css"'); 44 | expect(css).not.toMatch('--file: "utils2.css?.module.css"'); 45 | 46 | // util class is duplicated 47 | // https://github.com/vitejs/vite/issues/15683 48 | const utilityClass = Array.from(css!.matchAll(/util-class/g)); 49 | expect(utilityClass.length).toBeGreaterThan(1); 50 | }); 51 | 52 | test('inline', async () => { 53 | await using fixture = await createFixture(fixtures.inlineCssModules); 54 | 55 | const { js } = await viteBuild(fixture.path); 56 | const exported = await import(base64Module(js)); 57 | 58 | expect(typeof exported.default).toBe('string'); 59 | expect(exported.default).toMatch('--file: "style.module.css?inline"'); 60 | }); 61 | 62 | test('dev server', async () => { 63 | await using fixture = await createFixture({ 64 | ...fixtures.multiCssModules, 65 | ...fixtures.postcssLogFile, 66 | node_modules: ({ symlink }) => symlink(path.resolve('node_modules')), 67 | }); 68 | 69 | const code = await getViteDevCode(fixture.path); 70 | 71 | const cssSourcemaps = getCssSourceMaps(code); 72 | expect(cssSourcemaps.length).toBe(0); 73 | 74 | expect(code).toMatch(String.raw`--file: \"style1.module.css\"`); 75 | expect(code).toMatch(String.raw`--file: \"style2.module.css\"`); 76 | 77 | // Without the patch, PostCSS is not applied to composed dependencies 78 | expect(code).not.toMatch('--file: "utils1.css?.module.css"'); 79 | expect(code).not.toMatch('--file: "utils2.css?.module.css"'); 80 | 81 | // util class is duplicated 82 | // https://github.com/vitejs/vite/issues/15683 83 | const utilityClass = Array.from(code.matchAll(/util-class/g)); 84 | expect(utilityClass.length).toBeGreaterThan(1); 85 | }); 86 | 87 | test('devSourcemap', async () => { 88 | await using fixture = await createFixture({ 89 | ...fixtures.cssModulesValues, 90 | node_modules: ({ symlink }) => symlink(path.resolve('node_modules')), 91 | }); 92 | 93 | const code = await getViteDevCode(fixture.path, { 94 | css: { 95 | devSourcemap: true, 96 | }, 97 | }); 98 | 99 | const cssSourcemaps = getCssSourceMaps(code); 100 | expect(cssSourcemaps.length).toBe(1); 101 | expect(cssSourcemaps).toMatchObject([ 102 | { 103 | file: expect.stringMatching(/\/style\.module\.css$/), 104 | mappings: 'AAAA;CAAA,aAAA;CAAA;CAAA,aAAA;CAAA;ACGA;CACC,WAAS;CACT,uBAAqB;AAGtB;AACA;CACC,WAAS;AACV;ADXA;CAAA;CAAA', 105 | names: [], 106 | sources: [ 107 | '\u0000', 108 | expect.stringMatching(/\/style\.module\.css$/), 109 | ], 110 | sourcesContent: [ 111 | null, 112 | "@value primary as p1, simple-border from './utils1.css';\n" 113 | + "@value primary as p2 from './utils2.css';\n" 114 | + '\n' 115 | + '.class-name1 {\n' 116 | + '\tcolor: p1;\n' 117 | + '\tborder: simple-border;\n' 118 | + "\tcomposes: util-class from './utils1.css';\n" 119 | + "\tcomposes: util-class from './utils2.css';\n" 120 | + '}\n' 121 | + '.class-name2 {\n' 122 | + '\tcolor: p2;\n' 123 | + '}', 124 | ], 125 | version: 3, 126 | }, 127 | ]); 128 | }); 129 | 130 | test('devSourcemap with Vue.js', async () => { 131 | await using fixture = await createFixture({ 132 | ...fixtures.vue, 133 | node_modules: ({ symlink }) => symlink(path.resolve('node_modules')), 134 | }); 135 | 136 | const code = await getViteDevCode(fixture.path, { 137 | plugins: [ 138 | vitePluginVue(), 139 | ], 140 | css: { 141 | devSourcemap: true, 142 | }, 143 | }); 144 | 145 | const cssSourcemaps = getCssSourceMaps(code); 146 | expect(cssSourcemaps).toMatchObject([ 147 | { 148 | version: 3, 149 | file: expect.stringMatching(/\/comp\.vue$/), 150 | mappings: 'AAAA;CAAA;CAAA;CAAA;CAAA;;ACKA;CAEC,UAAU;AACX;ADRA;CAAA', 151 | names: [], 152 | sources: [ 153 | '\u0000', 154 | expect.stringMatching(/\/comp\.vue$/), 155 | ], 156 | sourcesContent: [ 157 | null, 158 | '\n' 161 | + '\n' 162 | + '', 168 | ], 169 | }, 170 | ]); 171 | }); 172 | 173 | // https://github.com/vitejs/vite/issues/10340 174 | test('mixed css + scss types doesnt build', async () => { 175 | await using fixture = await createFixture({ 176 | ...fixtures.mixedScssModules, 177 | ...fixtures.postcssLogFile, 178 | }); 179 | 180 | let error: CssSyntaxError | undefined; 181 | process.once('unhandledRejection', (reason) => { 182 | error = reason as CssSyntaxError; 183 | }); 184 | try { 185 | await viteBuild(fixture.path, { 186 | logLevel: 'silent', 187 | }); 188 | } catch {} 189 | 190 | expect(error?.reason).toBe(String.raw`Unexpected '/'. Escaping special characters with \ may help.`); 191 | }); 192 | 193 | // https://github.com/vitejs/vite/issues/14050 194 | test('reserved keywords', async () => { 195 | await using fixture = await createFixture(fixtures.reservedKeywords); 196 | 197 | const { js } = await viteBuild(fixture.path); 198 | const exported = await import(base64Module(js)); 199 | 200 | expect(exported).toMatchObject({ 201 | style: { 202 | default: { 203 | import: '_import_1inzh_1 _if_1bsbm_1', 204 | export: '_export_1inzh_6 _with_1bsbm_6', 205 | }, 206 | }, 207 | }); 208 | 209 | expect(exported.import).toBeUndefined(); 210 | expect(exported.export).toBeUndefined(); 211 | }); 212 | 213 | // This one is more for understanding expected behavior 214 | test('@values', async () => { 215 | await using fixture = await createFixture(fixtures.cssModulesValues); 216 | 217 | const { js, css } = await viteBuild(fixture.path); 218 | const exported = await import(base64Module(js)); 219 | 220 | expect(exported).toMatchObject({ 221 | default: { 222 | 'class-name1': '_class-name1_1220u_4 _util-class_irvot_4 _util-class_2pvet_3', 223 | 'class-name2': '_class-name2_1220u_10', 224 | 225 | // @values 226 | p1: '#fff', 227 | 'simple-border': '1px solid black', 228 | p2: '#000', 229 | }, 230 | 231 | // @values 232 | p1: '#fff', 233 | p2: '#000', 234 | }); 235 | 236 | // Without the patch, PostCSS is not applied to composed dependencies 237 | expect(css).not.toMatch('--file: "utils1.css?.module.css"'); 238 | 239 | expect(css).toMatch('border: 1px solid black'); 240 | }); 241 | 242 | // To understand expected behavior 243 | test('globalModulePaths', async () => { 244 | await using fixture = await createFixture(fixtures.globalModule); 245 | 246 | const { js, css } = await viteBuild(fixture.path, { 247 | css: { 248 | modules: { 249 | globalModulePaths: [/global\.module\.css/], 250 | }, 251 | }, 252 | }); 253 | 254 | const exported = await import(base64Module(js)); 255 | expect(exported).toMatchObject({ 256 | default: { 257 | title: expect.stringMatching(/^_title_\w{5}/), 258 | }, 259 | title: expect.stringMatching(/^_title_\w{5}/), 260 | }); 261 | 262 | expect(css).toMatch('.page {'); 263 | }); 264 | 265 | // To understand expected behavior 266 | test('getJSON', async () => { 267 | await using fixture = await createFixture(fixtures.multiCssModules); 268 | 269 | type JSON = { 270 | inputFile: string; 271 | exports: Record; 272 | outputFile: string; 273 | }; 274 | const jsons: JSON[] = []; 275 | 276 | await viteBuild(fixture.path, { 277 | css: { 278 | modules: { 279 | localsConvention: 'camelCaseOnly', 280 | getJSON: (inputFile, exports, outputFile) => { 281 | jsons.push({ 282 | inputFile, 283 | exports, 284 | outputFile, 285 | }); 286 | }, 287 | }, 288 | }, 289 | }); 290 | 291 | expect(jsons).toHaveLength(2); 292 | jsons.sort((a, b) => a.inputFile.localeCompare(b.inputFile)); 293 | 294 | const [style1, style2] = jsons; 295 | expect(style1).toMatchObject({ 296 | inputFile: expect.stringMatching(/style1\.module\.css$/), 297 | exports: { 298 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 299 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 300 | }, 301 | outputFile: expect.stringMatching(/style1\.module\.css$/), 302 | }); 303 | 304 | expect(style2).toMatchObject({ 305 | inputFile: expect.stringMatching(/style2\.module\.css$/), 306 | exports: { 307 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 308 | }, 309 | outputFile: expect.stringMatching(/style2\.module\.css$/), 310 | }); 311 | }); 312 | 313 | describe('error handling', ({ test }) => { 314 | test('missing class export does not error', async () => { 315 | await using fixture = await createFixture(fixtures.missingClassExport); 316 | 317 | const { js } = await viteBuild(fixture.path); 318 | const exported = await import(base64Module(js)); 319 | expect(exported).toMatchObject({ 320 | className1: '_className1_innx3_1 undefined', 321 | default: { 322 | className1: '_className1_innx3_1 undefined', 323 | }, 324 | }); 325 | }); 326 | }); 327 | 328 | test('hmr should work inside of root', async () => { 329 | await using fixture = await createFixture(fixtures.viteDev); 330 | 331 | await viteDevBrowser( 332 | fixture.path, 333 | {}, 334 | async (page, server) => { 335 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 336 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 337 | 338 | const newColor = fixtures.newRgb(); 339 | const newFile = fixtures.viteDev['style1.module.css'].replace('red', newColor); 340 | fixture.writeFile('style1.module.css', newFile); 341 | 342 | await new Promise((resolve) => { server.watcher.once('change', resolve); }); 343 | await setTimeout(1000); 344 | 345 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 346 | expect(textColorAfter).toBe(newColor); 347 | }, 348 | ); 349 | }, 10_000); 350 | 351 | test('hmr should work outside of root', async () => { 352 | await using fixture = await createFixture(fixtures.viteDevOutsideRoot); 353 | 354 | await viteDevBrowser( 355 | fixture.getPath('nested-dir'), 356 | {}, 357 | async (page, server) => { 358 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 359 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 360 | 361 | const newColor = fixtures.newRgb(); 362 | const newFile = fixtures.viteDev['style1.module.css'].replace('red', newColor); 363 | fixture.writeFile('style1.module.css', newFile); 364 | 365 | await new Promise((resolve) => { server.watcher.once('change', resolve); }); 366 | await setTimeout(1000); 367 | 368 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 369 | expect(textColorAfter).toBe(newColor); 370 | }, 371 | ); 372 | }, 10_000); 373 | }); 374 | 375 | describe('LightningCSS', ({ describe, test }) => { 376 | describe('no config', ({ test }) => { 377 | test('build', async () => { 378 | await using fixture = await createFixture(fixtures.multiCssModules); 379 | 380 | const { js, css } = await viteBuild(fixture.path, { 381 | css: { 382 | transformer: 'lightningcss', 383 | }, 384 | }); 385 | 386 | const exported = await import(base64Module(js)); 387 | expect(exported).toMatchObject({ 388 | style1: { 389 | className1: expect.stringMatching(/^[\w-]+_className1 [\w-]+_util-class$/), 390 | default: { 391 | className1: expect.stringMatching(/^[\w-]+_className1 [\w-]+_util-class$/), 392 | 'class-name2': expect.stringMatching(/^[\w-]+_class-name2 [\w-]+_util-class [\w-]+_util-class [\w-]+_class-name2 [\w-]+_util-class$/), 393 | }, 394 | }, 395 | style2: { 396 | default: { 397 | 'class-name2': expect.stringMatching(/^[\w-]+_class-name2 [\w-]+_util-class$/), 398 | }, 399 | }, 400 | }); 401 | 402 | // util class is duplicated 403 | // https://github.com/vitejs/vite/issues/15683 404 | const utilityClass = Array.from(css!.matchAll(/util-class/g)); 405 | expect(utilityClass.length).toBeGreaterThan(1); 406 | }); 407 | 408 | test('dev server', async () => { 409 | await using fixture = await createFixture(fixtures.multiCssModules); 410 | 411 | const code = await getViteDevCode(fixture.path, { 412 | css: { 413 | transformer: 'lightningcss', 414 | }, 415 | }); 416 | 417 | const cssSourcemaps = getCssSourceMaps(code); 418 | expect(cssSourcemaps.length).toBe(0); 419 | 420 | // util class is duplicated 421 | // https://github.com/vitejs/vite/issues/15683 422 | const utilityClass = Array.from(code.matchAll(/util-class/g)); 423 | expect(utilityClass.length).toBeGreaterThan(1); 424 | }); 425 | }); 426 | 427 | test('dashedIdents', async () => { 428 | await using fixture = await createFixture(fixtures.lightningCustomPropertiesFrom); 429 | 430 | const { css } = await viteBuild(fixture.path, { 431 | css: { 432 | transformer: 'lightningcss', 433 | lightningcss: { 434 | cssModules: { 435 | dashedIdents: true, 436 | }, 437 | }, 438 | }, 439 | }); 440 | 441 | // util custom property is duplicated 442 | // https://github.com/vitejs/vite/issues/15683 443 | const utilityClass = Array.from(css!.matchAll(/hotpink/g)); 444 | expect(utilityClass.length).toBeGreaterThan(1); 445 | }); 446 | 447 | test('devSourcemap', async () => { 448 | await using fixture = await createFixture(fixtures.lightningCustomPropertiesFrom); 449 | 450 | const code = await getViteDevCode(fixture.path, { 451 | css: { 452 | transformer: 'lightningcss', 453 | devSourcemap: true, 454 | lightningcss: { 455 | cssModules: { 456 | dashedIdents: true, 457 | }, 458 | }, 459 | }, 460 | }); 461 | 462 | const cssSourcemaps = getCssSourceMaps(code); 463 | expect(cssSourcemaps.length).toBe(2); 464 | expect(cssSourcemaps).toMatchObject([ 465 | { 466 | version: 3, 467 | sourceRoot: null, 468 | 469 | /** 470 | * Can't reliably match mappings because the fixture path changes every time 471 | * and even though Vite uses relative paths for the entry, they can't use relative 472 | * paths for the rest of the files that Lightning resolves via bundle API 473 | * https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/src/node/plugins/css.ts#L2250 474 | */ 475 | // mappings: 'ACAA,oCDAA', 476 | sources: [ 477 | 'style1.module.css', 478 | expect.stringMatching(/\bvars\.module\.css$/), 479 | ], 480 | sourcesContent: [ 481 | '.button {\n\tbackground: var(--accent-color from "./vars.module.css");\n}', 482 | ':root {\n\t--accent-color: hotpink;\n}', 483 | ], 484 | names: [], 485 | }, 486 | { 487 | version: 3, 488 | sourceRoot: null, 489 | // mappings: 'ACAA,oCDAA', 490 | sources: [ 491 | 'style2.module.css', 492 | expect.stringMatching(/\bvars\.module\.css$/), 493 | ], 494 | sourcesContent: [ 495 | '.input {\n\tcolor: var(--accent-color from "./vars.module.css");\n}', 496 | ':root {\n\t--accent-color: hotpink;\n}', 497 | ], 498 | names: [], 499 | }, 500 | ]); 501 | }); 502 | 503 | test('hmr should work inside of root', async () => { 504 | await using fixture = await createFixture(fixtures.viteDev); 505 | 506 | await viteDevBrowser( 507 | fixture.path, 508 | { 509 | css: { 510 | transformer: 'lightningcss', 511 | }, 512 | }, 513 | async (page, server) => { 514 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 515 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 516 | 517 | const newColor = fixtures.newRgb(); 518 | const newFile = fixtures.viteDev['style1.module.css'].replace('red', newColor); 519 | fixture.writeFile('style1.module.css', newFile); 520 | 521 | await new Promise((resolve) => { server.watcher.once('change', resolve); }); 522 | await setTimeout(1000); 523 | 524 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 525 | expect(textColorAfter).toBe(newColor); 526 | }, 527 | ); 528 | }, 10_000); 529 | 530 | test('hmr should work outside of root', async () => { 531 | await using fixture = await createFixture(fixtures.viteDevOutsideRoot); 532 | 533 | await viteDevBrowser( 534 | fixture.getPath('nested-dir'), 535 | { 536 | css: { 537 | transformer: 'lightningcss', 538 | }, 539 | }, 540 | async (page, server) => { 541 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 542 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 543 | 544 | const newColor = fixtures.newRgb(); 545 | const newFile = fixtures.viteDev['style1.module.css'].replace('red', newColor); 546 | fixture.writeFile('style1.module.css', newFile); 547 | 548 | await new Promise((resolve) => { server.watcher.once('change', resolve); }); 549 | await setTimeout(1000); 550 | 551 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 552 | expect(textColorAfter).toBe(newColor); 553 | }, 554 | ); 555 | }, 10_000); 556 | }); 557 | }); 558 | }); 559 | -------------------------------------------------------------------------------- /tests/specs/patched/postcss.spec.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from 'node:timers/promises'; 2 | import { readdir } from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | import { createFixture } from 'fs-fixture'; 5 | import { testSuite, expect } from 'manten'; 6 | import vitePluginVue from '@vitejs/plugin-vue'; 7 | import { outdent } from 'outdent'; 8 | import { base64Module } from '../../utils/base64-module.js'; 9 | import * as fixtures from '../../fixtures.js'; 10 | import { viteBuild, getViteDevCode, viteDevBrowser } from '../../utils/vite.js'; 11 | import { getCssSourceMaps } from '../../utils/get-css-source-maps.js'; 12 | import { patchCssModules } from '#vite-css-modules'; 13 | 14 | export default testSuite(({ describe }) => { 15 | describe('PostCSS', ({ test, describe }) => { 16 | describe('no config', ({ test }) => { 17 | test('build', async () => { 18 | await using fixture = await createFixture({ 19 | ...fixtures.multiCssModules, 20 | ...fixtures.postcssLogFile, 21 | }); 22 | 23 | const { js, css } = await viteBuild(fixture.path, { 24 | plugins: [ 25 | patchCssModules(), 26 | ], 27 | build: { 28 | target: 'es2022', 29 | }, 30 | }); 31 | 32 | expect(css).toMatch('--file: "style1.module.css"'); 33 | expect(css).toMatch('--file: "style2.module.css"'); 34 | 35 | // Ensure that PostCSS is applied to the composed 36 | expect(css).toMatch('--file: "utils1.css?.module.css"'); 37 | expect(css).toMatch('--file: "utils2.css?.module.css"'); 38 | 39 | // Util is not duplicated despite being used twice 40 | const utilityClass = Array.from(css!.matchAll(/foo/g)); 41 | expect(utilityClass.length).toBe(1); 42 | 43 | /* 44 | class-name2 from style2 is not duplicated 45 | despite being directly imported and also composed from 46 | */ 47 | const style2className = Array.from(css!.matchAll(/pink/g)); 48 | expect(style2className.length).toBe(1); 49 | 50 | const exported = await import(base64Module(js)); 51 | expect(exported).toMatchObject({ 52 | style1: { 53 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 54 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 55 | default: { 56 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 57 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 58 | }, 59 | }, 60 | style2: { 61 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 62 | default: { 63 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 64 | }, 65 | }, 66 | }); 67 | 68 | const classes = [ 69 | ...exported.style1.className1.split(' '), 70 | ...exported.style1['class-name2'].split(' '), 71 | ...exported.style2['class-name2'].split(' '), 72 | ]; 73 | 74 | for (const className of classes) { 75 | // eslint-disable-next-line regexp/no-super-linear-backtracking 76 | expect(className).toMatch(/^(_[-\w]+_\w{7}\s?)+$/); 77 | expect(css).toMatch(`.${className}`); 78 | } 79 | }); 80 | 81 | test('scss', async () => { 82 | await using fixture = await createFixture(fixtures.scssModules); 83 | 84 | const { js, css } = await viteBuild(fixture.path, { 85 | plugins: [ 86 | patchCssModules(), 87 | ], 88 | build: { 89 | target: 'es2022', 90 | }, 91 | }); 92 | 93 | expect(css).toMatch('--file: "style.module.scss"'); 94 | 95 | const exported = await import(base64Module(js)); 96 | expect(exported['text-primary']).toMatch(/^_[-\w]+_\w{7}$/); 97 | 98 | const className = exported['text-primary']; 99 | expect(css).toMatch(`.${className}`); 100 | }); 101 | 102 | // https://github.com/vitejs/vite/issues/10340 103 | test('mixed css + scss types', async () => { 104 | await using fixture = await createFixture(fixtures.mixedScssModules); 105 | 106 | const { js, css } = await viteBuild(fixture.path, { 107 | plugins: [ 108 | patchCssModules(), 109 | ], 110 | build: { 111 | target: 'es2022', 112 | }, 113 | }); 114 | 115 | expect(css).toMatch('--file: "css.module.css"'); 116 | expect(css).toMatch('--file: "scss.module.scss"'); 117 | 118 | const exported = await import(base64Module(js)); 119 | 120 | // eslint-disable-next-line regexp/no-super-linear-backtracking 121 | expect(exported['text-primary']).toMatch(/^(_[-\w]+_\w{7}\s?)+$/); 122 | 123 | const classNames = exported['text-primary'].split(' '); 124 | for (const className of classNames) { 125 | expect(css).toMatch(`.${className}`); 126 | } 127 | }); 128 | 129 | test('inline', async () => { 130 | await using fixture = await createFixture(fixtures.inlineCssModules); 131 | 132 | const { js, css } = await viteBuild(fixture.path, { 133 | plugins: [ 134 | patchCssModules(), 135 | ], 136 | }); 137 | expect(css).toBeUndefined(); 138 | 139 | const exported = await import(base64Module(js)); 140 | expect(typeof exported.default).toBe('string'); 141 | expect(exported.default).toMatch(/--file: "style.module.css\?inline"/); 142 | }); 143 | 144 | // https://github.com/vitejs/vite/issues/14050 145 | test('reserved keywords', async () => { 146 | await using fixture = await createFixture(fixtures.reservedKeywords); 147 | 148 | const { js } = await viteBuild(fixture.path, { 149 | plugins: [ 150 | patchCssModules(), 151 | ], 152 | build: { 153 | target: 'es2022', 154 | }, 155 | }); 156 | const exported = await import(base64Module(js)); 157 | expect(exported).toMatchObject({ 158 | style: { 159 | default: { 160 | import: '_import_1f0f104 _if_36a6377', 161 | export: '_export_31ef8f2 _with_779bcbb', 162 | }, 163 | export: '_export_31ef8f2 _with_779bcbb', 164 | import: '_import_1f0f104 _if_36a6377', 165 | }, 166 | }); 167 | }); 168 | 169 | test('dev server', async () => { 170 | await using fixture = await createFixture({ 171 | ...fixtures.multiCssModules, 172 | ...fixtures.postcssLogFile, 173 | node_modules: ({ symlink }) => symlink(path.resolve('node_modules')), 174 | }); 175 | 176 | const code = await getViteDevCode( 177 | fixture.path, 178 | { 179 | plugins: [ 180 | patchCssModules(), 181 | ], 182 | }, 183 | ); 184 | 185 | const cssSourcemaps = getCssSourceMaps(code); 186 | expect(cssSourcemaps.length).toBe(0); 187 | 188 | expect(code).toMatch(String.raw`--file: \"style1.module.css\"`); 189 | expect(code).toMatch(String.raw`--file: \"style2.module.css\"`); 190 | 191 | // Ensure that PostCSS is applied to the composed 192 | expect(code).toMatch(String.raw`--file: \"utils1.css?.module.css\"`); 193 | expect(code).toMatch(String.raw`--file: \"utils2.css?.module.css\"`); 194 | 195 | // Util is not duplicated despite being used twice 196 | const utilityClass = Array.from(code.matchAll(/foo/g)); 197 | expect(utilityClass.length).toBe(1); 198 | }); 199 | 200 | test('devSourcemap', async () => { 201 | await using fixture = await createFixture({ 202 | ...fixtures.cssModulesValues, 203 | node_modules: ({ symlink }) => symlink(path.resolve('node_modules')), 204 | }); 205 | 206 | const code = await getViteDevCode( 207 | fixture.path, 208 | { 209 | plugins: [ 210 | patchCssModules(), 211 | ], 212 | css: { 213 | devSourcemap: true, 214 | }, 215 | }, 216 | ); 217 | 218 | const cssSourcemaps = getCssSourceMaps(code); 219 | expect(cssSourcemaps.length).toBe(3); 220 | expect(cssSourcemaps).toMatchObject([ 221 | { 222 | version: 3, 223 | file: expect.stringMatching(/\/style\.module\.css$/), 224 | mappings: 'AAGA;QACC,IAAS;SACT,eAAqB;AAGtB;AACA;QACC,IAAS;AACV;ACXA', 225 | names: [], 226 | sources: [ 227 | expect.stringMatching(/\/style\.module\.css$/), 228 | '\u0000', 229 | ], 230 | sourcesContent: [ 231 | "@value primary as p1, simple-border from './utils1.css';\n" 232 | + "@value primary as p2 from './utils2.css';\n" 233 | + '\n' 234 | + '.class-name1 {\n' 235 | + '\tcolor: p1;\n' 236 | + '\tborder: simple-border;\n' 237 | + "\tcomposes: util-class from './utils1.css';\n" 238 | + "\tcomposes: util-class from './utils2.css';\n" 239 | + '}\n' 240 | + '.class-name2 {\n' 241 | + '\tcolor: p2;\n' 242 | + '}', 243 | null, 244 | ], 245 | }, 246 | { 247 | version: 3, 248 | file: expect.stringMatching(/\/utils1\.css$/), 249 | mappings: 'AAGA;CACC,YAAe;AAChB;;ACLA;CAAA', 250 | names: [], 251 | sources: [ 252 | expect.stringMatching(/\/utils1\.css$/), 253 | '\u0000', 254 | ], 255 | sourcesContent: [ 256 | '@value primary: #fff;\n' 257 | + '@value simple-border: 1px solid black;\n' 258 | + '\n' 259 | + '.util-class {\n' 260 | + '\tborder: primary;\n' 261 | + '}', 262 | null, 263 | ], 264 | }, 265 | { 266 | version: 3, 267 | file: expect.stringMatching(/\/utils2\.css$/), 268 | mappings: 'AAEA;CACC,YAAe;AAChB;;ACJA;CAAA', 269 | names: [], 270 | sources: [ 271 | expect.stringMatching(/\/utils2\.css$/), 272 | '\u0000', 273 | ], 274 | sourcesContent: [ 275 | '@value primary: #000;\n\n.util-class {\n\tborder: primary;\n}', 276 | null, 277 | ], 278 | }, 279 | ]); 280 | }); 281 | 282 | test('devSourcemap with Vue.js', async () => { 283 | await using fixture = await createFixture({ 284 | ...fixtures.vue, 285 | node_modules: ({ symlink }) => symlink(path.resolve('node_modules')), 286 | }); 287 | 288 | const code = await getViteDevCode(fixture.path, { 289 | plugins: [ 290 | patchCssModules(), 291 | vitePluginVue(), 292 | ], 293 | css: { 294 | devSourcemap: true, 295 | }, 296 | }); 297 | 298 | const cssSourcemaps = getCssSourceMaps(code); 299 | expect(cssSourcemaps.length).toBe(2); 300 | expect(cssSourcemaps).toMatchObject([ 301 | { 302 | version: 3, 303 | file: expect.stringMatching(/\/comp\.vue$/), 304 | mappings: 'AAKA;CAEC,UAAU;AACX;ACRA;CAAA', 305 | names: [], 306 | sources: [ 307 | expect.stringMatching(/\/comp\.vue$/), 308 | '\u0000', 309 | ], 310 | sourcesContent: [ 311 | '\n' 314 | + '\n' 315 | + '', 321 | null, 322 | ], 323 | }, 324 | { 325 | version: 3, 326 | file: expect.stringMatching(/\/utils\.css$/), 327 | mappings: 'AAAA;CACC,aAAa;CACb,WAAW;AACZ;;AAEA;CACC,aAAa;AACd;;ACPA;CAAA', 328 | names: [], 329 | sources: [ 330 | expect.stringMatching(/\/utils\.css$/), 331 | '\u0000', 332 | ], 333 | sourcesContent: [ 334 | '.util-class {\n' 335 | + "\t--name: 'foo';\n" 336 | + '\tcolor: blue;\n' 337 | + '}\n' 338 | + '\n' 339 | + '.unused-class {\n' 340 | + '\tcolor: yellow;\n' 341 | + '}', 342 | null, 343 | ], 344 | }, 345 | ]); 346 | }); 347 | }); 348 | 349 | test('PostCSS configured', async () => { 350 | await using fixture = await createFixture({ 351 | ...fixtures.multiCssModules, 352 | ...fixtures.postcssLogFile, 353 | }); 354 | 355 | const { js, css } = await viteBuild(fixture.path, { 356 | plugins: [ 357 | patchCssModules(), 358 | ], 359 | build: { 360 | target: 'es2022', 361 | }, 362 | css: { 363 | modules: { 364 | generateScopedName: 'asdf_[local]', 365 | }, 366 | }, 367 | }); 368 | 369 | const exported = await import(base64Module(js)); 370 | expect(exported).toMatchObject({ 371 | style1: { 372 | className1: expect.stringMatching(/^asdf_className1\s+asdf_util-class$/), 373 | }, 374 | style2: { 375 | 'class-name2': expect.stringMatching(/^asdf_class-name2\s+asdf_util-class$/), 376 | }, 377 | }); 378 | 379 | expect(css).toMatch('--file: "style1.module.css"'); 380 | expect(css).toMatch('--file: "style2.module.css"'); 381 | 382 | // Ensure that PostCSS is applied to the composed files 383 | expect(css).toMatch('--file: "utils1.css?.module.css"'); 384 | expect(css).toMatch('--file: "utils2.css?.module.css"'); 385 | 386 | // Util is not duplicated 387 | const utilityClass = Array.from(css!.matchAll(/foo/g)); 388 | expect(utilityClass.length).toBe(1); 389 | }); 390 | 391 | describe('localsConvention', ({ test }) => { 392 | test('camelCase', async () => { 393 | await using fixture = await createFixture(fixtures.multiCssModules); 394 | 395 | const { js } = await viteBuild(fixture.path, { 396 | plugins: [ 397 | patchCssModules(), 398 | ], 399 | build: { 400 | target: 'es2022', 401 | }, 402 | css: { 403 | modules: { 404 | localsConvention: 'camelCase', 405 | }, 406 | }, 407 | }); 408 | 409 | const exported = await import(base64Module(js)); 410 | expect(exported).toMatchObject({ 411 | style1: { 412 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 413 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 414 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 415 | default: { 416 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 417 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 418 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 419 | }, 420 | }, 421 | style2: { 422 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 423 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 424 | default: { 425 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 426 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 427 | }, 428 | }, 429 | }); 430 | }); 431 | 432 | test('camelCaseOnly', async () => { 433 | await using fixture = await createFixture(fixtures.multiCssModules); 434 | 435 | const { js } = await viteBuild(fixture.path, { 436 | plugins: [ 437 | patchCssModules(), 438 | ], 439 | css: { 440 | modules: { 441 | localsConvention: 'camelCaseOnly', 442 | }, 443 | }, 444 | }); 445 | 446 | const exported = await import(base64Module(js)); 447 | expect(exported).toMatchObject({ 448 | style1: { 449 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 450 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 451 | default: { 452 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 453 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 454 | }, 455 | }, 456 | style2: { 457 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 458 | default: { 459 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 460 | }, 461 | }, 462 | }); 463 | }); 464 | 465 | test('dashes', async () => { 466 | await using fixture = await createFixture(fixtures.multiCssModules); 467 | 468 | const { js } = await viteBuild(fixture.path, { 469 | plugins: [ 470 | patchCssModules(), 471 | ], 472 | build: { 473 | target: 'es2022', 474 | }, 475 | css: { 476 | modules: { 477 | localsConvention: 'dashes', 478 | }, 479 | }, 480 | }); 481 | 482 | const exported = await import(base64Module(js)); 483 | expect(exported).toMatchObject({ 484 | style1: { 485 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 486 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 487 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 488 | default: { 489 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 490 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 491 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 492 | }, 493 | }, 494 | style2: { 495 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 496 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 497 | default: { 498 | 'class-name2': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 499 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 500 | }, 501 | }, 502 | }); 503 | }); 504 | 505 | test('dashesOnly', async () => { 506 | await using fixture = await createFixture(fixtures.multiCssModules); 507 | 508 | const { js } = await viteBuild(fixture.path, { 509 | plugins: [ 510 | patchCssModules(), 511 | ], 512 | css: { 513 | modules: { 514 | localsConvention: 'dashesOnly', 515 | }, 516 | }, 517 | }); 518 | 519 | const exported = await import(base64Module(js)); 520 | expect(exported).toMatchObject({ 521 | style1: { 522 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 523 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 524 | default: { 525 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 526 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 527 | }, 528 | }, 529 | style2: { 530 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 531 | default: { 532 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 533 | }, 534 | }, 535 | }); 536 | }); 537 | 538 | test('function', async () => { 539 | await using fixture = await createFixture(fixtures.multiCssModules); 540 | 541 | const { js } = await viteBuild(fixture.path, { 542 | plugins: [ 543 | patchCssModules(), 544 | ], 545 | build: { 546 | target: 'es2022', 547 | }, 548 | css: { 549 | modules: { 550 | localsConvention: originalClassname => `${originalClassname}123`, 551 | }, 552 | }, 553 | }); 554 | 555 | const exported = await import(base64Module(js)); 556 | expect(exported).toMatchObject({ 557 | style1: { 558 | className1123: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 559 | 'class-name2123': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 560 | default: { 561 | className1123: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 562 | 'class-name2123': expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 563 | }, 564 | }, 565 | style2: { 566 | 'class-name2123': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 567 | default: { 568 | 'class-name2123': expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 569 | }, 570 | }, 571 | }); 572 | }); 573 | }); 574 | 575 | test('globalModulePaths', async () => { 576 | await using fixture = await createFixture(fixtures.globalModule); 577 | 578 | const { js, css } = await viteBuild(fixture.path, { 579 | plugins: [ 580 | patchCssModules(), 581 | ], 582 | css: { 583 | modules: { 584 | globalModulePaths: [/global\.module\.css/], 585 | }, 586 | }, 587 | }); 588 | 589 | const exported = await import(base64Module(js)); 590 | expect(exported).toMatchObject({ 591 | default: { 592 | title: expect.stringMatching(/^_title_\w{5}/), 593 | }, 594 | title: expect.stringMatching(/^_title_\w{5}/), 595 | }); 596 | 597 | expect(css).toMatch('.page {'); 598 | }); 599 | 600 | test('getJSON', async () => { 601 | await using fixture = await createFixture(fixtures.multiCssModules); 602 | 603 | type JSON = { 604 | inputFile: string; 605 | exports: Record; 606 | outputFile: string; 607 | }; 608 | const jsons: JSON[] = []; 609 | 610 | await viteBuild(fixture.path, { 611 | plugins: [ 612 | patchCssModules(), 613 | ], 614 | css: { 615 | modules: { 616 | localsConvention: 'camelCaseOnly', 617 | getJSON: (inputFile, exports, outputFile) => { 618 | jsons.push({ 619 | inputFile, 620 | exports, 621 | outputFile, 622 | }); 623 | }, 624 | }, 625 | }, 626 | }); 627 | 628 | // This plugin treats each CSS Module as a JS module so it emits on each module 629 | // rather than the final "bundle" which postcss-module emits on 630 | expect(jsons).toHaveLength(4); 631 | jsons.sort((a, b) => a.inputFile.localeCompare(b.inputFile)); 632 | 633 | const [style1, style2, utils1, utils2] = jsons; 634 | expect(style1).toMatchObject({ 635 | inputFile: expect.stringMatching(/style1\.module\.css$/), 636 | exports: { 637 | className1: expect.stringMatching(/_className1_\w+ _util-class_\w+/), 638 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+ _util-class_\w+/), 639 | }, 640 | outputFile: expect.stringMatching(/style1\.module\.css$/), 641 | }); 642 | 643 | expect(style2).toMatchObject({ 644 | inputFile: expect.stringMatching(/style2\.module\.css$/), 645 | exports: { 646 | className2: expect.stringMatching(/_class-name2_\w+ _util-class_\w+/), 647 | }, 648 | outputFile: expect.stringMatching(/style2\.module\.css$/), 649 | }); 650 | 651 | expect(utils1).toMatchObject({ 652 | inputFile: expect.stringMatching(/utils1\.css\?\.module\.css$/), 653 | exports: { 654 | unusedClass: expect.stringMatching(/_unused-class_\w+/), 655 | utilClass: expect.stringMatching(/_util-class_\w+/), 656 | }, 657 | outputFile: expect.stringMatching(/utils1\.css\?\.module\.css$/), 658 | }); 659 | 660 | expect(utils2).toMatchObject({ 661 | inputFile: expect.stringMatching(/utils2\.css\?\.module\.css$/), 662 | exports: { 663 | utilClass: expect.stringMatching(/_util-class_\w+/), 664 | }, 665 | outputFile: expect.stringMatching(/utils2\.css\?\.module\.css$/), 666 | }); 667 | }); 668 | 669 | test('Empty CSS Module', async () => { 670 | await using fixture = await createFixture(fixtures.emptyCssModule); 671 | 672 | const { js, css } = await viteBuild(fixture.path, { 673 | plugins: [ 674 | patchCssModules(), 675 | ], 676 | css: { 677 | modules: { 678 | generateScopedName: 'asdf_[local]', 679 | }, 680 | }, 681 | }); 682 | 683 | const exported = await import(base64Module(js)); 684 | expect(exported).toMatchObject({ 685 | default: {}, 686 | }); 687 | expect(css).toBeUndefined(); 688 | }); 689 | 690 | describe('@value', ({ test }) => { 691 | test('build', async () => { 692 | await using fixture = await createFixture(fixtures.cssModulesValues); 693 | 694 | const { js, css } = await viteBuild(fixture.path, { 695 | plugins: [ 696 | patchCssModules(), 697 | ], 698 | build: { 699 | target: 'es2022', 700 | }, 701 | }); 702 | 703 | const exported = await import(base64Module(js)); 704 | expect(exported).toMatchObject({ 705 | default: { 706 | 'class-name1': expect.stringMatching(/^_class-name1_\w+ _util-class_\w+ _util-class_\w+$/), 707 | 'class-name2': expect.stringMatching(/^_class-name2_\w+$/), 708 | }, 709 | 'class-name1': expect.stringMatching(/_class-name1_\w+ _util-class_\w+ _util-class_\w+/), 710 | 'class-name2': expect.stringMatching(/^_class-name2_\w+$/), 711 | }); 712 | 713 | expect(css).toMatch('color: #fff'); 714 | expect(css).toMatch('border: #fff'); 715 | expect(css).toMatch('color: #000'); 716 | expect(css).toMatch('border: #000'); 717 | expect(css).toMatch('border: 1px solid black'); 718 | 719 | // Ensure that PostCSS is applied to the composed files 720 | expect(css).toMatch('--file: "style.module.css"'); 721 | expect(css).toMatch('--file: "utils1.css?.module.css"'); 722 | expect(css).toMatch('--file: "utils2.css?.module.css"'); 723 | }); 724 | 725 | test('@value multiple exports', async () => { 726 | await using fixture = await createFixture(fixtures.cssModulesValuesMultipleExports); 727 | 728 | const { js, css } = await viteBuild(fixture.path, { 729 | plugins: [ 730 | patchCssModules(), 731 | ], 732 | build: { 733 | target: 'es2022', 734 | }, 735 | }); 736 | 737 | const exported = await import(base64Module(js)); 738 | expect(exported).toMatchObject({ 739 | 'class-name1': expect.stringMatching(/^_class-name1_\w+ _class-name2_\w+/), 740 | 'class-name2': expect.stringMatching(/^_class-name2_\w+$/), 741 | }); 742 | 743 | // Assert that class-name2 only appears once 744 | const utilityClass = Array.from(css!.matchAll(/\._class-name2_/g)); 745 | expect(utilityClass.length).toBe(1); 746 | }); 747 | }); 748 | 749 | describe('error handling', ({ test }) => { 750 | test('missing class export', async () => { 751 | await using fixture = await createFixture(fixtures.missingClassExport); 752 | 753 | await expect(() => viteBuild(fixture.path, { 754 | logLevel: 'silent', 755 | plugins: [ 756 | patchCssModules(), 757 | ], 758 | })).rejects.toThrow('Cannot resolve "non-existent" from "./utils.css"'); 759 | }); 760 | 761 | test('exporting a non-safe class name via esm doesnt throw', async () => { 762 | await using fixture = await createFixture(fixtures.moduleNamespace); 763 | 764 | await viteBuild(fixture.path, { 765 | plugins: [ 766 | patchCssModules(), 767 | ], 768 | }); 769 | }); 770 | }); 771 | 772 | describe('.d.ts', ({ test }) => { 773 | test('exportMode: both', async () => { 774 | await using fixture = await createFixture(fixtures.reservedKeywords); 775 | 776 | await viteBuild(fixture.path, { 777 | plugins: [ 778 | patchCssModules({ 779 | generateSourceTypes: true, 780 | }), 781 | ], 782 | build: { 783 | target: 'es2022', 784 | }, 785 | css: { 786 | modules: { 787 | localsConvention: 'camelCase', 788 | }, 789 | }, 790 | }); 791 | const files = await readdir(fixture.path); 792 | expect(files).toStrictEqual([ 793 | 'dist', 794 | 'index.js', 795 | 'node_modules', 796 | 'style.module.css', 797 | 'style.module.css.d.ts', 798 | 'utils.css', 799 | ]); 800 | const dts = await fixture.readFile('style.module.css.d.ts', 'utf8'); 801 | expect(dts).toBe( 802 | outdent` 803 | /* eslint-disable */ 804 | /* prettier-ignore */ 805 | // @ts-nocheck 806 | /** 807 | * Generated by vite-css-modules 808 | * https://npmjs.com/vite-css-modules 809 | */ 810 | 811 | declare const _import: string; 812 | declare const _export: string; 813 | declare const _default: string; 814 | 815 | export { 816 | _import as "import", 817 | _export as "export" 818 | }; 819 | 820 | declare const __default_export__: { 821 | "import": typeof _import; 822 | "export": typeof _export; 823 | "default": typeof _default; 824 | }; 825 | export default __default_export__; 826 | 827 | `, 828 | ); 829 | }); 830 | 831 | test('exportMode: named', async () => { 832 | await using fixture = await createFixture(fixtures.reservedKeywords); 833 | 834 | await viteBuild(fixture.path, { 835 | plugins: [ 836 | patchCssModules({ 837 | generateSourceTypes: true, 838 | exportMode: 'named', 839 | }), 840 | ], 841 | build: { 842 | target: 'es2022', 843 | }, 844 | css: { 845 | modules: { 846 | localsConvention: 'camelCase', 847 | }, 848 | }, 849 | }); 850 | const files = await readdir(fixture.path); 851 | expect(files).toStrictEqual([ 852 | 'dist', 853 | 'index.js', 854 | 'node_modules', 855 | 'style.module.css', 856 | 'style.module.css.d.ts', 857 | 'utils.css', 858 | ]); 859 | const dts = await fixture.readFile('style.module.css.d.ts', 'utf8'); 860 | expect(dts).toBe( 861 | outdent` 862 | /* eslint-disable */ 863 | /* prettier-ignore */ 864 | // @ts-nocheck 865 | /** 866 | * Generated by vite-css-modules 867 | * https://npmjs.com/vite-css-modules 868 | */ 869 | 870 | declare const _import: string; 871 | declare const _export: string; 872 | declare const _default: string; 873 | 874 | export { 875 | _import as "import", 876 | _export as "export", 877 | _default as "default" 878 | }; 879 | 880 | `, 881 | ); 882 | }); 883 | 884 | test('exportMode: default', async () => { 885 | await using fixture = await createFixture(fixtures.reservedKeywords); 886 | 887 | await viteBuild(fixture.path, { 888 | plugins: [ 889 | patchCssModules({ 890 | generateSourceTypes: true, 891 | exportMode: 'default', 892 | }), 893 | ], 894 | build: { 895 | target: 'es2022', 896 | }, 897 | css: { 898 | modules: { 899 | localsConvention: 'camelCase', 900 | }, 901 | }, 902 | }); 903 | const files = await readdir(fixture.path); 904 | expect(files).toStrictEqual([ 905 | 'dist', 906 | 'index.js', 907 | 'node_modules', 908 | 'style.module.css', 909 | 'style.module.css.d.ts', 910 | 'utils.css', 911 | ]); 912 | const dts = await fixture.readFile('style.module.css.d.ts', 'utf8'); 913 | expect(dts).toBe( 914 | outdent` 915 | /* eslint-disable */ 916 | /* prettier-ignore */ 917 | // @ts-nocheck 918 | /** 919 | * Generated by vite-css-modules 920 | * https://npmjs.com/vite-css-modules 921 | */ 922 | 923 | declare const _import: string; 924 | declare const _export: string; 925 | declare const _default: string; 926 | 927 | declare const __default_export__: { 928 | "import": typeof _import; 929 | "export": typeof _export; 930 | "default": typeof _default; 931 | }; 932 | export default __default_export__; 933 | 934 | `, 935 | ); 936 | }); 937 | 938 | test('empty css module creates empty .d.ts', async () => { 939 | await using fixture = await createFixture({ 940 | 'index.js': 'export * as style from \'./style.module.css\';', 941 | 'style.module.css': '', 942 | }); 943 | await viteBuild(fixture.path, { 944 | plugins: [ 945 | patchCssModules({ 946 | generateSourceTypes: true, 947 | }), 948 | ], 949 | build: { 950 | target: 'es2022', 951 | }, 952 | css: { 953 | modules: { 954 | localsConvention: 'camelCase', 955 | }, 956 | }, 957 | }); 958 | const files = await readdir(fixture.path); 959 | expect(files).toStrictEqual([ 960 | 'dist', 961 | 'index.js', 962 | 'node_modules', 963 | 'style.module.css', 964 | 'style.module.css.d.ts', 965 | ]); 966 | const dts = await fixture.readFile('style.module.css.d.ts', 'utf8'); 967 | expect(dts).toBe( 968 | outdent` 969 | /* eslint-disable */ 970 | /* prettier-ignore */ 971 | // @ts-nocheck 972 | /** 973 | * Generated by vite-css-modules 974 | * https://npmjs.com/vite-css-modules 975 | */ 976 | 977 | `, 978 | ); 979 | }); 980 | }); 981 | 982 | describe('exportMode', ({ test }) => { 983 | test('both (default)', async () => { 984 | await using fixture = await createFixture(fixtures.exportModeBoth); 985 | 986 | const { js } = await viteBuild(fixture.path, { 987 | plugins: [ 988 | patchCssModules({ 989 | exportMode: 'both', 990 | }), 991 | ], 992 | build: { 993 | target: 'es2022', 994 | }, 995 | }); 996 | const exported = await import(base64Module(js)); 997 | expect(exported).toMatchObject({ 998 | style: { 999 | default: { 1000 | class: '_class_6a3525e _util_f9ba12f', 1001 | }, 1002 | class: '_class_6a3525e _util_f9ba12f', 1003 | }, 1004 | }); 1005 | }); 1006 | 1007 | test('named', async () => { 1008 | await using fixture = await createFixture(fixtures.exportModeBoth); 1009 | 1010 | const { js } = await viteBuild(fixture.path, { 1011 | plugins: [ 1012 | patchCssModules({ 1013 | exportMode: 'named', 1014 | }), 1015 | ], 1016 | build: { 1017 | target: 'es2022', 1018 | }, 1019 | }); 1020 | const exported = await import(base64Module(js)); 1021 | expect(exported).toMatchObject({ 1022 | style: { 1023 | class: '_class_6a3525e _util_f9ba12f', 1024 | }, 1025 | }); 1026 | expect(exported.style.default).toBeUndefined(); 1027 | }); 1028 | 1029 | test('default', async () => { 1030 | await using fixture = await createFixture(fixtures.exportModeBoth); 1031 | 1032 | const { js } = await viteBuild(fixture.path, { 1033 | plugins: [ 1034 | patchCssModules({ 1035 | exportMode: 'default', 1036 | }), 1037 | ], 1038 | build: { 1039 | target: 'es2022', 1040 | }, 1041 | }); 1042 | const exported = await import(base64Module(js)); 1043 | expect(exported).toMatchObject({ 1044 | style: { 1045 | default: { 1046 | class: '_class_6a3525e _util_f9ba12f', 1047 | }, 1048 | }, 1049 | }); 1050 | 1051 | expect( 1052 | Object.keys(exported.style).length, 1053 | ).toBe(1); 1054 | }); 1055 | }); 1056 | 1057 | describe('default as named export', ({ test }) => { 1058 | test('should warn & omit `default` from named export', async () => { 1059 | await using fixture = await createFixture(fixtures.defaultAsName); 1060 | 1061 | const { js, warnings } = await viteBuild(fixture.path, { 1062 | plugins: [ 1063 | patchCssModules(), 1064 | ], 1065 | build: { 1066 | target: 'es2022', 1067 | }, 1068 | }); 1069 | const exported = await import(base64Module(js)); 1070 | expect(exported).toMatchObject({ 1071 | style: { 1072 | default: { 1073 | typeof: '_typeof_06003d4', 1074 | default: '_default_1733f38', 1075 | }, 1076 | typeof: '_typeof_06003d4', 1077 | }, 1078 | }); 1079 | expect(warnings).toHaveLength(1); 1080 | expect(warnings[0]).toMatch('you cannot use "default" as a class name'); 1081 | }); 1082 | 1083 | test('should work with exportMode: \'default\'', async () => { 1084 | await using fixture = await createFixture(fixtures.defaultAsName); 1085 | 1086 | const { js, warnings } = await viteBuild(fixture.path, { 1087 | plugins: [ 1088 | patchCssModules({ 1089 | exportMode: 'default', 1090 | }), 1091 | ], 1092 | build: { 1093 | target: 'es2022', 1094 | }, 1095 | }); 1096 | const exported = await import(base64Module(js)); 1097 | expect(exported).toMatchObject({ 1098 | style: { 1099 | default: { 1100 | default: '_default_1733f38', 1101 | typeof: '_typeof_06003d4', 1102 | }, 1103 | }, 1104 | }); 1105 | expect(warnings).toHaveLength(0); 1106 | }); 1107 | 1108 | test('should work with exportMode: \'named\'', async () => { 1109 | await using fixture = await createFixture(fixtures.defaultAsName); 1110 | 1111 | const { js, warnings } = await viteBuild(fixture.path, { 1112 | plugins: [ 1113 | patchCssModules({ 1114 | exportMode: 'named', 1115 | }), 1116 | ], 1117 | build: { 1118 | target: 'es2022', 1119 | }, 1120 | }); 1121 | const exported = await import(base64Module(js)); 1122 | expect(exported).toMatchObject({ 1123 | style: { 1124 | typeof: '_typeof_06003d4', 1125 | default: '_default_1733f38', 1126 | }, 1127 | }); 1128 | expect(warnings).toHaveLength(0); 1129 | }); 1130 | 1131 | test('composes default', async () => { 1132 | await using fixture = await createFixture(fixtures.defaultAsComposedName); 1133 | 1134 | const { js } = await viteBuild(fixture.path, { 1135 | plugins: [ 1136 | patchCssModules(), 1137 | ], 1138 | build: { 1139 | target: 'es2022', 1140 | }, 1141 | }); 1142 | const exported = await import(base64Module(js)); 1143 | expect(exported).toMatchObject({ 1144 | style: { 1145 | default: { 1146 | typeof: '_typeof_06003d4 _default_59c1934', 1147 | }, 1148 | typeof: '_typeof_06003d4 _default_59c1934', 1149 | }, 1150 | }); 1151 | }); 1152 | }); 1153 | 1154 | test('queries in requests should be preserved', async () => { 1155 | await using fixture = await createFixture(fixtures.requestQuery); 1156 | const { css } = await viteBuild(fixture.path, { 1157 | plugins: [ 1158 | patchCssModules(), 1159 | ], 1160 | build: { 1161 | target: 'es2022', 1162 | }, 1163 | }); 1164 | expect(css).toMatch('style.module.css?some-query'); 1165 | }); 1166 | 1167 | test('hmr', async () => { 1168 | await using fixture = await createFixture(fixtures.viteDev); 1169 | 1170 | await viteDevBrowser( 1171 | fixture.path, 1172 | { 1173 | plugins: [ 1174 | patchCssModules(), 1175 | ], 1176 | }, 1177 | async (page) => { 1178 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 1179 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 1180 | 1181 | const newColor = fixtures.newRgb(); 1182 | const newFile = fixtures.viteDev['style1.module.css'].replace('red', newColor); 1183 | await fixture.writeFile('style1.module.css', newFile); 1184 | 1185 | await setTimeout(1000); 1186 | 1187 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 1188 | expect(textColorAfter).toBe(newColor); 1189 | }, 1190 | ); 1191 | }); 1192 | 1193 | test('hmr outside root', async () => { 1194 | await using fixture = await createFixture(fixtures.viteDevOutsideRoot); 1195 | 1196 | await viteDevBrowser( 1197 | fixture.getPath('nested-dir'), 1198 | { 1199 | plugins: [ 1200 | patchCssModules(), 1201 | ], 1202 | }, 1203 | async (page) => { 1204 | const textColorBefore = await page.evaluate('getComputedStyle(myText).color'); 1205 | expect(textColorBefore).toBe('rgb(255, 0, 0)'); 1206 | 1207 | const newColor = fixtures.newRgb(); 1208 | const newFile = fixtures.viteDevOutsideRoot['style1.module.css'].replace('red', newColor); 1209 | await fixture.writeFile('style1.module.css', newFile); 1210 | 1211 | await setTimeout(1000); 1212 | 1213 | const textColorAfter = await page.evaluate('getComputedStyle(myText).color'); 1214 | expect(textColorAfter).toBe(newColor); 1215 | }, 1216 | ); 1217 | }); 1218 | 1219 | test('enabling sourcemap doesnt emit warning', async () => { 1220 | await using fixture = await createFixture(fixtures.multiCssModules); 1221 | 1222 | const { warnings } = await viteBuild(fixture.path, { 1223 | plugins: [ 1224 | patchCssModules(), 1225 | ], 1226 | build: { 1227 | sourcemap: true, 1228 | }, 1229 | }); 1230 | 1231 | expect(warnings).toHaveLength(0); 1232 | }); 1233 | }); 1234 | }); 1235 | --------------------------------------------------------------------------------