Skip to content

Commit 83f29f3

Browse files
authored
add enforce-css-module-identifier-casing rule (#258)
* add enforce-css-module-identifier-casing rule * changeset * formatting
1 parent 906bd97 commit 83f29f3

9 files changed

+257
-2
lines changed

.changeset/slimy-zebras-roll.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-primer-react': minor
3+
---
4+
5+
Add enforce-css-module-identifier-casing rule
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Enforce CSS Module Identifier Casing (enforce-css-module-identifier-casing)
2+
3+
CSS Modules should expose class names written in PascalCase.
4+
5+
## Rule details
6+
7+
This rule disallows the use of any CSS Module property that does not match the desired casing.
8+
9+
👎 Examples of **incorrect** code for this rule:
10+
11+
```jsx
12+
/* eslint primer-react/enforce-css-module-identifier-casing: "error" */
13+
import {Button} from '@primer/react'
14+
import classes from './some.module.css'
15+
16+
<Button className={classes.button} />
17+
<Button className={classes['button']} />
18+
<Button className={clsx(classes.button)} />
19+
20+
let ButtonClass = "button"
21+
<Button className={clsx(classes[ButtonClass])} />
22+
```
23+
24+
👍 Examples of **correct** code for this rule:
25+
26+
```jsx
27+
/* eslint primer-react/enforce-css-module-identifier-casing: "error" */
28+
import {Button} from '@primer/react'
29+
import classes from './some.module.css'
30+
;<Button className={classes.Button} />
31+
```
32+
33+
## Options
34+
35+
- `casing` (default: `'pascal'`)
36+
37+
By default, the `enforce-css-module-identifier-casing` rule will check for identifiers matching PascalCase.
38+
Changing this to `'camel'` will instead enforce camelCasing rules. Changing this to `'kebab'` will instead
39+
enforce kebab-casing rules.

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/configs/recommended.js

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ module.exports = {
2020
'primer-react/a11y-use-next-tooltip': 'error',
2121
'primer-react/no-unnecessary-components': 'error',
2222
'primer-react/prefer-action-list-item-onselect': 'error',
23+
'primer-react/enforce-css-module-identifier-casing': 'error',
2324
},
2425
settings: {
2526
github: {

src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
'no-wildcard-imports': require('./rules/no-wildcard-imports'),
1515
'no-unnecessary-components': require('./rules/no-unnecessary-components'),
1616
'prefer-action-list-item-onselect': require('./rules/prefer-action-list-item-onselect'),
17+
'enforce-css-module-identifier-casing': require('./rules/enforce-css-module-identifier-casing'),
1718
},
1819
configs: {
1920
recommended: require('./configs/recommended'),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
const rule = require('../enforce-css-module-identifier-casing')
2+
const {RuleTester} = require('eslint')
3+
4+
const ruleTester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 'latest',
7+
sourceType: 'module',
8+
ecmaFeatures: {
9+
jsx: true,
10+
},
11+
},
12+
})
13+
14+
ruleTester.run('enforce-css-module-identifier-casing', rule, {
15+
valid: [
16+
'import classes from "a.module.css"; function Foo() { return <Box className={classes.Foo}/> }',
17+
'import classes from "a.module.css"; function Foo() { return <Box className={clsx(classes.Foo)}/> }',
18+
'import classes from "a.module.css"; function Foo() { return <Box className={clsx(className, classes.Foo)}/> }',
19+
'import classes from "a.module.css"; function Foo() { return <Box className={`${classes.Foo}`}/> }',
20+
'import classes from "a.module.css"; function Foo() { return <Box className={`${classes["Foo"]}`}/> }',
21+
'import classes from "a.module.css"; let x = "Foo"; function Foo() { return <Box className={`${classes[x]}`}/> }',
22+
],
23+
invalid: [
24+
{
25+
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes.foo}/> }',
26+
errors: [
27+
{
28+
messageId: 'pascal',
29+
data: {name: 'foo'},
30+
},
31+
],
32+
},
33+
{
34+
code: 'import classes from "a.module.css"; function Foo() { return <Box className={clsx(classes.foo)}/> }',
35+
errors: [
36+
{
37+
messageId: 'pascal',
38+
data: {name: 'foo'},
39+
},
40+
],
41+
},
42+
{
43+
code: 'import classes from "a.module.css"; function Foo() { return <Box className={clsx(className, classes.foo)}/> }',
44+
errors: [
45+
{
46+
messageId: 'pascal',
47+
data: {name: 'foo'},
48+
},
49+
],
50+
},
51+
{
52+
code: 'import classes from "a.module.css"; function Foo() { return <Box className={`${classes.foo}`}/> }',
53+
errors: [
54+
{
55+
messageId: 'pascal',
56+
data: {name: 'foo'},
57+
},
58+
],
59+
},
60+
{
61+
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes["foo"]}/> }',
62+
errors: [
63+
{
64+
messageId: 'pascal',
65+
data: {name: 'foo'},
66+
},
67+
],
68+
},
69+
{
70+
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes.Foo}/> }',
71+
options: [{casing: 'camel'}],
72+
errors: [
73+
{
74+
messageId: 'camel',
75+
data: {name: 'Foo'},
76+
},
77+
],
78+
},
79+
{
80+
code: 'import classes from "a.module.css"; let FooClass = "foo"; function Foo() { return <Box className={classes[FooClass]}/> }',
81+
errors: [
82+
{
83+
messageId: 'pascal',
84+
data: {name: 'foo'},
85+
},
86+
],
87+
},
88+
{
89+
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes[x]}/> }',
90+
options: [{casing: 'camel'}],
91+
errors: [
92+
{
93+
messageId: 'bad',
94+
data: {type: 'Identifier'},
95+
},
96+
],
97+
},
98+
],
99+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
const {availableCasings, casingMatches} = require('../utils/casing-matches')
2+
const {identifierIsCSSModuleBinding} = require('../utils/css-modules')
3+
4+
module.exports = {
5+
meta: {
6+
type: 'suggestion',
7+
fixable: 'code',
8+
schema: [
9+
{
10+
properties: {
11+
casing: {
12+
enum: availableCasings,
13+
},
14+
},
15+
},
16+
],
17+
messages: {
18+
bad: 'Class names should be in a recognisable case, and either an identifier or literal, saw: {{ type }}',
19+
camel: 'Class names should be camelCase in both CSS and JS, saw: {{ name }}',
20+
pascal: 'Class names should be PascalCase in both CSS and JS, saw: {{ name }}',
21+
kebab: 'Class names should be kebab-case in both CSS and JS, saw: {{ name }}',
22+
},
23+
},
24+
create(context) {
25+
const casing = context.options[0]?.casing || 'pascal'
26+
return {
27+
['JSXAttribute[name.name="className"] JSXExpressionContainer MemberExpression[object.type="Identifier"]']:
28+
function (node) {
29+
if (!identifierIsCSSModuleBinding(node.object, context)) return
30+
if (!node.computed && node.property?.type === 'Identifier') {
31+
if (!casingMatches(node.property.name || '', casing)) {
32+
context.report({
33+
node: node.property,
34+
messageId: casing,
35+
data: {name: node.property.name},
36+
})
37+
}
38+
} else if (node.property?.type === 'Literal') {
39+
if (!casingMatches(node.property.value || '', casing)) {
40+
context.report({
41+
node: node.property,
42+
messageId: casing,
43+
data: {name: node.property.value},
44+
})
45+
}
46+
} else if (node.computed) {
47+
const ref = context
48+
.getScope()
49+
.references.find(reference => reference.identifier.name === node.property.name)
50+
const def = ref.resolved?.defs?.[0]
51+
if (def?.node?.init?.type === 'Literal') {
52+
if (!casingMatches(def.node.init.value || '', casing)) {
53+
context.report({
54+
node: node.property,
55+
messageId: casing,
56+
data: {name: def.node.init.value},
57+
})
58+
}
59+
} else {
60+
context.report({
61+
node: node.property,
62+
messageId: 'bad',
63+
data: {type: node.property.type},
64+
})
65+
}
66+
} else {
67+
context.report({
68+
node: node.property,
69+
messageId: 'bad',
70+
data: {type: node.property.type},
71+
})
72+
}
73+
},
74+
}
75+
},
76+
}

