Add File Generators to Your Project with Plop

11 min read

When working in a code base we develop habits and patterns that help keep our codebase consistent. For example, we probably store new UI components in a shared folder, maybe with a specific pattern for the sub folder hierarchy. Along with the file for the component implementation itself, we'll probably add a file for component tests, and maybe a third file for some storybook stories.

These patterns make our code base consistent and predictable. It's easy to find what we're looking for when we need to add a feature or track down a bug. This also means we end up performing some repetitive tasks and copying some starter code into new files any time we need to add a new component (or utility, controller, service function, etc.) to our project.

Once we've established one of these patterns and find that it's repeatable, it's probably time to consider adding some automation. It'll save us a little bit of time in the long run, but it also makes it easy for everybody on a team to adhere to the agreed upon patterns.

The Plop library makes it straightforward to create generators for our projects. Let's take it for a spin!

The Goal

You can use plop in any project, but here we'll use it to set up a generator to add React components to a project. These examples are based on a Next.js project with TypeScript enabled that has Storybook installed and configured and is setup for testing with Jest and Testing Library. If you're working from a different base, you'll want to adjust your paths and template contents to work in your setup.

We'll generate a simple shell for a React component, along with a colocated test file and a file for storybook stories. We'll create templates that handle some of the boilerplate code and handle importing the component for the test and stories.

Add Plop to Your Project

We'll install plop as a dev dependency in the project.

npm install --save-dev plop

The docs recommend installing plop globally for easy access. You can certainly do that, but I opt to keep it local to my project and run it using npx with npx plop <GENERATOR_NAME>.

Creating Our First Generator

The first thing we'll need in order to use plop is a plop file in the root of our project.

Create the Plop File

Create plopfile.js in the root of your project. This will be a node module that exports a function. That function will receive the plop object as an argument when executed.

plopfile.js

module.exports = function (plop) {}

Inside the exported function, we'll use the plop object's setGenerator method to define our component generator.

module.exports = function (plop) {
  plop.setGenerator('component', {
    description: 'Generate a new component file',
  })
}

The first argument to setGenerator is the name of our generator, and will be how we specify which generator to use when we run plop.

We'll also need to specify arrays for prompts and actions. The prompts array enables the CLI to gather input, and actions is how we'll tell plop what to do with those inputs (generate files).

module.exports = function (plop) {
  plop.setGenerator('component', {
    description: 'Generate a new component file',
    prompts: [],
    actions: [],
  })
}

At this point, if you open the terminal and run npx plop or npx plop component, you won't see any output. We haven't told our generator to do anything yet, but we're also not seeing any errors, so that's progress!

Adding a prompt

Let's get some input. In the case of this generator, we really only need to know the name of the new component to create. We'll define our prompt as an object that specifies a type, name, and message.

module.exports = function (plop) {
  plop.setGenerator('component', {
    description: "Generate a new component file",
-   prompts: [],
+   prompts: [
+     {
+       type: 'input',
+       name: 'ComponentName',
+       message: 'What should we call this component?',
+     },
+   ],
    actions: []
  })
}

If you run npx plop or npx plop component in the terminal, you will see our prompt "What should we call this component?". You can supply a response and plop will do nothing and exit.

Adding an action

Now that we're getting our component name as input, we need to do something worthwhile with it.

module.exports = function (plop) {
  plop.setGenerator('component', {
    description: "Generate a new component file",
    prompts: [
      {
        type: 'input',
        name: 'ComponentName',
        message: 'What should we call this component?',
      },
    ],
-   actions: []
+   actions: [
+     {
+       type: 'add',
+       path: 'components/{{ComponentName}}.tsx',
+       templateFile: 'plop-templates/component.tsx.hbs',
+     },
+   ],
  })
}

We're using the add action type to tell plop that we want to create a new file. We can specify a dynamic file name by using the ComponentName value from our prompt in double curly braces {{ }}.

Creating a template with handlebars

Our action won't work at this point because it's pointing at a template file that doesn't exist yet. We'll start by creating a new directory in the root of our project called plop-templates, then add a handlebars file in that directory called component.tsx.hbs.

