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
visitors
which contains our visitor functions - Visit each child node of
this.ast
- Call the
element
visitor 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. visit
returns early if nonode
is presentvisit
checks to see if our node is an HTML element- if it is,
visit
calls our visitor function visit
is 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-styles
node
, 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:
value
is initialised with an empty string, which is used to build the HTML- We calculate the length of the
quasis
array, 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
value
string - And if an
expression
exists, we also append it tovalue
.
- We return
value
which 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
value
to build the HTML - We’ll look at the length of
node.quasi.quasis
to 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 ourvalue
string. - Because an element exists in the first index of the
expressions
array, we’ll create a placeholder forval
- At the end of the first loop iteration, our
value
looks like<div style="{{__Q:i__}}
- Grab the first quasi,
- Loop 2:
- Grab the second quasi,
"></div>
and append it to ourvalue
string. - Because no element exists in the second index of the
expressions
array, we skip this step - at the end of the second loop iteration, our
value
looks like<div style="{{__Q:i__}}"></div>
- Grab the second quasi,
- We return our fully-formed
value
string, 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
lit
expression into an HTML AST - traverse the nodes of our HTML AST
- check to see if a
style
attribute 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: