Repository: webpack-contrib/css-loader (js)

Issue:
modules.mode callback function
<!--
  Issues are so 🔥

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  👉🏽 Need support, advice, or help? Don't open an issue!
  Head to StackOverflow or https://gitter.im/webpack/webpack.
-->

- Operating System: Ubuntu 18.04
- Node Version: v13.11.0
- NPM Version: 6.13.7
- webpack Version: 4.42.1
- css-loader Version: 3.4.2

### Feature Proposal

[`modules.mode`](https://github.com/webpack-contrib/css-loader#mode) can be set to `global` or `local` (AFAICT) -- I propose allowing it to take a callback function as well. Callback will be given the full filepath/name which can then return a string, "global" or "local". 

### Feature Use Case

Projects that were started with existing CSS files will likely need to use "global" mode to avoid having to refactor everything/wrap every file. However, new files/components will probably want to use "local" mode. Being able to turn global/local on for specific directories will make this much easier.

**Edit:** I suppose if [this](https://github.com/webpack/webpack/issues/8952) gets fixed, that'd work too.

Pull request description:
feat: modules.mode callback function
<!--
  HOLY CRAP a Pull Request. We ❤️ those!

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  Please place an x (no spaces!) in all [ ] that apply
-->

This PR contains a:

- [ ] **bugfix**
- [x] new **feature**
- [ ] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [ ] **metadata update**

### Motivation / Use-Case

fixes #1063 

### Breaking Changes

No

### Additional Info


Patch:
diff --git a/README.md b/README.md
index 8020201..737f15c 100644
--- a/README.md
+++ b/README.md
@@ -541,11 +541,17 @@ module.exports = {
 
 ##### `mode`
 
-Type: `String`
+Type: `String|Function`
 Default: `'local'`
 
 Setup `mode` option. You can omit the value when you want `local` mode.
 
+###### `String`
+
+Possible values:
+
+`local`, `global`, `pure`
+
 **webpack.config.js**
 
 ```js
@@ -566,6 +572,39 @@ module.exports = {
 };
 ```
 
+###### `Function`
+
+Allows set different values for the `mode` option based on a filename
+
+Possible return values - `local`, `global` and `pure`
+
+**webpack.config.js**
+
+```js
+module.exports = {
+  module: {
+    rules: [
+      {
+        test: /\.css$/i,
+        loader: 'css-loader',
+        options: {
+          modules: {
+            // Callback must return "local", "global" or "pure"
+            mode: (filename) => {
+              if (/global.css$/i.test(filename)) {
+                return 'global';
+              }
+
+              return 'local';
+            },
+          },
+        },
+      },
+    ],
+  },
+};
+```
+
 ##### `localIdentName`
 
 Type: `String`
diff --git a/src/options.json b/src/options.json
index 7701584..742c28c 100644
--- a/src/options.json
+++ b/src/options.json
@@ -37,7 +37,14 @@
           "additionalProperties": false,
           "properties": {
             "mode": {
-              "enum": ["local", "global", "pure"]
+              "anyOf": [
+                {
+                  "enum": ["local", "global", "pure"]
+                },
+                {
+                  "instanceof": "Function"
+                }
+              ]
             },
             "localIdentName": {
               "type": "string"
diff --git a/src/utils.js b/src/utils.js
index cd53cbe..0bfed95 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -115,25 +115,24 @@ function getModulesPlugins(options, loaderContext) {
     modulesOptions = Object.assign({}, modulesOptions, options.modules);
   }
 
-  return [
-    modulesValues,
-    localByDefault({ mode: modulesOptions.mode }),
-    extractImports(),
-    modulesScope({
-      generateScopedName: function generateScopedName(exportName) {
-        let localIdent = modulesOptions.getLocalIdent(
-          loaderContext,
-          modulesOptions.localIdentName,
-          exportName,
-          {
-            context: modulesOptions.context,
-            hashPrefix: modulesOptions.hashPrefix,
-            regExp: modulesOptions.localIdentRegExp,
-          }
-        );
+  if (typeof modulesOptions.mode === 'function') {
+    const modeFromFunction = modulesOptions.mode(loaderContext.resourcePath);
+
+    if (modeFromFunction === 'local' || modeFromFunction === 'global') {
+      modulesOptions.mode = modeFromFunction;
+    }
+  }
+
+  let plugins = [];
 
-        if (!localIdent) {
-          localIdent = getLocalIdent(
+  try {
+    plugins = [
+      modulesValues,
+      localByDefault({ mode: modulesOptions.mode }),
+      extractImports(),
+      modulesScope({
+        generateScopedName: function generateScopedName(exportName) {
+          let localIdent = modulesOptions.getLocalIdent(
             loaderContext,
             modulesOptions.localIdentName,
             exportName,
@@ -143,12 +142,29 @@ function getModulesPlugins(options, loaderContext) {
               regExp: modulesOptions.localIdentRegExp,
             }
           );
-        }
 
-        return localIdent;
-      },
-    }),
-  ];
+          if (!localIdent) {
+            localIdent = getLocalIdent(
+              loaderContext,
+              modulesOptions.localIdentName,
+              exportName,
+              {
+                context: modulesOptions.context,
+                hashPrefix: modulesOptions.hashPrefix,
+                regExp: modulesOptions.localIdentRegExp,
+              }
+            );
+          }
+
+          return localIdent;
+        },
+      }),
+    ];
+  } catch (error) {
+    loaderContext.emitError(error);
+  }
+
+  return plugins;
 }
 
 function normalizeSourceMap(map) {


Test patch:
diff --git a/test/__snapshots__/modules-option.test.js.snap b/test/__snapshots__/modules-option.test.js.snap
index 85e61ce..ae8349d 100644
--- a/test/__snapshots__/modules-option.test.js.snap
+++ b/test/__snapshots__/modules-option.test.js.snap
@@ -363,6 +363,107 @@ div:not(.\\\\1F600) {
 
 exports[`"modules" option issue #995: warnings 1`] = `Array []`;
 
+exports[`"modules" option issue #1063 throw error: errors 1`] = `
+Array [
+  "ModuleError: Module Error (from \`replaced original path\`):
+options.mode must be either \\"global\\", \\"local\\" or \\"pure\\" (default \\"local\\")",
+  "ModuleError: Module Error (from \`replaced original path\`):
+options.mode must be either \\"global\\", \\"local\\" or \\"pure\\" (default \\"local\\")",
+]
+`;
+
+exports[`"modules" option issue #1063 throw error: module 1`] = `
+"// Imports
+var ___CSS_LOADER_API_IMPORT___ = require(\\"../../../../src/runtime/api.js\\");
+exports = ___CSS_LOADER_API_IMPORT___(false);
+// Module
+exports.push([module.id, \\".classNameLocalFile {\\\\n  color: green;\\\\n}\\\\n\\\\n:global(.otherClassLocalFile) {\\\\n  color: blue;\\\\n}\\\\n\\", \\"\\"]);
+// Exports
+module.exports = exports;
+"
+`;
+
+exports[`"modules" option issue #1063 throw error: module 2`] = `
+"// Imports
+var ___CSS_LOADER_API_IMPORT___ = require(\\"../../../../src/runtime/api.js\\");
+exports = ___CSS_LOADER_API_IMPORT___(false);
+// Module
+exports.push([module.id, \\".classNameGlobalFile {\\\\n  color: black;\\\\n}\\\\n\\\\n:local(.otherClassGlobalFile) {\\\\n  color: coral;\\\\n}\\\\n\\", \\"\\"]);
+// Exports
+module.exports = exports;
+"
+`;
+
+exports[`"modules" option issue #1063 throw error: result 1`] = `
+".classNameLocalFile {
+  color: green;
+}
+
+:global(.otherClassLocalFile) {
+  color: blue;
+}
+.classNameGlobalFile {
+  color: black;
+}
+
+:local(.otherClassGlobalFile) {
+  color: coral;
+}
+"
+`;
+
+exports[`"modules" option issue #1063 throw error: warnings 1`] = `Array []`;
+
+exports[`"modules" option issue #1063: errors 1`] = `Array []`;
+
+exports[`"modules" option issue #1063: module 1`] = `
+"// Imports
+var ___CSS_LOADER_API_IMPORT___ = require(\\"../../../../src/runtime/api.js\\");
+exports = ___CSS_LOADER_API_IMPORT___(false);
+// Module
+exports.push([module.id, \\"._2cZDGA9F7Kx1vfQmWcLP51 {\\\\n  color: green;\\\\n}\\\\n\\\\n.otherClassLocalFile {\\\\n  color: blue;\\\\n}\\\\n\\", \\"\\"]);
+// Exports
+exports.locals = {
+	\\"classNameLocalFile\\": \\"_2cZDGA9F7Kx1vfQmWcLP51\\"
+};
+module.exports = exports;
+"
+`;
+
+exports[`"modules" option issue #1063: module 2`] = `
+"// Imports
+var ___CSS_LOADER_API_IMPORT___ = require(\\"../../../../src/runtime/api.js\\");
+exports = ___CSS_LOADER_API_IMPORT___(false);
+// Module
+exports.push([module.id, \\".classNameGlobalFile {\\\\n  color: black;\\\\n}\\\\n\\\\n._2rcag09JpwrP4_hfyyRmm- {\\\\n  color: coral;\\\\n}\\\\n\\", \\"\\"]);
+// Exports
+exports.locals = {
+	\\"otherClassGlobalFile\\": \\"_2rcag09JpwrP4_hfyyRmm-\\"
+};
+module.exports = exports;
+"
+`;
+
+exports[`"modules" option issue #1063: result 1`] = `
+"._2cZDGA9F7Kx1vfQmWcLP51 {
+  color: green;
+}
+
+.otherClassLocalFile {
+  color: blue;
+}
+.classNameGlobalFile {
+  color: black;
+}
+
+._2rcag09JpwrP4_hfyyRmm- {
+  color: coral;
+}
+"
+`;
+
+exports[`"modules" option issue #1063: warnings 1`] = `Array []`;
+
 exports[`"modules" option should avoid unnecessary "require": errors 1`] = `Array []`;
 
 exports[`"modules" option should avoid unnecessary "require": module 1`] = `
diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap
index f65d36c..e7f0351 100644
--- a/test/__snapshots__/validate-options.test.js.snap
+++ b/test/__snapshots__/validate-options.test.js.snap
@@ -86,26 +86,58 @@ exports[`validate options should throw an error on the "modules" option with "{"
 
 exports[`validate options should throw an error on the "modules" option with "{"mode":"globals"}" value 1`] = `
 "Invalid options object. CSS Loader has been initialized using an options object that does not match the API schema.
- - options.modules.mode should be one of these:
-   \\"local\\" | \\"global\\" | \\"pure\\""
+ - options.modules should be one of these:
+   boolean | \\"local\\" | \\"global\\" | \\"pure\\" | object { mode?, localIdentName?, localIdentRegExp?, context?, hashPrefix?, getLocalIdent? }
+   -> Enables/Disables CSS Modules and their configuration (https://github.com/webpack-contrib/css-loader#modules).
+   Details:
+    * options.modules.mode should be one of these:
+      \\"local\\" | \\"global\\" | \\"pure\\" | function
+      Details:
+       * options.modules.mode should be one of these:
+         \\"local\\" | \\"global\\" | \\"pure\\"
+       * options.modules.mode should be an instance of function."
 `;
 
 exports[`validate options should throw an error on the "modules" option with "{"mode":"locals"}" value 1`] = `
 "Invalid options object. CSS Loader has been initialized using an options object that does not match the API schema.
- - options.modules.mode should be one of these:
-   \\"local\\" | \\"global\\" | \\"pure\\""
+ - options.modules should be one of these:
+   boolean | \\"local\\" | \\"global\\" | \\"pure\\" | object { mode?, localIdentName?, localIdentRegExp?, context?, hashPrefix?, getLocalIdent? }
+   -> Enables/Disables CSS Modules and their configuration (https://github.com/webpack-contrib/css-loader#modules).
+   Details:
+    * options.modules.mode should be one of these:
+      \\"local\\" | \\"global\\" | \\"pure\\" | function
+      Details:
+       * options.modules.mode should be one of these:
+         \\"local\\" | \\"global\\" | \\"pure\\"
+       * options.modules.mode should be an instance of function."
 `;
 
 exports[`validate options should throw an error on the "modules" option with "{"mode":"pures"}" value 1`] = `
 "Invalid options object. CSS Loader has been initialized using an options object that does not match the API schema.
- - options.modules.mode should be one of these:
-   \\"local\\" | \\"global\\" | \\"pure\\""
+ - options.modules should be one of these:
+   boolean | \\"local\\" | \\"global\\" | \\"pure\\" | object { mode?, localIdentName?, localIdentRegExp?, context?, hashPrefix?, getLocalIdent? }
+   -> Enables/Disables CSS Modules and their configuration (https://github.com/webpack-contrib/css-loader#modules).
+   Details:
+    * options.modules.mode should be one of these:
+      \\"local\\" | \\"global\\" | \\"pure\\" | function
+      Details:
+       * options.modules.mode should be one of these:
+         \\"local\\" | \\"global\\" | \\"pure\\"
+       * options.modules.mode should be an instance of function."
 `;
 
 exports[`validate options should throw an error on the "modules" option with "{"mode":true}" value 1`] = `
 "Invalid options object. CSS Loader has been initialized using an options object that does not match the API schema.
- - options.modules.mode should be one of these:
-   \\"local\\" | \\"global\\" | \\"pure\\""
+ - options.modules should be one of these:
+   boolean | \\"local\\" | \\"global\\" | \\"pure\\" | object { mode?, localIdentName?, localIdentRegExp?, context?, hashPrefix?, getLocalIdent? }
+   -> Enables/Disables CSS Modules and their configuration (https://github.com/webpack-contrib/css-loader#modules).
+   Details:
+    * options.modules.mode should be one of these:
+      \\"local\\" | \\"global\\" | \\"pure\\" | function
+      Details:
+       * options.modules.mode should be one of these:
+         \\"local\\" | \\"global\\" | \\"pure\\"
+       * options.modules.mode should be an instance of function."
 `;
 
 exports[`validate options should throw an error on the "modules" option with "globals" value 1`] = `
diff --git a/test/fixtures/modules/issue-1063/global.css b/test/fixtures/modules/issue-1063/global.css
new file mode 100644
index 0000000..95eb977
--- /dev/null
+++ b/test/fixtures/modules/issue-1063/global.css
@@ -0,0 +1,7 @@
+.classNameGlobalFile {
+  color: black;
+}
+
+:local(.otherClassGlobalFile) {
+  color: coral;
+}
diff --git a/test/fixtures/modules/issue-1063/issue-1063.js b/test/fixtures/modules/issue-1063/issue-1063.js
new file mode 100644
index 0000000..c602c8c
--- /dev/null
+++ b/test/fixtures/modules/issue-1063/issue-1063.js
@@ -0,0 +1,6 @@
+import css1 from './local.css';
+import css2 from './global.css';
+
+__export__ = css1 + css2;
+
+export default css1 + css2;
diff --git a/test/fixtures/modules/issue-1063/local.css b/test/fixtures/modules/issue-1063/local.css
new file mode 100644
index 0000000..f68ef05
--- /dev/null
+++ b/test/fixtures/modules/issue-1063/local.css
@@ -0,0 +1,7 @@
+.classNameLocalFile {
+  color: green;
+}
+
+:global(.otherClassLocalFile) {
+  color: blue;
+}
diff --git a/test/modules-option.test.js b/test/modules-option.test.js
index 6ba9d5f..b62a708 100644
--- a/test/modules-option.test.js
+++ b/test/modules-option.test.js
@@ -565,4 +565,54 @@ describe('"modules" option', () => {
     expect(getWarnings(stats)).toMatchSnapshot('warnings');
     expect(getErrors(stats)).toMatchSnapshot('errors');
   });
+
+  it('issue #1063', async () => {
+    const compiler = getCompiler('./modules/issue-1063/issue-1063.js', {
+      modules: {
+        mode: (resourcePath) => {
+          if (/global.css$/i.test(resourcePath)) {
+            return 'global';
+          }
+
+          return 'local';
+        },
+      },
+    });
+    const stats = await compile(compiler);
+
+    expect(
+      getModuleSource('./modules/issue-1063/local.css', stats)
+    ).toMatchSnapshot('module');
+    expect(
+      getModuleSource('./modules/issue-1063/global.css', stats)
+    ).toMatchSnapshot('module');
+    expect(getExecutedCode('main.bundle.js', compiler, stats)).toMatchSnapshot(
+      'result'
+    );
+    expect(getWarnings(stats)).toMatchSnapshot('warnings');
+    expect(getErrors(stats)).toMatchSnapshot('errors');
+  });
+
+  it('issue #1063 throw error', async () => {
+    const compiler = getCompiler('./modules/issue-1063/issue-1063.js', {
+      modules: {
+        mode: () => {
+          return 'not local, global or pure';
+        },
+      },
+    });
+    const stats = await compile(compiler);
+
+    expect(
+      getModuleSource('./modules/issue-1063/local.css', stats)
+    ).toMatchSnapshot('module');
+    expect(
+      getModuleSource('./modules/issue-1063/global.css', stats)
+    ).toMatchSnapshot('module');
+    expect(getExecutedCode('main.bundle.js', compiler, stats)).toMatchSnapshot(
+      'result'
+    );
+    expect(getWarnings(stats)).toMatchSnapshot('warnings');
+    expect(getErrors(stats)).toMatchSnapshot('errors');
+  });
 });
diff --git a/test/validate-options.test.js b/test/validate-options.test.js
index 969765e..db5ad6c 100644
--- a/test/validate-options.test.js
+++ b/test/validate-options.test.js
@@ -20,6 +20,7 @@ describe('validate options', () => {
         { mode: 'global' },
         { mode: 'local' },
         { mode: 'pure' },
+        { mode: () => 'local' },
         { localIdentName: '[path][name]__[local]--[hash:base64:5]' },
         { context: 'context' },
         { hashPrefix: 'hash' },