plop-templates/component.tsx.hbs


export function {{ComponentName}}(props) {
  return (<>{{ComponentName}}</>)
}

This template will have access to the ComponentName value from our prompt, so we'll use that in double curly braces {{ }} anywhere we want that value to appear in our file.

If we run npx plop component we can supply a PascalCased component name, and we should see the component file added to our project. Cool!

Generating Additional Files

Generating this small component file is arguably not all that helpful. We're doing very little here and not really saving ourselves a whole lot of time or effort. Let's generate some more files to make this generator more valuable.

Generate the Test File

We want to make sure we have tests for our components. I like to colocate my test files with my components. So let's add an action and a template to handle this for us.

In the actions array, we'll add a new add action.

module.exports = function (plop) {
  plop.setGenerator('component', {
    description: 'Generate a new component and associated storybook file',
    prompts: [
      {
        type: 'input',
        name: 'ComponentName',
        message: 'What should we call this component?',
      },
    ],
    actions: [
      {
        type: 'add',
        path: 'components/{{ComponentName}}.tsx',
        templateFile: 'plop-templates/component.tsx.hbs',
      },
+     {
+       type: 'add',
+       path: 'components/{{ComponentName}}.spec.js',
+       templateFile: 'plop-templates/component.spec.js.hbs',
+     },
    ],
  })
}

We need to create the template referenced in our action, so we'll add the following file in plop-templates.

plop-templates/component.spec.js.hbs

import { render } from '@testing-library/react' import { {{ComponentName}} }
from './{{ComponentName}}' describe('{{ComponentName}}', () => { it('renders
without error', () => { render(<{{ComponentName}} />) }) })

Our template will handle the basic structure of our test file, add the import for the component we're generating, and add a single test that simply renders our new component.

If we run npx plop component now and supply a component name (remember to make it PascalCase), we should see both the component file and the test file in our project. Running our test script should also show that this new test runs and passes.

Generate the Storybook File

We can make this generator even more helpful by generating the starting point for our component stories. We'll add another add action for the Component.stories.tsx file.

plopfile.js

module.exports = function (plop) {
  plop.setGenerator('component', {
    description: 'Generate a new component and associated storybook file',
    prompts: [
      {
        type: 'input',
        name: 'ComponentName',
        message: 'What should we call this component?',
      },
    ],
    actions: [
      {
        type: 'add',
        path: 'components/{{ComponentName}}.tsx',
        templateFile: 'plop-templates/component.tsx.hbs',
      },
      {
        type: 'add',
        path: 'components/{{ComponentName}}.spec.js',
        templateFile: 'plop-templates/component.spec.js.hbs',
      },
+     {
+       type: 'add',
+       path: 'components/{{ComponentName}}.stories.tsx',
+       templateFile: 'plop-templates/component.stories.tsx.hbs',
+     },
    ],
  })
}

And then we can create the following template.

plop-templates/component.stories.tsx.hbs

import React from 'react' import { ComponentStory, ComponentMeta } from
'@storybook/react' import { {{ComponentName}} } from './{{ComponentName}}'
export default { title: '{{ComponentName}}', component: {{ComponentName}}, } as
ComponentMeta<typeof {{ComponentName}}>
  const Template: ComponentStory<typeof {{ComponentName}}>
    = (args) => (<{{ComponentName}} {...args} />) export const Default =
    Template.bind({})
  </typeof></typeof
>

For me, this template is where we start to see the real value in generating these files. There is a lot of boilerplate code here and many references to the component name.

Running our generator now will create all three files, handle the imports for us, and everything should work. We still need to add implementation, but we've take a multi-step process of file creation and copy/paste/replace and turned it into a single input to a CLI!

Using Case Helpers

One last change would make this a bit better. To this point, it's really only worked for us when we specify our component name in PascalCase. This isn't a huge inconvenience, but if we accidentally enter our component name in camelCase, then we need to go through the generated files and update names, or delete the files and run it again.

Plop gives us access to several case modifier helpers. One of those is pascalCase. We can use these helpers both in the dynamic portions of our actions and in the templates. To use it, we simply add it before a variable name inside the double curly braces like {{pascalCase ComponentName}} wherever we need it. Let's update all our files to apply this modifier.

We'll start by adding this modifier in our plop file to ensure our file names are in the proper case.

plopfile.js

module.exports = function (plop) {
  plop.setGenerator('component', {
    description: 'Generate a new component and associated storybook file',
    prompts: [
      {
        type: 'input',
        name: 'ComponentName',
        message: 'What should we call this component?',
      },
    ],
    actions: [
      {
        type: 'add',
-       path: 'components/{{ComponentName}}.tsx',
+       path: 'components/{{pascalCase ComponentName}}.tsx',
        templateFile: 'plop-templates/component.tsx.hbs',
      },
      {
        type: 'add',
-       path: 'components/{{ComponentName}}.spec.js',
+       path: 'components/{{pascalCase ComponentName}}.spec.js',
        templateFile: 'plop-templates/component.spec.js.hbs',
      },
     {
       type: 'add',
-      path: 'components/{{ComponentName}}.stories.tsx',
+      path: 'components/{{pascalCase ComponentName}}.stories.tsx',
       templateFile: 'plop-templates/component.stories.tsx.hbs',
     },
    ],
  })
}

Next, we'll apply the modifier in the component template.

plop-templates/component.tsx.hbs


-export function {{ComponentName}}(props) {
+export function {{pascalCase ComponentName}}(props) {
-  return (<>{{ComponentName}}</>)
+  return (<>{{pascalCase ComponentName}}</>)
 }

And then we can do the same for our test file template, taking care to apply this to every instance of {{ComponentName}}.

plop-templates/component.spec.js.hbs

 import { render } from '@testing-library/react'
-import { {{ComponentName}} } from './{{ComponentName}}'
+import { {{pascalCase ComponentName}} } from './{{pascalCase ComponentName}}'

-describe('{{ComponentName}}', () => {
+describe('{{pascalCase ComponentName}}', () => {
   it('renders without error', () => {
-    render(<{{ComponentName}} />)
+    render(<{{pascalCase ComponentName}} />)
   })
 })

And finally, the storybook template receives the same treatment.

plop-templates/component.stories.tsx.hbs

 import React from 'react'
 import { ComponentStory, ComponentMeta } from '@storybook/react'

-import { {{ComponentName}} } from './{{ComponentName}}'
+import { {{pascalCase ComponentName}} } from './{{pascalCase ComponentName}}'

 export default {
-  title: '{{ComponentName}}',
+  title: '{{pascalCase ComponentName}}',
-  component: {{ComponentName}},
+  component: {{pascalCase ComponentName}},
-} as ComponentMeta<typeof {{ComponentName}}>
+} as ComponentMeta<typeof {{pascalCase ComponentName}}>

-const Template: ComponentStory<typeof {{ComponentName}}> = (args) => (<{{ComponentName}} {...args} />)
+const Template: ComponentStory<typeof {{pascalCase ComponentName}}> = (args) => (<{{pascalCase ComponentName}} {...args} />)

 export const Default = Template.bind({})

With the modifier applied across the board, we can safely run our generator and know that if we specify a component name in camelCase or even as multiple words, our file names and generated code will be correct.

So now running npx plop component and entering something like registration form at the prompt will yield a RegistrationForm component file, along with the starting point for the tests and stories!

Conclusion

Plop has capabilities beyond what we've covered here but for me, this is where the bulk of the benefit comes from.

Prior to adding generators like these to my site, I would open one existing files, copy/paste code, then replace component references. Generating these files saves time and makes it more likely that I won't get lazy and leave tests or stories for later. When working on a team, this not only saves time but it will make your code base more predictable. Anything that saves time, reduces cognitive friction, and prevents additional context switching is a huge win!

You don't want to add automation until you know you have a repeatable process, so I caution against making generators for everything in a new project. But if you know you have good cases for automation, I think it's worth a little bit of time to replace those tedious manual file creation tasks with something like Plop.