Skip to main content
Photo from unsplash: lautaro-andreani-xkBaqlcqeb4-unsplash_tzsksx

Monorepo components generator | Plop

Written on August 21, 2022 by Kevin

6 min read

Recently, my team was given a task to create a new monorepo containing UI components. The end result was scheduled to have 50+ packages, that would be contributed by multiple developers.

As early as at the POC stage, we started realizing we are facing an issue.

  • Every package requires a configuration setup — Typescript, Webpack, Storybook, and installing package dependencies.
  • Our team has coding standards, file hierarchy and overall best practices that we would like to be implemented throughout of our project.
  • Writing all of the boilerplate for every package (or sloppily copy-and-pasting) is not only time consuming, it lends itself to error and encourages inconsistency between projects. Our solution was adding a generator tool that would bootstrap each new package for the developer—meaning it would create all the necessary boilerplate files a and install the necessary dependencies.

A generator is like an interactive boilerplate. It speeds up development without bloat, improves the developer experience and encourages consistency from beginning to finished product.

Plop is a generator tool which allows you to define a set of functions and helpers, alongside a set of templates that would be generated on the fly, either automatically or by answering to some CLI prompts.

For this article I’ll assume you have a certain understanding of generators. Enough prologuing, let’s begin with the actual code!

1. Install Plop

We will install Plop as a development dependency within the project. If you’ve not used yarn workspaces before, the -W flag signals that this is a shared dependency to be installed in the root node_modules folder.

So, at the root of your project, type the following in the console:

yarn add plop -D -W
shell

2. Add a ‘plop’ file

Let’s create a folder at the root of our project, called very originally- plop, and inside we’ll create a plopfile.mjs file

Since we have created a local copy, we need to call Plop via the npm scripts. Add the “generate-component” script to the main package.json file:

{ ..., "scripts": { ..., "generate-component": "plop component --plopfile 'plop/plopfile.mjs'" }, ... }
json

3. Create the template files

The templates are simply handlebars files (*.hbs) which, at their most basic level, take a set of variables and transform them into a text output. As the name suggests, the placeholders for each variable appear in the template between {{handlebars}} which then get replaced with supplied values when the script is called.

We will create eight template files and place them inside a new folder called templates inside our plop folder

  • index.ts.hbs
  • jest.config.js.hbs
  • main-component.tsx.hbs
  • package.json.hbs
  • stories.mdx.hbs
  • styles.ts.hbs
  • tsconfig.json.hbs
  • types.ts.hbs

Let’s go over each and every one of the templates:

  • index.ts.hbs:

This will be the main index file for our project, which will just bundle all our exports

export * from './{{kebabCase name}}'; export { default } from './{{kebabCase name}}';
jsx
  • jest.js.config.hbs:

This file is not mandatory and can be replaced with whatever testing library you prefer

const base = require('../../jest.config.base'); const packageJson = require('./package'); module.exports = { ...base, rootDir: './../../', name: packageJson.name, displayName: packageJson.name, };
jsx
  • main-component.tsx.hbs: Now we’ll create our main react component, enforcing Typescript usage and adding custom selectors that could be used for later tests/debugging
import React, { FC, forwardRef } from 'react' import { {{pascalCase name}}Props } from './types' const ID = '{{pascalCase name}}' export const {{pascalCase name}}: FC<{{pascalCase name}}Props> = forwardRef< HTMLDivElement, {{pascalCase name}}Props >(({ 'data-selector': dataSelector = ID, className, 'aria-label': ariaLabel }, ref) => { return ( <div className={className} aria-label={ariaLabel} data-selector={dataSelector} ref={ref} > {{pascalCase name}} Content </div> ) }) {{pascalCase name}}.displayName = '{{pascalCase name}}' export default Object.assign({{pascalCase name}}, { ID, }) as typeof {{pascalCase name}} & { ID: string }
jsx
  • package.json.hbs:
{ "name": "@monorepo/{{kebabCase name}}", "version": "1.0.0", "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", "files": ["dist"], "scripts": { "build": "tsc -b", "start": "nodemon --inspect dist/index.js", "lint": "eslint ./src --ext .ts,.tsx", "clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo", "watch": "tsc -b -w --preserveWatchOutput" } }
json
  • stories.mdx.hbs: Here we’re generating a uniform structure for our storybook app’s pages
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs' import {{pascalCase name}} from '../' <Meta title='Components/{{pascalCase group}}/{{pascalCase name}}' component={ {{pascalCase name}} } /> ### {{pascalCase name}} <Canvas> <Story name='Preview' args={ {} }> <{{pascalCase name}} /> </Story> </Canvas> ### {{pascalCase name}} Props <ArgsTable of={ {{pascalCase name}} } />
jsx
  • styles.ts.hbs:

The styles file is empty, but we’re still creating it to maintain the project’s structure. We’re basically saying to the developer “if you’re using custom styles, add them to here”

  • tsconfig.json.hbs:
{ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "module": "esnext", "target": "ES2019", "lib": ["dom", "ES2019"] }, "include": ["src"], "exclude": ["node_modules", "src/**/*.spec.ts", "build", "dist"] }
json
  • types.ts.hbs: Adding common prop types like aria-label for accessibility and a data-selector for tests and debugging purposes
export interface {{pascalCase name}}Props { className?: string; "data-selector"?: string; "aria-label"?: string; }
ts

Now it’s time to put everything together

We’ll start by creating a Plop function, with a generator inside it called “component”

export default function (plop) { plop.setGenerator('component', { description: 'Creating new react components', prompts: [], actions: [], }); }
jsx

prompts

prompts takes an array of Inquirer.js questions. The questions are documented in full here, however in this example we use:

  • type — “input”, since we are requesting values for variables
  • name — the name of the variable used in the templates, i.e. name, type, and tag
  • message — A message to display to the user in the console

we’ll start by getting the list of groups (parent folders inside packages) and asking the user to choose one for his new component.

prompts: [ { type: "getGroup", name: "group", message: "Choose component group", source: function (answersSoFar, input) { return getComponentsList(input); }, }, ],
js

Here is my implementation to getComponentsList function. It’s a simple filesystem lookup that scans our packages dir and returns all directories inside

const { INSTALL_DIR, PWD } = process.env; const pathPostfix = INSTALL_DIR || 'packages'; const basePath = PWD || '/'; const compPath = path.resolve(basePath, pathPostfix); const getDirList = (path) => { return readdirSync(path, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name); }; const groupsList = getDirList(compPath); const getComponentsList = (input) => { if (!input || input === '') return groupsList; return groupsList.filter((group) => group.includes(input)); };
jsx

Next we’ll prompt the user to enter his component’s name:

{ type: "input", message: "Enter component name", name: "name", },
js

The result for these prompts looks like this:

Resize Rem