├── .eslintrc ├── .github ├── pull_request_template.md └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .yarn └── releases │ └── yarn-3.6.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── jest.config.js ├── lib ├── craco-less.dev.test.js ├── craco-less.js ├── craco-less.prod.test.js ├── craco-less.test.test.js ├── test-utils.js ├── utils.js └── utils.test.js ├── package.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:prettier/recommended"], 3 | "parserOptions": { 4 | "ecmaVersion": 2021 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Thanks for helping to maintain `craco-less`! 2 | 3 | Before you submit this PR, please check the following: 4 | 5 | - 100% test coverage 6 | 7 | ``` 8 | yarn test 9 | ``` 10 | 11 | - Code is formatted with Prettier 12 | 13 | ``` 14 | yarn format 15 | ``` 16 | 17 | - No ESLint warnings 18 | 19 | ``` 20 | yarn lint 21 | ``` 22 | 23 | - No security vulnerabilities in any NPM packages 24 | 25 | ``` 26 | yarn audit 27 | ``` 28 | 29 | You are also welcome to add your GitHub username to the [Contributors](#Contributors) section at the bottom of this README. (_optional_) 30 | 31 | **Please don't submit this pull request if it does not meet the above requirements.** 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | os: [windows-latest, ubuntu-latest, macos-latest] 14 | node-version: [16.x, 18.x, 20.x] 15 | 16 | name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: "yarn" 26 | 27 | - run: yarn install 28 | - run: yarn lint 29 | - run: yarn format 30 | - run: yarn test 31 | 32 | - name: Coveralls 33 | uses: coverallsapp/github-action@master 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | flag-name: node-${{ matrix.node-version }}-on-${{ matrix.os }} 37 | parallel: true 38 | 39 | finish: 40 | needs: test 41 | name: Coveralls Finished 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Coveralls Finished 45 | uses: coverallsapp/github-action@master 46 | with: 47 | github-token: ${{ secrets.GITHUB_TOKEN }} 48 | parallel-finished: true 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /node_modules 3 | *.log 4 | /errors 5 | /test.js 6 | /test.ts 7 | /test.css 8 | /.vscode 9 | /dist 10 | .DS_Store 11 | coverage 12 | /.idea 13 | /test-results 14 | craco-less-*.tgz 15 | 16 | # Yarn without Zero Install 17 | .pnp.* 18 | .yarn/* 19 | !.yarn/patches 20 | !.yarn/plugins 21 | !.yarn/releases 22 | !.yarn/sdks 23 | !.yarn/versions -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | .eslintrc 4 | .prettierrc 5 | .prettierignore 6 | jest.config.js 7 | yarn-error.log 8 | .vscode/ 9 | .idea/ 10 | coverage 11 | lib/*.test.js 12 | lib/test-utils.js 13 | yarn.lock 14 | craco-less-*.tgz 15 | .github/ 16 | .yarn/ 17 | .yarnrc.yml -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.3.1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto" 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.6.1.cjs 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present, Form Applications, Inc. 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 | [![Test Status](https://github.com/DocSpring/craco-less/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/DocSpring/craco-less/actions/workflows/test.yml) 2 | [![Coverage Status](https://coveralls.io/repos/github/DocSpring/craco-less/badge.svg?branch=master)](https://coveralls.io/github/DocSpring/craco-less?branch=master) 3 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) 4 | 5 | --- 6 | 7 | ### Community Maintained 8 | 9 | We rely on your help to keep this project up to date and work with the latest versions of `craco` and `react-scripts`. 10 | 11 | Before you send a PR, please check the following: 12 | 13 | - 100% test coverage 14 | 15 | ``` 16 | yarn test 17 | ``` 18 | 19 | - Code is formatted with Prettier 20 | 21 | ``` 22 | yarn format 23 | ``` 24 | 25 | - No ESLint warnings 26 | 27 | ``` 28 | yarn lint 29 | ``` 30 | 31 | - No security vulnerabilities in any NPM packages 32 | 33 | ``` 34 | yarn audit 35 | ``` 36 | 37 | You are also welcome to add your GitHub username to the [Contributors](#Contributors) section at the bottom of this README. (_optional_) 38 | 39 | ### Please don't send a pull request if it does not meet the above requirements 40 | 41 | Pull requests will be ignored and closed if there is a failing build on Travis CI. 42 | 43 | --- 44 | 45 | # Craco Less Plugin 46 | 47 | This is a [craco](https://github.com/dilanx/craco) plugin that adds Less support to [create-react-app](https://facebook.github.io/create-react-app/) version >= 2. 48 | 49 | > Use [react-app-rewired](https://github.com/timarney/react-app-rewired) for `create-react-app` version 1. 50 | 51 | ## Ant Design 52 | 53 | If you want to use [Ant Design](https://ant.design/) with `create-react-app`, 54 | you should use the [`craco-antd`](https://github.com/DocSpring/craco-antd) plugin. 55 | `craco-antd` includes Less and `babel-plugin-import` (to only include the required CSS.) It also makes it easy to customize the theme variables. 56 | 57 | ## Supported Versions 58 | 59 | `craco-less` is tested with: 60 | 61 | - `react-scripts`: `^5.0.1` 62 | - `@craco/craco`: `^7.1.0` 63 | 64 | ## Installation 65 | 66 | First, follow the [`craco` Installation Instructions](https://github.com/gsoft-inc/craco/blob/master/packages/craco/README.md#installation) to install the `craco` package, create a `craco.config.js` file, and modify the scripts in your `package.json`. 67 | 68 | Then install `craco-less`: 69 | 70 | ```bash 71 | $ yarn add -D craco-less 72 | 73 | # OR 74 | 75 | $ npm i -D craco-less 76 | ``` 77 | 78 | ## Usage 79 | 80 | Here is a complete `craco.config.js` configuration file that adds Less compilation to `create-react-app`: 81 | 82 | ```js 83 | const CracoLessPlugin = require("craco-less"); 84 | 85 | module.exports = { 86 | plugins: [{ plugin: CracoLessPlugin }], 87 | }; 88 | ``` 89 | 90 | ## Configuration 91 | 92 | You can pass an `options` object to configure the loaders and plugins(configure _less_ and _less modules_ at the same time). You can also pass a `modifyLessRule`(or `modifyLessModuleRule`) callback to have full control over the Less webpack rule. 93 | 94 | - `options.styleLoaderOptions` 95 | - _Default:_ `{}` 96 | - [View the `style-loader` options](https://webpack.js.org/loaders/style-loader/#options) 97 | - `options.cssLoaderOptions` 98 | - _Default:_ `{ importLoaders: 2 }` 99 | - [View the `css-loader` options](https://webpack.js.org/loaders/css-loader/#options) 100 | - `options.postcssLoaderOptions` 101 | - _Default:_ `{ ident: "postcss", plugins: () => [ ... ] }` 102 | - [View the `postcss-loader` options](https://webpack.js.org/loaders/postcss-loader/#options) 103 | - `options.lessLoaderOptions` 104 | - _Default:_ `{}` 105 | - [View the `less-loader` documentation](https://webpack.js.org/loaders/less-loader/) 106 | - [View the Less options](http://lesscss.org/usage/#less-options) 107 | - You must use "camelCase" instead of "dash-case", e.g. `--source-map` => `sourceMap` 108 | - `options.miniCssExtractPluginOptions` _(only used in production)_ 109 | - _Default:_ `{}` 110 | - [View the `mini-css-extract-plugin` documentation](https://github.com/webpack-contrib/mini-css-extract-plugin) 111 | - `options.modifyLessRule(lessRule, context)` 112 | - A callback function that receives two arguments: the webpack rule, and the context. You must return an updated rule object. 113 | - `lessRule`: 114 | - `test`: Regex (default: `/\.less$/`) 115 | - `exclude`: Regex (default: `/\.module\.less$/`) 116 | - `use`: Array of loaders and options. 117 | - `sideEffects`: Boolean (default: `true`) 118 | - `context`: 119 | - `env`: "development" or "production" 120 | - `paths`: An object with paths, e.g. `appBuild`, `appPath`, `ownNodeModules` 121 | - `options.modifyLessModuleRule(lessModuleRule, context)` 122 | - A callback function that receives two arguments: the webpack rule, and the context. You must return an updated rule object. 123 | - `lessModuleRule`: 124 | - `test`: Regex (default: `/\.module\.less$/`) 125 | - `use`: Array of loaders and options. 126 | - `context`: 127 | - `env`: "development" or "production" 128 | - `paths`: An object with paths, e.g. `appBuild`, `appPath`, `ownNodeModules` 129 | 130 | For example, to configure `less-loader`: 131 | 132 | ```js 133 | const CracoLessPlugin = require("craco-less"); 134 | 135 | module.exports = { 136 | plugins: [ 137 | { 138 | plugin: CracoLessPlugin, 139 | options: { 140 | lessLoaderOptions: { 141 | lessOptions: { 142 | modifyVars: { 143 | "@primary-color": "#1DA57A", 144 | "@link-color": "#1DA57A", 145 | "@border-radius-base": "2px", 146 | }, 147 | javascriptEnabled: true, 148 | }, 149 | }, 150 | }, 151 | }, 152 | ], 153 | }; 154 | ``` 155 | 156 | ## CSS / Less Modules 157 | 158 | **CSS / Less modules are enabled by default, and the default file suffix for _less modules_ is `.module.less`.** 159 | 160 | If your project is using typescript, please append the following code to `./src/react-app-env.d.ts` 161 | 162 | ```ts 163 | declare module "*.module.less" { 164 | const classes: { readonly [key: string]: string }; 165 | export default classes; 166 | } 167 | ``` 168 | 169 | You can use `modifyLessModuleRule` to configure the file suffix and loaders ([css-loader](https://webpack.js.org/loaders/css-loader/), [less-loader](https://webpack.js.org/loaders/less-loader/) ...) for _less modules_. 170 | 171 | For example: 172 | 173 | ```js 174 | const CracoLessPlugin = require("craco-less"); 175 | const { loaderByName } = require("@craco/craco"); 176 | 177 | module.exports = { 178 | plugins: [ 179 | { 180 | plugin: CracoLessPlugin, 181 | options: { 182 | modifyLessRule(lessRule, context) { 183 | // You have to exclude these file suffixes first, 184 | // if you want to modify the less module's suffix 185 | lessRule.exclude = /\.m\.less$/; 186 | return lessRule; 187 | }, 188 | modifyLessModuleRule(lessModuleRule, context) { 189 | // Configure the file suffix 190 | lessModuleRule.test = /\.m\.less$/; 191 | 192 | // Configure the generated local ident name. 193 | const cssLoader = lessModuleRule.use.find(loaderByName("css-loader")); 194 | cssLoader.options.modules = { 195 | localIdentName: "[local]_[hash:base64:5]", 196 | }; 197 | 198 | return lessModuleRule; 199 | }, 200 | }, 201 | }, 202 | ], 203 | }; 204 | ``` 205 | 206 | #### CSS modules gotcha 207 | 208 | There is a known problem with Less and [CSS modules](https://github.com/css-modules/css-modules) regarding relative file paths in `url(...)` statements. [See this issue for an explanation.](https://github.com/webpack-contrib/less-loader/issues/109#issuecomment-253797335) 209 | 210 | > ([Copied from the less-loader README](https://github.com/webpack-contrib/less-loader#css-modules-gotcha).) 211 | 212 | ## Further Configuration 213 | 214 | If you need to configure anything else for the webpack build, take a look at the 215 | [Configuration Overview section in the `craco` README](https://github.com/dilanx/craco/blob/master/packages/craco/README.md#configuration-overview). You can use `CracoLessPlugin` while making other changes to `babel` and `webpack`, etc. 216 | 217 | ## Contributing 218 | 219 | Install dependencies: 220 | 221 | ```bash 222 | $ yarn install 223 | 224 | # OR 225 | 226 | $ npm install 227 | ``` 228 | 229 | Run tests: 230 | 231 | ``` 232 | $ yarn test 233 | ``` 234 | 235 | Before submitting a pull request, please check the following: 236 | 237 | - All tests are passing 238 | - Run `yarn test` 239 | - 100% test coverage 240 | - Coverage will be printed after running tests. 241 | - Check the coverage results in your browser: `open coverage/lcov-report/index.html` 242 | - No ESLint errors 243 | - `yarn lint` 244 | - All code is formatted with [Prettier](https://prettier.io/) 245 | - `yarn format` 246 | - If you use VS Code, you should enable the `formatOnSave` option. 247 | - Using the correct webpack version as a dependency 248 | - `yarn update_deps` 249 | - NOTE: The `webpack` dependency is needed to silence some annoying warnings from NPM. 250 | This must always match the version from `react-scripts`. 251 | 252 | ## Releasing a new version 253 | 254 | - Make sure the "Supported Versions" section is updated at the top of the README. 255 | - Check which files will be included in the NPM package: 256 | - `npm pack` 257 | - Update `.npmignore` to exclude any files. 258 | - Release new version to NPM: 259 | - `npm publish` 260 | 261 | ## License 262 | 263 | [MIT](./LICENSE) 264 | 265 | ## Contributors 266 | 267 | - [ndbroadbent](https://github.com/ndbroadbent) 268 | - [tux-tn](https://github.com/tux-tn) 269 | - [alexandrtovmach](https://github.com/alexandrtovmach) 270 | - [cemremengu](https://github.com/cemremengu) 271 | - [AO17](https://github.com/AO17) 272 | - [Vovan-VE](https://github.com/Vovan-VE) 273 | - [yifanwangsh](https://github.com/yifanwangsh) 274 | - [swillis12](https://github.com/swillis12) 275 | - [nutgaard](https://github.com/nutgaard) 276 | - [alexander-svendsen](https://github.com/alexander-svendsen) 277 | - [sgtsquiggs](https://github.com/sgtsquiggs) 278 | - [fanck0605](https://github.com/fanck0605) 279 | - [xyy94813](https://github.com/xyy94813) 280 | - [kamronbatman](https://github.com/kamronbatman) 281 | - [fourpastmidnight](https://github.com/fourpastmidnight) 282 | -------------------------------------------------------------------------------- /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 the first failure 9 | // bail: false, 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: "/var/folders/9t/hpqz7_5s27dc8_j_2hd7p_7h0000gn/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: true, 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 | // Make calling deprecated APIs throw helpful error messages 46 | // errorOnDeprecated: false, 47 | 48 | // Force coverage collection from ignored files usin a array of glob patterns 49 | // forceCoverageMatch: [], 50 | 51 | // A path to a module which exports an async function that is triggered once before all test suites 52 | // globalSetup: null, 53 | 54 | // A path to a module which exports an async function that is triggered once after all test suites 55 | // globalTeardown: null, 56 | 57 | // A set of global variables that need to be available in all test environments 58 | // globals: {}, 59 | 60 | // An array of directory names to be searched recursively up from the requiring module's location 61 | // moduleDirectories: [ 62 | // "node_modules" 63 | // ], 64 | 65 | // An array of file extensions your modules use 66 | // moduleFileExtensions: [ 67 | // "js", 68 | // "json", 69 | // "jsx", 70 | // "node" 71 | // ], 72 | 73 | // A map from regular expressions to module names that allow to stub out resources with a single module 74 | // moduleNameMapper: {}, 75 | 76 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 77 | // modulePathIgnorePatterns: [], 78 | 79 | // Activates notifications for test results 80 | // notify: false, 81 | 82 | // An enum that specifies notification mode. Requires { notify: true } 83 | // notifyMode: "always", 84 | 85 | // A preset that is used as a base for Jest's configuration 86 | // preset: null, 87 | 88 | // Run tests from one or more projects 89 | // projects: null, 90 | 91 | // Use this configuration option to add custom reporters to Jest 92 | // reporters: undefined, 93 | 94 | // Automatically reset mock state between every test 95 | // resetMocks: false, 96 | 97 | // Reset the module registry before running each individual test 98 | // resetModules: false, 99 | 100 | // A path to a custom resolver 101 | // resolver: null, 102 | 103 | // Automatically restore mock state between every test 104 | // restoreMocks: false, 105 | 106 | // The root directory that Jest should scan for tests and modules within 107 | // rootDir: null, 108 | 109 | // A list of paths to directories that Jest should use to search for files in 110 | // roots: [ 111 | // "" 112 | // ], 113 | 114 | // Allows you to use a custom runner instead of Jest's default test runner 115 | // runner: "jest-runner", 116 | 117 | // The paths to modules that run some code to configure or set up the testing environment before each test 118 | // setupFiles: [], 119 | 120 | // The path to a module that runs some code to configure or set up the testing framework before each test 121 | // setupTestFrameworkScriptFile: null, 122 | 123 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 124 | // snapshotSerializers: [], 125 | 126 | // The test environment that will be used for testing 127 | testEnvironment: "node", 128 | 129 | // Options that will be passed to the testEnvironment 130 | // testEnvironmentOptions: {}, 131 | 132 | // Adds a location field to test results 133 | // testLocationInResults: false, 134 | 135 | // The glob patterns Jest uses to detect test files 136 | // testMatch: [ 137 | // "**/__tests__/**/*.js?(x)", 138 | // "**/?(*.)+(spec|test).js?(x)" 139 | // ], 140 | 141 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 142 | // testPathIgnorePatterns: [ 143 | // "/node_modules/" 144 | // ], 145 | 146 | // The regexp pattern Jest uses to detect test files 147 | // testRegex: "", 148 | 149 | // This option allows the use of a custom results processor 150 | // testResultsProcessor: null, 151 | 152 | // This option allows use of a custom test runner 153 | // testRunner: "jasmine2", 154 | 155 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 156 | // testURL: "http://localhost", 157 | 158 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 159 | // timers: "real", 160 | 161 | // A map from regular expressions to paths to transformers 162 | // transform: null, 163 | 164 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 165 | // transformIgnorePatterns: [ 166 | // "/node_modules/" 167 | // ], 168 | 169 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 170 | // unmockedModulePathPatterns: undefined, 171 | 172 | // Indicates whether each individual test should be reported during the run 173 | // verbose: null, 174 | 175 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 176 | // watchPathIgnorePatterns: [], 177 | 178 | // Whether to use watchman for file crawling 179 | // watchman: true, 180 | }; 181 | -------------------------------------------------------------------------------- /lib/craco-less.dev.test.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CracoLessPlugin = require("./craco-less"); 3 | const { 4 | applyCracoConfigPlugins, 5 | applyWebpackConfigPlugins, 6 | } = require("@craco/craco/dist/lib/features/plugins"); 7 | const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent"); 8 | 9 | const clone = require("clone"); 10 | 11 | const { craPaths, loadWebpackDevConfig } = require("@craco/craco/dist/lib/cra"); 12 | const { styleRuleByName } = require("./utils"); 13 | 14 | const context = { env: "development", paths: craPaths }; 15 | 16 | let webpackConfig; 17 | let originalWebpackConfig; 18 | beforeEach(() => { 19 | if (!originalWebpackConfig) { 20 | process.env.NODE_ENV = "development"; 21 | originalWebpackConfig = loadWebpackDevConfig({ 22 | reactScriptsVersion: "react-scripts", 23 | }); 24 | process.env.NODE_ENV = "test"; 25 | } 26 | 27 | // loadWebpackDevConfig() caches the config internally, so we need to 28 | // deep clone the object before each test. 29 | webpackConfig = clone(originalWebpackConfig); 30 | }); 31 | 32 | const applyCracoConfigAndOverrideWebpack = (cracoConfig) => { 33 | cracoConfig = applyCracoConfigPlugins(cracoConfig, context); 34 | webpackConfig = applyWebpackConfigPlugins( 35 | cracoConfig, 36 | webpackConfig, 37 | context, 38 | ); 39 | }; 40 | 41 | test("the webpack config is modified correctly without any options", () => { 42 | applyCracoConfigAndOverrideWebpack({ 43 | plugins: [{ plugin: CracoLessPlugin }], 44 | }); 45 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 46 | expect(oneOfRule).not.toBeUndefined(); 47 | const lessRule = oneOfRule.oneOf.find( 48 | (r) => r.test && r.test.toString() === "/\\.less$/", 49 | ); 50 | expect(lessRule).not.toBeUndefined(); 51 | expect(lessRule.use[0].loader).toContain(`${path.sep}style-loader`); 52 | expect(lessRule.use[0].options).toEqual({}); 53 | 54 | expect(lessRule.use[1].loader).toContain(`${path.sep}css-loader`); 55 | expect(lessRule.use[1].options).toEqual({ 56 | importLoaders: 3, 57 | modules: { 58 | mode: "icss", 59 | }, 60 | sourceMap: webpackConfig.devtool !== false, 61 | }); 62 | 63 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 64 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 65 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 66 | 67 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 68 | expect(lessRule.use[3].options).toEqual({ 69 | root: lessRule.use[3].options.root, 70 | sourceMap: webpackConfig.devtool !== false, 71 | }); 72 | 73 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 74 | expect(lessRule.use[4].options).toEqual({ sourceMap: true }); 75 | 76 | const lessModuleRule = oneOfRule.oneOf.find( 77 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 78 | ); 79 | expect(lessModuleRule).not.toBeUndefined(); 80 | expect(lessModuleRule.use[0].loader).toContain(`${path.sep}style-loader`); 81 | expect(lessModuleRule.use[0].options).toEqual({}); 82 | 83 | expect(lessModuleRule.use[1].loader).toContain(`${path.sep}css-loader`); 84 | expect(lessModuleRule.use[1].options).toEqual({ 85 | importLoaders: 3, 86 | modules: { 87 | getLocalIdent: getCSSModuleLocalIdent, 88 | mode: "local", 89 | }, 90 | sourceMap: webpackConfig.devtool !== false, 91 | }); 92 | 93 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 94 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 95 | expect( 96 | lessModuleRule.use[2].options.postcssOptions.plugins, 97 | ).not.toBeUndefined(); 98 | 99 | expect(lessModuleRule.use[3].loader).toContain( 100 | `${path.sep}resolve-url-loader`, 101 | ); 102 | expect(lessModuleRule.use[3].options).toEqual({ 103 | root: lessModuleRule.use[3].options.root, 104 | sourceMap: webpackConfig.devtool !== false, 105 | }); 106 | 107 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 108 | expect(lessModuleRule.use[4].options).toEqual({ sourceMap: true }); 109 | }); 110 | 111 | test("the webpack config is modified correctly without any options on Windows", () => { 112 | // Windows uses "\" path separators. 113 | // Note: This is a noop when running tests on Windows. 114 | const replaceSlashesInLoader = (rule) => { 115 | if (typeof rule === "string") { 116 | return rule.replace(/\//g, "\\"); 117 | } else if (rule.loader) { 118 | if (typeof rule.loader === "string") { 119 | // Ignore file-loader, because we use loaderByName from craco. 120 | if (rule.loader.includes(`${path.sep}file-loader${path.sep}`)) 121 | return rule; 122 | rule.loader = rule.loader.replace(/\//g, "\\"); 123 | } else { 124 | rule.loader = rule.loader.map(replaceSlashesInLoader); 125 | } 126 | } else if (rule.use) { 127 | rule.use = rule.use.map(replaceSlashesInLoader); 128 | } 129 | return rule; 130 | }; 131 | webpackConfig.module.rules[1].oneOf = webpackConfig.module.rules[1].oneOf.map( 132 | replaceSlashesInLoader, 133 | ); 134 | 135 | applyCracoConfigAndOverrideWebpack({ 136 | plugins: [{ plugin: CracoLessPlugin }], 137 | }); 138 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 139 | expect(oneOfRule).not.toBeUndefined(); 140 | const lessRule = oneOfRule.oneOf.find( 141 | (r) => r.test && r.test.toString() === "/\\.less$/", 142 | ); 143 | expect(lessRule).not.toBeUndefined(); 144 | expect(lessRule.use[0].loader).toContain("\\style-loader"); 145 | expect(lessRule.use[0].options).toEqual({}); 146 | 147 | expect(lessRule.use[1].loader).toContain("\\css-loader"); 148 | expect(lessRule.use[1].options).toEqual({ 149 | importLoaders: 3, 150 | modules: { 151 | mode: "icss", 152 | }, 153 | sourceMap: webpackConfig.devtool !== false, 154 | }); 155 | 156 | expect(lessRule.use[2].loader).toContain("\\postcss-loader"); 157 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 158 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 159 | 160 | expect(lessRule.use[3].loader).toContain(`\\resolve-url-loader`); 161 | expect(lessRule.use[3].options).toEqual({ 162 | root: lessRule.use[3].options.root, 163 | sourceMap: webpackConfig.devtool !== false, 164 | }); 165 | 166 | // We use `require.resolve("less-loader")`, so it's the OS separator here 167 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 168 | expect(lessRule.use[4].options).toEqual({ sourceMap: true }); 169 | 170 | const lessModuleRule = oneOfRule.oneOf.find( 171 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 172 | ); 173 | expect(lessModuleRule).not.toBeUndefined(); 174 | expect(lessModuleRule.use[0].loader).toContain("\\style-loader"); 175 | expect(lessModuleRule.use[0].options).toEqual({}); 176 | 177 | expect(lessModuleRule.use[1].loader).toContain("\\css-loader"); 178 | expect(lessModuleRule.use[1].options).toEqual({ 179 | importLoaders: 3, 180 | sourceMap: webpackConfig.devtool !== false, 181 | modules: { 182 | getLocalIdent: getCSSModuleLocalIdent, 183 | mode: "local", 184 | }, 185 | }); 186 | 187 | expect(lessModuleRule.use[2].loader).toContain("\\postcss-loader"); 188 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 189 | expect( 190 | lessModuleRule.use[2].options.postcssOptions.plugins, 191 | ).not.toBeUndefined(); 192 | 193 | expect(lessModuleRule.use[3].loader).toContain(`\\resolve-url-loader`); 194 | expect(lessModuleRule.use[3].options).toEqual({ 195 | root: lessModuleRule.use[3].options.root, 196 | sourceMap: webpackConfig.devtool !== false, 197 | }); 198 | 199 | // We use `require.resolve("less-loader")`, so it's the OS separator here 200 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 201 | expect(lessModuleRule.use[4].options).toEqual({ sourceMap: true }); 202 | }); 203 | 204 | test("the webpack config is modified correctly with less-loader options", () => { 205 | applyCracoConfigAndOverrideWebpack({ 206 | plugins: [ 207 | { 208 | plugin: CracoLessPlugin, 209 | options: { 210 | lessLoaderOptions: { 211 | modifyVars: { 212 | "@less-variable": "#fff", 213 | }, 214 | javascriptEnabled: true, 215 | }, 216 | }, 217 | }, 218 | ], 219 | }); 220 | 221 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 222 | expect(oneOfRule).not.toBeUndefined(); 223 | const lessRule = oneOfRule.oneOf.find( 224 | (r) => r.test && r.test.toString() === "/\\.less$/", 225 | ); 226 | expect(lessRule).not.toBeUndefined(); 227 | 228 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 229 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 230 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 231 | 232 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 233 | expect(lessRule.use[3].options).toEqual({ 234 | root: lessRule.use[3].options.root, 235 | sourceMap: webpackConfig.devtool !== false, 236 | }); 237 | 238 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 239 | expect(lessRule.use[4].options).toEqual({ 240 | sourceMap: true, 241 | javascriptEnabled: true, 242 | modifyVars: { 243 | "@less-variable": "#fff", 244 | }, 245 | }); 246 | 247 | const lessModuleRule = oneOfRule.oneOf.find( 248 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 249 | ); 250 | expect(lessModuleRule).not.toBeUndefined(); 251 | 252 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 253 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 254 | expect( 255 | lessModuleRule.use[2].options.postcssOptions.plugins, 256 | ).not.toBeUndefined(); 257 | 258 | expect(lessModuleRule.use[3].loader).toContain( 259 | `${path.sep}resolve-url-loader`, 260 | ); 261 | expect(lessModuleRule.use[3].options).toEqual({ 262 | root: lessModuleRule.use[3].options.root, 263 | sourceMap: webpackConfig.devtool !== false, 264 | }); 265 | 266 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 267 | expect(lessModuleRule.use[4].options).toEqual({ 268 | sourceMap: true, 269 | javascriptEnabled: true, 270 | modifyVars: { 271 | "@less-variable": "#fff", 272 | }, 273 | }); 274 | }); 275 | 276 | test("the webpack config is modified correctly with all loader options", () => { 277 | applyCracoConfigAndOverrideWebpack({ 278 | plugins: [ 279 | { 280 | plugin: CracoLessPlugin, 281 | options: { 282 | lessLoaderOptions: { 283 | modifyVars: { 284 | "@less-variable": "#fff", 285 | }, 286 | javascriptEnabled: true, 287 | }, 288 | cssLoaderOptions: { 289 | modules: true, 290 | localIdentName: "[local]_[hash:base64:5]", 291 | }, 292 | postcssLoaderOptions: { 293 | ident: "test-ident", 294 | }, 295 | styleLoaderOptions: { 296 | sourceMaps: true, 297 | }, 298 | miniCssExtractPluginOptions: { 299 | testOption: "test-value", 300 | }, 301 | }, 302 | }, 303 | ], 304 | }); 305 | 306 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 307 | expect(oneOfRule).not.toBeUndefined(); 308 | const lessRule = oneOfRule.oneOf.find( 309 | (r) => r.test && r.test.toString() === "/\\.less$/", 310 | ); 311 | expect(lessRule).not.toBeUndefined(); 312 | expect(lessRule.use[0].loader).toContain(`${path.sep}style-loader`); 313 | expect(lessRule.use[0].options).toEqual({ 314 | sourceMaps: true, 315 | }); 316 | 317 | expect(lessRule.use[1].loader).toContain(`${path.sep}css-loader`); 318 | expect(lessRule.use[1].options).toEqual({ 319 | modules: true, 320 | importLoaders: 3, 321 | localIdentName: "[local]_[hash:base64:5]", 322 | sourceMap: webpackConfig.devtool !== false, 323 | }); 324 | 325 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 326 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("test-ident"); 327 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 328 | 329 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 330 | expect(lessRule.use[3].options).toEqual({ 331 | root: lessRule.use[3].options.root, 332 | sourceMap: webpackConfig.devtool !== false, 333 | }); 334 | 335 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 336 | expect(lessRule.use[4].options).toEqual({ 337 | sourceMap: true, 338 | javascriptEnabled: true, 339 | modifyVars: { 340 | "@less-variable": "#fff", 341 | }, 342 | }); 343 | 344 | const lessModuleRule = oneOfRule.oneOf.find( 345 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 346 | ); 347 | expect(lessModuleRule).not.toBeUndefined(); 348 | expect(lessModuleRule.use[0].loader).toContain(`${path.sep}style-loader`); 349 | expect(lessModuleRule.use[0].options).toEqual({ 350 | sourceMaps: true, 351 | }); 352 | 353 | expect(lessModuleRule.use[1].loader).toContain(`${path.sep}css-loader`); 354 | expect(lessModuleRule.use[1].options).toEqual({ 355 | modules: true, 356 | importLoaders: 3, 357 | localIdentName: "[local]_[hash:base64:5]", 358 | sourceMap: webpackConfig.devtool !== false, 359 | }); 360 | 361 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 362 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual( 363 | "test-ident", 364 | ); 365 | expect( 366 | lessModuleRule.use[2].options.postcssOptions.plugins, 367 | ).not.toBeUndefined(); 368 | 369 | expect(lessModuleRule.use[3].loader).toContain( 370 | `${path.sep}resolve-url-loader`, 371 | ); 372 | expect(lessModuleRule.use[3].options).toEqual({ 373 | root: lessModuleRule.use[3].options.root, 374 | sourceMap: webpackConfig.devtool !== false, 375 | }); 376 | 377 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 378 | expect(lessModuleRule.use[4].options).toEqual({ 379 | sourceMap: true, 380 | javascriptEnabled: true, 381 | modifyVars: { 382 | "@less-variable": "#fff", 383 | }, 384 | }); 385 | }); 386 | 387 | test("the webpack config is modified correctly with the modifyLessRule option", () => { 388 | applyCracoConfigAndOverrideWebpack({ 389 | plugins: [ 390 | { 391 | plugin: CracoLessPlugin, 392 | options: { 393 | modifyLessRule: (rule, context) => { 394 | if (context.env === "production") { 395 | rule.use[0].options.testOption = "test-value-production"; 396 | } else { 397 | rule.use[0].options.testOption = "test-value-development"; 398 | } 399 | return rule; 400 | }, 401 | }, 402 | }, 403 | ], 404 | }); 405 | 406 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 407 | expect(oneOfRule).not.toBeUndefined(); 408 | const lessRule = oneOfRule.oneOf.find( 409 | (r) => r.test && r.test.toString() === "/\\.less$/", 410 | ); 411 | expect(lessRule).not.toBeUndefined(); 412 | 413 | expect(lessRule.use[0].loader).toContain(`${path.sep}style-loader`); 414 | expect(lessRule.use[0].options.testOption).toEqual("test-value-development"); 415 | 416 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 417 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 418 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 419 | 420 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 421 | expect(lessRule.use[3].options).toEqual({ 422 | root: lessRule.use[3].options.root, 423 | sourceMap: webpackConfig.devtool !== false, 424 | }); 425 | 426 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 427 | expect(lessRule.use[4].options).toEqual({ sourceMap: true }); 428 | }); 429 | 430 | test("the webpack config is modified correctly with the modifyLessModuleRule option", () => { 431 | applyCracoConfigAndOverrideWebpack({ 432 | plugins: [ 433 | { 434 | plugin: CracoLessPlugin, 435 | options: { 436 | modifyLessModuleRule: (rule, context) => { 437 | if (context.env === "production") { 438 | rule.use[0].options.testOption = "test-value-production"; 439 | rule.use[1].options.modules.getLocalIdent = 440 | "test-deep-clone-production"; 441 | } else { 442 | rule.use[0].options.testOption = "test-value-development"; 443 | rule.use[1].options.modules.getLocalIdent = 444 | "test-deep-clone-development"; 445 | } 446 | return rule; 447 | }, 448 | }, 449 | }, 450 | ], 451 | }); 452 | 453 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 454 | expect(oneOfRule).not.toBeUndefined(); 455 | const lessModuleRule = oneOfRule.oneOf.find( 456 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 457 | ); 458 | expect(lessModuleRule).not.toBeUndefined(); 459 | 460 | expect(lessModuleRule.use[0].loader).toContain(`${path.sep}style-loader`); 461 | expect(lessModuleRule.use[0].options.testOption).toEqual( 462 | "test-value-development", 463 | ); 464 | 465 | expect(lessModuleRule.use[1].options.modules.getLocalIdent).toEqual( 466 | "test-deep-clone-development", 467 | ); 468 | 469 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 470 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 471 | expect( 472 | lessModuleRule.use[2].options.postcssOptions.plugins, 473 | ).not.toBeUndefined(); 474 | 475 | expect(lessModuleRule.use[3].loader).toContain( 476 | `${path.sep}resolve-url-loader`, 477 | ); 478 | expect(lessModuleRule.use[3].options).toEqual({ 479 | root: lessModuleRule.use[3].options.root, 480 | sourceMap: webpackConfig.devtool !== false, 481 | }); 482 | 483 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 484 | expect(lessModuleRule.use[4].options).toEqual({ sourceMap: true }); 485 | 486 | const sassModuleRule = oneOfRule.oneOf.find( 487 | styleRuleByName("scss|sass", true), 488 | ); 489 | 490 | expect(sassModuleRule.use[1].options.modules.getLocalIdent).toEqual( 491 | getCSSModuleLocalIdent, 492 | ); 493 | }); 494 | 495 | test("throws an error when we can't find the oneOf rules in the webpack config", () => { 496 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 497 | oneOfRule.oneOf = null; 498 | 499 | const runTest = () => { 500 | applyCracoConfigAndOverrideWebpack({ 501 | plugins: [{ plugin: CracoLessPlugin }], 502 | }); 503 | }; 504 | 505 | expect(runTest).toThrowError( 506 | "Can't find a 'oneOf' rule under module.rules in the development webpack config!\n\n" + 507 | "This error probably occurred because you updated react-scripts or craco. " + 508 | "Please try updating craco-less to the latest version:\n\n" + 509 | " $ yarn upgrade craco-less\n\n" + 510 | "Or:\n\n" + 511 | " $ npm update craco-less\n\n" + 512 | "If that doesn't work, craco-less needs to be fixed to support the latest version.\n" + 513 | "Please check to see if there's already an issue in the DocSpring/craco-less repo:\n\n" + 514 | " * https://github.com/DocSpring/craco-less/issues?q=is%3Aissue+webpack+rules+oneOf\n\n" + 515 | "If not, please open an issue and we'll take a look. (Or you can send a PR!)\n\n" + 516 | "You might also want to look for related issues in the " + 517 | "craco and create-react-app repos:\n\n" + 518 | " * https://github.com/dilanx/craco/issues?q=is%3Aissue+webpack+rules+oneOf\n" + 519 | " * https://github.com/facebook/create-react-app/issues?q=is%3Aissue+webpack+rules+oneOf\n", 520 | ); 521 | }); 522 | 523 | test("throws an error when react-scripts adds an unknown webpack rule", () => { 524 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 525 | const sassRule = oneOfRule.oneOf.find(styleRuleByName("scss|sass", false)); 526 | sassRule.use.push({ 527 | loader: "/path/to/unknown-loader/index.js", 528 | }); 529 | const runTest = () => { 530 | applyCracoConfigAndOverrideWebpack({ 531 | plugins: [{ plugin: CracoLessPlugin }], 532 | }); 533 | }; 534 | expect(runTest).toThrowError( 535 | new RegExp( 536 | "Found an unhandled loader in the development webpack config: " + 537 | "/path/to/unknown-loader/index.js", 538 | ), 539 | ); 540 | }); 541 | 542 | test("throws an error when the sass rule is missing", () => { 543 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 544 | let matchSassRule = styleRuleByName("scss|sass", false); 545 | oneOfRule.oneOf = oneOfRule.oneOf.filter((rule) => !matchSassRule(rule)); 546 | 547 | const runTest = () => { 548 | applyCracoConfigAndOverrideWebpack({ 549 | plugins: [{ plugin: CracoLessPlugin }], 550 | }); 551 | }; 552 | expect(runTest).toThrowError( 553 | new RegExp( 554 | "Can't find the webpack rule to match scss/sass files in the " + 555 | "development webpack config!", 556 | ), 557 | ); 558 | }); 559 | 560 | test("throws an error when the sass module rule is missing", () => { 561 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 562 | let matchSassModuleRule = styleRuleByName("scss|sass", true); 563 | oneOfRule.oneOf = oneOfRule.oneOf.filter( 564 | (rule) => !matchSassModuleRule(rule), 565 | ); 566 | 567 | const runTest = () => { 568 | applyCracoConfigAndOverrideWebpack({ 569 | plugins: [{ plugin: CracoLessPlugin }], 570 | }); 571 | }; 572 | expect(runTest).toThrowError( 573 | new RegExp( 574 | "Can't find the webpack rule to match scss/sass module files in the " + 575 | "development webpack config!", 576 | ), 577 | ); 578 | }); 579 | -------------------------------------------------------------------------------- /lib/craco-less.js: -------------------------------------------------------------------------------- 1 | const { deepClone, styleRuleByName } = require("./utils"); 2 | const { throwUnexpectedConfigError } = require("@craco/craco"); 3 | 4 | const lessRegex = /\.less$/; 5 | const lessModuleRegex = /\.module\.less$/; 6 | 7 | const loaderRegexMap = { 8 | "style-loader": /[\\/]style-loader[\\/]/, 9 | "css-loader": /[\\/]css-loader[\\/]/, 10 | "postcss-loader": /[\\/]postcss-loader[\\/]/, 11 | "resolve-url-loader": /[\\/]resolve-url-loader[\\/]/, 12 | "mini-css-extract-plugin": /[\\/]mini-css-extract-plugin[\\/]/, 13 | "sass-loader": /[\\/]sass-loader[\\/]/, 14 | }; 15 | 16 | const hasLoader = (loaderName, ruleLoader) => 17 | loaderRegexMap[loaderName].test(ruleLoader); 18 | 19 | const throwError = (message, githubIssueQuery) => 20 | throwUnexpectedConfigError({ 21 | packageName: "craco-less", 22 | githubRepo: "DocSpring/craco-less", 23 | message, 24 | githubIssueQuery, 25 | }); 26 | 27 | const overrideWebpackConfig = ({ context, webpackConfig, pluginOptions }) => { 28 | pluginOptions = pluginOptions || {}; 29 | 30 | const createLessRule = ({ baseRule, overrideRule }) => { 31 | baseRule = deepClone(baseRule); 32 | const lessRule = { 33 | ...baseRule, 34 | ...overrideRule, 35 | use: [], 36 | }; 37 | 38 | const loaders = baseRule.use; 39 | loaders.forEach((ruleOrLoader) => { 40 | let rule; 41 | if (typeof ruleOrLoader === "string") { 42 | rule = { 43 | loader: ruleOrLoader, 44 | options: {}, 45 | }; 46 | } else { 47 | rule = ruleOrLoader; 48 | } 49 | 50 | if ( 51 | (context.env === "development" || context.env === "test") && 52 | hasLoader("style-loader", rule.loader) 53 | ) { 54 | lessRule.use.push({ 55 | loader: rule.loader, 56 | options: { 57 | ...rule.options, 58 | ...(pluginOptions.styleLoaderOptions || {}), 59 | }, 60 | }); 61 | } else if (hasLoader("css-loader", rule.loader)) { 62 | lessRule.use.push({ 63 | loader: rule.loader, 64 | options: { 65 | ...rule.options, 66 | ...(pluginOptions.cssLoaderOptions || {}), 67 | }, 68 | }); 69 | } else if (hasLoader("postcss-loader", rule.loader)) { 70 | lessRule.use.push({ 71 | loader: rule.loader, 72 | options: { 73 | ...rule.options, 74 | postcssOptions: { 75 | ...rule.options.postcssOptions, 76 | ...(pluginOptions.postcssLoaderOptions || {}), 77 | }, 78 | }, 79 | }); 80 | } else if (hasLoader("resolve-url-loader", rule.loader)) { 81 | lessRule.use.push({ 82 | loader: rule.loader, 83 | options: { 84 | ...rule.options, 85 | ...(pluginOptions.resolveUrlLoaderOptions || {}), 86 | }, 87 | }); 88 | } else if ( 89 | context.env === "production" && 90 | hasLoader("mini-css-extract-plugin", rule.loader) 91 | ) { 92 | lessRule.use.push({ 93 | loader: rule.loader, 94 | options: { 95 | ...rule.options, 96 | ...(pluginOptions.miniCssExtractPluginOptions || {}), 97 | }, 98 | }); 99 | } else if (hasLoader("sass-loader", rule.loader)) { 100 | lessRule.use.push({ 101 | loader: require.resolve("less-loader"), 102 | options: { 103 | ...rule.options, 104 | ...pluginOptions.lessLoaderOptions, 105 | }, 106 | }); 107 | } else { 108 | throwError( 109 | `Found an unhandled loader in the ${context.env} webpack config: ${rule.loader}`, 110 | "webpack+unknown+rule", 111 | ); 112 | } 113 | }); 114 | 115 | return lessRule; 116 | }; 117 | 118 | const oneOfRule = webpackConfig.module.rules.find((rule) => rule.oneOf); 119 | if (!oneOfRule) { 120 | throwError( 121 | `Can't find a 'oneOf' rule under module.rules in the ${context.env} webpack config!`, 122 | "webpack+rules+oneOf", 123 | ); 124 | } 125 | 126 | const sassRule = oneOfRule.oneOf.find(styleRuleByName("scss|sass", false)); 127 | if (!sassRule) { 128 | throwError( 129 | `Can't find the webpack rule to match scss/sass files in the ${context.env} webpack config!`, 130 | "webpack+rules+scss+sass", 131 | ); 132 | } 133 | 134 | let lessRule = createLessRule({ 135 | context, 136 | baseRule: sassRule, 137 | overrideRule: { 138 | test: lessRegex, 139 | exclude: lessModuleRegex, 140 | }, 141 | pluginOptions, 142 | }); 143 | 144 | if (pluginOptions.modifyLessRule) { 145 | lessRule = pluginOptions.modifyLessRule(lessRule, context); 146 | } 147 | 148 | const sassModuleRule = oneOfRule.oneOf.find( 149 | styleRuleByName("scss|sass", true), 150 | ); 151 | if (!sassModuleRule) { 152 | throwError( 153 | `Can't find the webpack rule to match scss/sass module files in the ${context.env} webpack config!`, 154 | "webpack+rules+scss+sass", 155 | ); 156 | } 157 | let lessModuleRule = createLessRule({ 158 | baseRule: sassModuleRule, 159 | overrideRule: { 160 | test: lessModuleRegex, 161 | }, 162 | }); 163 | 164 | if (pluginOptions.modifyLessModuleRule) { 165 | lessModuleRule = pluginOptions.modifyLessModuleRule( 166 | lessModuleRule, 167 | context, 168 | ); 169 | } 170 | 171 | // https://github.com/facebook/create-react-app/blob/9673858a3715287c40aef9e800c431c7d45c05a2/packages/react-scripts/config/webpack.config.js#L590-L596 172 | // insert less loader before resource loader 173 | // https://webpack.js.org/guides/asset-modules/ 174 | const resourceLoaderIndex = oneOfRule.oneOf.findIndex( 175 | ({ type }) => type === "asset/resource", 176 | ); 177 | oneOfRule.oneOf.splice(resourceLoaderIndex, 0, lessRule, lessModuleRule); 178 | 179 | return webpackConfig; 180 | }; 181 | 182 | const overrideJestConfig = ({ context, jestConfig }) => { 183 | const moduleNameMapper = jestConfig.moduleNameMapper; 184 | const cssModulesPattern = Object.keys(moduleNameMapper).find((p) => 185 | p.match(/\\\.module\\\.\(.*?css.*?\)/), 186 | ); 187 | 188 | if (!cssModulesPattern) { 189 | throwError( 190 | `Can't find CSS Modules pattern under moduleNameMapper in the ${context.env} jest config!`, 191 | "jest+moduleNameMapper+css", 192 | ); 193 | } 194 | 195 | moduleNameMapper[cssModulesPattern.replace("css", "css|less")] = 196 | moduleNameMapper[cssModulesPattern]; 197 | delete moduleNameMapper[cssModulesPattern]; 198 | 199 | const transformIgnorePatterns = jestConfig.transformIgnorePatterns; 200 | const cssModulesPatternIndex = transformIgnorePatterns.findIndex((p) => 201 | p.match(/\\\.module\\\.\(.*?css.*?\)/), 202 | ); 203 | if (cssModulesPatternIndex === -1) { 204 | throwError( 205 | `Can't find CSS Modules pattern under transformIgnorePatterns in the ${context.env} jest config!`, 206 | "jest+transformIgnorePatterns+css", 207 | ); 208 | } 209 | 210 | transformIgnorePatterns[cssModulesPatternIndex] = transformIgnorePatterns[ 211 | cssModulesPatternIndex 212 | ].replace("css", "css|less"); 213 | 214 | return jestConfig; 215 | }; 216 | 217 | module.exports = { 218 | overrideWebpackConfig, 219 | overrideJestConfig, 220 | }; 221 | -------------------------------------------------------------------------------- /lib/craco-less.prod.test.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CracoLessPlugin = require("./craco-less"); 3 | const { 4 | applyCracoConfigPlugins, 5 | applyWebpackConfigPlugins, 6 | } = require("@craco/craco/dist/lib/features/plugins"); 7 | const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent"); 8 | 9 | const clone = require("clone"); 10 | 11 | const { 12 | craPaths, 13 | loadWebpackProdConfig, 14 | } = require("@craco/craco/dist/lib/cra"); 15 | const { styleRuleByName } = require("./utils"); 16 | 17 | const context = { env: "production", paths: craPaths }; 18 | 19 | let webpackConfig; 20 | let originalWebpackConfig; 21 | beforeEach(() => { 22 | if (!originalWebpackConfig) { 23 | process.env.NODE_ENV = "production"; 24 | originalWebpackConfig = loadWebpackProdConfig({ 25 | reactScriptsVersion: "react-scripts", 26 | }); 27 | process.env.NODE_ENV = "test"; 28 | } 29 | 30 | // loadWebpackProdConfig() caches the config internally, so we need to 31 | // deep clone the object before each test. 32 | webpackConfig = clone(originalWebpackConfig); 33 | }); 34 | 35 | const applyCracoConfigAndOverrideWebpack = (cracoConfig) => { 36 | cracoConfig = applyCracoConfigPlugins(cracoConfig, context); 37 | webpackConfig = applyWebpackConfigPlugins( 38 | cracoConfig, 39 | webpackConfig, 40 | context, 41 | ); 42 | }; 43 | 44 | test("the webpack config is modified correctly without any options", () => { 45 | applyCracoConfigAndOverrideWebpack({ 46 | plugins: [{ plugin: CracoLessPlugin }], 47 | }); 48 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 49 | expect(oneOfRule).not.toBeUndefined(); 50 | const lessRule = oneOfRule.oneOf.find( 51 | (r) => r.test && r.test.toString() === "/\\.less$/", 52 | ); 53 | expect(lessRule).not.toBeUndefined(); 54 | expect(lessRule.use[0].loader).toContain( 55 | `${path.sep}mini-css-extract-plugin`, 56 | ); 57 | expect(lessRule.use[0].options).toEqual({}); 58 | 59 | expect(lessRule.use[1].loader).toContain(`${path.sep}css-loader`); 60 | expect(lessRule.use[1].options).toEqual({ 61 | importLoaders: 3, 62 | modules: { 63 | mode: "icss", 64 | }, 65 | sourceMap: true, 66 | }); 67 | 68 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 69 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 70 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 71 | 72 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 73 | expect(lessRule.use[3].options).toEqual({ 74 | root: lessRule.use[3].options.root, 75 | sourceMap: true, 76 | }); 77 | 78 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 79 | expect(lessRule.use[4].options).toEqual({ 80 | sourceMap: true, 81 | }); 82 | 83 | const lessModuleRule = oneOfRule.oneOf.find( 84 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 85 | ); 86 | expect(lessModuleRule).not.toBeUndefined(); 87 | expect(lessModuleRule.use[0].loader).toContain( 88 | `${path.sep}mini-css-extract-plugin`, 89 | ); 90 | expect(lessModuleRule.use[0].options).toEqual({}); 91 | 92 | expect(lessModuleRule.use[1].loader).toContain(`${path.sep}css-loader`); 93 | expect(lessModuleRule.use[1].options).toEqual({ 94 | importLoaders: 3, 95 | sourceMap: true, 96 | modules: { 97 | getLocalIdent: getCSSModuleLocalIdent, 98 | mode: "local", 99 | }, 100 | }); 101 | 102 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 103 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 104 | expect( 105 | lessModuleRule.use[2].options.postcssOptions.plugins, 106 | ).not.toBeUndefined(); 107 | 108 | expect(lessModuleRule.use[3].loader).toContain( 109 | `${path.sep}resolve-url-loader`, 110 | ); 111 | expect(lessModuleRule.use[3].options).toEqual({ 112 | root: lessModuleRule.use[3].options.root, 113 | sourceMap: true, 114 | }); 115 | 116 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 117 | expect(lessModuleRule.use[4].options).toEqual({ 118 | sourceMap: true, 119 | }); 120 | }); 121 | 122 | test("the webpack config is modified correctly without any options on Windows", () => { 123 | // Windows uses "\" path separators. 124 | // Note: This is a noop when running tests on Windows. 125 | const replaceSlashesInLoader = (rule) => { 126 | if (typeof rule === "string") { 127 | return rule.replace(/\//g, "\\"); 128 | } else if (rule.loader) { 129 | if (typeof rule.loader === "string") { 130 | // Ignore file-loader, because we use loaderByName from craco. 131 | if (rule.loader.includes(`${path.sep}file-loader${path.sep}`)) 132 | return rule; 133 | rule.loader = rule.loader.replace(/\//g, "\\"); 134 | } else { 135 | rule.loader = rule.loader.map(replaceSlashesInLoader); 136 | } 137 | } else if (rule.use) { 138 | rule.use = rule.use.map(replaceSlashesInLoader); 139 | } 140 | return rule; 141 | }; 142 | webpackConfig.module.rules[1].oneOf = webpackConfig.module.rules[1].oneOf.map( 143 | replaceSlashesInLoader, 144 | ); 145 | 146 | applyCracoConfigAndOverrideWebpack({ 147 | plugins: [{ plugin: CracoLessPlugin }], 148 | }); 149 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 150 | expect(oneOfRule).not.toBeUndefined(); 151 | const lessRule = oneOfRule.oneOf.find( 152 | (r) => r.test && r.test.toString() === "/\\.less$/", 153 | ); 154 | expect(lessRule).not.toBeUndefined(); 155 | expect(lessRule.use[0].loader).toContain("\\mini-css-extract-plugin"); 156 | expect(lessRule.use[0].options).toEqual({}); 157 | 158 | expect(lessRule.use[1].loader).toContain("\\css-loader"); 159 | expect(lessRule.use[1].options).toEqual({ 160 | importLoaders: 3, 161 | modules: { 162 | mode: "icss", 163 | }, 164 | sourceMap: true, 165 | }); 166 | 167 | expect(lessRule.use[2].loader).toContain("\\postcss-loader"); 168 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 169 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 170 | 171 | expect(lessRule.use[3].loader).toContain(`\\resolve-url-loader`); 172 | expect(lessRule.use[3].options).toEqual({ 173 | root: lessRule.use[3].options.root, 174 | sourceMap: true, 175 | }); 176 | 177 | // We use `require.resolve("less-loader")`, so it's a forward slash here 178 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 179 | expect(lessRule.use[4].options).toEqual({ sourceMap: true }); 180 | 181 | const lessModuleRule = oneOfRule.oneOf.find( 182 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 183 | ); 184 | expect(lessModuleRule).not.toBeUndefined(); 185 | expect(lessModuleRule.use[0].loader).toContain("\\mini-css-extract-plugin"); 186 | expect(lessModuleRule.use[0].options).toEqual({}); 187 | 188 | expect(lessModuleRule.use[1].loader).toContain("\\css-loader"); 189 | expect(lessModuleRule.use[1].options).toEqual({ 190 | importLoaders: 3, 191 | sourceMap: true, 192 | modules: { 193 | getLocalIdent: getCSSModuleLocalIdent, 194 | mode: "local", 195 | }, 196 | }); 197 | 198 | expect(lessModuleRule.use[2].loader).toContain("\\postcss-loader"); 199 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 200 | expect( 201 | lessModuleRule.use[2].options.postcssOptions.plugins, 202 | ).not.toBeUndefined(); 203 | 204 | expect(lessModuleRule.use[3].loader).toContain(`\\resolve-url-loader`); 205 | expect(lessModuleRule.use[3].options).toEqual({ 206 | root: lessModuleRule.use[3].options.root, 207 | sourceMap: true, 208 | }); 209 | 210 | // We use `require.resolve("less-loader")`, so it's a forward slash here 211 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 212 | expect(lessModuleRule.use[4].options).toEqual({ sourceMap: true }); 213 | }); 214 | 215 | test("the webpack config is modified correctly with less-loader options", () => { 216 | applyCracoConfigAndOverrideWebpack({ 217 | plugins: [ 218 | { 219 | plugin: CracoLessPlugin, 220 | options: { 221 | lessLoaderOptions: { 222 | modifyVars: { 223 | "@less-variable": "#fff", 224 | }, 225 | javascriptEnabled: true, 226 | }, 227 | }, 228 | }, 229 | ], 230 | }); 231 | 232 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 233 | expect(oneOfRule).not.toBeUndefined(); 234 | const lessRule = oneOfRule.oneOf.find( 235 | (r) => r.test && r.test.toString() === "/\\.less$/", 236 | ); 237 | expect(lessRule).not.toBeUndefined(); 238 | 239 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 240 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 241 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 242 | 243 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 244 | expect(lessRule.use[3].options).toEqual({ 245 | root: lessRule.use[3].options.root, 246 | sourceMap: true, 247 | }); 248 | 249 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 250 | expect(lessRule.use[4].options).toEqual({ 251 | javascriptEnabled: true, 252 | modifyVars: { 253 | "@less-variable": "#fff", 254 | }, 255 | sourceMap: true, 256 | }); 257 | 258 | const lessModuleRule = oneOfRule.oneOf.find( 259 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 260 | ); 261 | expect(lessModuleRule).not.toBeUndefined(); 262 | 263 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 264 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 265 | expect( 266 | lessModuleRule.use[2].options.postcssOptions.plugins, 267 | ).not.toBeUndefined(); 268 | 269 | expect(lessModuleRule.use[3].loader).toContain( 270 | `${path.sep}resolve-url-loader`, 271 | ); 272 | expect(lessModuleRule.use[3].options).toEqual({ 273 | root: lessModuleRule.use[3].options.root, 274 | sourceMap: true, 275 | }); 276 | 277 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 278 | expect(lessModuleRule.use[4].options).toEqual({ 279 | javascriptEnabled: true, 280 | modifyVars: { 281 | "@less-variable": "#fff", 282 | }, 283 | sourceMap: true, 284 | }); 285 | }); 286 | 287 | test("the webpack config is modified correctly with all loader options", () => { 288 | applyCracoConfigAndOverrideWebpack({ 289 | plugins: [ 290 | { 291 | plugin: CracoLessPlugin, 292 | options: { 293 | lessLoaderOptions: { 294 | modifyVars: { 295 | "@less-variable": "#fff", 296 | }, 297 | javascriptEnabled: true, 298 | }, 299 | cssLoaderOptions: { 300 | modules: true, 301 | localIdentName: "[local]_[hash:base64:5]", 302 | }, 303 | postcssLoaderOptions: { 304 | ident: "test-ident", 305 | }, 306 | styleLoaderOptions: { 307 | sourceMaps: true, 308 | }, 309 | miniCssExtractPluginOptions: { 310 | testOption: "test-value", 311 | }, 312 | }, 313 | }, 314 | ], 315 | }); 316 | 317 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 318 | expect(oneOfRule).not.toBeUndefined(); 319 | const lessRule = oneOfRule.oneOf.find( 320 | (r) => r.test && r.test.toString() === "/\\.less$/", 321 | ); 322 | expect(lessRule).not.toBeUndefined(); 323 | expect(lessRule.use[0].loader).toContain( 324 | `${path.sep}mini-css-extract-plugin`, 325 | ); 326 | expect(lessRule.use[0].options).toEqual({ 327 | testOption: "test-value", 328 | }); 329 | 330 | expect(lessRule.use[1].loader).toContain(`${path.sep}css-loader`); 331 | expect(lessRule.use[1].options).toEqual({ 332 | modules: true, 333 | importLoaders: 3, 334 | localIdentName: "[local]_[hash:base64:5]", 335 | sourceMap: true, 336 | }); 337 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 338 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("test-ident"); 339 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 340 | 341 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 342 | expect(lessRule.use[3].options).toEqual({ 343 | root: lessRule.use[3].options.root, 344 | sourceMap: true, 345 | }); 346 | 347 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 348 | expect(lessRule.use[4].options).toEqual({ 349 | javascriptEnabled: true, 350 | modifyVars: { 351 | "@less-variable": "#fff", 352 | }, 353 | sourceMap: true, 354 | }); 355 | 356 | const lessModuleRule = oneOfRule.oneOf.find( 357 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 358 | ); 359 | expect(lessModuleRule).not.toBeUndefined(); 360 | expect(lessModuleRule.use[0].loader).toContain( 361 | `${path.sep}mini-css-extract-plugin`, 362 | ); 363 | expect(lessModuleRule.use[0].options).toEqual({ 364 | testOption: "test-value", 365 | }); 366 | 367 | expect(lessModuleRule.use[1].loader).toContain(`${path.sep}css-loader`); 368 | expect(lessModuleRule.use[1].options).toEqual({ 369 | modules: true, 370 | importLoaders: 3, 371 | localIdentName: "[local]_[hash:base64:5]", 372 | sourceMap: true, 373 | }); 374 | 375 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 376 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual( 377 | "test-ident", 378 | ); 379 | expect( 380 | lessModuleRule.use[2].options.postcssOptions.plugins, 381 | ).not.toBeUndefined(); 382 | 383 | expect(lessModuleRule.use[3].loader).toContain( 384 | `${path.sep}resolve-url-loader`, 385 | ); 386 | expect(lessModuleRule.use[3].options).toEqual({ 387 | root: lessModuleRule.use[3].options.root, 388 | sourceMap: true, 389 | }); 390 | 391 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 392 | expect(lessModuleRule.use[4].options).toEqual({ 393 | javascriptEnabled: true, 394 | modifyVars: { 395 | "@less-variable": "#fff", 396 | }, 397 | sourceMap: true, 398 | }); 399 | }); 400 | 401 | test("the webpack config is modified correctly with the modifyLessRule option", () => { 402 | applyCracoConfigAndOverrideWebpack({ 403 | plugins: [ 404 | { 405 | plugin: CracoLessPlugin, 406 | options: { 407 | modifyLessRule: (rule, context) => { 408 | if (context.env === "production") { 409 | rule.use[0].options.testOption = "test-value-production"; 410 | } else { 411 | rule.use[0].options.testOption = "test-value-development"; 412 | } 413 | return rule; 414 | }, 415 | }, 416 | }, 417 | ], 418 | }); 419 | 420 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 421 | expect(oneOfRule).not.toBeUndefined(); 422 | const lessRule = oneOfRule.oneOf.find( 423 | (r) => r.test && r.test.toString() === "/\\.less$/", 424 | ); 425 | expect(lessRule).not.toBeUndefined(); 426 | 427 | expect(lessRule.use[0].loader).toContain( 428 | `${path.sep}mini-css-extract-plugin`, 429 | ); 430 | expect(lessRule.use[0].options.testOption).toEqual("test-value-production"); 431 | 432 | expect(lessRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 433 | expect(lessRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 434 | expect(lessRule.use[2].options.postcssOptions.plugins).not.toBeUndefined(); 435 | 436 | expect(lessRule.use[3].loader).toContain(`${path.sep}resolve-url-loader`); 437 | expect(lessRule.use[3].options).toEqual({ 438 | root: lessRule.use[3].options.root, 439 | sourceMap: true, 440 | }); 441 | 442 | expect(lessRule.use[4].loader).toContain(`${path.sep}less-loader`); 443 | expect(lessRule.use[4].options).toEqual({ 444 | sourceMap: true, 445 | }); 446 | }); 447 | 448 | test("the webpack config is modified correctly with the modifyLessModuleRule option", () => { 449 | applyCracoConfigAndOverrideWebpack({ 450 | plugins: [ 451 | { 452 | plugin: CracoLessPlugin, 453 | options: { 454 | modifyLessModuleRule: (rule, context) => { 455 | if (context.env === "production") { 456 | rule.use[0].options.testOption = "test-value-production"; 457 | rule.use[1].options.modules.getLocalIdent = 458 | "test-deep-clone-production"; 459 | } else { 460 | rule.use[0].options.testOption = "test-value-development"; 461 | rule.use[1].options.modules.getLocalIdent = 462 | "test-deep-clone-development"; 463 | } 464 | return rule; 465 | }, 466 | }, 467 | }, 468 | ], 469 | }); 470 | 471 | const oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 472 | expect(oneOfRule).not.toBeUndefined(); 473 | const lessModuleRule = oneOfRule.oneOf.find( 474 | (r) => r.test && r.test.toString() === "/\\.module\\.less$/", 475 | ); 476 | expect(lessModuleRule).not.toBeUndefined(); 477 | 478 | expect(lessModuleRule.use[0].loader).toContain( 479 | `${path.sep}mini-css-extract-plugin`, 480 | ); 481 | expect(lessModuleRule.use[0].options.testOption).toEqual( 482 | "test-value-production", 483 | ); 484 | 485 | expect(lessModuleRule.use[1].options.modules.getLocalIdent).toEqual( 486 | "test-deep-clone-production", 487 | ); 488 | 489 | expect(lessModuleRule.use[2].loader).toContain(`${path.sep}postcss-loader`); 490 | expect(lessModuleRule.use[2].options.postcssOptions.ident).toEqual("postcss"); 491 | expect( 492 | lessModuleRule.use[2].options.postcssOptions.plugins, 493 | ).not.toBeUndefined(); 494 | 495 | expect(lessModuleRule.use[3].loader).toContain( 496 | `${path.sep}resolve-url-loader`, 497 | ); 498 | expect(lessModuleRule.use[3].options).toEqual({ 499 | root: lessModuleRule.use[3].options.root, 500 | sourceMap: true, 501 | }); 502 | 503 | expect(lessModuleRule.use[4].loader).toContain(`${path.sep}less-loader`); 504 | expect(lessModuleRule.use[4].options).toEqual({ 505 | sourceMap: true, 506 | }); 507 | 508 | const sassModuleRule = oneOfRule.oneOf.find( 509 | styleRuleByName("scss|sass", true), 510 | ); 511 | 512 | expect(sassModuleRule.use[1].options.modules.getLocalIdent).toEqual( 513 | getCSSModuleLocalIdent, 514 | ); 515 | }); 516 | 517 | test("throws an error when we can't find the oneOf rules in the webpack config", () => { 518 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 519 | oneOfRule.oneOf = null; 520 | 521 | const runTest = () => { 522 | applyCracoConfigAndOverrideWebpack({ 523 | plugins: [{ plugin: CracoLessPlugin }], 524 | }); 525 | }; 526 | 527 | expect(runTest).toThrowError( 528 | "Can't find a 'oneOf' rule under module.rules in the production webpack config!\n\n" + 529 | "This error probably occurred because you updated react-scripts or craco. " + 530 | "Please try updating craco-less to the latest version:\n\n" + 531 | " $ yarn upgrade craco-less\n\n" + 532 | "Or:\n\n" + 533 | " $ npm update craco-less\n\n" + 534 | "If that doesn't work, craco-less needs to be fixed to support the latest version.\n" + 535 | "Please check to see if there's already an issue in the DocSpring/craco-less repo:\n\n" + 536 | " * https://github.com/DocSpring/craco-less/issues?q=is%3Aissue+webpack+rules+oneOf\n\n" + 537 | "If not, please open an issue and we'll take a look. (Or you can send a PR!)\n\n" + 538 | "You might also want to look for related issues in the " + 539 | "craco and create-react-app repos:\n\n" + 540 | " * https://github.com/dilanx/craco/issues?q=is%3Aissue+webpack+rules+oneOf\n" + 541 | " * https://github.com/facebook/create-react-app/issues?q=is%3Aissue+webpack+rules+oneOf\n", 542 | ); 543 | }); 544 | 545 | test("throws an error when react-scripts adds an unknown webpack rule", () => { 546 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 547 | const sassRule = oneOfRule.oneOf.find(styleRuleByName("scss|sass", false)); 548 | sassRule.use.push({ 549 | loader: "/path/to/unknown-loader/index.js", 550 | }); 551 | const runTest = () => { 552 | applyCracoConfigAndOverrideWebpack({ 553 | plugins: [{ plugin: CracoLessPlugin }], 554 | }); 555 | }; 556 | expect(runTest).toThrowError( 557 | new RegExp( 558 | "Found an unhandled loader in the production webpack config: " + 559 | "/path/to/unknown-loader/index.js", 560 | ), 561 | ); 562 | }); 563 | 564 | test("throws an error when the sass rule is missing", () => { 565 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 566 | let matchSassRule = styleRuleByName("scss|sass", false); 567 | oneOfRule.oneOf = oneOfRule.oneOf.filter((rule) => !matchSassRule(rule)); 568 | 569 | const runTest = () => { 570 | applyCracoConfigAndOverrideWebpack({ 571 | plugins: [{ plugin: CracoLessPlugin }], 572 | }); 573 | }; 574 | expect(runTest).toThrowError( 575 | new RegExp( 576 | "Can't find the webpack rule to match scss/sass files in the " + 577 | "production webpack config!", 578 | ), 579 | ); 580 | }); 581 | 582 | test("throws an error when the sass module rule is missing", () => { 583 | let oneOfRule = webpackConfig.module.rules.find((r) => r.oneOf); 584 | let matchSassModuleRule = styleRuleByName("scss|sass", true); 585 | oneOfRule.oneOf = oneOfRule.oneOf.filter( 586 | (rule) => !matchSassModuleRule(rule), 587 | ); 588 | 589 | const runTest = () => { 590 | applyCracoConfigAndOverrideWebpack({ 591 | plugins: [{ plugin: CracoLessPlugin }], 592 | }); 593 | }; 594 | expect(runTest).toThrowError( 595 | new RegExp( 596 | "Can't find the webpack rule to match scss/sass module files in the " + 597 | "production webpack config!", 598 | ), 599 | ); 600 | }); 601 | -------------------------------------------------------------------------------- /lib/craco-less.test.test.js: -------------------------------------------------------------------------------- 1 | const { createJestConfig } = require("@craco/craco"); 2 | const { processCracoConfig } = require("@craco/craco/dist/lib/config"); 3 | const { 4 | applyJestConfigPlugins, 5 | } = require("@craco/craco/dist/lib/features/plugins"); 6 | const clone = require("clone"); 7 | const CracoLessPlugin = require("./craco-less"); 8 | const { getCracoContext } = require("./test-utils"); 9 | 10 | process.env.NODE_ENV = "test"; 11 | 12 | const baseCracoConfig = {}; 13 | const cracoContext = getCracoContext(baseCracoConfig); 14 | const originalJestConfig = createJestConfig(baseCracoConfig); 15 | 16 | const overrideJestConfig = (callerCracoConfig, jestConfig) => { 17 | return applyJestConfigPlugins( 18 | processCracoConfig({ 19 | ...baseCracoConfig, 20 | ...callerCracoConfig, 21 | }), 22 | jestConfig, 23 | cracoContext, 24 | ); 25 | }; 26 | 27 | let jestConfig; 28 | beforeEach(() => { 29 | // deep clone the object before each test. 30 | jestConfig = clone(originalJestConfig); 31 | }); 32 | 33 | test("the jest config is modified correctly", () => { 34 | jestConfig = overrideJestConfig( 35 | { 36 | plugins: [{ plugin: CracoLessPlugin }], 37 | }, 38 | jestConfig, 39 | ); 40 | 41 | const moduleNameMapper = jestConfig.moduleNameMapper; 42 | expect(moduleNameMapper["^.+\\.module\\.(css|sass|scss)$"]).toBeUndefined(); 43 | expect(moduleNameMapper["^.+\\.module\\.(css|less|sass|scss)$"]).toEqual( 44 | "identity-obj-proxy", 45 | ); 46 | 47 | const transformIgnorePatterns = jestConfig.transformIgnorePatterns; 48 | expect(transformIgnorePatterns[1]).toEqual( 49 | "^.+\\.module\\.(css|less|sass|scss)$", 50 | ); 51 | }); 52 | 53 | test("throws an error when we can't find CSS Modules pattern under moduleNameMapper in the jest config", () => { 54 | delete jestConfig.moduleNameMapper["^.+\\.module\\.(css|sass|scss)$"]; 55 | 56 | const runTest = () => { 57 | overrideJestConfig( 58 | { 59 | plugins: [{ plugin: CracoLessPlugin }], 60 | }, 61 | jestConfig, 62 | ); 63 | }; 64 | 65 | expect(runTest).toThrowError( 66 | /^Can't find CSS Modules pattern under moduleNameMapper in the test jest config!/, 67 | ); 68 | }); 69 | 70 | test("throws an error when we can't find CSS Modules pattern under transformIgnorePatterns in the jest config", () => { 71 | jestConfig.transformIgnorePatterns = 72 | jestConfig.transformIgnorePatterns.filter( 73 | (e) => e !== "^.+\\.module\\.(css|sass|scss)$", 74 | ); 75 | 76 | const runTest = () => { 77 | overrideJestConfig( 78 | { 79 | plugins: [{ plugin: CracoLessPlugin }], 80 | }, 81 | jestConfig, 82 | ); 83 | }; 84 | 85 | expect(runTest).toThrowError( 86 | /^Can't find CSS Modules pattern under transformIgnorePatterns in the test jest config!/, 87 | ); 88 | }); 89 | -------------------------------------------------------------------------------- /lib/test-utils.js: -------------------------------------------------------------------------------- 1 | const { processCracoConfig } = require("@craco/craco/dist/lib/config"); 2 | const { getCraPaths } = require("@craco/craco/dist/lib/cra"); 3 | 4 | const getCracoContext = (callerCracoConfig, env = process.env.NODE_ENV) => { 5 | const context = { env }; 6 | const cracoConfig = processCracoConfig(callerCracoConfig, { env }); 7 | context.paths = getCraPaths(cracoConfig); 8 | return context; 9 | }; 10 | 11 | module.exports = { 12 | getCracoContext, 13 | }; 14 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const mapValues = (object, callback) => { 2 | const O = {}; 3 | for (let key in object) { 4 | O[key] = callback(object[key]); 5 | } 6 | return O; 7 | }; 8 | 9 | const deepClone = (value) => { 10 | switch (value.constructor) { 11 | case Array: 12 | return value.map(deepClone); 13 | case Object: 14 | return mapValues(value, deepClone); 15 | default: 16 | return value; 17 | } 18 | }; 19 | 20 | const styleRuleByName = (name, module) => { 21 | return (rule) => { 22 | if (rule.test) { 23 | const test = rule.test.toString(); 24 | 25 | const includeName = test.includes(name); 26 | const includeModule = test.includes("module"); 27 | 28 | return module 29 | ? includeName && includeModule 30 | : includeName && !includeModule; 31 | } 32 | 33 | return false; 34 | }; 35 | }; 36 | 37 | module.exports = { 38 | mapValues, 39 | deepClone, 40 | styleRuleByName, 41 | }; 42 | -------------------------------------------------------------------------------- /lib/utils.test.js: -------------------------------------------------------------------------------- 1 | const { mapValues, deepClone, styleRuleByName } = require("./utils"); 2 | 3 | test("the object's values are mapped correctly", () => { 4 | const oldObject = { a: 2, b: 3, c: 4 }; 5 | const newObject = mapValues(oldObject, (k) => k * k); 6 | 7 | expect(newObject).toEqual({ a: 4, b: 9, c: 16 }); 8 | 9 | newObject.a = 3; 10 | expect(newObject).toEqual({ a: 3, b: 9, c: 16 }); 11 | expect(oldObject).toEqual({ a: 2, b: 3, c: 4 }); 12 | }); 13 | 14 | test("the new configuration is a copy of the old one", () => { 15 | const cssLoaderGetLocalIdent = () => ""; 16 | const postcssLoaderPlugins = function () {}; 17 | 18 | const oldConfig = { 19 | test: /\.module\.less$/, 20 | use: [ 21 | "style-loader", 22 | { 23 | loader: "css-loader", 24 | options: { 25 | sourceMaps: true, 26 | modules: { getLocalIdent: cssLoaderGetLocalIdent }, 27 | }, 28 | }, 29 | { 30 | loader: "postcss-loader", 31 | options: { plugins: postcssLoaderPlugins }, 32 | }, 33 | ], 34 | }; 35 | const newConfig = deepClone(oldConfig); 36 | 37 | expect(newConfig).toEqual(oldConfig); 38 | 39 | newConfig.use[0] = { loader: "style-loader", options: {} }; 40 | 41 | newConfig.use[1].options.sourceMaps = false; 42 | newConfig.use[1].options.modules.getLocalIdent = "test"; 43 | 44 | expect(newConfig.use[0]).toEqual({ loader: "style-loader", options: {} }); 45 | expect(newConfig.use[1].options).toEqual({ 46 | sourceMaps: false, 47 | modules: { getLocalIdent: "test" }, 48 | }); 49 | 50 | expect(oldConfig.use[0]).toEqual("style-loader"); 51 | expect(oldConfig.use[1].options).toEqual({ 52 | sourceMaps: true, 53 | modules: { getLocalIdent: cssLoaderGetLocalIdent }, 54 | }); 55 | }); 56 | 57 | test("the style rule matcher can match the rules correctly", () => { 58 | const lessRule = { test: /\.less$/ }; 59 | const lessModuleRule = { test: /\.module\.less$/ }; 60 | const sassRule = { test: /\.(scss|sass)$/ }; 61 | const sassModuleRule = { test: /\.module\.(scss|sass)$/ }; 62 | 63 | const fileLoader = { loader: "file-loader" }; 64 | 65 | const matchLessRule = styleRuleByName("less", false); 66 | const matchLessModuleRule = styleRuleByName("less", true); 67 | 68 | expect(matchLessRule(lessRule)).toEqual(true); 69 | 70 | expect(matchLessRule(lessModuleRule)).toEqual(false); 71 | expect(matchLessRule(sassRule)).toEqual(false); 72 | expect(matchLessRule(sassModuleRule)).toEqual(false); 73 | expect(matchLessRule(fileLoader)).toEqual(false); 74 | 75 | expect(matchLessModuleRule(lessModuleRule)).toEqual(true); 76 | 77 | expect(matchLessModuleRule(lessRule)).toEqual(false); 78 | expect(matchLessModuleRule(sassRule)).toEqual(false); 79 | expect(matchLessModuleRule(sassModuleRule)).toEqual(false); 80 | expect(matchLessModuleRule(fileLoader)).toEqual(false); 81 | }); 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "craco-less", 3 | "version": "3.0.1", 4 | "description": "A Less plugin for craco / react-scripts / create-react-app", 5 | "files": [ 6 | "lib" 7 | ], 8 | "main": "./lib/craco-less.js", 9 | "scripts": { 10 | "test": "jest --coverage", 11 | "lint": "eslint --fix \"**/*.{js,ts}\"", 12 | "format": "prettier --write \"**/*.{js,ts,json,yml,md}\"" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+ssh://git@github.com/DocSpring/craco-less.git" 17 | }, 18 | "keywords": [ 19 | "craco", 20 | "create-react-app" 21 | ], 22 | "author": "Nathan Broadbent", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/DocSpring/craco-less/issues" 26 | }, 27 | "homepage": "https://github.com/DocSpring/craco-less#readme", 28 | "devDependencies": { 29 | "@craco/craco": "^7.1.0", 30 | "@craco/types": "^7.1.0", 31 | "clone": "^2.1.2", 32 | "eslint": "^8.44.0", 33 | "eslint-config-prettier": "^8.8.0", 34 | "eslint-plugin-prettier": "^5.0.0-alpha.2", 35 | "jest": "^29.6.1", 36 | "prettier": "^3.0.0", 37 | "react-dev-utils": "^12.0.1", 38 | "react-scripts": "^5.0.1" 39 | }, 40 | "dependencies": { 41 | "less": "^4.1.3", 42 | "less-loader": "^11.1.3" 43 | }, 44 | "peerDependencies": { 45 | "@craco/craco": "^6 || ^7", 46 | "react-scripts": "^5" 47 | }, 48 | "packageManager": "yarn@3.6.1" 49 | } 50 | --------------------------------------------------------------------------------