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! 👋