Skip to content

Commit

Permalink
add enforce-css-module-identifier-casing rule (#258)
Browse files Browse the repository at this point in the history
* add enforce-css-module-identifier-casing rule

* changeset

* formatting
  • Loading branch information
keithamus authored Oct 24, 2024
1 parent 906bd97 commit 83f29f3
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/slimy-zebras-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-primer-react': minor
---

Add enforce-css-module-identifier-casing rule
39 changes: 39 additions & 0 deletions docs/rules/enforce-css-module-identifier-casing.md
Original file line number Diff line number Diff line change
@@ -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'

<Button className={classes.button} />
<Button className={classes['button']} />
<Button className={clsx(classes.button)} />

let ButtonClass = "button"
<Button className={clsx(classes[ButtonClass])} />
```

👍 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'
;<Button className={classes.Button} />
```

## 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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
99 changes: 99 additions & 0 deletions src/rules/__tests__/enforce-css-module-identifier-casing.test.js
Original file line number Diff line number Diff line change
@@ -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 <Box className={classes.Foo}/> }',
'import classes from "a.module.css"; function Foo() { return <Box className={clsx(classes.Foo)}/> }',
'import classes from "a.module.css"; function Foo() { return <Box className={clsx(className, classes.Foo)}/> }',
'import classes from "a.module.css"; function Foo() { return <Box className={`${classes.Foo}`}/> }',
'import classes from "a.module.css"; function Foo() { return <Box className={`${classes["Foo"]}`}/> }',
'import classes from "a.module.css"; let x = "Foo"; function Foo() { return <Box className={`${classes[x]}`}/> }',
],
invalid: [
{
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes.foo}/> }',
errors: [
{
messageId: 'pascal',
data: {name: 'foo'},
},
],
},
{
code: 'import classes from "a.module.css"; function Foo() { return <Box className={clsx(classes.foo)}/> }',
errors: [
{
messageId: 'pascal',
data: {name: 'foo'},
},
],
},
{
code: 'import classes from "a.module.css"; function Foo() { return <Box className={clsx(className, classes.foo)}/> }',
errors: [
{
messageId: 'pascal',
data: {name: 'foo'},
},
],
},
{
code: 'import classes from "a.module.css"; function Foo() { return <Box className={`${classes.foo}`}/> }',
errors: [
{
messageId: 'pascal',
data: {name: 'foo'},
},
],
},
{
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes["foo"]}/> }',
errors: [
{
messageId: 'pascal',
data: {name: 'foo'},
},
],
},
{
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes.Foo}/> }',
options: [{casing: 'camel'}],
errors: [
{
messageId: 'camel',
data: {name: 'Foo'},
},
],
},
{
code: 'import classes from "a.module.css"; let FooClass = "foo"; function Foo() { return <Box className={classes[FooClass]}/> }',
errors: [
{
messageId: 'pascal',
data: {name: 'foo'},
},
],
},
{
code: 'import classes from "a.module.css"; function Foo() { return <Box className={classes[x]}/> }',
options: [{casing: 'camel'}],
errors: [
{
messageId: 'bad',
data: {type: 'Identifier'},
},
],
},
],
})
76 changes: 76 additions & 0 deletions src/rules/enforce-css-module-identifier-casing.js
Original file line number Diff line number Diff line change
@@ -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},
})
}
},
}
},
}
19 changes: 19 additions & 0 deletions src/utils/casing-matches.js
Original file line number Diff line number Diff line change
@@ -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']
15 changes: 15 additions & 0 deletions src/utils/css-modules.js
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 83f29f3

Please sign in to comment.