Creating a custom ESlint plugin

Abhay
smallcase Engineering
11 min readApr 27, 2022

--

Photo by Mateo Giraud on Unsplash

ESLint is a tool for identifying and reporting on patterns found in ECMAScript/JavaScript code, with the goal of making code more consistent and avoiding bugs.

Today we will look at how we can create our own custom plugin for ESlint which we can use in our apps for pattern and code checking.

Background:

At

, we have a lot of coding conventions, some of which are general, some are specific to how we write code, or specific to things that are being used in our code. For enforcing some of the general conventions we use existing ESlint rules by either extending from a popular config, or directly enabling / disabling rules as required. For specific code conventions though, we usually have to enforce them through PR reviews manually, or in some cases, when possible and the effort is justified, we write our own custom eslint rules.

Some examples of these specific conventions which can be covered through custom ESlint rules are:

  • we are currently migrating from js to ts in one of our codebases, this is being done incrementally and not all at once. As part of the migration, we want to make sure that any new stories (or tests) are written typescript. Or if someone is making changes to the existing stories, they convert the js file to ts as part of those changes.
  • As a convention, we do not want to allow using role=”button” on a non semantic element, as it requires a lot of work to make it accessible, instead we want devs to use a button, reset the styles and then style it accordingly. This can be detected and enforced using an ESlint rule
  • We have created a component called ExternalLink which is supposed to be used for links which take the user outside smallcase owned properties. These links have to be treated differently from regular links as generally we would want to add noreferrer noopener to these. There is an existing ESlint rule that warns about this for links with target blank, but our component makes this more semantic and easy to understand. So we might write a ESlint rule which detects links which are outside the smallcase ecosystem and suggest devs to use the ExternalLink component.
  • We have deprecated one of our base components, but there is still some legacy code which extends from this base component. To make sure that whenever the file containing the extended component changes, the Devs replace this deprecated usage with the new usage, we could write an ESlint rule which detects this and warns the dev.

In this blog we will understand everything that is required to create your own custom ESlint plugin, and I will walk you through one such ESlint rule

What we will build

  • We are building a custom shareable ESlint plugin which can be shared across teams to enforce common lint rules. This plugin will contain a rule to check if the stories are written in typescript file or not. If stories are not written in typescript files then throw an error.

Quick note about rules vs plugins vs configs

Rules:

An ESLint rule is custom rule which is enabled for your codebase for better code quality or write something is a specific manner which is adopted as a pattern in your codebase.

A custom ESlint rule resides in your codebase locally and can’t be shipped to other code bases directly. To ship it you need to create this rule inside of a plugin.

Plugin:

A plugin is a custom set of rules which can be imported and used in the project based on your requirements and needs. ESLint comes with a large number of built-in rules but sometime they don’t cover all of your cases in your codebase so you can add more rules through plugins. Plugins are published as npm modules with names in the format of **eslint-plugin-<plugin-name>.**A plugin is where all the rules are defined, but just including the plugin in your eslint config does not enable the rule in your codebase.

Config:

A Configuration File use a JavaScript, JSON, or YAML file to specify configuration information for an entire directory and all of its subdirectories. This can be in the form of an .eslintrc.* file or an eslintConfig field in a package.json file, both of which ESLint will look for and read automatically.

The config we create are important to the project and there might be multiple projects in your organisation which uses more or less same eslint configurations, to share these config across codebases ESLint lets you share your config by allowing you to publish it to npm. Similar to plugins, shareable configs are also published with names in the format of eslint-config-<config-name>

A config is where you include different plugins, which bring their own set of rules, and in the config, you actually configure the rules and turn them on / off.

Basic scaffolding

  • Let’s start by scaffolding a basic template for our plugin, You can use the Yeoman Generator. This will help you with setting up the basic skeleton of project.
  • This sets you up with a folder structure for rules and tests and a README file for documentation and basic setup guide for your users which you can update according to rules available in this package.
  • First thing is to take a look at README.md This is the first thing which needs to be done, This will guide you through how you can ship your plugin and how your consumers can use it.
  • Now let’s look at the package.json file, The package.json will look like this
{
"name": "<name of your plugin>",
"version": "0.0.0",
"description": "<description of plugin>",
"keywords": [
"eslint",
"eslintplugin",
"eslint-plugin"
],
"author": "<author's name>",
"main": "lib/index.js",
"scripts": {
"lint": "eslint .",
"test": "mocha tests --recursive"
},
"dependencies": {
"requireindex": "^1.2.0"
},
"devDependencies": {
"eslint": "^8.0.1",
"eslint-plugin-eslint-plugin": "^4.0.1",
"eslint-plugin-node": "^11.1.0",
"mocha": "^9.1.3"
},
"engines": {
"node": "12.x || 14.x || >= 16"
},
"peerDependencies": {
"eslint": ">=6"
},
"license": "ISC"
}
  • Few Interesting things are here requireIndex as dependency and mocha as dependency for testing and eslint-plugin-node because ESlint runs on node so you have to use pre es6 syntax because node don’t support modern ES6 syntax.
  • If you are wondering why requireIndex is added as dependency then you can hop on to index.js inside the lib . The index file will look like this
/**
* @fileoverview <pluginname here>
* @author <author's name>
*/
"use strict";
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
const requireIndex = require("requireindex");//------------------------------------------------------------------------------
// Plugin Definition
//------------------------------------------------------------------------------
// import all rules in lib/rules
module.exports.rules = requireIndex(__dirname + "/rules");
  • This is used to import all the rules from rules directory and export them as rules so that they can be found by eslint in your main codebase.

Adding a rule

  • Now inside our rules folder we can create a file no-js-stories.js . Note the name of the file will be used as the rule name because of requireIndex you it is important that you name your file the same what you want your rules name to be.
  • After this we can write a basic code in our file which looks like this
module.exports = {
meta: {
type: 'problem', // `problem`, `suggestion`, or `layout`
docs: {
description: 'rule to disallow stories to be a js file',
recommended: false,
url: null, // URL to the documentation page for this rule
},
fixable: null, // Or `code` or `whitespace`
schema: [], // Add a schema if the rule has options
},
create: function (context) {
return {
Program: function (node) {
// Add the rule here
},
};
},
};
  • This meta object will be used to provide some meta information about your rule along with the error message.
  • The second thing is the create property which is a function and has an argument context
  • This context object as name suggests provides the context object contains information that is relevant to the context of the rule. There are multiple properties which context provides but what we are interested in is getFilename function. You can read about the context object in detail here
  • The getFileName provides the file name associated with the source.
  • We can use this function to get the file name, time to add some util functions to parse the file name and perform the check if file is a storybook file and is in JS or not.
  • Let’s create an another folder inside lib called utils to contain the utils functions.
cd <package-name/lib> && mkdir utils
  • First let’s create a util to parse the filename provided by the getFilename function.
  • Add a new file inside your utils folder named parseFilename.js .
  • Inside the parseFilename we can create a default exported function parseFilename which excepts the filename and use the path module to breakdown the file name and return an object containing dir base extension and name of the file
const path = require('path');/**
* function to parse the filename
* @param {string} filename - the file name of the file
* @returns {{dir: string; base: string: extension: string; name: string}} - the parsed file name
*/
module.exports = function parseFilename(filename) {
const extension = path.extname(filename);
return {
dir: path.dirname(filename), // name of the folder
base: path.basename(filename), // full name of the file
extension: extension, // extension of the file
name: path.basename(filename, extension), // name without extension
};
};
  • Now we are parsing the filename provided by the ESlint to us and we can use that information to check if the stories file are in JS or not.
  • To perform this check we will create a separate util function. You can create a new file isStoryInJs alongside the parseFilename.js
  • Inside isStoryInJs we can add a function to check if the story file is in JS or not.
  • Here again we will create a default exported function isStoryInJs . This function will expect the parsed filename object as parameter and it will return true if the file is a stories file and in JS else it will return false.
module.exports = function isStoryInJs(parsed) {
return !!parsed.base.includes('.stories.js'); // stories have extension .stories.js or .stories.tsx
};
  • We have unlocked the ability to parse the file name and check if the file is a stories file and in JS or not.
  • Coming back to our main no-js-stories file, we can use these utils functions here to create our custom rule.
  • create function returns an object with property Program which is required because ESlint uses this function to parse the file and apply this rule. This Program property needs to be a function which we have already done in our basic file and we can hop on back to the Program function and start writing the rule.
  • Let’s import our newly added utils in this file and path package also.
const path = require('path');
const parseFilename = require('../utils/parseFilname');
const isStoryInJs = require('../utils/isStoryInJs');
  • After import let’s write the logic for the rule which is like
  • Get filename from context
  • parse the file name
  • check if the story is in JS or not
  • If file is a story in JS report an ESlint error

In terms of code our logic will look like this

const filename = context.getFilename(); // get the filename from contextconst absoluteFilename = path.resolve(filename); // create an absolute and normalized pathconst parsed = parseFilename(absoluteFilename); // get the parsed filename objectconst isStoryInJS = isStoryInJs(parsed); // check if file is story in js or notif (isStoryInJS) { // if story in js then report an error
context.report({
node: node,
message: "stories must be written in typescript",
});
}
  • You can add this code in your Program function and it will look like this
const path = require('path');
const parseFilename = require('../utils/parseFilname');
const isStoryInJs = require('../utils/isStoryInJs');
const rulesMessageMap = require('../constants/constants');
/*
constants.js looks like this
const rulesMessageMap = {
'no-js-stories': 'stories must be written in typescript',
};

module.exports = rulesMessageMap;
*/
/**
* @type {import('eslint').Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem', // `problem`, `suggestion`, or `layout`
docs: {
description: 'rule to disallow stories to be a js file',
recommended: false,
url: null, // URL to the documentation page for this rule
},
fixable: null, // Or `code` or `whitespace`
schema: [], // Add a schema if the rule has options
},
create: function (context) {
return {
Program: function (node) {
const filename = context.getFilename();
const absoluteFilename = path.resolve(filename);
const parsed = parseFilename(absoluteFilename);
const isStoryInJS = isStoryInJs(parsed);
if (isStoryInJS) {
context.report({
node: node,
message: rulesMessageMap['no-js-stories'],
});
}
},
};
},
};

Testing the rules

Our plugin is ready, let’s add some tests for our rules, You can now add a file inside your tests folder, you can name it anything but I’ll name it the same as of the main file no-js-stories.js

  • For running tests related to the rules we can use the RuleTester provided by the ESLint
  • We then have to create a new ruleTester object like this
const ruleTester = new RuleTester();
  • This ruleTester object provides a run function which take 3 arguments
  1. The name of the rule.
  2. The rule itself, you can import your rule and pass that as second argument
  3. an object containing valid and invalid example for the rule
  • The valid and invalid properties will be an array containing an object which contains a dummy code and filename of the file and an error object for invalid cases
  • The dummy code can be added as constant like this
const code = "var foo = 'bar';";
  • And our valid object will look like this
{
code,
filename: 'index.stories.tsx',
},
  • And for invalid case our snippet will look like this
const rulesMessageMap = require('../../../lib/constants/constants'); // rulesMessageMap is imported here{
code,
filename: 'index.stories.js',
errors: [
{
message: rulesMessageMap['no-js-stories'], // you can change this with your message
column: 1,
line: 1,
},
],
},
  • And the final test file will look like this, you can add more cases according to your use case
const { RuleTester } = require('eslint');
const rulesMessageMap = require('../../../lib/constants/constants');
const rule = require('../../../lib/rules/no-js-stories');
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
const ruleTester = new RuleTester();const code = "var foo = 'bar';";ruleTester.run('no-js-stories', rule, {
valid: [
{
code,
filename: 'index.stories.tsx',
},
],
invalid: [
{
code,
filename: 'index.stories.js',
errors: [
{
message: rulesMessageMap['no-js-stories'],
column: 1,
line: 1,
},
],
}
],
});
  • Hooray, We did it that’s it, You have now created your custom plugin and now you can ship it by private npm registry or however you want and your consumers can use it.

How consumers can use it

  • You can read the README.md file generate by the Yeoman generator. The file will guide you on how your consumers can use the plugin.
  • We have named our package as @smallcase/eslint-plugin and it will be published to our private npm registry so consumers can follow the following steps to use this rule in their codebases
  • First the plugin needs to be installed npm i @smallcase/eslint-plugin
  • An entry in the plugins array of the eslintrc is needed which will be like this
"plugins": ["@smallcase/eslint-plugin",
// rest plugins here ...
],
  • Note: If your plugin name starts with eslint-plugin prefix you can omit it. For e.g.
  • If we name our plugin something like eslint-plugin-smallcase then in plugin array we can simply add smallcase
  • Then in the rules section we need to add entry and type for these rules which can be done like this
{
"rules": {
"@smallcase/rule-name": 2
}
}
  • That’s it, thats how your consumers can use your plugin once you ship it via npm private registries or by npm public registry.

References

--

--