The Codemod Side Quest

In my post from a couple months ago, Adding RSS to My Next.js Website, I mentioned a minor side quest: building a codemod and executing it on all of my existing blog posts. An excerpt from that post:

Codemods are scripts that automate code changes when you need to make the same change to a large number of files. If you're familiar with batch-processing in Photoshop, this is similar, except a codemod is for code.

In order to build an RSS feed, all of my posts needed to include a new object containing each posts metadata (title, summary, published status, etc). While I could have updated each post manually, I am a Real Developer, and Real Developers do things The Needlessly Difficult Way. /s

Actually, writing a codemod seemed like a fun challenge. It was hyped at the most recent React Conf as an invaluable tool at Facebook, and I liked the idea of using code to modify code.

Luckily, I had already been structuring my blog posts in a consistent way, making a batch edit feasible. Here's an example post, pre-codemod.


import BlogPage from "@core/blog-page";

export default () => (
  return <BlogPage
    dateTime="2020-03-21"
    description="A description of the post."
    ogImage="/assets/image.jpg"
    title="The Post Title"
  >
    <header>
      <h1>The Post Title</h1>
    </header>
    <p>
      The content of the post.
    </p>
  </BlogPage>
);
              

My posts didn't include the aforementioned metadata object, but all of that data was there as props. Here's how I wanted my posts to look post-codemod:


import BlogPage from "@core/blog-page";

export const meta: Meta = {
  published: true,
  publishedAt: "2020-03-21",
  summary: "A description of the post.",
  image: "/assets/image.jpg",
  title: "The Post Title"
};

export default () => (
  return <BlogPage
    dateTime={meta.publishedAt}
    description={meta.summary}
    ogImage={meta.image}
    title={meta.title}
  >
    <header>
      <h1>{meta.title}</h1>
    </header>
    <p>
      The content of the post.
    </p>
  </BlogPage>
);
              

Getting Set Up

I was able to get everything kicked off with this post by Anthony Accomazzo. It thoroughly covered how to install and and run jscodeshift - the command line tool for running codemods. Exactly what I needed.

One of my favorite tips from this post was the dry run flag. By including a -d (dry run) and -p (print) in my command, I was able to preview what my codemod would do without modifying the files themselves.

Next, I explored Facebook's library of codemods, reactjs/react-codemod. I figured this would be the best place to pick up on good patterns to use in my own codemod.

I also took a look at AST Explorer. It's a popular tool that lets you paste in your code and codemod and quickly see the result. I wasn't able to get it to work for my needs at the time, however.

I ended up heavily referencing Creating a custom transform for jscodeshift by Spencer Skovy, another very thorough how-to post.

Writing the Codemod

Go ahead and scroll up to view the pre- and post-codemod blog posts to get a feel for the modifications I needed to make. Here they are:

  • Get all of the metadata from the props on BlogPage.
  • Update the h1 heading so that it pulls from the metadata.
  • Update the props so that they pull from the metadata.
  • Create the metadata object.
  • Add the metadata object after the last import.

Here's the resulting codemod with comments describing how each of these tasks was accomplished. I also published this codemod as a gist if you'd like to leave feedback.


const transform = (file, api) => {
  const j = api.jscodeshift;
  const root = j(file.source);

  // Creates a map of properties. For instance, the `dateTime` prop becomes the
  // `publishedAt` metadata property.
  const blogPageProps = [
    { name: "dateTime", type: "Literal", metaName: "publishedAt" },
    { name: "description", type: "Literal", metaName: "summary" },
    { name: "ogImage", type: "Literal", metaName: "image" },
    { name: "title", type: "Literal", metaName: "title" }
  ];

  // Not all blog posts include all of the possible props - I collected them for
  // each post in this array.
  const metaProps = [];

  // Updates the h1 heading so that it pulls from the metadata.
  root
    .findJSXElements("h1")
    .replaceWith(() => {
      return "<h1>{meta.title}</h1>";
    });

  const blogPage = root
    .findJSXElements("BlogPage");

  // This looks through each possible prop.
  blogPageProps.forEach(prop => {
    blogPage
      .find(j.JSXAttribute, {
        name: {
          type: "JSXIdentifier",
          name: prop.name
        },
        value: {
          type: prop.type
        }
      })
      .find(j.Literal)
      .replaceWith(nodePath => {
        const { node } = nodePath;
        // The data for this prop is added to the metaProps array and replaced
        // with a reference to the metadata object, such as `meta.publishedAt`.
        metaProps.push({ key: prop.metaName, value: node.value });
        return j.jsxExpressionContainer(j.identifier(`meta.${prop.metaName}`));
      });
  });

  // This converts the collected metadata to an array of strings. These become
  // lines of code in the post, such as `publishedAt: "2019-12-12"`. A bit
  // janky, but it works.
  const metaPropsStrings = metaProps.map(
    prop => `
  ${prop.key}: "${prop.value}"`
  );

  // The last import in the example above is
  // `import BlogPage from "@core/blog-page";`. This adds the metadata object
  // below that import.
  const LAST_IMPORT = root.find(j.ImportDeclaration).at(-1);
  LAST_IMPORT.insertAfter(`export const meta: Meta = {${metaPropsStrings}
};`);

  return root.toSource();
};

export default transform;
          

Note: I did forget to include one thing. This codemod doesn't add a published property, so I ended up doing a bit of cleanup after the fact.

Codemod Complete!

Frequent writers of codemods won't be impressed with this code, but it accomplished what I was seeking to do - and I'm happy with that. Feel free to bother me on Twitter if you have thoughts, I'd love to hear them.

Thanks for reading! 👋