Design System Best Practices with ESLint Series
Part 2: Translating Your Design System Best Practices to ESLint
Welcome to part 2 of the 3-part Encouraging design system best practices with ESLint rules article series
The series aims to help you encourage those consuming your design system to follow your best practices.
We covered a lot of theory in part 1, such as:
- What parsing JavaScript means
- What an Abstract Syntax Tree is
- The fundamentals of ESLint
- The visitor pattern
- and more
Back in the first article, we talked about how you’ll be creating two rules:
- Avoiding inline styles in your elements
- Ensuring that tooltips don’t contain interactive content.
Let’s take a moment to talk about why I’ve chosen these rules.
For starters, they’re both great rules to act as introductions to writing rules. While the implementations we cover in these articles may not be production-ready, they’ll help solidify the learning you’ve made so far.
Aside from being a teaching aid, there’s also practical application for both these rules.
For our first rule, you might want to encourage your end-users to be consistent with their styling implementation, which may help with readability and maintenance. CSS preference varies from person-to-person, and from library-to-library, so you may find that this particular rule doesn’t apply to your library. However, targeting a specific attribute is very useful. For instance, you might want to discourage the use of tab-indexes on non-interactive elements, or you might want to encourage the use of an attribute for a certain element, like the type attribute for a button component.
I’ve chosen the second rule because we’ll be using an existing design system as an example, the Simba design system, which is written using the Lion web components.
In the Simba design system docs for the tooltip element, the opening description states:
“Interactive content should not be placed in a tooltip content slot. Tooltips are just meant for showing additional information.”
This might be enough to prevent some end-users from not using interactive content within the tooltip but some developers, like me, are lazy. Other developers, also like me, have terrible memories. Having an ESLint rule that enforces the rules stated in the docs will make it much easier for people to remember and abide by the rules of your framework.
Setting up our project
Let’s begin by cloning the starter repo. The repo covers a lot of the tedious setup such as:
- Installing packages
- ESLint boilerplate
- Setting up test suites
To get started with the repo, follow the following steps:
- Clone the repo using
git clone https://github.com/andrico1234/custom-eslint-tutorial/tree/0-begin-first-rule - Navigate into the project’s directory
- Install your dependencies with
npm i - open the repo in your code editor
Here’s a quick rundown of the starter repo:
lib/rules
lib/rules contains our rules. I’ve prepared the first one for you by writing the boilerplate. I’ve added some JSDoc annotations to the file, as per the working with rules’s recommendation. This will give you some sweet, sweet intellisense to help when writing our lint logic.
tests/lib/rules
tests/lib/rules contains the tests we’ll write for our rules. I’ve added a handful for the no-inline-styles, but you’re welcome to add even more if you like. We’re using ESLint’s built-in RuleTester to test our rules. I’ve created an instance of the RuleTester and am calling run with a number of valid and invalid cases.
valid is an array of stringified JavaScript that shouldn’t report linting errors.
invalid is an array of objects that contains two properties: code and errors. code is the stringified JavaScript that will report linting errors. The errors property is an array of objects. These objects can hold different types of assertions, so not only can we check to see if the snippet fails the lint rule, but we can check to see the error message we get back and the location of the error in our IDE, this is how VSCode determines where to place the squiggly lines.
Once you’re familiar with the repo, you can start the test runner by running npm test. The test runner will run every time you make a change to the rules file, which will provide immediate feedback when something goes wrong or, more hopefully, right
Creating a simple ESLint rule
The first rule we’ll be writing will display an error message if a lit element contains inline styles.
Creating our visitor function
Let’s begin by jumping into the lib/rules/no-inline-styles.js file.
If you remember from the first article, when our JavaScript gets parsed the html function gets represented as a TaggedTemplateExpression node in the AST. We can ask ESLint to visit this node whenever it reaches it by defining a TaggedTemplateExpression in the rule’s create function.
Don’t forget that not all TaggedTemplateExpressions will be the html expression that we want to run our lint rule against. We’ll want to check the function’s name and to see if it’s “html”.
Try filling out the create function with the above logic, or you can peek below for my answer.
const rule = {
meta: {
type: 'suggestion',
},
create: function (context) {
return {
TaggedTemplateExpression: (node) => {
const isLitExpression = node.tag.name === 'html';
if (isLitExpression) {
// TODO: parse it into HTML
}
},
};
},
};
The next step is to take the node and parse it into a valid HTML AST.
Parsing our HTML
Since the logic to parse our node isn’t part of our rule’s business logic, I’ve created a class stub in a separate utilities directory. So jump over to the utils/index.js file.
We need to do two things here:
- Convert our template expression into an HTML string
- Parse our string into an HTML AST and store it as an instance variable
For the first task, let’s take the easy route and behave as if our Lit expressions will contain no dynamic content.
In other words let’s just worry about:
html`<div style="display:none;"></div>`;
and not:
html`<div style="${val}"></div>`;
I’ll provide the solution below, but try and use the tools we’ve run through in this article to solve this problem yourself.
You can either give it a shot yourself, or view my solution below:
import { parseFragment } from 'parse5';
export class TemplateAnalyzer {
_ast = null;
/**
* Instantiates our TemplateAnalyzer
*
* @param {import("estree").TaggedTemplateExpression} node
*/
constructor(node) {
const html = this.templateExpressionToHtml(node);
this._ast = parseFragment(html);
}
/**
* Converts a JavaScript expression into an HTML string
*
* @param {import("estree").TaggedTemplateExpression} node
* @return {string}
*/
templateExpressionToHtml(node) {
const value = node.quasi.quasis[0].value.raw;
// TODO: replace placeholders with corresponding expressions
return value;
}
}
What’s the deal with templateExpressionToHtml?
The above code is ignoring more complex use cases by only pulling the first item in the quasis array.
This means that for the following lit expressions these tests will run fine:
html`<div style=""></div>`;
html`<div style="display:none;"></div>`;
This is because these lit expressions don’t contain any JavaScript expressions inside of the template literals.
This means the node.quasi object will look something like this:
{
quasi: {
quasis: ['<div style="display:none;"></div>'],
expressions: [],
}
}
Unfortunately, this means that the tests will fail for the following lit expression:
html`<div style="${val}"></div>`;
Because this lit expression contains a JavaScript expression within the template literal, the node.quasi object will look something like this:
{
quasi: {
quasis: ['<div style="', '"></div>'],
expressions: ['val'],
}
}
The third test case will fail until we fully implement the templateExpressionToHtml function.
If you’ve got the tests running, all the valid cases should still be passing and all the invalid cases should be failing. This is a good indicator to see whether there are any fundamental parsing issues within our TemplateAnalyzer’s constructor.
Creating our visitor functions
The next step is to create a traversal function for our analyzer. This will enter each HTML node, as ESLint does for JavaScript, which will call a visitor function that we’ll use to make our assertions.
It will take our visitor functions as an input, and then visit every node and call our visitor.
To keep things simple, we’ll need to check to see if the node is an element, and then call the correct visitor. Don’t worry about other node types, like comment nodes or text nodes.
Our traverse function will:
- Take an object called
visitorswhich contains our visitor functions - Visit each child node of
this.ast - Call the
elementvisitor function for each element node
Once implemented, the traverse function will look something like this:
traverse(visitors) {
const visit = (parentNode) => {
if (!parentNode) return;
if (this.isElement(parentNode)) {
visitors.element(parentNode);
}
if (parentNode.childNodes.length) {
parentNode.childNodes.forEach((node) => {
visit(node);
});
}
};
visit(this._ast);
}
This is what the function is doing:
- A function
visit, is declared, that handles the core logic. visitreturns early if nonodeis presentvisitchecks to see if our node is an HTML element- if it is,
visitcalls our visitor function visitis called recursively with the child nodes.
The only other piece of functionality that’s left to implement is the isElement helper. This helper will take a node as an argument and check to see if the node.tagName value is truthy.
isElement(node) {
return !!node.tagName;
}
Writing our assertions
Let’s revisit our ESLint rule now that we can start writing assertions. Let’s use the new visitor function to visit each element.
if (isLitExpression) {
const analyzer = new TemplateAnalyzer(node);
analyzer.traverse({
element(node) {
// run your checks to satisfy your unit tests
},
});
}
We can now continue in TDD fashion by fixing the failing tests. The first failing test is for the following case:
html`<div style=""></div>`;
This case fails because the style attribute is present. If the attribute is present, we should report an ESLint error. Take a few minutes to try and implement the attribute check before continuing. Don’t forget to use the AST explorer if you’re not sure about the shape of the nodes.
The next step is to report the error and we’ll do this using the context.report function that ESLint provides. The report function takes an object which we’ll use to supply to values:
message, which will beno-inline-stylesnode, which will be our ESLint node.
Take some time looking over the AST and give it a shot yourself. You’ll know you’ve implemented a passing solution if all, but one of, your tests pass
Here’s my solution:
create: function (context) {
return {
TaggedTemplateExpression: (node) => {
const isLitExpression = node.tag.name === "html";
if (isLitExpression) {
const analyzer = new TemplateAnalyzer(node);
analyzer.traverse({
element(elNode) {
const { attrs = [] } = elNode;
const hasStyleAttr = attrs.some((attr) => attr.name === "style");
if (hasStyleAttr) {
context.report({
message: "no-inline-style",
node,
});
}
},
});
}
},
};
},
Back to templateExpressionToHtml
The last thing we need to do is fix the final failing test. This means we need to make changes to our templateExpressionToHtml function.
Since our node’s quasi value looks like this:
{
quasi: {
quasis: ['<div style="', '"></div>'],
expressions: ['val'],
}
}
All we need to do is reconcile them in the following pattern: quasis[0] → expression[0] → quasi[1] → etc.
This sounds like a job for a simple loop!
Because we’re not concerned with the value of the expression, we can apply a placeholder to help distinguish it from the rest of the HTML string. This is helpful when we run checks later in our HTML traverser.
We’ll copy the placeholder template that eslint-plugin-lit uses for their own placeholders: {{__Q:${i}__}}.
Try writing the loop, you’ll know you’ve nailed it once the final test passes. Here’s how my templateExpressionToHtml function looks after completing it:
templateExpressionToHtml(node) {
let value = "";
const quasiLength = node.quasi.quasis.length;
for (let i = 0; i < quasiLength; i++) {
const quasi = node.quasi.quasis[i];
const expression = node.quasi.expressions[i];
value += quasi.value.raw;
if (expression) {
value += `{{__Q:${i}__}}`;
}
}
return value;
}
Let’s go through this step-by-step:
valueis initialised with an empty string, which is used to build the HTML- We calculate the length of the
quasisarray, which will be used to inform our loop’s iteration count. - We kick off our loop and we:
- Access the quasi by the current index
- Access the expression by the current index
- Append the current quasi to our
valuestring - And if an
expressionexists, we also append it tovalue.
- We return
valuewhich we can now use to parse into an HTML AST.
And to really hit the point home, let’s run through the loop for the following code snippet:
<div style="${val}"></div>
As a reminder, the node we’ll be given will look like this:
{
type: "TaggedTemplateExpression",
quasi: {
type: "TemplateLiteral",
expressions: [
{
name: "val",
},
],
quasis: [
{
type: "TemplateElement",
value: {
raw: '<div style="',
},
},
{
type: "TemplateElement",
value: {
raw: '"></div>',
},
},
],
},
};
- We’ll use
valueto build the HTML - We’ll look at the length of
node.quasi.quasisto determine our loop’s iteration count. In this case we have two elements in the array. One for<div style="and another for"></div>. - Loop 1:
- Grab the first quasi,
<div style="and append it to ourvaluestring. - Because an element exists in the first index of the
expressionsarray, we’ll create a placeholder forval - At the end of the first loop iteration, our
valuelooks like<div style="{{__Q:i__}}
- Grab the first quasi,
- Loop 2:
- Grab the second quasi,
"></div>and append it to ourvaluestring. - Because no element exists in the second index of the
expressionsarray, we skip this step - at the end of the second loop iteration, our
valuelooks like<div style="{{__Q:i__}}"></div>
- Grab the second quasi,
- We return our fully-formed
valuestring, which is ready to be parsed into an HTML AST.
After implementing the templateExpressionToHtml function, the entirety of my test suite passes, and hopefully yours does too!
Aside: The limitations of ESLint
By definition, we use static analysis tools like ESLint, to check our code without running it. We can use ESLint to flag code style problems with our code, like we’ve just done in this article.
What ESLint can’t do is run our code, and report errors on the outputs. As a result, ESLint can’t report a problem for the following code snippet:
let foo = `style="width: 1px"`;
return html`<div ${foo}></div>`;
This is because expressions we pass as values won’t be evaluated by ESLint.
Vibe Check
After all the theory from part 1, it was a treat finally getting to write some code. I hope everything you learned came in handy and that understanding how ASTs and visitors work made writing your tests feel intuitive.
Let’s recap all the things we learnt in part 2:
- We can use ESLint’s RuleTester to test our rules against isolated pieces of code
- ESLint parses our code into an AST and traverses the nodes
- ESLint visits the nodes we request it to, and we do so by providing ESLint with a callback function named after our node
- When we visit our node, we:
- parse our
litexpression into an HTML AST - traverse the nodes of our HTML AST
- check to see if a
styleattribute is present - if it is, we report an error to ESLint
- parse our
If we compare the list above with the vibe check from the first article, you’ll see that all of the concepts are carried through to part 2. This knowledge will carry over to other rules that you write, as we’ll see in part 3 of this series.
In part 3, we’ll take things a little further. We’ll write a more complicated rule along with a handful of unit tests. We’ll implement an ESLint rule for an existing design system’s implementation of a Tooltip component. This rule will be particularly useful as it will ensure that:
- the component is used in its intended way
- will avoid tricky accessibility issue that may exclude whole populations from being able to use the component
See you all in part 3!
The Design System Best Practices with ESLint Series: