DEV Community

loading...
Cover image for Incrementally adding Stylelint rules with Betterer

Incrementally adding Stylelint rules with Betterer

Craig ☠️💀👻
Craig is a Software Engineer from New Zealand, working at Spotify in Stockholm. He loves building things that help teams build cool things! He also loves punk rock, Disney's Frozen, and his cat Cosy!
・5 min read

I just released v4.0.0 of Betterer 🎉 (now with sweet new docs!) and it has a bunch of simplified APIs for writing tests. And just before I shipped it, I got an issue asking how to write a Stylelint test, so let's do it here and explain it line by line:

TL;DR;

Here's the full test:

// stylelint.ts
import { BettererFileResolver, BettererFileTest } from '@betterer/betterer';
import { promises as fs } from 'fs';
import { Configuration, lint } from 'stylelint';

export function stylelint(configOverrides: Partial<Configuration> = {}) {
  const resolver = new BettererFileResolver();
  return new BettererFileTest(resolver, async (filePaths, fileTestResult) => {
    const result = await lint({
      files: [...filePaths],
      configOverrides
    });

    await Promise.all(
      result.results.map(async (result) => {
        const contents = await fs.readFile(result.source, 'utf8');
        const file = fileTestResult.addFile(result.source, contents);
        result.warnings.forEach((warning) => {
          const { line, column, text } = warning;
          file.addIssue(line - 1, column - 1, line - 1, column - 1, text, text);
        });
      })
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

And then using the test:

// .betterer.ts
import { stylelint } from './stylelint'; 

export default {
  'no stylelint issues': stylelint({
    rules: {
      'unit-no-unknown': true
    }
  }).include('./**/*.css')
};
Enter fullscreen mode Exit fullscreen mode

NTL;PR (not that long, please read 😂)

Stylelint

So how does it all work? Let's start with the actual Stylelint part.

Stylelint is pretty easy to set-up. You need a .stylelintrc.json file with configuration:

{
  "extends": "stylelint-config-standard"
}
Enter fullscreen mode Exit fullscreen mode

And then run it on your CSS files:

stylelint "**/*.css"
Enter fullscreen mode Exit fullscreen mode

Running that does the following:

1) searches for the stylelintrc.json configuration file
2) reads the configuration
3) finds the valid files
4) runs the rules
5) returns the results

Stylelint also has a JS API which we're going to use:

import { lint } from 'stylelint';

const result = await lint({
  // ...
});
Enter fullscreen mode Exit fullscreen mode

We could just run the above and it will test the current state of the files with the current configuration in stylelintrc.json. And that's great ✨!

Augmenting the configuration:

For the Betterer test we want to augment the stylelintrc.json configuration with some extra rules... and Stylelint has a really easy way to do that:

import { Configuration, lint } from 'stylelint';

function stylelint(configOverrides: Partial<Configuration> = {}) {
    const result = await lint({
      configOverrides
    });
}
Enter fullscreen mode Exit fullscreen mode

Passing the list of files:

Stylelint also allows us to pass a specific set of files to test:

import { Configuration, lint } from 'stylelint';

function stylelint(configOverrides: Partial<Configuration> = {}, files: Array<string>) {
    const result = await lint({
      files,
      configOverrides
    });
}
Enter fullscreen mode Exit fullscreen mode

So we could call the stylelint function like:

stylelint({
  rules: {
    'unit-no-unknown': true
  }
}, './**/*.css')
Enter fullscreen mode Exit fullscreen mode

And that will run the Stylelint from the stylelinerc.json file, plus the unit-no-unknown rule, on all .css files! Thats most of the tricky stuff sorted ⭐️!

Hooking into Betterer:

This test needs to take advantage of all the snapshotting and diffing magic of Betterer, so we need to wrap it in a test. We want to be able to target individual files, so it specifically needs to be a BettererFileTest.

We first create a BettererFileResolver, which is a little bit of magic that helps work out which file paths are relevant for the test. That is passed as the first argument to BettererFileTest. The second argument is the actual test, which will be an async function that runs the linter.

import { BettererFileResolver, BettererFileTest } from '@betterer/betterer';
import { Configuration, lint } from 'stylelint';

