CSS codemods with PostCSS

profile picture

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.

  1. Codemods typically are only ran once on a codebase. Unlike a build tool, which is ran anytime something changes.
  2. 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.

default view of AST explorer
Default view of AST explorer.

Since we're working with CSS click the language option currently set to "JavaScript" and set it to "CSS."

AST explorer language option
Changing the AST Explorer language option 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.

AST explorer parser option
Changing the AST Explorer parser option to postcss.

Now the snippet of CSS we're working with can be copied and pasted into the AST Explorer code editor in the left pane.

AST explorer with code
AST Explorer with our CSS code snippet input.

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.

AST explorer CSS color node
CSS color node highlighted in AST Explorer.

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:

  1. Find all declaration nodes
  2. Filter all declaration nodes to only those where the prop is "color"
  3. Set the value of the remaining nodes to red

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; to border: 1px solid magenta;. The entire value 1px solid red is represented as a single string which can make these types of changes tricky. The postcss-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