In this post I’m going to take you on the rollercoaster of style linting. We are going to create a new stylelint plugin, which will help enforce certain CSS properties that can only be found in certain files. Along the way we will learn how to have multiple rules per a single plugin (you’d think that’s trivial, but surprise surprise!), how to use the rules options and even make the lint message dynamic.
Buckle up, here we go.
Sometimes there are cases where you’d like a certain CSS property to reside in certain files, like “font-family” for instance. It’s ok to have “font-family” in your main CSS file, but if developers start scattering this property in different CSS files, it becomes overwhelming to introduce cross-application modifications to it later on.
But we need to remember the first rule of code standards:
You cannot enforce what you cannot automate
You can ask the developers in your organization not to add a certain property in some files, but that leaves you at the mercy of one’s memory, and sadly it is never enough.
Fortunately we have Stylelint for it.
Stylelint allows you to statically inspect files (not just CSS, or SASS as we will learn later on) and run some validations over them, the same way ESlint works. You can add this process to your CI pipeline or to your pre-commit hooks, and it will help you maintain the coding standards you’re going after.
We first start with defining what exactly we wish our rule to do -
I would like to be able to define the set of CSS properties, and for each define the files it is allowed or forbidden to be in.
If I’m trying to think of how it should look like on the Stylelint configuration file, it should be something like this:
{
"plugins": ["@pedalboard/stylelint-plugin-craftsmanlint"],
"rules": {
"stylelint-plugin-craftsmanlint/props-in-files": [
{
"font-family": {
"allowed": ["my-css-file.css"]
}
},
{
"severity": "warning"
}
]
}
}
Above I’m allowing the font-family
property to reside in the my-css-file.css
only. If the rule finds other instances of it in different files it will output a warning.
The Stylelint Plugin
Let’s create a stylelint plugin. For that I’m creating a new package called “stylelint-plugin-craftsmanlint” and in it there’s gonna be a single rule (for now) called props-in-files
(I’m saying “package” but you can have it on a directory on your project if you wish)
We start with installing stylelint
with yarn add stylelint
. Once we have that installed we can take the plugin scaffold template from the “Writing Plugins” docs as a starting point and tweak it a bit.
Well, perhaps a little more than “a bit”. Let me put the tweaked code here and explain below:
/// Abbreviated example
import stylelint from 'stylelint';
import type * as PostCSS from 'postcss';
const ruleName = 'stylelint-plugin-craftsmanlint/props-in-files';
const messages = stylelint.utils.ruleMessages(ruleName, {
expected: 'Expected ...',
});
const meta = {
url: 'https://github.com/mbarzeev/pedalboard/blob/master/packages/stylelint-plugin-craftsmanlint/README.md',
};
const ruleFunction = (primaryOption: Record<string, any>, secondaryOptionObject: Record<string, any>) => {
return (postcssRoot: any, postcssResult: any) => {
const validOptions = stylelint.utils.validateOptions(postcssResult, ruleName, {
actual: null,
});
if (!validOptions) {
return;
}
// ... some logic ...
stylelint.utils.report({
ruleName,
result: postcssResult,
message: messages.expected,
node: {} as PostCSS.Node, // Specify the reported node
word: 'some-prop', // Which exact word caused the error? This positions the error properly
});
};
};
ruleFunction.ruleName = ruleName;
ruleFunction.messages = messages;
ruleFunction.meta = meta;
export default ruleFunction;
First thing to notice is that the code above is not using CommonJS exports, but ESM.
It does not enforce anything yet. I’ve changed the rule name and meta according to my needs, and you can see that I converted the file to TypeScript, but still I’m not going to deal with aligning the types at the moment (forgive the “any”s please).
The most important thing here is the fact that I’m exporting the rule function and not the createPlugin
return value (as the documentation suggests). I do this so I can later aggregate several rules under this plugin. We will see that soon.
Multi-Rules Stylelint Plugin
It is time to export the rule in the main index.ts
of the package, alas the docs are not very clear on how you can export multi rules per plugin.
If you look at the example from the docs you will see that the rule code is actually exporting a plugin, not a rule function. So it basically means that this plugin has only a single rule to it.
The createPlugin
method does not receive an array of rules. So how do you export multiple rules for a single plugin?
I had to dig a bit and find that if you export an array of plugins, with the same rules namespace then it acts as if your plugin supports multiple rules. TBH, Not intuitive and confusing to say the least, but good thing you have me to face these blockers and kick them out for ya, right? :)
Inside the rules
directory we will create an index.ts
file. This one will aggregate all the rules the plugin has:
import propsInFiles from './props-in-files';
export default {
'props-in-files': propsInFiles,
};
And in the root index.ts
file we will go over these rules and create a plugin for each, store them all in an Array and export it:
import {createPlugin} from 'stylelint';
import rules from './src/rules';
const NAMESPACE = 'stylelint-plugin-craftsmanlint';
const rulesPlugins = Object.keys(rules).map((ruleName) => {
return createPlugin(`${NAMESPACE}/${ruleName}`, rules[ruleName]);
});
export default rulesPlugins;
And after the build is done we have a package ready to be used as a stylelint plugin.
Using it
For the sake of checking if this plugin works I will open a different project and install stylelint for it (but now as a dev dependency).
If you’re having plugin defined in your project, you can get it from the location it is at, but mine is published as a package so I will install it using Yarn:
yarn add -D @pedalboard/stylelint-plugin-craftsmanlint
For any project which wishes to use Stylelint you need to have some kind of configuration telling the tool how to act. This configuration is done with a file called .stylelintrc.json
(can have other flavors to it, but I chose the JSON one).
I create a .stylelintrc.json
configuration file which has my plugin in it, like so:
{
"plugins": ["@pedalboard/stylelint-plugin-craftsmanlint"],
"rules": {
"stylelint-plugin-craftsmanlint/props-in-files": true
}
}
BTW, if your project has SASS or LESS you can define a customSyntax which can take care of these files and parse them correctly. Read more about it here.
Stylelint is a bit greedy and “wants” to lint everything, so we must tell it to relax and check only files we interested in. We can do that with a .stylelintignore
file defined like this:
*.js
*.svg
*.ts*
*.mdx
Once the configuration is ready I’m adding a script to my package.json
file that will launch the stylelint:
"lint:style": "stylelint ./src",
And now I can launch my linter, by running yarn lint:style
.
Funny, we haven’t even started talking about the logic this rule should implement. Taking a deep breath…
Let’s continue.
Adding logic
Before I start - a disclaimer:
The logic presented here does what it should, but is relatively naive and missing some aspects, like validating the props, tests, better typing and some refactoring. I hope you could see past that to gain the true value of it.
What our logic does is go over the different nodes in our CSS files and inspect them. It receives the properties it needs to look out for, and a list of files they are allowed or forbidden to reside in, for each.
The way the Stylelint rules work is you can terminate the rule function at any point in the code and by that avoid reporting a violation. If none of the conditions is met the function does not terminate and ends up with reporting the node it is currently in.
I’m also using a dynamic message here, which is simply defining it as a function which receives the property currently inspected and concatenating in the final message.
import stylelint from 'stylelint';
const ruleName = 'stylelint-plugin-craftsmanlint/props-in-files';
const messages = stylelint.utils.ruleMessages(ruleName, {
expected: (property) => `"${property}" CSS property was found in a file it should not be in`,
});
const meta = {
url: 'https://github.com/mbarzeev/pedalboard/blob/master/packages/stylelint-plugin-craftsmanlint/README.md',
};
const ruleFunction = (primaryOption: Record<string, any>, secondaryOptionObject: Record<string, any>) => {
//@ts-ignore
return (postcssRoot, postcssResult) => {
const validOptions = stylelint.utils.validateOptions(postcssResult, ruleName, {
actual: null,
});
if (!validOptions) {
return;
}
//@ts-ignore
postcssRoot.walkDecls((decl) => {
//Iterate CSS declarations
const propRule = primaryOption[decl.prop];
if (!propRule) {
return;
}
const file = postcssRoot?.source?.input?.file;
const allowedFiles = propRule.allowed;
const forbiddenFiles = propRule.forbidden;
let shouldReport = false;
//@ts-ignore
const isFileInList = (inspectedFile) => file.includes(inspectedFile);
if (allowedFiles) {
shouldReport = !allowedFiles.some(isFileInList);
}
if (forbiddenFiles) {
shouldReport = forbiddenFiles.some(isFileInList);
}
if (!shouldReport) {
return;
}
stylelint.utils.report({
ruleName,
result: postcssResult,
message: messages.expected(decl.prop),
node: decl,
});
});
};
};
ruleFunction.ruleName = ruleName;
ruleFunction.messages = messages;
ruleFunction.meta = meta;
export default ruleFunction;
And now when we run the yarn lint:style
command, we’re getting this expected error:
And... that’s it 🙂
The code can be found here if you wish to further inspect it, and the package can be downloaded from NPM.
I hope this helped you getting started with your first Stylelint plugin and rules. If you have any questions or comments please leave them at the comments section below so we can all learn from them
Hey! for more content like the one you've just read check out @mattibarzeev on Twitter 🍻
Photo by Jessica Ruscello on Unsplash
Top comments (2)
Thanks for this, I was looking for this for quite a while. It seems like this approach should be in the official documentation
Thanks so much for the kind words :) glad I could help and save some time 🍻