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 no node is present
  • visit 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:

  1. message, which will be no-inline-styles
  2. 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 to value.
  • 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 our value string.
    • Because an element exists in the first index of the expressions array, we'll create a placeholder for val
    • At the end of the first loop iteration, our value looks like <div style="{{__Q:i__}}
  • Loop 2:
    • Grab the second quasi, "></div> and append it to our value 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>
  • 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

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: