Automating Busywork by Scaffolding Boilerplate Files
At 4/19/2024
When developing websites, I often create the same boilerplate structure over and over. By creating a custom scaffolding script, I can automate this boring task and save a lot of time.
On a recent Preact project, each new component required a new directory with the following files:
- A Preact component file (
/my-component/my-component.tsx
) - A Sass file for component styles (
/my-component/my-component.scss
) - A test file for any relevant unit tests (
/my-component/my-component.test.tsx
) - A Storybook file for documentation (
/my-component/my-component.stories.mdx
)
Don’t worry if the tools I mentioned aren’t the same tools you use. The core concepts here will apply regardless of your tech stack. (Though all of the code examples are in Node/JavaScript.)
I had to recreate this folder structure dozens of times throughout the project. At first, I manually created each file. Then I switched to copying and pasting whole directories and editing the individual files. This got old fast.
Automating boilerplate
This is a common pain point on many projects, but with a bit of work up-front, we can automate this file creation and save a lot of time down the road. We’ll be writing a Node script with the following features:
- Running via a command-line interface
- Creating directories and files
- Populating those files
- Accepting user input
First off, let’s create a Node script called scaffold-component.js
and place it at the root of our project. We can run this script by typing node scaffold-component.js
in our terminal.
To make our script more discoverable, we can add it as a script in our project’s package.json
file:
{
"scripts": {
"scaffold-component": "node scaffold-component.js"
}
}
Code language: JSON / JSON with Comments (json)
We can now run the script by typing npm run scaffold-component
. This step isn’t necessary, but moving the script to a standardized location will make it easier for other developers on the team to find it.
Creating directories and files
Alright, let’s start writing our script. We can use a few built-in Node functions to generate our files:
// I'm using the import syntax here which requires you to tell Node that you're using the ESM syntax.
// https://nodejs.org/docs/latest-v13.x/api/esm.html#esm_enabling
import { writeFileSync, mkdirSync, existsSync } from "fs";
// We'll make this configurable later in the article.
const componentName = 'MyComponent';
// You can tweak the path to better match your project's structure.
// All directories in this path must already exist.
const componentsPath = './src/components/';
const directoryPath = `${componentsPath}${componentName}`
// Throw an error if a directory with this name already exists.
if(existsSync(directoryPath)) {
throw new Error(`A component directory named ${componentName} already exists`);
}
// Make the directory
mkdirSync(directoryPath);
// Create our files
writeFileSync(`${directoryPath}/${componentName}.tsx`, ''); // Our Preact component
writeFileSync(`${directoryPath}/${componentName}.scss`, ''); // Our Sass styles
writeFileSync(`${directoryPath}/${componentName}.test.ts`, ''); // Our unit tests
writeFileSync(`${directoryPath}/${componentName}.stories.mdx`, ''); // Our Storybook docs
Code language: JavaScript (javascript)
This script works, but it only creates empty files. We’ll still need to write some boilerplate code in the generated files. Let’s see if we can avoid some of that by prepopulating the files with code.
Our Preact component should import our styles and stub out a simple component:
writeFileSync(`${directoryPath}/${componentName}.tsx`, `
import './${componentName}.scss';
export const ${componentName} = () => (
<div>I'm a ${componentName} component!</div>
);
`.trim());
Code language: JavaScript (javascript)
We can leave our Sass file empty for now. It doesn’t require a whole lot of boilerplate.
writeFileSync(`${directoryPath}/${componentName}.scss`, '');
Code language: JavaScript (javascript)
We can stub out a simple test to be filled out as appropriate. We’d want to be sure to remove this if we didn’t end up writing a real test for this component.
writeFileSync(`${directoryPath}/${componentName}.test.ts`, `
test('This is an example test that should be populated or removed before merging.', () => {
expect(sum(1, 2)).toBe(3);
});
`.trim());
Code language: JavaScript (javascript)
Finally, we can also get a jump start on our Storybook documentation file:
writeFileSync(`${directoryPath}/${componentName}.stories.mdx`, `
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { ${componentName} } from './${componentName}.tsx';
<Meta title="Components/${componentName}" />
# ${componentName}
<Canvas>
<Story name="Basic Usage">
<${componentName} />
</Story>
</Canvas>
`.trim());
Code language: JavaScript (javascript)
What specific boilerplate you’ll need will depend on the tools you’re using, but you can see we’re avoiding having to write a lot of repeated code ourselves. After running the script, we have a working component with places for styles, docs, and tests.
Our script works great, but there’s no way to pass in a custom component name. Let’s add the ability to accept user input when running the script.
Accepting User Input
To accept user input, we will update our script to accept a command line argument. We want to be able to pass in an argument via the command line like so:
npm run scaffold-component -- --name=Button.
Code language: Bash (bash)
What’s up with the double dashes before our argument? It’s a special npm syntax to pass custom arguments through to the node script. You can learn more in the npm docs.
Reading command line arguments from Node is a little awkward. We can access an array called process.argv
, which includes our arguments as strings, but it also includes other information we don’t need, like our node executable and the script’s file path. Here’s an example of what that array looks like when I run the command above:
[
'/Users/paulhebert/.nvm/versions/node/v18.16.0/bin/node',
'/Users/paulhebert/repos/scaffolding-example/scaffold-component.js',
'--name=Button'
]
Code language: JSON / JSON with Comments (json)
We need to find the argument we want and determine its value. We can update our script to use the argument as our component name:
// Find the argument starting with the correct name
const nameArg = process.argv.find((arg) => arg.startsWith("--name="));
// Handle a missing name.
if (!nameArg) {
throw new Error("Please pass in a component name using the `--name` argument");
}
// Split the string at the equals sign
const nameParts = nameArg.split('=');
// Use the part after the equals sign as our name.
const componentName = nameParts[1];
Code language: JavaScript (javascript)
Now, we can pass in a name via a command line argument.
This works fine for our use case but could get tricky if we wanted to add more options, like omitting certain files. Luckily, there are tools like prompts and hygen which are built to help with accepting user input and scaffolding files. I won’t do a deep dive into those here, but they can be helpful if your hand-written script isn’t cutting it.
Next Steps
Now, I can automate a lot of the busy work of creating new components. You can view a complete example repo on GitHub.
These examples probably won’t work out of the box with your tools. You may need to tweak them to generate the specific files your project expects. But once you’ve got them set up, you can happily skip a common but tedious development task.
What are you going to do with all your new free time? I’ve heard gardening is fun.