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

Issue:
Allow named exports to have underscores in names
<!--
  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: macOS 10.15.6
- Node Version: 12.18.4
- NPM Version: 6.14.6
- webpack Version: 4.44.2
- css-loader Version: 4.3.0

### Feature Proposal

When I set css-loader options like this:

```js
{
    loader: 'css-loader',
    options: {
        modules: {
            namedExport: true,
            exportLocalsConvention: 'dashesOnly',
        },
    }
},
```

The loader emits an error for each CSS file: `The "modules.namedExport" option requires the "modules.exportLocalsConvention" option to be "camelCaseOnly"`.

I understand that class names mustn't contain dashes and many other characters to be able to be constant names, but underscore (`_`) is allowed and I want to use it in constant names.

In order to achieve this, there can be a value like `leaveUnderscore` for the `exportLocalsConvention` option that shall camelize everything except underscores and be accepted when `namedExports` is `true`.

### Feature Use Case

BEM. Even though CSS modules make BEM be completely meaningless, underscore can be used to split logical parts in CSS class names, especially when CSS is produced by a preprocessor. For example, here is my SCSS:

```sass
.button {
    &_label {
    }
}
.label {
    &_icon {
    }
}
```

I want to use the class names as is:

```
import {button, button_label, label, label_icon} from './style.scss?module';
```

Pull request description:
feat: allow named exports to have underscores in names
<!--
  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 #1206

### Breaking Changes

No

### Additional Info

No


Patch:
diff --git a/src/utils.js b/src/utils.js
index 39a8597..4a1d1cc 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -111,6 +111,14 @@ function getFilter(filter, resourcePath) {
   };
 }
 
+function getValidLocalName(localName, exportLocalsConvention) {
+  if (exportLocalsConvention === 'dashesOnly') {
+    return dashesCamelCase(localName);
+  }
+
+  return camelCase(localName);
+}
+
 const moduleRegExp = /\.module(s)?\.\w+$/i;
 const icssRegExp = /\.icss\.\w+$/i;
 
@@ -203,9 +211,12 @@ function getModulesOptions(rawOptions, loaderContext) {
       );
     }
 
-    if (modulesOptions.exportLocalsConvention !== 'camelCaseOnly') {
+    if (
+      modulesOptions.exportLocalsConvention !== 'camelCaseOnly' &&
+      modulesOptions.exportLocalsConvention !== 'dashesOnly'
+    ) {
       throw new Error(
-        'The "modules.namedExport" option requires the "modules.exportLocalsConvention" option to be "camelCaseOnly"'
+        'The "modules.namedExport" option requires the "modules.exportLocalsConvention" option to be "camelCaseOnly" or "dashesOnly"'
       );
     }
   }
