Monorepo components generator | Plop

Omri levi
Nielsen-TLV-Tech-Blog
7 min readNov 21, 2022

--

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, but if not this link will help you make sense of the following.

Enough prologuing, let’s begin with the actual code!

I am using this excellent monorepo setup guide as the base for my project, which produces a React Typescript monorepo, with Lerna + yarn workspaces to handle project dependencies. In case you don’t already have your own monorepo project set up, you can use this one:

git clone https://github.com/lomri123/monorepo-component-generator.git

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

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'"
},
...
}

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}}'
  • 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,
};
  • 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
}
  • 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"
}
}
  • 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}} } />
  • 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"]
}
  • 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;
}

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: []
});
}

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);
},
},
],

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));
};

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

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

The result for these prompts looks like this:

** I’m using inquirer-autocomplete-prompt for the interactive select component

Actions

There are several types of built-in actions you can use in your GeneratorConfig. You specify which type of action (all paths are based on the location of the plopfile), and a template to use.

I’ve created a validateFields function which runs as soon as the prompts stage is done

actions: [
{
type: "validateFields",
},
],

I then registered an actionType with the corresponding name, which basically checks that the entered name matches our project’s standards

  plop.setActionType("validateFields", function (answers, config, plop) {
const { group, name } = answers || {};
const nameValid = validateInput(name);
const groupValid = validateInput(group);
if (!nameValid || !groupValid) {
throw new Error(
`[${!nameValid && "component name"}${
!groupValid && ", group name"
}}] invalid` // can refer to a conventions url here
);
}
const { status, message } = checkDuplicateComponent(
name
.replace(/([a-z])([A-Z])/g, "$1-$2")
.replace(/[\s_]+/g, "-")
.toLowerCase()
);
if (status) {
throw new Error(message);
}
return "fields valid";
});

That’s it for the input part. Now let’s generate the output!

      {
type: "add",
templateFile: "templates/package.json.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/package.json`,
},
{
type: "add",
templateFile: "templates/tsconfig.json.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/tsconfig.json`,
},
{
type: "add",
templateFile: "templates/types.ts.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/src/types.ts`,
},
{
type: "add",
templateFile: "templates/styles.ts.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/src/styles.ts`,
},
{
type: "add",
templateFile: "templates/jest.config.js.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/jest.config.js`,
},
{
type: "add",
templateFile: "templates/index.ts.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/src/index.ts`,
},
{
type: "add",
templateFile: "templates/main-component.tsx.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/src/{{kebabCase name}}.tsx`,
},
{
type: "add",
templateFile: "templates/stories.mdx.hbs",
path: `${compPath}/{{kebabCase group}}/{{kebabCase name}}/src/stories/{{pascalCase name}}.stories.mdx`,
},

Another cool trick is executing a custom script to automatically install project dependencies. We’ll add another action called installDependencies

      {
type: "installDependencies",
},
  plop.setActionType("installDependencies", function (answers, config, plop) {
const { name } = answers;
console.log("installing dependencies");
const foundationDev = `lerna add typescript --scope=@monorepo/${toKebabCase(
name
)} --dev`;
return exec(`${foundationDev}`)
.then(() => "dependencies installed successfully")
.catch((err) => `error installing dependencies: ${err}`);
});

here’s how the second stage looks like:

The complete plop.mjs can be found here

--

--

Omri levi
Nielsen-TLV-Tech-Blog

Frontend Developer Working as part of the Infra team, experienced with building design systems from scratch and providing micro frontends wrapper layer.