diff --git a/.changeset/slimy-zebras-roll.md b/.changeset/slimy-zebras-roll.md
new file mode 100644
index 00000000..6932a7af
--- /dev/null
+++ b/.changeset/slimy-zebras-roll.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-primer-react': minor
+---
+
+Add enforce-css-module-identifier-casing rule
diff --git a/docs/rules/enforce-css-module-identifier-casing.md b/docs/rules/enforce-css-module-identifier-casing.md
new file mode 100644
index 00000000..ca835155
--- /dev/null
+++ b/docs/rules/enforce-css-module-identifier-casing.md
@@ -0,0 +1,39 @@
+# Enforce CSS Module Identifier Casing (enforce-css-module-identifier-casing)
+
+CSS Modules should expose class names written in PascalCase.
+
+## Rule details
+
+This rule disallows the use of any CSS Module property that does not match the desired casing.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+/* eslint primer-react/enforce-css-module-identifier-casing: "error" */
+import {Button} from '@primer/react'
+import classes from './some.module.css'
+
+
+
+
+
+let ButtonClass = "button"
+
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+/* eslint primer-react/enforce-css-module-identifier-casing: "error" */
+import {Button} from '@primer/react'
+import classes from './some.module.css'
+;
+```
+
+## Options
+
+- `casing` (default: `'pascal'`)
+
+ By default, the `enforce-css-module-identifier-casing` rule will check for identifiers matching PascalCase.
+ Changing this to `'camel'` will instead enforce camelCasing rules. Changing this to `'kebab'` will instead
+ enforce kebab-casing rules.
diff --git a/package-lock.json b/package-lock.json
index d4bad0b6..c4f9a944 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "eslint-plugin-primer-react",
- "version": "6.1.2",
+ "version": "6.1.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "eslint-plugin-primer-react",
- "version": "6.1.2",
+ "version": "6.1.6",
"license": "MIT",
"dependencies": {
"@styled-system/props": "^5.1.5",
diff --git a/src/configs/recommended.js b/src/configs/recommended.js
index 3bb02d23..1ef6ee5c 100644
--- a/src/configs/recommended.js
+++ b/src/configs/recommended.js
@@ -20,6 +20,7 @@ module.exports = {
'primer-react/a11y-use-next-tooltip': 'error',
'primer-react/no-unnecessary-components': 'error',
'primer-react/prefer-action-list-item-onselect': 'error',
+ 'primer-react/enforce-css-module-identifier-casing': 'error',
},
settings: {
github: {
diff --git a/src/index.js b/src/index.js
index bc284b02..fc2e2b9f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,6 +14,7 @@ module.exports = {
'no-wildcard-imports': require('./rules/no-wildcard-imports'),
'no-unnecessary-components': require('./rules/no-unnecessary-components'),
'prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'),
+ 'enforce-css-module-identifier-casing': require('./rules/enforce-css-module-identifier-casing'),
},
configs: {
recommended: require('./configs/recommended'),
diff --git a/src/rules/__tests__/enforce-css-module-identifier-casing.test.js b/src/rules/__tests__/enforce-css-module-identifier-casing.test.js
new file mode 100644
index 00000000..e719aff8
--- /dev/null
+++ b/src/rules/__tests__/enforce-css-module-identifier-casing.test.js
@@ -0,0 +1,99 @@
+const rule = require('../enforce-css-module-identifier-casing')
+const {RuleTester} = require('eslint')
+
+const ruleTester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+})
+
+ruleTester.run('enforce-css-module-identifier-casing', rule, {
+ valid: [
+ 'import classes from "a.module.css"; function Foo() { return }',
+ 'import classes from "a.module.css"; function Foo() { return }',
+ 'import classes from "a.module.css"; function Foo() { return }',
+ 'import classes from "a.module.css"; function Foo() { return }',
+ 'import classes from "a.module.css"; function Foo() { return }',
+ 'import classes from "a.module.css"; let x = "Foo"; function Foo() { return }',
+ ],
+ invalid: [
+ {
+ code: 'import classes from "a.module.css"; function Foo() { return }',
+ errors: [
+ {
+ messageId: 'pascal',
+ data: {name: 'foo'},
+ },
+ ],
+ },
+ {
+ code: 'import classes from "a.module.css"; function Foo() { return }',
+ errors: [
+ {
+ messageId: 'pascal',
+ data: {name: 'foo'},
+ },
+ ],
+ },
+ {
+ code: 'import classes from "a.module.css"; function Foo() { return }',
+ errors: [
+ {
+ messageId: 'pascal',
+ data: {name: 'foo'},
+ },
+ ],
+ },
+ {
+ code: 'import classes from "a.module.css"; function Foo() { return }',
+ errors: [
+ {
+ messageId: 'pascal',
+ data: {name: 'foo'},
+ },
+ ],
+ },
+ {
+ code: 'import classes from "a.module.css"; function Foo() { return }',
+ errors: [
+ {
+ messageId: 'pascal',
+ data: {name: 'foo'},
+ },
+ ],
+ },
+ {
+ code: 'import classes from "a.module.css"; function Foo() { return }',
+ options: [{casing: 'camel'}],
+ errors: [
+ {
+ messageId: 'camel',
+ data: {name: 'Foo'},
+ },
+ ],
+ },
+ {
+ code: 'import classes from "a.module.css"; let FooClass = "foo"; function Foo() { return }',
+ errors: [
+ {
+ messageId: 'pascal',
+ data: {name: 'foo'},
+ },
+ ],
+ },
+ {
+ code: 'import classes from "a.module.css"; function Foo() { return }',
+ options: [{casing: 'camel'}],
+ errors: [
+ {
+ messageId: 'bad',
+ data: {type: 'Identifier'},
+ },
+ ],
+ },
+ ],
+})
diff --git a/src/rules/enforce-css-module-identifier-casing.js b/src/rules/enforce-css-module-identifier-casing.js
new file mode 100644
index 00000000..b46667ff
--- /dev/null
+++ b/src/rules/enforce-css-module-identifier-casing.js
@@ -0,0 +1,76 @@
+const {availableCasings, casingMatches} = require('../utils/casing-matches')
+const {identifierIsCSSModuleBinding} = require('../utils/css-modules')
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ fixable: 'code',
+ schema: [
+ {
+ properties: {
+ casing: {
+ enum: availableCasings,
+ },
+ },
+ },
+ ],
+ messages: {
+ bad: 'Class names should be in a recognisable case, and either an identifier or literal, saw: {{ type }}',
+ camel: 'Class names should be camelCase in both CSS and JS, saw: {{ name }}',
+ pascal: 'Class names should be PascalCase in both CSS and JS, saw: {{ name }}',
+ kebab: 'Class names should be kebab-case in both CSS and JS, saw: {{ name }}',
+ },
+ },
+ create(context) {
+ const casing = context.options[0]?.casing || 'pascal'
+ return {
+ ['JSXAttribute[name.name="className"] JSXExpressionContainer MemberExpression[object.type="Identifier"]']:
+ function (node) {
+ if (!identifierIsCSSModuleBinding(node.object, context)) return
+ if (!node.computed && node.property?.type === 'Identifier') {
+ if (!casingMatches(node.property.name || '', casing)) {
+ context.report({
+ node: node.property,
+ messageId: casing,
+ data: {name: node.property.name},
+ })
+ }
+ } else if (node.property?.type === 'Literal') {
+ if (!casingMatches(node.property.value || '', casing)) {
+ context.report({
+ node: node.property,
+ messageId: casing,
+ data: {name: node.property.value},
+ })
+ }
+ } else if (node.computed) {
+ const ref = context
+ .getScope()
+ .references.find(reference => reference.identifier.name === node.property.name)
+ const def = ref.resolved?.defs?.[0]
+ if (def?.node?.init?.type === 'Literal') {
+ if (!casingMatches(def.node.init.value || '', casing)) {
+ context.report({
+ node: node.property,
+ messageId: casing,
+ data: {name: def.node.init.value},
+ })
+ }
+ } else {
+ context.report({
+ node: node.property,
+ messageId: 'bad',
+ data: {type: node.property.type},
+ })
+ }
+ } else {
+ context.report({
+ node: node.property,
+ messageId: 'bad',
+ data: {type: node.property.type},
+ })
+ }
+ },
+ }
+ },
+}
diff --git a/src/utils/casing-matches.js b/src/utils/casing-matches.js
new file mode 100644
index 00000000..01142686
--- /dev/null
+++ b/src/utils/casing-matches.js
@@ -0,0 +1,19 @@
+const camelReg = /^[a-z]+(?:[A-Z0-9][a-z0-9]+)*?$/
+const pascalReg = /^(?:[A-Z0-9][a-z0-9]+)+?$/
+const kebabReg = /^[a-z]+(?:-[a-z0-9]+)*?$/
+
+function casingMatches(name, type) {
+ switch (type) {
+ case 'camel':
+ return camelReg.test(name)
+ case 'pascal':
+ return pascalReg.test(name)
+ case 'kebab':
+ return kebabReg.test(name)
+ default:
+ throw new Error(`Invalid case type ${type}`)
+ }
+}
+exports.casingMatches = casingMatches
+
+exports.availableCasings = ['camel', 'pascal', 'kebab']
diff --git a/src/utils/css-modules.js b/src/utils/css-modules.js
new file mode 100644
index 00000000..d3f9830d
--- /dev/null
+++ b/src/utils/css-modules.js
@@ -0,0 +1,15 @@
+function importBindingIsFromCSSModuleImport(node) {
+ return node.type === 'ImportBinding' && node.parent?.source?.value?.endsWith('.module.css')
+}
+
+function identifierIsCSSModuleBinding(node, context) {
+ if (node.type !== 'Identifier') return false
+ const ref = context.getScope().references.find(reference => reference.identifier.name === node.name)
+ if (ref.resolved?.defs?.some(importBindingIsFromCSSModuleImport)) {
+ return true
+ }
+ return false
+}
+
+exports.importBindingIsFromCSSModuleImport = importBindingIsFromCSSModuleImport
+exports.identifierIsCSSModuleBinding = identifierIsCSSModuleBinding