CSS codemods with PostCSS
Spencer Miskoviak
January 24, 2022 • Last updated on April 20, 2022
Photo by Chris Lawton
Since originally writing this I've turned the code in this post into a package:
css-codemod
. It's a more generalized approach with more features. Consider using it for complex transforms instead of the custom transform script contained in this post.
When looking to codemod, or transform, JavaScript code there are tools like jscodeshift to streamline the process, and blog posts to help get started. This tooling and content exists because transforming JavaScript in an evolving codebase is a common enough need.
While it's less likely that similar changes would need to be made to CSS, it's not zero. I was surprised by the lack of streamlined tooling and content when doing research to create a CSS codemod to convert SASS variables to CSS variables.
Fortunately, PostCSS provides all of the necessary functionality, but turning it into a CSS codemod requires some boilerplate and stitching together a few packages. This blog post will walk through a custom CSS codemod that will provide the basic structure to transform any CSS.
PostCSS
PostCSS is a tool for transforming CSS with JavaScript. That's exactly what we want!
However, PostCSS is almost always used as a plugin as part of larger build process, such as webpack. There are a few important distinctions between using PostCSS as part of a build process versus a codemod.
- Codemods typically are only ran once on a codebase. Unlike a build tool, which is ran anytime something changes.
- The output from the codemod is written back to the original source file. Unlike a build tool which typically outputs a new transformed file and leaves the original source unmodified.
For these reasons, it takes some additional setup to handle the tasks commonly handled by a build tool such as reading files, writing files, and actually performing the transformation. Fortunately again, all this functionality already exists in either node or as npm packages, so it's really about putting these puzzle pieces together in the right way.
Getting started
Let's start with a "hello world" of CSS codemods: turn every color
property value to red
!
For example, if given the following snippet of CSS with the property color
with a value of #000
the codemod should transform the value to red
. All other properties should remain untouched.
.class {
margin-top: 42px;
- color: #000;
+ color: red;
background-color: #fff;
}
To begin, we'll create a new project for a codemod that is capable of solving this problem. By the end, we'll have a flexible codemod template that can be used for any CSS codemod.
# Create a new directory and change into it.
mkdir css-codemod-demo
cd css-codemod-demo
# Initialize a new `package.json` file with either `npm` or `yarn`.
# This post will use `yarn`.
yarn init -y
Then, add a few npm dependencies and initialize a TypeScript project.
# Install npm dependencies.
yarn add -D postcss typescript ts-node @types/node
# Initialize a new TypeScript project (create a `tsconfig.json`).
yarn tsc --init
The only required dependency is postcss
. However, typescript
(and it's related dependencies) catch a lot of errors and typos, and provide autocomplete. This is especially helpful when dealing with potentially complex nodes in an abstract syntax tree like we will be with PostCSS.
Now the project is initialized, it's time to create the CSS codemod. Add a new transform.ts
file with the original CSS snippet.
// transform.ts
const css = `.class {
margin-top: 42px;
color: #000;
background-color: #fff;
}`;
This css
variable is a string that contains a CSS rule. This will act as the input to start, in a later step we'll add support for arbitrary files.
With the CSS input in place, we can add the boilerplate for processing CSS with PostCSS.
// transform.ts
// Import `postcss` and the `AcceptedPlugin` type.
// This type defines what PostCSS considers as acceptable plugins.
import postcss, { AcceptedPlugin } from "postcss";
// Existing CSS snippet.
const css = `.class {
margin-top: 42px;
color: #000;
background-color: #fff;
}`;
// We don't have any plugins yet, but will in a moment.
// Define an empty array of plugins. Explicitly define the
// type because (a) there is no type to infer, yet, and
// (b) ensure all plugins meet the defined type definition
// contract to avoid invalid plugins.
// Examples: https://github.com/postcss/postcss/blob/main/docs/plugins.md
const plugins: AcceptedPlugin[] = [];
// Initialize a PostCSS processor, passing plugins to be included.
// Processor docs: https://postcss.org/api/#processor
const processor = postcss(plugins);
// Define a helper function to execute the transform.
const transform = async () => {
// Using the processor initialized above, process the
// CSS string defined above and await for the result.
// Result docs: https://postcss.org/api/#result
const result = await processor.process(css);
// Print the processed CSS string. Since there were
// no plugins there were no transformations. The
// identical CSS string will be printed. This verifies
// everything is working as expected.
console.log(result.css);
};
// Run the transform.
transform();
Next, we can add a script to package.json
to make it easy to run the transform.
// package.json
{
// ...
"scripts": {
"transform": "ts-node ./transform.ts"
}
// ...
}
ts-node
was installed earlier with the TypeScript dependencies. It's a quick replacement for node
that supports TypeScript execution that can be used to run the transform.
yarn transform
Since there were no plugins, there won't be any transformations made to the input. Therefore, the output CSS that was processed by PostCSS will print the identical CSS.
.class {
margin-top: 42px;
color: #000;
background-color: #fff;
}
You may also notice a warning being printed.
Without `from` option PostCSS could generate wrong source map and will not find Browserslist config. Set it to CSS file path or to `undefined` to prevent this warning.
This is from PostCSS warning about a missing configuration that is important for sourcemaps. This makes sense in the context of a build tool, but for a codemod that's transforming code directly in place there's no need for a sourcemap. We can explicitly set this configuration option to undefined
like the warning suggests to silence it.
// transform.ts
// ...
const transform = async () => {
const result = await processor.process(css, {
// Explicitly set the `from` option to `undefined` to prevent
// sourcemap warnings which aren't relevant to this use case.
from: undefined,
});
console.log(result.css);
};
// ...
At this point we've verified that this transform can accept input CSS, run it through PostCSS, and print the results.
The next step is to make an actual code transformation. Before that, let's understand how we need to transform the code.
AST Explorer
When working with codemods, it's helpful to first understand the change you're trying to make. In order to do that with PostCSS, we need to understand how it processes and transforms CSS. At a high level, PostCSS is converting CSS into an abstract syntax tree. An abstract syntax tree is a tree data structure that represents the code as nodes.
How do we know which nodes to change if we don't know what the nodes are? AST Explorer.
After navigating to AST Explorer for the first time you'll see something like the screenshot below. It defaults to an example JavaScript snippet on the left and the abstract syntax tree on the right that represents that JavaScript input.
Since we're working with CSS click the language option currently set to "JavaScript" and set it to "CSS."
For CSS, AST Explorer defaults to cssom
as the parser but we're already using postcss
. Similarly, change the parser to postcss
. Different parsers can produce different trees and nodes so it's important to use the identical parser to avoid subtle differences.
Now the snippet of CSS we're working with can be copied and pasted into the AST Explorer code editor in the left pane.
Then, clicking the color
property in the CSS code snippet should highlight the node that represents that piece of code in the abstract syntax tree in the right pane.
The node highlighted in the tree can also be represented with the following simplified JSON snippet.
{
// The type of node. In this case, a Declaration node.
// Declaration nodes have a `prop` to represent the name
// of the property and a `value` to represent it's value.
// Declaration docs: https://postcss.org/api/#declaration
"type": "decl",
// The name of the declaration property.
"prop": "color",
// The value of the declaration.
"value": "#000"
}
Since we only want to transform color
properties this is all the information we need.
In order to transform the code, we need to do the following:
- Find all declaration nodes
- Filter all declaration nodes to only those where the
prop
is"color"
- Set the
value
of the remaining nodes tored
Now we can turn these steps into a custom PostCSS plugin to transform code.
Creating a custom PostCSS plugin
There are a few ways plugins can be defined with PostCSS, but the simplest is an object. The only required property is postcssPlugin
, a string that represents the plugin's name. The object also accepts a method for any node type. That method will be called for every node of that type while processing the CSS.
In this case, we want to check all Declaration nodes.
// transform.ts
// Add another import for a `Plugin` type, which represents
// one of the accepted plugin types.
import postcss, { AcceptedPlugin, Plugin } from "postcss";
const css = `.class {
margin-top: 42px;
color: #000;
background-color: #fff;
}`;
// Define a new PostCSS plugin to perform the code transform.
// Docs: https://github.com/postcss/postcss/blob/main/docs/writing-a-plugin.md
const transformPlugin: Plugin = {
// The name of the plugin.
postcssPlugin: "Transform Plugin",
// The type of node to visit. It will be invoked for each
// node of this type in the tree. It receives the current
// node as a parameter.
Declaration(decl) {
// Log the value of the `prop` property for the current node.
// This will print the CSS property name for each declaration.
console.log(decl.prop);
},
};
// Add the plugin to the list of plugins used to initialize PostCSS.
const plugins: AcceptedPlugin[] = [transformPlugin];
const processor = postcss(plugins);
const transform = async () => {
const result = await processor.process(css, { from: undefined });
// Temporarily comment this out to focus on the above logs.
// console.log(result.css);
};
transform();
Now a custom PostCSS plugin was added that will visit each declaration node and print it's CSS property. Let's go ahead and run it again.
yarn transform
The output should look like the following, each CSS property name that was used in the example snippet will be printed. With minimal code we were able to reliably target all the CSS declarations.
margin-top
color
background-color
With the correct nodes targeted, we can add the remaining logic to perform the transform.
// transform.ts
// ...
const transformPlugin: Plugin = {
postcssPlugin: "Transform Plugin",
Declaration(decl) {
// Only target declarations for `color` properties.
if (decl.prop === "color") {
// Change it's value to `red`.
decl.value = "red";
}
},
};
// ...
After uncommenting console.log(result.css)
, running the transform should make the change this time.
yarn transform
The output CSS correctly has only the color
property updated to red
.
.class {
margin-top: 42px;
color: red;
background-color: #fff;
}
This is exactly what we were trying to do. The only problem is that with a codemod we're always dealing with a bunch of existing files. How do we apply this transform to an entire codebase?
Working with files
Before trying to work with files, let's create two example files (a.css
and b.css
) in a src
subdirectory in this project. This is an arbitrary example to represent a few files.
/* src/a.css */
.class {
margin-top: 42px;
color: #000;
background-color: #fff;
}
/* src/b.css */
.another {
color: #fff;
background-color: #000;
}
When working with codemods, it's common to target files to transform through a glob pattern (eg: src/**/*.css
) since codemods often process all the files with a certain extension, or in a certain directory.
The glob
package makes this easy to support.
yarn add -D glob @types/glob
With the test files and a new dependency, the transform can be updated one last time.
// transform.ts
// File system helpers provided by node.
// Docs: https://nodejs.org/api/fs.html
import fs from "fs";
import postcss, { AcceptedPlugin, Plugin } from "postcss";
// Import glob for use below.
import glob from "glob";
// NOTE: the demo CSS string is now removed since we're working with files.
const transformPlugin: Plugin = {
postcssPlugin: "Transform Plugin",
Declaration(decl) {
if (decl.prop === "color") {
decl.value = "red";
}
},
};
const plugins: AcceptedPlugin[] = [transformPlugin];
const processor = postcss(plugins);
const transform = async () => {
// Use glob's synchronous method to find all CSS files
// in the `src` directory (or any of it's subdirectories)
// that have a `.css` extension. It returns an array of
// file paths that match the glob. If working on a codemod
// for another project this might be something like:
// "../other-project/src/**/*.css".
const files = glob.sync("./src/**/*.css");
// Loop through each of the files. Since processing the CSS
// is async, handling each file is async so we end up with
// an array of promises.
const filePromises = files.map(async (file) => {
// Read the file and convert it to a string.
// This is effectively equivalent to the `css`
// variable that was previously defined above.
const contents = fs.readFileSync(file).toString();
// Identical, but the `css` variable was swapped for the file `contents`.
const result = await processor.process(contents, { from: undefined });
// Instead of logging the result, write the
// result back to the original file, completing
// the transformation for this file.
fs.writeFileSync(file, result.css);
});
// Wait for the array of promises to all resolve.
await Promise.all(filePromises);
};
transform();
The transform is now finding all CSS files in the src
directory, reading each file's CSS content, transforming the CSS, and writing the transformed CSS back to the original file. Run the transform one last time.
yarn transform
Now, inspecting the a.css
and b.css
files added earlier should both have their color
properties updated to red
.
/* src/a.css */
.class {
margin-top: 42px;
color: red;
background-color: #fff;
}
/* src/b.css */
.another {
color: red;
background-color: #000;
}
While this example of changing all color
properties to red
may seem contrived, this transform is surprisingly flexible. It's effectively leveraged all of the power PostCSS provides to transform CSS in build tools to instead transform CSS in-place.
You can follow similar steps to modify this transform for a different codemod. Start with a simplified CSS snippet and paste it into AST Explorer (with the correct configuration) to understand the different nodes. Then modify the transform plugin to target the correct nodes with the desired logic.
Tips and Tricks
Below are a list of a few links and packages that I found useful when working with CSS codemods:
- The PostCSS API documentation is helpful to understand different return values, helpers, nodes, etc.
- The documentation for writing a PostCSS Plugin is helpful specifically when working with the plugin.
- Some CSS transforms might require updating parts of values. For example, say you want to change
border: 1px solid red;
toborder: 1px solid magenta;
. The entire value1px solid red
is represented as a single string which can make these types of changes tricky. Thepostcss-value-parser
can be used to effectively turn values into mini abstract syntax trees. There are additional parsers like this in the plugin documentation above. - Additional packages like
postcss-scss
can be used to extend support for syntax like SASS.
Next time you're renaming variables, updating to newer syntax, or any other sweeping CSS change consider creating a codemod to reliably and quickly transform CSS.
The source for this example can be found on GitHub.
Tags:
course
Practical Abstract Syntax Trees
Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.
Check out the course