src/utils/casing-matches.js

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const camelReg = /^[a-z]+(?:[A-Z0-9][a-z0-9]+)*?$/
2+
const pascalReg = /^(?:[A-Z0-9][a-z0-9]+)+?$/
3+
const kebabReg = /^[a-z]+(?:-[a-z0-9]+)*?$/
4+
5+
function casingMatches(name, type) {
6+
switch (type) {
7+
case 'camel':
8+
return camelReg.test(name)
9+
case 'pascal':
10+
return pascalReg.test(name)
11+
case 'kebab':
12+
return kebabReg.test(name)
13+
default:
14+
throw new Error(`Invalid case type ${type}`)
15+
}
16+
}
17+
exports.casingMatches = casingMatches
18+
19+
exports.availableCasings = ['camel', 'pascal', 'kebab']

src/utils/css-modules.js

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
function importBindingIsFromCSSModuleImport(node) {
2+
return node.type === 'ImportBinding' && node.parent?.source?.value?.endsWith('.module.css')
3+
}
4+
5+
function identifierIsCSSModuleBinding(node, context) {
6+
if (node.type !== 'Identifier') return false
7+
const ref = context.getScope().references.find(reference => reference.identifier.name === node.name)
8+
if (ref.resolved?.defs?.some(importBindingIsFromCSSModuleImport)) {
9+
return true
10+
}
11+
return false
12+
}
13+
14+
exports.importBindingIsFromCSSModuleImport = importBindingIsFromCSSModuleImport
15+
exports.identifierIsCSSModuleBinding = identifierIsCSSModuleBinding

0 commit comments

Comments
 (0)