├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── src ├── index.js ├── persist.js ├── utils.js └── verify.js └── test ├── __snapshots__ └── index.test.js.snap ├── example-no-locals.css ├── example-shared.css ├── example.css ├── index.test.js └── utils.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # A special property that should be specified at the top of the file outside of 4 | # any sections. Set to true to stop .editor config file search on current file 5 | root = true 6 | 7 | [*] 8 | # Indentation style 9 | # Possible values - tab, space 10 | indent_style = space 11 | 12 | # Indentation size in single-spaced characters 13 | # Possible values - an integer, tab 14 | indent_size = 2 15 | 16 | # Line ending file format 17 | # Possible values - lf, crlf, cr 18 | end_of_line = lf 19 | 20 | # File character encoding 21 | # Possible values - latin1, utf-8, utf-16be, utf-16le 22 | charset = utf-8 23 | 24 | # Denotes whether to trim whitespace at the end of lines 25 | # Possible values - true, false 26 | trim_trailing_whitespace = true 27 | 28 | # Denotes whether file should end with a newline 29 | # Possible values - true, false 30 | insert_final_newline = true 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | 10 | jobs: 11 | tests-locked-packages: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | 20 | - run: npm ci 21 | - run: npm test 22 | 23 | tests-latest-packages: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: 16 31 | 32 | - run: rm package-lock.json 33 | 34 | - run: npm install 35 | - run: npm test 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | increment: 7 | description: "Select increment (patch, minor, major)" 8 | required: true 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | - name: git config 18 | run: | 19 | git config user.name "${GITHUB_ACTOR}" 20 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 21 | - run: npm ci 22 | - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 23 | - run: npm run release -- ${{ github.event.inputs.increment }} --ci 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | lib 4 | bundle.js 5 | example*.css.d.ts 6 | *.log 7 | .npmrc 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !src/**/* 3 | !package.json 4 | !README.md -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest", 11 | "program": "${workspaceFolder}/node_modules/.bin/jest", 12 | "args": ["${workspaceFolder}/test/index.test.js"], 13 | "console": "integratedTerminal", 14 | "autoAttachChildProcesses": true, 15 | "internalConsoleOptions": "neverOpen" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 TeamSupercell 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm][npm]][npm-url] 2 | [![build][build]][build-url] 3 | [![deps][deps]][deps-url] 4 | 5 | # typings-for-css-modules-loader 6 | 7 | Webpack loader that generates TypeScript typings for CSS modules from css-loader on the fly 8 | 9 | ## Disclaimer 10 | 11 | This repository is a fork of the unmaintained https://github.com/Jimdo/typings-for-css-modules-loader repository. 12 | 13 | ## Installation 14 | 15 | Install via npm `npm install --save-dev @teamsupercell/typings-for-css-modules-loader` 16 | 17 | **webpack.config.js** 18 | 19 | ```js 20 | module.exports = { 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.css$/i, 25 | use: [ 26 | "style-loader", 27 | "@teamsupercell/typings-for-css-modules-loader", 28 | { 29 | loader: "css-loader", 30 | options: { modules: true } 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | }; 37 | ``` 38 | 39 | ## Options 40 | 41 | | Name | Type | Description | 42 | | :-----------------------------------------------: | :---------: | :----------------------------------------------------------: | 43 | | **[`banner`](#banner)** | `{String}` | To add a 'banner' prefix to each generated `*.d.ts` file | 44 | | **[`formatter`](#formatter)** | `{String}` | Formats the generated `*.d.ts` file with specified formatter, eg. `prettier` | 45 | | **[`eol`](#eol)** | `{String}` | Newline character to be used in generated `*.d.ts` files | 46 | | **[`verifyOnly`](#verifyOnly)** | `{Boolean}` | Validate generated `*.d.ts` files and fail if an update is needed (useful in CI) | 47 | | **[`disableLocalsExport`](#disableLocalsExport)** | `{Boolean}` | Disable the use of locals export. | 48 | | **[`prettierConfigFile`](#prettierConfigFile)** | `{String}` | Path to prettier config file | 49 | 50 | ### `banner` 51 | 52 | To add a "banner" prefix to each generated `*.d.ts` file, you can pass a string to this option as shown below. The prefix is quite literally prefixed into the generated file, so please ensure it conforms to the type definition syntax. 53 | 54 | ```js 55 | module.exports = { 56 | module: { 57 | rules: [ 58 | { 59 | test: /\.css$/i, 60 | use: [ 61 | { 62 | loader: "@teamsupercell/typings-for-css-modules-loader", 63 | options: { 64 | banner: 65 | "// autogenerated by typings-for-css-modules-loader. \n// Please do not change this file!" 66 | } 67 | }, 68 | { 69 | loader: "css-loader", 70 | options: { modules: true } 71 | } 72 | ] 73 | } 74 | ] 75 | } 76 | }; 77 | ``` 78 | 79 | ### `formatter` 80 | 81 | Possible options: `none` and `prettier` (requires `prettier` package to be installed). Defaults to prettier if `prettier` module can be resolved. 82 | 83 | ```js 84 | module.exports = { 85 | module: { 86 | rules: [ 87 | { 88 | test: /\.css$/i, 89 | use: [ 90 | { 91 | loader: "@teamsupercell/typings-for-css-modules-loader", 92 | options: { 93 | formatter: "prettier" 94 | } 95 | }, 96 | { 97 | loader: "css-loader", 98 | options: { modules: true } 99 | } 100 | ] 101 | } 102 | ] 103 | } 104 | }; 105 | ``` 106 | 107 | ### `eol` 108 | 109 | Newline character to be used in generated `*.d.ts` files. By default a value from `require('os').eol` is used. 110 | This option is ignored when [`formatter`](#formatter) `prettier` is used. 111 | 112 | ```js 113 | module.exports = { 114 | module: { 115 | rules: [ 116 | { 117 | test: /\.css$/i, 118 | use: [ 119 | { 120 | loader: "@teamsupercell/typings-for-css-modules-loader", 121 | options: { 122 | eol: "\r\n" 123 | } 124 | }, 125 | { 126 | loader: "css-loader", 127 | options: { modules: true } 128 | } 129 | ] 130 | } 131 | ] 132 | } 133 | }; 134 | ``` 135 | 136 | ### `verifyOnly` 137 | 138 | Validate generated `*.d.ts` files and fail if an update is needed (useful in CI). 139 | 140 | ```js 141 | module.exports = { 142 | module: { 143 | rules: [ 144 | { 145 | test: /\.css$/i, 146 | use: [ 147 | { 148 | loader: "@teamsupercell/typings-for-css-modules-loader", 149 | options: { 150 | verifyOnly: process.env.NODE_ENV === 'production' 151 | } 152 | }, 153 | { 154 | loader: "css-loader", 155 | options: { modules: true } 156 | } 157 | ] 158 | } 159 | ] 160 | } 161 | }; 162 | ``` 163 | 164 | ### `disableLocalsExport` 165 | 166 | Disable the use of locals export. Defaults to `false`. 167 | 168 | ```js 169 | module.exports = { 170 | module: { 171 | rules: [ 172 | { 173 | test: /\.css$/i, 174 | use: [ 175 | { 176 | loader: "@teamsupercell/typings-for-css-modules-loader", 177 | options: { 178 | disableLocalsExport: true 179 | } 180 | }, 181 | { 182 | loader: "css-loader", 183 | options: { modules: true } 184 | } 185 | ] 186 | } 187 | ] 188 | } 189 | }; 190 | ``` 191 | 192 | ### `prettierConfigFile` 193 | 194 | Path to the prettier config file 195 | 196 | ```js 197 | module.exports = { 198 | module: { 199 | rules: [ 200 | { 201 | test: /\.css$/i, 202 | use: [ 203 | { 204 | loader: "@teamsupercell/typings-for-css-modules-loader", 205 | options: { 206 | prettierConfigFile: resolve(__dirname, '../.prettierrc'), 207 | } 208 | }, 209 | { 210 | loader: "css-loader", 211 | options: { modules: true } 212 | } 213 | ] 214 | } 215 | ] 216 | } 217 | }; 218 | ``` 219 | 220 | 221 | 222 | ## Example 223 | 224 | Imagine you have a file `~/my-project/src/component/MyComponent/myComponent.scss` in your project with the following content: 225 | 226 | ```scss 227 | .some-class { 228 | // some styles 229 | &.someOtherClass { 230 | // some other styles 231 | } 232 | &-sayWhat { 233 | // more styles 234 | } 235 | } 236 | ``` 237 | 238 | Adding the `typings-for-css-modules-loader` will generate a file `~/my-project/src/component/MyComponent/myComponent.scss.d.ts` that has the following content: 239 | 240 | ```ts 241 | declare namespace MyComponentScssModule { 242 | export interface IMyComponentScss { 243 | "some-class": string; 244 | someOtherClass: string; 245 | "some-class-sayWhat": string; 246 | } 247 | } 248 | 249 | declare const MyComponentScssModule: MyComponentScssModule.IMyComponentScss & { 250 | /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */ 251 | locals: MyComponentScssModule.IMyComponentScss; 252 | }; 253 | 254 | export = MyComponentScssModule; 255 | ``` 256 | 257 | ```ts 258 | // using wildcard export when used with style-loader or mini-css-extract-plugin 259 | // or default export only when typescript `esModuleInterop` enabled 260 | import * as styles from "./myComponent.scss"; 261 | 262 | console.log(styles["some-class"]); 263 | console.log(styles.someOtherClass); 264 | ``` 265 | 266 | ```ts 267 | // using locals export when used without style-loader or mini-css-extract-plugin 268 | import { locals } from "./myComponent.scss"; 269 | 270 | console.log(locals["some-class"]); 271 | console.log(locals.someOtherClass); 272 | ``` 273 | 274 | ### Example in Visual Studio Code 275 | 276 | ![typed-css-modules](https://cloud.githubusercontent.com/assets/749171/16340497/c1cb6888-3a28-11e6-919b-f2f51a282bba.gif) 277 | 278 | ## Upgrade from v1: 279 | - Update webpack config 280 | - This package no longer replaces `css-loader`, but it has to be added alongside `css-loader`: 281 | - `css-loader` is no longer a peer dependency due to the change above 282 | - `css-loader` will need to be configured to output CSS Modules (e.g. `options: { modules: true; }`) 283 | ```diff 284 | module.exports = { 285 | module: { 286 | rules: [ 287 | { 288 | test: /\.css$/i, 289 | use: [ 290 | "style-loader", 291 | { 292 | loader: "@teamsupercell/typings-for-css-modules-loader", 293 | options: { 294 | // pass all the options for `css-loader` to `css-loader`, eg. 295 | - namedExport: true, 296 | - modules: true 297 | } 298 | }, 299 | + { 300 | + loader: "css-loader", 301 | + options: { 302 | + modules: true 303 | + } 304 | + }, 305 | ] 306 | } 307 | ] 308 | } 309 | }; 310 | ``` 311 | 312 | ## Support 313 | 314 | As the loader just acts as an intermediary it can handle all kind of css preprocessors (`sass`, `scss`, `stylus`, `less`, ...). 315 | The only requirement is that those preprocessors have proper webpack loaders defined - meaning they can already be loaded by webpack anyways. 316 | 317 | ## Requirements 318 | 319 | The loader is supposed to be used with `css-loader`(https://github.com/webpack/css-loader). Thus it is a peer-dependency and the expected loader to create CSS Modules. 320 | 321 | ## Known issues 322 | 323 | ### Webpack rebuilds / builds slow 324 | 325 | As the loader generates typing files, it is wise to tell webpack to ignore them. 326 | The fix is luckily very simple. Webpack ships with a "WatchIgnorePlugin" out of the box. 327 | Simply add this to your webpack plugins: 328 | 329 | ``` 330 | plugins: [ 331 | new webpack.WatchIgnorePlugin([ 332 | /css\.d\.ts$/ 333 | ]), 334 | ... 335 | ] 336 | ``` 337 | 338 | where `css` is the file extension of your style files. If you use `sass` you need to put `sass` here instead. If you use `less`, `stylus` or any other style language use their file ending. 339 | 340 | ### Typescript does not find the typings 341 | 342 | As the webpack process is independent from your typescript "runtime" it may take a while for typescript to pick up the typings. 343 | 344 | It is possible to write a custom webpack plugin using the `fork-ts-checker-service-before-start` hook from https://github.com/TypeStrong/fork-ts-checker-webpack-plugin#plugin-hooks to delay the start of type checking until all the `*.d.ts` files are generated. Potentially, this plugin can be included in this repository. 345 | 346 | [npm]: https://img.shields.io/npm/v/@teamsupercell/typings-for-css-modules-loader.svg 347 | [npm-url]: https://npmjs.com/package/@teamsupercell/typings-for-css-modules-loader 348 | [build]: https://travis-ci.com/TeamSupercell/typings-for-css-modules-loader.svg?branch=master 349 | [build-url]: https://travis-ci.com/TeamSupercell/typings-for-css-modules-loader 350 | [deps]: https://david-dm.org/@teamsupercell/typings-for-css-modules-loader.svg 351 | [deps-url]: https://david-dm.org/@teamsupercell/typings-for-css-modules-loader 352 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/1m/6k9cpcjd2g70568d5t7k_0h00000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: null, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | // moduleDirectories: [ 65 | // "node_modules" 66 | // ], 67 | 68 | // An array of file extensions your modules use 69 | // moduleFileExtensions: [ 70 | // "js", 71 | // "json", 72 | // "jsx", 73 | // "ts", 74 | // "tsx", 75 | // "node" 76 | // ], 77 | 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | // preset: null, 92 | 93 | // Run tests from one or more projects 94 | // projects: null, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | // reporters: undefined, 98 | 99 | // Automatically reset mock state between every test 100 | // resetMocks: false, 101 | 102 | // Reset the module registry before running each individual test 103 | // resetModules: false, 104 | 105 | // A path to a custom resolver 106 | // resolver: null, 107 | 108 | // Automatically restore mock state between every test 109 | // restoreMocks: false, 110 | 111 | // The root directory that Jest should scan for tests and modules within 112 | // rootDir: null, 113 | 114 | // A list of paths to directories that Jest should use to search for files in 115 | // roots: [ 116 | // "" 117 | // ], 118 | 119 | // Allows you to use a custom runner instead of Jest's default test runner 120 | // runner: "jest-runner", 121 | 122 | // The paths to modules that run some code to configure or set up the testing environment before each test 123 | // setupFiles: [], 124 | 125 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 126 | // setupFilesAfterEnv: [], 127 | 128 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 129 | // snapshotSerializers: [], 130 | 131 | // The test environment that will be used for testing 132 | testEnvironment: "node", 133 | 134 | // Options that will be passed to the testEnvironment 135 | // testEnvironmentOptions: {}, 136 | 137 | // Adds a location field to test results 138 | // testLocationInResults: false, 139 | 140 | // The glob patterns Jest uses to detect test files 141 | // testMatch: [ 142 | // "**/__tests__/**/*.[jt]s?(x)", 143 | // "**/?(*.)+(spec|test).[tj]s?(x)" 144 | // ], 145 | 146 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 147 | // testPathIgnorePatterns: [ 148 | // "/node_modules/" 149 | // ], 150 | 151 | // The regexp pattern or array of patterns that Jest uses to detect test files 152 | // testRegex: [], 153 | 154 | // This option allows the use of a custom results processor 155 | // testResultsProcessor: null, 156 | 157 | // This option allows use of a custom test runner 158 | // testRunner: "jasmine2", 159 | 160 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 161 | // testURL: "http://localhost", 162 | 163 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 164 | // timers: "real", 165 | 166 | // A map from regular expressions to paths to transformers 167 | // transform: null, 168 | 169 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 170 | // transformIgnorePatterns: [ 171 | // "/node_modules/" 172 | // ], 173 | 174 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 175 | // unmockedModulePathPatterns: undefined, 176 | 177 | // Indicates whether each individual test should be reported during the run 178 | // verbose: null, 179 | 180 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 181 | watchPathIgnorePatterns: ["/dist", ".css.d.ts$"] 182 | 183 | // Whether to use watchman for file crawling 184 | // watchman: true, 185 | }; 186 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "moduleResolution": "node" 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@teamsupercell/typings-for-css-modules-loader", 3 | "version": "2.5.2", 4 | "description": "Webpack loader that generates TypeScript typings for CSS modules from css-loader on the fly", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "release": "release-it" 9 | }, 10 | "author": "Tim Sebastian ", 11 | "license": "MIT", 12 | "keywords": [ 13 | "Typescript", 14 | "TypeScript", 15 | "CSS Modules", 16 | "CSSModules", 17 | "CSS Modules typings", 18 | "Webpack", 19 | "Webpack loader", 20 | "Webpack css module typings loader", 21 | "typescript webpack typings", 22 | "css modules webpack typings" 23 | ], 24 | "dependencies": { 25 | "camelcase": "^5.3.1", 26 | "loader-utils": "^1.4.2", 27 | "schema-utils": "^2.0.1" 28 | }, 29 | "devDependencies": { 30 | "@types/jest": "^29.2.6", 31 | "auto-changelog": "^2.2.1", 32 | "css-loader": "*", 33 | "css-loader3": "npm:css-loader@^3.1.0", 34 | "eslint": "8.32.0", 35 | "eslint-config-prettier": "^8.6.0", 36 | "jest": "^29.3.1", 37 | "memfs": "^3.4.13", 38 | "prettier": "*", 39 | "release-it": "^15.6.0", 40 | "typescript": "^4.9.4", 41 | "webpack": "^5.75.0" 42 | }, 43 | "optionalDependencies": { 44 | "prettier": "*" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/TeamSupercell/typings-for-css-modules-loader.git" 49 | }, 50 | "bugs": { 51 | "url": "https://github.com/TeamSupercell/typings-for-css-modules-loader/issues" 52 | }, 53 | "homepage": "https://github.com/TeamSupercell/typings-for-css-modules-loader#readme", 54 | "eslintConfig": { 55 | "parserOptions": { 56 | "ecmaVersion": 2017 57 | }, 58 | "extends": [ 59 | "prettier" 60 | ] 61 | }, 62 | "release-it": { 63 | "github": { 64 | "release": true 65 | }, 66 | "git": { 67 | "changelog": "npx auto-changelog --stdout --commit-limit false --unreleased --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs" 68 | }, 69 | "hooks": { 70 | "after:bump": "npx auto-changelog --commit-limit false https://raw.githubusercontent.com/release-it/release-it/master/templates/keepachangelog.hbs" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { 3 | filenameToPascalCase, 4 | filenameToTypingsFilename, 5 | getCssModuleKeys, 6 | generateGenericExportInterface, 7 | } = require("./utils"); 8 | const persist = require("./persist"); 9 | const verify = require("./verify"); 10 | const { getOptions } = require("loader-utils"); 11 | const validateOptions = require("schema-utils"); 12 | 13 | const schema = { 14 | type: "object", 15 | properties: { 16 | eol: { 17 | description: 18 | "Newline character to be used in generated d.ts files. Uses OS default. This option is overridden by the formatter option.", 19 | type: "string", 20 | }, 21 | banner: { 22 | description: "To add a 'banner' prefix to each generated `*.d.ts` file", 23 | type: "string", 24 | }, 25 | formatter: { 26 | description: 27 | "Possible options: none and prettier (requires prettier package installed). Defaults to prettier if `prettier` module can be resolved", 28 | enum: ["prettier", "none"], 29 | }, 30 | disableLocalsExport: { 31 | description: "Disable the use of locals export. Defaults to `false`", 32 | type: "boolean", 33 | }, 34 | verifyOnly: { 35 | description: 36 | "Validate generated `*.d.ts` files and fail if an update is needed (useful in CI). Defaults to `false`", 37 | type: "boolean", 38 | }, 39 | prettierConfigFile: { 40 | description: 41 | "Path to prettier config file", 42 | type: "string", 43 | } 44 | }, 45 | additionalProperties: false, 46 | }; 47 | 48 | /** @type {any} */ 49 | const configuration = { 50 | name: "typings-for-css-modules-loader", 51 | baseDataPath: "options", 52 | }; 53 | 54 | /** @type {((this: import('webpack').loader.LoaderContext, ...args: any[]) => void) & {pitch?: import('webpack').loader.Loader['pitch']}} */ 55 | module.exports = function (content, ...args) { 56 | const options = getOptions(this) || {}; 57 | 58 | validateOptions(schema, options, configuration); 59 | 60 | if (this.cacheable) { 61 | this.cacheable(); 62 | } 63 | 64 | // let's only check `exports.locals` for keys to avoid getting keys from the sourcemap when it's enabled 65 | // if we cannot find locals, then the module only contains global styles 66 | const indexOfLocals = content.indexOf(".locals"); 67 | const cssModuleKeys = 68 | indexOfLocals === -1 69 | ? [] 70 | : getCssModuleKeys(content.substring(indexOfLocals)); 71 | 72 | /** @type {any} */ 73 | const callback = this.async(); 74 | 75 | const successfulCallback = () => { 76 | callback(null, content, ...args); 77 | }; 78 | 79 | if (cssModuleKeys.length === 0) { 80 | // no css module output found 81 | successfulCallback(); 82 | return; 83 | } 84 | 85 | const filename = this.resourcePath; 86 | 87 | const cssModuleInterfaceFilename = filenameToTypingsFilename(filename); 88 | const cssModuleDefinition = generateGenericExportInterface( 89 | cssModuleKeys, 90 | filenameToPascalCase(filename), 91 | options.disableLocalsExport 92 | ); 93 | 94 | applyFormattingAndOptions(cssModuleDefinition, options) 95 | .then((output) => { 96 | if (options.verifyOnly === true) { 97 | return verify(cssModuleInterfaceFilename, output); 98 | } else { 99 | persist(cssModuleInterfaceFilename, output); 100 | } 101 | }) 102 | .catch((err) => { 103 | this.emitError(err); 104 | }) 105 | .then(successfulCallback); 106 | }; 107 | 108 | /** 109 | * @param {string} cssModuleDefinition 110 | * @param {any} options 111 | */ 112 | async function applyFormattingAndOptions(cssModuleDefinition, options) { 113 | if (options.banner) { 114 | // Prefix banner to CSS module 115 | cssModuleDefinition = options.banner + "\n" + cssModuleDefinition; 116 | } 117 | 118 | if ( 119 | options.formatter === "prettier" || 120 | (!options.formatter && canUsePrettier()) 121 | ) { 122 | cssModuleDefinition = await applyPrettier(cssModuleDefinition, options); 123 | } else { 124 | // at very least let's ensure we're using OS eol if it's not provided 125 | cssModuleDefinition = cssModuleDefinition.replace( 126 | /\r?\n/g, 127 | options.eol || require("os").EOL 128 | ); 129 | } 130 | 131 | return cssModuleDefinition; 132 | } 133 | 134 | /** 135 | * @param {string} input 136 | * @param {any} options 137 | * @returns {Promise} 138 | */ 139 | async function applyPrettier(input, options) { 140 | const prettier = require("prettier"); 141 | 142 | const configPath = options.prettierConfigFile ? options.prettierConfigFile : "./"; 143 | const config = await prettier.resolveConfig(configPath, { 144 | editorconfig: true, 145 | }); 146 | 147 | return prettier.format( 148 | input, 149 | Object.assign({}, config, { parser: "typescript" }) 150 | ); 151 | } 152 | 153 | let isPrettierInstalled; 154 | /** 155 | * @returns {boolean} 156 | */ 157 | function canUsePrettier() { 158 | if (typeof isPrettierInstalled !== "boolean") { 159 | try { 160 | require.resolve("prettier"); 161 | isPrettierInstalled = true; 162 | } catch (_) { 163 | isPrettierInstalled = false; 164 | } 165 | } 166 | 167 | return isPrettierInstalled; 168 | } 169 | -------------------------------------------------------------------------------- /src/persist.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const fs = require("fs"); 3 | 4 | /** 5 | * @param {string} filename 6 | * @param {string} content 7 | * @returns {void} 8 | */ 9 | module.exports = (filename, content) => { 10 | if (fs.existsSync(filename)) { 11 | const currentInput = fs.readFileSync(filename, "utf-8"); 12 | 13 | // compare file contents ignoring whitespace 14 | if (currentInput.replace(/\s+/g, "") !== content.replace(/\s+/g, "")) { 15 | fs.writeFileSync(filename, content, "utf8"); 16 | } 17 | } else { 18 | fs.writeFileSync(filename, content, "utf8"); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const path = require("path"); 3 | const camelCase = require("camelcase"); 4 | 5 | /** 6 | * @param {string} content 7 | * @returns {string[]} 8 | */ 9 | const getCssModuleKeys = (content) => { 10 | const keyRegex = /"([^"\n]+)":/g; 11 | let match; 12 | const cssModuleKeys = []; 13 | 14 | while ((match = keyRegex.exec(content))) { 15 | if (cssModuleKeys.indexOf(match[1]) < 0) { 16 | cssModuleKeys.push(match[1]); 17 | } 18 | } 19 | return cssModuleKeys; 20 | }; 21 | 22 | /** 23 | * @param {string} filename 24 | */ 25 | const filenameToPascalCase = (filename) => { 26 | return camelCase(path.basename(filename), { pascalCase: true }); 27 | }; 28 | 29 | /** 30 | * @param {string[]} cssModuleKeys 31 | * @param {string=} indent 32 | */ 33 | const cssModuleToTypescriptInterfaceProperties = (cssModuleKeys, indent) => { 34 | return [...cssModuleKeys] 35 | .sort() 36 | .map((key) => `${indent || ""}'${key}': string;`) 37 | .join("\n"); 38 | }; 39 | 40 | const filenameToTypingsFilename = (filename) => { 41 | const dirName = path.dirname(filename); 42 | const baseName = path.basename(filename); 43 | return path.join(dirName, `${baseName}.d.ts`); 44 | }; 45 | 46 | /** 47 | * @param {string[]} cssModuleKeys 48 | * @param {string} pascalCaseFileName 49 | */ 50 | const generateGenericExportInterface = ( 51 | cssModuleKeys, 52 | pascalCaseFileName, 53 | disableLocalsExport 54 | ) => { 55 | const interfaceName = `I${pascalCaseFileName}`; 56 | const moduleName = `${pascalCaseFileName}Module`; 57 | const namespaceName = `${pascalCaseFileName}Namespace`; 58 | 59 | const localsExportType = disableLocalsExport 60 | ? `` 61 | : ` & { 62 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 63 | locals: ${namespaceName}.${interfaceName}; 64 | }`; 65 | 66 | const interfaceProperties = cssModuleToTypescriptInterfaceProperties( 67 | cssModuleKeys, 68 | " " 69 | ); 70 | return `declare namespace ${namespaceName} { 71 | export interface I${pascalCaseFileName} { 72 | ${interfaceProperties} 73 | } 74 | } 75 | 76 | declare const ${moduleName}: ${namespaceName}.${interfaceName}${localsExportType}; 77 | 78 | export = ${moduleName};`; 79 | }; 80 | 81 | module.exports = { 82 | getCssModuleKeys, 83 | filenameToPascalCase, 84 | filenameToTypingsFilename, 85 | generateGenericExportInterface, 86 | }; 87 | -------------------------------------------------------------------------------- /src/verify.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const fs = require("fs"); 3 | const util = require("util"); 4 | const fsStat = util.promisify(fs.stat); 5 | const fsReadFile = util.promisify(fs.readFile); 6 | /** 7 | * @param {string} filename 8 | * @param {string} content 9 | * @returns {Promise} 10 | */ 11 | module.exports = async (filename, content) => { 12 | const fileExists = await fsStat(filename) 13 | .then(() => true) 14 | .catch(() => false); 15 | 16 | if (!fileExists) { 17 | throw new Error( 18 | `Verification failed: Generated typings for css-module file '${filename}' is not found. ` + 19 | "It typically happens when the generated typings were not committed." 20 | ); 21 | } 22 | 23 | const existingFileContent = await fsReadFile(filename, "utf-8"); 24 | 25 | // let's not fail the build if there are whitespace changes only 26 | if (existingFileContent.replace(/\s+/g, "") !== content.replace(/\s+/g, "")) { 27 | throw new Error( 28 | `Verification failed: Generated typings for css-modules file '${filename}' is out of date. ` + 29 | "It typically happens when the up-to-date generated typings are not committed." 30 | ); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /test/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`css-loader@3 default options 1`] = ` 4 | "declare namespace ExampleCssNamespace { 5 | export interface IExampleCss { 6 | "bar-baz": string; 7 | composed: string; 8 | foo: string; 9 | } 10 | } 11 | 12 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 13 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 14 | locals: ExampleCssNamespace.IExampleCss; 15 | }; 16 | 17 | export = ExampleCssModule; 18 | " 19 | `; 20 | 21 | exports[`css-loader@3 localsConvention asIs 1`] = ` 22 | "declare namespace ExampleCssNamespace { 23 | export interface IExampleCss { 24 | "bar-baz": string; 25 | composed: string; 26 | foo: string; 27 | } 28 | } 29 | 30 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 31 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 32 | locals: ExampleCssNamespace.IExampleCss; 33 | }; 34 | 35 | export = ExampleCssModule; 36 | " 37 | `; 38 | 39 | exports[`css-loader@3 localsConvention camelCase 1`] = ` 40 | "declare namespace ExampleCssNamespace { 41 | export interface IExampleCss { 42 | "bar-baz": string; 43 | barBaz: string; 44 | composed: string; 45 | foo: string; 46 | } 47 | } 48 | 49 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 50 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 51 | locals: ExampleCssNamespace.IExampleCss; 52 | }; 53 | 54 | export = ExampleCssModule; 55 | " 56 | `; 57 | 58 | exports[`css-loader@3 with banner 1`] = ` 59 | "// autogenerated by typings-for-css-modules-loader 60 | declare namespace ExampleCssNamespace { 61 | export interface IExampleCss { 62 | "bar-baz": string; 63 | composed: string; 64 | foo: string; 65 | } 66 | } 67 | 68 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 69 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 70 | locals: ExampleCssNamespace.IExampleCss; 71 | }; 72 | 73 | export = ExampleCssModule; 74 | " 75 | `; 76 | 77 | exports[`css-loader@3 with locals export disabled 1`] = ` 78 | "declare namespace ExampleCssNamespace { 79 | export interface IExampleCss { 80 | "bar-baz": string; 81 | composed: string; 82 | foo: string; 83 | } 84 | } 85 | 86 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss; 87 | 88 | export = ExampleCssModule; 89 | " 90 | `; 91 | 92 | exports[`css-loader@3 with no formatter 1`] = ` 93 | "declare namespace ExampleCssNamespace { 94 | export interface IExampleCss { 95 | 'bar-baz': string; 96 | 'composed': string; 97 | 'foo': string; 98 | } 99 | } 100 | 101 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 102 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 103 | locals: ExampleCssNamespace.IExampleCss; 104 | }; 105 | 106 | export = ExampleCssModule;" 107 | `; 108 | 109 | exports[`css-loader@3 with prettier 1`] = ` 110 | "declare namespace ExampleCssNamespace { 111 | export interface IExampleCss { 112 | "bar-baz": string; 113 | composed: string; 114 | foo: string; 115 | } 116 | } 117 | 118 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 119 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 120 | locals: ExampleCssNamespace.IExampleCss; 121 | }; 122 | 123 | export = ExampleCssModule; 124 | " 125 | `; 126 | 127 | exports[`css-loader@3 with sourcemap 1`] = ` 128 | "declare namespace ExampleCssNamespace { 129 | export interface IExampleCss { 130 | "bar-baz": string; 131 | composed: string; 132 | foo: string; 133 | } 134 | } 135 | 136 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 137 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 138 | locals: ExampleCssNamespace.IExampleCss; 139 | }; 140 | 141 | export = ExampleCssModule; 142 | " 143 | `; 144 | 145 | exports[`css-loader@latest default options 1`] = ` 146 | "declare namespace ExampleCssNamespace { 147 | export interface IExampleCss { 148 | "bar-baz": string; 149 | composed: string; 150 | foo: string; 151 | } 152 | } 153 | 154 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 155 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 156 | locals: ExampleCssNamespace.IExampleCss; 157 | }; 158 | 159 | export = ExampleCssModule; 160 | " 161 | `; 162 | 163 | exports[`css-loader@latest localsConvention asIs 1`] = ` 164 | "declare namespace ExampleCssNamespace { 165 | export interface IExampleCss { 166 | "bar-baz": string; 167 | composed: string; 168 | foo: string; 169 | } 170 | } 171 | 172 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 173 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 174 | locals: ExampleCssNamespace.IExampleCss; 175 | }; 176 | 177 | export = ExampleCssModule; 178 | " 179 | `; 180 | 181 | exports[`css-loader@latest localsConvention camelCase 1`] = ` 182 | "declare namespace ExampleCssNamespace { 183 | export interface IExampleCss { 184 | "bar-baz": string; 185 | barBaz: string; 186 | composed: string; 187 | foo: string; 188 | } 189 | } 190 | 191 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 192 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 193 | locals: ExampleCssNamespace.IExampleCss; 194 | }; 195 | 196 | export = ExampleCssModule; 197 | " 198 | `; 199 | 200 | exports[`css-loader@latest with banner 1`] = ` 201 | "// autogenerated by typings-for-css-modules-loader 202 | declare namespace ExampleCssNamespace { 203 | export interface IExampleCss { 204 | "bar-baz": string; 205 | composed: string; 206 | foo: string; 207 | } 208 | } 209 | 210 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 211 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 212 | locals: ExampleCssNamespace.IExampleCss; 213 | }; 214 | 215 | export = ExampleCssModule; 216 | " 217 | `; 218 | 219 | exports[`css-loader@latest with locals export disabled 1`] = ` 220 | "declare namespace ExampleCssNamespace { 221 | export interface IExampleCss { 222 | "bar-baz": string; 223 | composed: string; 224 | foo: string; 225 | } 226 | } 227 | 228 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss; 229 | 230 | export = ExampleCssModule; 231 | " 232 | `; 233 | 234 | exports[`css-loader@latest with no formatter 1`] = ` 235 | "declare namespace ExampleCssNamespace { 236 | export interface IExampleCss { 237 | 'bar-baz': string; 238 | 'composed': string; 239 | 'foo': string; 240 | } 241 | } 242 | 243 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 244 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 245 | locals: ExampleCssNamespace.IExampleCss; 246 | }; 247 | 248 | export = ExampleCssModule;" 249 | `; 250 | 251 | exports[`css-loader@latest with prettier 1`] = ` 252 | "declare namespace ExampleCssNamespace { 253 | export interface IExampleCss { 254 | "bar-baz": string; 255 | composed: string; 256 | foo: string; 257 | } 258 | } 259 | 260 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 261 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 262 | locals: ExampleCssNamespace.IExampleCss; 263 | }; 264 | 265 | export = ExampleCssModule; 266 | " 267 | `; 268 | 269 | exports[`css-loader@latest with sourcemap 1`] = ` 270 | "declare namespace ExampleCssNamespace { 271 | export interface IExampleCss { 272 | "bar-baz": string; 273 | composed: string; 274 | foo: string; 275 | } 276 | } 277 | 278 | declare const ExampleCssModule: ExampleCssNamespace.IExampleCss & { 279 | /** WARNING: Only available when \`css-loader\` is used without \`style-loader\` or \`mini-css-extract-plugin\` */ 280 | locals: ExampleCssNamespace.IExampleCss; 281 | }; 282 | 283 | export = ExampleCssModule; 284 | " 285 | `; 286 | -------------------------------------------------------------------------------- /test/example-no-locals.css: -------------------------------------------------------------------------------- 1 | div { 2 | background: red; 3 | } 4 | -------------------------------------------------------------------------------- /test/example-shared.css: -------------------------------------------------------------------------------- 1 | .shared-style { 2 | color: black; 3 | } 4 | -------------------------------------------------------------------------------- /test/example.css: -------------------------------------------------------------------------------- 1 | .foo { 2 | color: white; 3 | } 4 | 5 | .bar-baz { 6 | color: green; 7 | } 8 | 9 | .composed { 10 | composes: shared-style from "./example-shared.css"; 11 | } 12 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /// 3 | const webpack = require("webpack"); 4 | const path = require("path"); 5 | const { Volume } = require("memfs"); 6 | 7 | beforeEach(() => { 8 | jest.mock("../src/persist"); 9 | jest.mock("../src/verify"); 10 | }); 11 | 12 | describe("css-loader@latest", () => { 13 | const runTest = createTestRunner(); 14 | 15 | it("default options", async () => { 16 | await runTest(); 17 | 18 | const persistMock = jest.requireMock("../src/persist"); 19 | expect(persistMock).toBeCalledTimes(1); 20 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 21 | 22 | const verifyMock = jest.requireMock("../src/verify"); 23 | expect(verifyMock).toBeCalledTimes(0); 24 | }); 25 | 26 | it("with sourcemap", async () => { 27 | await runTest({ 28 | cssLoaderOptions: { 29 | sourceMap: true, 30 | }, 31 | }); 32 | 33 | const persistMock = jest.requireMock("../src/persist"); 34 | expect(persistMock).toBeCalledTimes(1); 35 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 36 | }); 37 | 38 | it("no locals in output", async () => { 39 | await runTest({ 40 | fileName: "./example-no-locals.css", 41 | cssLoaderOptions: { 42 | sourceMap: true, 43 | }, 44 | }); 45 | 46 | const persistMock = jest.requireMock("../src/persist"); 47 | expect(persistMock).toBeCalledTimes(0); 48 | }); 49 | 50 | it("no modules", async () => { 51 | await runTest({ 52 | cssLoaderOptions: { 53 | modules: false, 54 | }, 55 | }); 56 | 57 | const persistMock = jest.requireMock("../src/persist"); 58 | expect(persistMock).toBeCalledTimes(0); 59 | }); 60 | 61 | it("localsConvention asIs", async () => { 62 | await runTest({ 63 | cssLoaderOptions: { 64 | modules: { 65 | exportLocalsConvention: "asIs", 66 | }, 67 | }, 68 | }); 69 | 70 | const persistMock = jest.requireMock("../src/persist"); 71 | expect(persistMock).toBeCalledTimes(1); 72 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 73 | }); 74 | 75 | it("localsConvention camelCase", async () => { 76 | await runTest({ 77 | cssLoaderOptions: { 78 | modules: { 79 | exportLocalsConvention: "camelCase", 80 | }, 81 | }, 82 | }); 83 | 84 | const persistMock = jest.requireMock("../src/persist"); 85 | expect(persistMock).toBeCalledTimes(1); 86 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 87 | }); 88 | 89 | it("with prettier", async () => { 90 | await runTest({ 91 | options: { 92 | formatter: "prettier", 93 | }, 94 | }); 95 | 96 | const persistMock = jest.requireMock("../src/persist"); 97 | expect(persistMock).toBeCalledTimes(1); 98 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 99 | }); 100 | 101 | it("with no formatter", async () => { 102 | await runTest({ 103 | options: { 104 | formatter: "none", 105 | }, 106 | }); 107 | 108 | const persistMock = jest.requireMock("../src/persist"); 109 | expect(persistMock).toBeCalledTimes(1); 110 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 111 | }); 112 | 113 | it("with banner", async () => { 114 | await runTest({ 115 | options: { 116 | banner: "// autogenerated by typings-for-css-modules-loader", 117 | }, 118 | }); 119 | 120 | const persistMock = jest.requireMock("../src/persist"); 121 | expect(persistMock).toBeCalledTimes(1); 122 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 123 | }); 124 | 125 | it("with locals export disabled", async () => { 126 | await runTest({ 127 | options: { 128 | disableLocalsExport: true, 129 | }, 130 | }); 131 | 132 | const persistMock = jest.requireMock("../src/persist"); 133 | expect(persistMock).toBeCalledTimes(1); 134 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 135 | }); 136 | 137 | it("with verify only", async () => { 138 | await runTest({ 139 | options: { 140 | verifyOnly: true, 141 | }, 142 | }); 143 | 144 | const persistMock = jest.requireMock("../src/persist"); 145 | expect(persistMock).toBeCalledTimes(0); 146 | 147 | const verifyMock = jest.requireMock("../src/verify"); 148 | expect(verifyMock).toBeCalledTimes(1); 149 | }); 150 | }); 151 | 152 | describe("css-loader@3", () => { 153 | const runTest = createTestRunner("css-loader3"); 154 | 155 | it("default options", async () => { 156 | await runTest(); 157 | 158 | const persistMock = jest.requireMock("../src/persist"); 159 | expect(persistMock).toBeCalledTimes(1); 160 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 161 | 162 | const verifyMock = jest.requireMock("../src/verify"); 163 | expect(verifyMock).toBeCalledTimes(0); 164 | }); 165 | 166 | it("with sourcemap", async () => { 167 | await runTest({ 168 | cssLoaderOptions: { 169 | sourceMap: true, 170 | }, 171 | }); 172 | 173 | const persistMock = jest.requireMock("../src/persist"); 174 | expect(persistMock).toBeCalledTimes(1); 175 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 176 | }); 177 | 178 | it("no locals in output", async () => { 179 | await runTest({ 180 | fileName: "./example-no-locals.css", 181 | cssLoaderOptions: { 182 | sourceMap: true, 183 | }, 184 | }); 185 | 186 | const persistMock = jest.requireMock("../src/persist"); 187 | expect(persistMock).toBeCalledTimes(0); 188 | }); 189 | 190 | it("no modules", async () => { 191 | await runTest({ 192 | cssLoaderOptions: { 193 | modules: false, 194 | }, 195 | }); 196 | 197 | const persistMock = jest.requireMock("../src/persist"); 198 | expect(persistMock).toBeCalledTimes(0); 199 | }); 200 | 201 | it("localsConvention asIs", async () => { 202 | await runTest({ 203 | cssLoaderOptions: { 204 | localsConvention: "asIs", 205 | }, 206 | }); 207 | 208 | const persistMock = jest.requireMock("../src/persist"); 209 | expect(persistMock).toBeCalledTimes(1); 210 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 211 | }); 212 | 213 | it("localsConvention camelCase", async () => { 214 | await runTest({ 215 | cssLoaderOptions: { 216 | localsConvention: "camelCase", 217 | }, 218 | }); 219 | 220 | const persistMock = jest.requireMock("../src/persist"); 221 | expect(persistMock).toBeCalledTimes(1); 222 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 223 | }); 224 | 225 | it("with prettier", async () => { 226 | await runTest({ 227 | options: { 228 | formatter: "prettier", 229 | }, 230 | }); 231 | 232 | const persistMock = jest.requireMock("../src/persist"); 233 | expect(persistMock).toBeCalledTimes(1); 234 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 235 | }); 236 | 237 | it("with no formatter", async () => { 238 | await runTest({ 239 | options: { 240 | formatter: "none", 241 | }, 242 | }); 243 | 244 | const persistMock = jest.requireMock("../src/persist"); 245 | expect(persistMock).toBeCalledTimes(1); 246 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 247 | }); 248 | 249 | it("with banner", async () => { 250 | await runTest({ 251 | options: { 252 | banner: "// autogenerated by typings-for-css-modules-loader", 253 | }, 254 | }); 255 | 256 | const persistMock = jest.requireMock("../src/persist"); 257 | expect(persistMock).toBeCalledTimes(1); 258 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 259 | }); 260 | 261 | it("with locals export disabled", async () => { 262 | await runTest({ 263 | options: { 264 | disableLocalsExport: true, 265 | }, 266 | }); 267 | 268 | const persistMock = jest.requireMock("../src/persist"); 269 | expect(persistMock).toBeCalledTimes(1); 270 | expect(persistMock.mock.calls[0][1]).toMatchSnapshot(); 271 | }); 272 | 273 | it("with verify only", async () => { 274 | await runTest({ 275 | options: { 276 | verifyOnly: true, 277 | }, 278 | }); 279 | 280 | const persistMock = jest.requireMock("../src/persist"); 281 | expect(persistMock).toBeCalledTimes(0); 282 | 283 | const verifyMock = jest.requireMock("../src/verify"); 284 | expect(verifyMock).toBeCalledTimes(1); 285 | }); 286 | }); 287 | 288 | function createTestRunner(cssLoaderModule = "css-loader") { 289 | return async ({ 290 | fileName = "./example.css", 291 | options = {}, 292 | cssLoaderOptions = {}, 293 | } = {}) => { 294 | const compiler = webpack({ 295 | entry: path.resolve(__dirname, fileName), 296 | target: "node", 297 | module: { 298 | rules: [ 299 | { 300 | test: /\.css$/, 301 | use: [ 302 | { 303 | loader: require.resolve("../src/index.js"), 304 | options, 305 | }, 306 | { 307 | loader: cssLoaderModule, 308 | options: Object.assign( 309 | { 310 | modules: true, 311 | }, 312 | cssLoaderOptions 313 | ), 314 | }, 315 | ], 316 | }, 317 | ], 318 | }, 319 | mode: "none", 320 | }); 321 | 322 | compiler.outputFileSystem = new Volume(); 323 | 324 | /** @type {webpack.Stats} */ 325 | const stats = await new Promise((resolve, reject) => { 326 | compiler.run((err, stats) => { 327 | if (err) { 328 | reject(err); 329 | } else { 330 | resolve(stats); 331 | } 332 | }); 333 | }); 334 | 335 | const s = stats.toJson(); 336 | expect(s.errors).toHaveLength(0); 337 | }; 338 | } 339 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { filenameToPascalCase, getCssModuleKeys } = require("../src/utils"); 3 | 4 | describe("filenameToPascalCase", () => { 5 | it("camelCase", () => { 6 | const actual = filenameToPascalCase("reactDatePicker"); 7 | expect(actual).toBe("ReactDatePicker"); 8 | }); 9 | 10 | it("PascalCase", () => { 11 | const actual = filenameToPascalCase("reactDatePicker"); 12 | expect(actual).toBe("ReactDatePicker"); 13 | }); 14 | 15 | it("snake_case", () => { 16 | const actual = filenameToPascalCase("_React_date_picker"); 17 | expect(actual).toBe("ReactDatePicker"); 18 | }); 19 | 20 | it("_mixed-case", () => { 21 | const actual = filenameToPascalCase("_React-date_picker"); 22 | expect(actual).toBe("ReactDatePicker"); 23 | }); 24 | }); 25 | 26 | describe("getCssModuleKeys", () => { 27 | it("empty CSS module", () => { 28 | const content = ` 29 | exports = module.exports = require("../node_modules/css-loader/dist/runtime/api.js")(false); 30 | // Module 31 | exports.push([module.id, "", ""]); 32 | `; 33 | const actual = getCssModuleKeys(content); 34 | expect(actual).toEqual([]); 35 | }); 36 | 37 | it("CSS module with one class", () => { 38 | const content = `exports.locals = { 39 | "test": "test" 40 | };`; 41 | const actual = getCssModuleKeys(content); 42 | expect(actual).toEqual(["test"]); 43 | }); 44 | 45 | it("CSS module with multiple classes", () => { 46 | const content = `exports.locals = { 47 | "test1": "test1", 48 | "test2": "test2" 49 | };`; 50 | const actual = getCssModuleKeys(content); 51 | expect(actual).toEqual(["test1", "test2"]); 52 | }); 53 | 54 | it("CSS module with :root pseudo-class only", () => { 55 | const content = ` 56 | exports = module.exports = require("../node_modules/css-loader/dist/runtime/api.js")(false); 57 | // Module 58 | exports.push([module.id, ":root {\n --background: green; }\n", ""]); 59 | `; 60 | const actual = getCssModuleKeys(content); 61 | expect(actual).toEqual([]); 62 | }); 63 | 64 | it("CSS module with special class names", () => { 65 | const content = `.locals = { 66 | "øæå": "nordic", 67 | "+~@": "special", 68 | "f\\'o\\'o": "escaped", 69 | };`; 70 | const actual = getCssModuleKeys(content); 71 | expect(actual).toEqual(["øæå", "+~@", "f\\'o\\'o"]); 72 | }); 73 | 74 | it("CSS module with newline in class names should be ignored", () => { 75 | const content = `.locals = { 76 | "line1 77 | line2": "twolinesdoesnotmakesense" 78 | };`; 79 | const actual = getCssModuleKeys(content); 80 | expect(actual).toEqual([]); 81 | }); 82 | }); 83 | --------------------------------------------------------------------------------