@@ -516,7 +527,10 @@ function getModuleCode(result, api, replacements, options, loaderContext) {
       code = code.replace(new RegExp(replacementName, 'g'), () =>
         options.modules.namedExport
           ? `" + ${importName}_NAMED___[${JSON.stringify(
-              camelCase(localName)
+              getValidLocalName(
+                localName,
+                options.modules.exportLocalsConvention
+              )
             )}] + "`
           : `" + ${importName}.locals[${JSON.stringify(localName)}] + "`
       );
@@ -551,9 +565,7 @@ function getExportCode(exports, replacements, options) {
 
   const addExportToLocalsCode = (name, value) => {
     if (options.modules.namedExport) {
-      localsCode += `export const ${camelCase(name)} = ${JSON.stringify(
-        value
-      )};\n`;
+      localsCode += `export const ${name} = ${JSON.stringify(value)};\n`;
     } else {
       if (localsCode) {
         localsCode += `,\n`;
@@ -609,7 +621,7 @@ function getExportCode(exports, replacements, options) {
       localsCode = localsCode.replace(new RegExp(replacementName, 'g'), () => {
         if (options.modules.namedExport) {
           return `" + ${importName}_NAMED___[${JSON.stringify(
-            camelCase(localName)
+            getValidLocalName(localName, options.modules.exportLocalsConvention)
           )}] + "`;
         } else if (options.modules.exportOnlyLocals) {
           return `" + ${importName}[${JSON.stringify(localName)}] + "`;


Test patch:
diff --git a/test/__snapshots__/modules-option.test.js.snap b/test/__snapshots__/modules-option.test.js.snap
index 9aba4b8..c2b4c4b 100644
--- a/test/__snapshots__/modules-option.test.js.snap
+++ b/test/__snapshots__/modules-option.test.js.snap
@@ -1614,7 +1614,7 @@ exports[`"modules" option should throw an error when class has unsupported name
 exports[`"modules" option should throw an error when the "namedExport" is enabled and the "exportLocalsConvention" options has not "camelCaseOnly" value: errors 1`] = `
 Array [
   "ModuleBuildError: Module build failed (from \`replaced original path\`):
-Error: The \\"modules.namedExport\\" option requires the \\"modules.exportLocalsConvention\\" option to be \\"camelCaseOnly\\"",
+Error: The \\"modules.namedExport\\" option requires the \\"modules.exportLocalsConvention\\" option to be \\"camelCaseOnly\\" or \\"dashesOnly\\"",
 ]
 `;
 
@@ -1629,6 +1629,15 @@ Error: The \\"modules.namedExport\\" option requires the \\"esModules\\" option
 
 exports[`"modules" option should throw an error when the "namedExport" option is "true", but the "esModule" is "false": warnings 1`] = `Array []`;
 
+exports[`"modules" option should throw error with composes when the "namedExport" is enabled and "exportLocalsConvention" options has invalid value: errors 1`] = `
+Array [
+  "ModuleBuildError: Module build failed (from \`replaced original path\`):
+Error: The \\"modules.namedExport\\" option requires the \\"modules.exportLocalsConvention\\" option to be \\"camelCaseOnly\\" or \\"dashesOnly\\"",
+]
+`;
+
+exports[`"modules" option should throw error with composes when the "namedExport" is enabled and "exportLocalsConvention" options has invalid value: warnings 1`] = `Array []`;
+
 exports[`"modules" option should work and correctly replace escaped symbols: errors 1`] = `Array []`;
 
 exports[`"modules" option should work and correctly replace escaped symbols: module 1`] = `
@@ -3719,6 +3728,39 @@ Object {
 
 exports[`"modules" option should work js template with "namedExport" option: warnings 1`] = `Array []`;
 
+exports[`"modules" option should work when the "namedExport" is enabled and the "exportLocalsConvention" options has "dashesOnly" value: errors 1`] = `Array []`;
+
+exports[`"modules" option should work when the "namedExport" is enabled and the "exportLocalsConvention" options has "dashesOnly" value: module 1`] = `
+"// Imports
+import ___CSS_LOADER_API_IMPORT___ from \\"../../../../../src/runtime/api.js\\";
+var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(function(i){return i[1]});
+// Module
+___CSS_LOADER_EXPORT___.push([module.id, \\".foo_barBaz {\\\\n  color: red;\\\\n}\\\\n\\\\n.bar {\\\\n  color: red;\\\\n}\\\\n\\", \\"\\"]);
+// Exports
+export const foo_barBaz = \\"foo_barBaz\\";
+export default ___CSS_LOADER_EXPORT___;
+"
+`;
+
+exports[`"modules" option should work when the "namedExport" is enabled and the "exportLocalsConvention" options has "dashesOnly" value: result 1`] = `
+Array [
+  Array [
+    "./modules/namedExport/dashesOnly/index.css",
+    ".foo_barBaz {
+  color: red;
+}
+
+.bar {
+  color: red;
+}
+",
+    "",
+  ],
+]
+`;
+
+exports[`"modules" option should work when the "namedExport" is enabled and the "exportLocalsConvention" options has "dashesOnly" value: warnings 1`] = `Array []`;
+
 exports[`"modules" option should work with "exportOnlyLocals" and "esModule" with "false" value options: errors 1`] = `Array []`;
 
 exports[`"modules" option should work with "exportOnlyLocals" and "esModule" with "false" value options: module 1`] = `
@@ -12152,6 +12194,67 @@ Array [
 
 exports[`"modules" option should work with case \`values-10\` (\`modules\` value is \`true)\`: warnings 1`] = `Array []`;
 
+exports[`"modules" option should work with composes when the "namedExport" is enabled and "exportLocalsConvention" options has "dashesOnly" value: errors 1`] = `Array []`;
+
+exports[`"modules" option should work with composes when the "namedExport" is enabled and "exportLocalsConvention" options has "dashesOnly" value: module 1`] = `
+"// Imports
+import ___CSS_LOADER_API_IMPORT___ from \\"../../../../../src/runtime/api.js\\";
+import ___CSS_LOADER_ICSS_IMPORT_0___, * as ___CSS_LOADER_ICSS_IMPORT_0____NAMED___ from \\"-!../../../../../src/index.js??[ident]!./values.css\\";
+var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(function(i){return i[1]});
+___CSS_LOADER_EXPORT___.i(___CSS_LOADER_ICSS_IMPORT_0___, \\"\\", true);
+// Module
+___CSS_LOADER_EXPORT___.push([module.id, \\"._ghi {\\\\n  color: \\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"v_def\\"] + \\";\\\\n}\\\\n\\\\n._my-class {\\\\n  color: \\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"sWhite\\"] + \\";\\\\n}\\\\n\\\\n._other {\\\\n  display: \\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"mSmall\\"] + \\";\\\\n}\\\\n\\\\n._other-other {\\\\n  width: \\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"v_def\\"] + \\";\\\\n}\\\\n\\\\n._green {\\\\n  color: \\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"v_otherOther\\"] + \\";\\\\n}\\\\n\\", \\"\\"]);
+// Exports
+export const v_def = \\"\\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"v_def\\"] + \\"\\";
+export const v_otherOther = \\"\\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"v_otherOther\\"] + \\"\\";
+export const sWhite = \\"\\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"sWhite\\"] + \\"\\";
+export const mSmall = \\"\\" + ___CSS_LOADER_ICSS_IMPORT_0____NAMED___[\\"mSmall\\"] + \\"\\";
+export const ghi = \\"_ghi\\";
+export const myClass = \\"_my-class\\";
+export const other = \\"_other\\";
+export const otherOther = \\"_other-other\\";
+export const green = \\"_green\\";
+export default ___CSS_LOADER_EXPORT___;
+"
+`;
+
+exports[`"modules" option should work with composes when the "namedExport" is enabled and "exportLocalsConvention" options has "dashesOnly" value: result 1`] = `
+Array [
+  Array [
+    "../../src/index.js?[ident]!./modules/namedExport/composes/values.css",
+    "
+",
+    "",
+  ],
+  Array [
+    "./modules/namedExport/composes/composes.css",
+    "._ghi {
+  color: red;
+}
+
+._my-class {
+  color: white;
+}
+
+._other {
+  display: (min-width: 320px);
+}
+
+._other-other {
+  width: red;
+}
+
+._green {
+  color: green;
+}
+",
+    "",
+  ],
+]
+`;
+
+exports[`"modules" option should work with composes when the "namedExport" is enabled and "exportLocalsConvention" options has "dashesOnly" value: warnings 1`] = `Array []`;
+
 exports[`"modules" option should work with the "[local]" placeholder for the "localIdentName" option: errors 1`] = `Array []`;
 
 exports[`"modules" option should work with the "[local]" placeholder for the "localIdentName" option: module 1`] = `
diff --git a/test/fixtures/modules/namedExport/composes/composes.css b/test/fixtures/modules/namedExport/composes/composes.css
new file mode 100644
index 0000000..7a7d4fa
--- /dev/null
+++ b/test/fixtures/modules/namedExport/composes/composes.css
@@ -0,0 +1,24 @@
+@value v_def from './values.css';
+@value v_other-other from './values.css';
+@value s-white from './values.css';
+@value m-small from './values.css';
+
+.ghi {
+  color: v_def;
+}
+
+.my-class {
+  color: s-white;
+}
+
+.other {
+  display: m-small;
+}
+
+.other-other {
+  width: v_def;
+}
+
+.green {
+  color: v_other-other;
+}
diff --git a/test/fixtures/modules/namedExport/composes/composes.js b/test/fixtures/modules/namedExport/composes/composes.js
new file mode 100644
index 0000000..ba922e3
--- /dev/null
+++ b/test/fixtures/modules/namedExport/composes/composes.js
@@ -0,0 +1,5 @@
+import css from './composes.css';
+
+__export__ = css;
+
+export default css;
diff --git a/test/fixtures/modules/namedExport/composes/values.css b/test/fixtures/modules/namedExport/composes/values.css
new file mode 100644
index 0000000..075f524
--- /dev/null
+++ b/test/fixtures/modules/namedExport/composes/values.css
@@ -0,0 +1,4 @@
+@value v_def: red;
+@value v_other-other: green;
+@value s-white: white;
+@value m-small: (min-width: 320px);
diff --git a/test/fixtures/modules/namedExport/dashesOnly/index.css b/test/fixtures/modules/namedExport/dashesOnly/index.css
new file mode 100644
index 0000000..6c1ec23
--- /dev/null
+++ b/test/fixtures/modules/namedExport/dashesOnly/index.css
@@ -0,0 +1,7 @@
+:local(.foo_barBaz) {
+  color: red;
+}
+
+:global(.bar) {
+  color: red;
+}
diff --git a/test/fixtures/modules/namedExport/dashesOnly/index.js b/test/fixtures/modules/namedExport/dashesOnly/index.js
new file mode 100644
index 0000000..e1a1930
--- /dev/null
+++ b/test/fixtures/modules/namedExport/dashesOnly/index.js
@@ -0,0 +1,5 @@
+import css from './index.css';
+
+__export__ = css;
+
+export default css;
diff --git a/test/modules-option.test.js b/test/modules-option.test.js
index 1bb121a..d442c6c 100644
--- a/test/modules-option.test.js
+++ b/test/modules-option.test.js
@@ -1203,6 +1203,60 @@ describe('"modules" option', () => {
     expect(getErrors(stats)).toMatchSnapshot('errors');
   });
 
+  it('should work when the "namedExport" is enabled and the "exportLocalsConvention" options has "dashesOnly" value', async () => {
+    const compiler = getCompiler('./modules/namedExport/dashesOnly/index.js', {
+      modules: {
+        localIdentName: '[local]',
+        namedExport: true,
+        exportLocalsConvention: 'dashesOnly',
+      },
+    });
+    const stats = await compile(compiler);
+
+    expect(
+      getModuleSource('./modules/namedExport/dashesOnly/index.css', stats)
+    ).toMatchSnapshot('module');
+    expect(getExecutedCode('main.bundle.js', compiler, stats)).toMatchSnapshot(
+      'result'
+    );
+    expect(getWarnings(stats)).toMatchSnapshot('warnings');
+    expect(getErrors(stats, true)).toMatchSnapshot('errors');
+  });
+
+  it('should work with composes when the "namedExport" is enabled and "exportLocalsConvention" options has "dashesOnly" value', async () => {
+    const compiler = getCompiler('./modules/namedExport/composes/composes.js', {
+      modules: {
+        localIdentName: '_[local]',
+        namedExport: true,
+        exportLocalsConvention: 'dashesOnly',
+      },
+    });
+    const stats = await compile(compiler);
+
+    expect(
+      getModuleSource('./modules/namedExport/composes/composes.css', stats)
+    ).toMatchSnapshot('module');
+    expect(getExecutedCode('main.bundle.js', compiler, stats)).toMatchSnapshot(
+      'result'
+    );
+    expect(getWarnings(stats)).toMatchSnapshot('warnings');
+    expect(getErrors(stats)).toMatchSnapshot('errors');
+  });
+
+  it('should throw error with composes when the "namedExport" is enabled and "exportLocalsConvention" options has invalid value', async () => {
+    const compiler = getCompiler('./modules/namedExport/composes/composes.js', {
+      modules: {
+        localIdentName: '_[local]',
+        namedExport: true,
+        exportLocalsConvention: 'dashes',
+      },
+    });
+    const stats = await compile(compiler);
+
+    expect(getWarnings(stats)).toMatchSnapshot('warnings');
+    expect(getErrors(stats, true)).toMatchSnapshot('errors');
+  });
+
   it('should throw an error when the "namedExport" option is "true", but the "esModule" is "false"', async () => {
     const compiler = getCompiler('./modules/namedExport/base/index.js', {
       esModule: false,