function stylelint(configOverrides: Partial<Configuration> = {}) {
  const resolver = new BettererFileResolver();
  return new BettererFileTest(resolver, async (filePaths) => {
    // ...
  });
}
Enter fullscreen mode Exit fullscreen mode

Each time it runs Betterer will call that function with the relevant set of files, which we will pass along to Stylelint:

import { BettererFileResolver, BettererFileTest } from '@betterer/betterer';
import { Configuration, lint } from 'stylelint';

function stylelint(configOverrides: Partial<Configuration> = {}) {
  const resolver = new BettererFileResolver();
  return new BettererFileTest(resolver, async (filePaths) => {
    const result = await lint({
      files: [...filePaths],
      configOverrides
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Adding files:

Next thing is telling Betterer about all the files with issues reported by Stylelint. To do this we can use the BettererFileTestResult object, which is the second parameter of the test function:

new BettererFileTest(resolver, async (filePaths, fileTestResult) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The result object from Stylelint contains a list of results. For each item in that list, we need to read the file with Node's fs module, and then call addFile() with the file path (result.source), and the contents of the file. That returns a BettererFile object:

import { promises as fs } from 'fs';

await Promise.all(
  result.results.map(async (result) => {
    const contents = await fs.readFile(result.source, 'utf8');
    const file = fileTestResult.addFile(result.source, contents);
  })
);
Enter fullscreen mode Exit fullscreen mode

Adding issues:

The last thing to do is convert from Stylelint warnings to Betterer issues. To do that we use the addIssue() function! In this case we will use the following overload:

addIssue(startLine: number, startCol: number, endLine: number, endCol: number, message: string, hash?: string):
Enter fullscreen mode Exit fullscreen mode

Stylelint only gives us the line and column for the start of the issue, so we use that as both the start position and the end position. Betterer expects them to be zero-indexed so we subtract 1 from both. This also means that the VS Code extension will add a diagnostic to the whole token with the issue, which is pretty handy! We also pass the text of the issue twice, once as the message, and a second time as the hash. The hash is used by Betterer to track issues as they move around within a file. Stylelint adds specific details to the message so that makes it a good enough hash for our purposes. All up, converting an issue looks like this:

result.warnings.forEach((warning) => {
  const { line, column, text } = warning;
  file.addIssue(line - 1, column - 1, line - 1, column - 1, text, text);
});
Enter fullscreen mode Exit fullscreen mode

The whole test:

Putting that all together and you get this:

// stylelint.ts
import { BettererFileResolver, BettererFileTest } from '@betterer/betterer';
import { promises as fs } from 'fs';
import { Configuration, lint } from 'stylelint';

export function stylelint(configOverrides: Partial<Configuration> = {}) {
  const resolver = new BettererFileResolver();
  return new BettererFileTest(resolver, async (filePaths, fileTestResult) => {
    const result = await lint({
      files: [...filePaths],
      configOverrides
    });

    await Promise.all(
      result.results.map(async (result) => {
        const contents = await fs.readFile(result.source, 'utf8');
        const file = fileTestResult.addFile(result.source, contents);
        result.warnings.forEach((warning) => {
          const { line, column, text } = warning;
          file.addIssue(line - 1, column - 1, line - 1, column - 1, text, text);
        });
      })
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

And then we can use the test like this:

// .betterer.ts
import { stylelint } from './stylelint'; 

export default {
  'no stylelint issues': stylelint({
    rules: {
      'unit-no-unknown': true
    }
  }).include('./**/*.css')
};
Enter fullscreen mode Exit fullscreen mode

And that's about it! The Stylelint API is the real MVP here, nice job to their team! 🔥🔥🔥

Hopefully that makes sense! I'm still pretty excited by Betterer, so hit me up on Twitter if you have thoughts/feelings/ideas, or leave a comment here! ☀️

Discussion (1)

Collapse
titungdup profile image
dhondup

Hi Craig, This might be a bit off-topic but it's related to stylelint. I was configuring the stylelint-order plugin to sort CSS by properties. I succeeded but it sorts in the build file which is not really helpful because it will be minified anyway. What I wanted to do was sort in the working CSS file. Is there any way I can achieve that by using the stylelint-order plugin? I also found the Prettier plugin (prettier-plugin-style-order) but couldn't make it work.