Create a custom local eslint rule

Last updated June 22, 2021 by Jacob Paris

We have a styleguide at work that says all of our labels should be in sentence-case, so instead of "Add Products" it's "Add products" with a lowercase 'p', and instead of "User Account" it's "User account" with a lowercase 'a'.

It's easy enough to follow when you're paying attention, and even when you're not it'll often be caught in code review, and even when it's not it's a fairly minor defect in the final product that will probably get fixed later. But the time spent fixing it later, plus the time spent finding it later, plus the time spent looking for it in code reviews, all adds up to more time than needs to be spent.

And besides: reviewers with a preoccupation for being human linters are spending effort that could be better spent on their regular occupation of being human reviewers.

As it turns out, running custom ESLint rules locally is really easy, so there's lots of room to explore automating rules and guidelines that are important to your project but maybe not anyone else. I used the package eslint-plugin-local-rules which makes this a breeze.

bash
npm install eslint-plugin-local-rules --save-dev

Local rules go in a local directory, so I made a new file at ./eslint-local-rules/no-title-case.js and pieced together enough example code and stack overflow responses until I had a plugin that looked right

js./eslint-local-rules/no-title-case.js
1function checkString(str, node, context) {
2 // \p{Ll} matches a lowercase letter that has an uppercase variant
3 // \s matches any whitespace character
4 // \p{Lu} matches an uppercase letter that has a lowercase variant
5
6 /**
7 * Look for the pattern {lowercase} {space} {uppercase} {lowercase}
8 */
9 const lowerToUpperCount = (str.match(/\p{Ll}\s\p{Lu}\p{Ll}/gu) || []).length
10
11 if (lowerToUpperCount > 0) {
12 context.report({
13 node,
14 message:
15 'String literals should not use title case. For titles, where that may be wanted, use the class ".text-capitalize"',
16 fix(fixer) {
17 return fixer.replaceText(
18 node,
19 node.raw.replace(/\s\p{Lu}\p{Ll}/gu, (token) => token.toLowerCase()),
20 )
21 },
22 })
23 }
24}
25
26module.exports = {
27 meta: {
28 docs: {
29 description: "Enforce that string literals aren't in title case.",
30 recommended: false,
31 },
32 fixable: 'code',
33 },
34
35 create: function (context) {
36 return {
37 Literal: (node) => {
38 if (typeof node.value === 'string') {
39 checkString(node.value, node, context)
40 }
41 },
42 JSXText: (node) => {
43 checkString(node.value, node, context)
44 },
45 }
46 },
47}
48

The eslint-local-rules plugin actually looks for an index file containing all the rules, so I started a collection there at ./eslint-local-rules/index.js

js./eslint-local-rules/index.js
1module.exports = {
2 'no-title-case': require('./no-title-case'),
3}
4

And the last step was to add the plugin and enable the rule in the .eslintrc file. I didn't want to be too aggressive here and block CI from passing, so I set the rule to warn instead of throw errors.

js.eslintrc
1module.exports = {
2 plugins: ['local-rules'],
3 rules: {
4 'local-rules/no-title-case': 'warn',
5 },
6}
7