Intro
Everyone involved in writing code sometimes deals with copy and pasting at some point. It may be a single file, a small folder of related files with specific structure, or even a project boilerplate.
As a React developer I used to do this ALOT. When it comes to creating a new component I usually copy the folder of the component I've already created.
Lets assume I want to create a Title
component. I start by copying the nearest Button
component:
|____Button
| |____Button.module.scss
| |____Button.stories.mdx
| |____Button.tsx
| |____index.ts
| |____README.md
Then I need to:
- rename folder and filenames
- find and replace all occurrences of
Button
in source code - get rid of redundant
props
,import
s, etc - 🤦
So after a small research I decided to create a simple CLI tool that would save me some time by providing a short npx
command. Something like
npx mycooltool rfc ./components
where rfc
is the name of the template (React functional component) and ./components
is the path to put it in.
Rest of the article will guide you through the development process of the above CLI utility but looking ahead, if you want to jump straight to code, this is what I came up with:
bystro
A CLI utility library for scaffolding code templates and boilerplates
Sometimes you can find yourself copypasting a whole folder of files which represents some component (f.e. React component) and then renaming filenames, variables, etc. to satisfy your needs. Bystro helps you to automate this process.
Install
$ npm install -D bystro
Usage
$ bystro <template_name> <path>
Note: You can alternatively run
npx bystro <template_name> <path>
without install step.
Arguments
<template_name>
- Name of the template you want to scaffold.
<path>
- Path to scaffold template in.
List of available templates can be found here
Creating a template
To create a local template start by making a .bystro
directory in the current working directory:
$ mkdir .bystro
After that you can add templates:
$ mkdir .bystro/my_template
$ mkdir .bystro/my_template/__Name__
$ echo 'import "./__Name__.css";' > .bystro/my_template/__Name__.js
$ echo '// hello from __Name__'
…Planning
Before writing any code I found it reasonable to put together some small description of the algorithm that I expect from my CLI tool to implement:
Obtain user input (
<template_name>
and<path_to_clone_into>
) from the command line.Get template data by
<template_name>
by checking customtemplates
folder created by user and if not found fetch it from predefinedtemplates
folder inside of the package itself.If template was found prompt user to fill the variables required in the template.
Interpolate template filenames and contents according to users input.
Write result template files into
<path_to_clone_into>
Architecture
For now it is totally fine to store shared templates inside of package source code but the bigger it gets the slower npx
will execute it.
Later we could implement templates search using github api or something. That is why we need our code to be loosely coupled to easily switch between template repository implementations.
Clean architecture to the rescue. I won't go in much details explaining it here, there are a lot of great official resources to read. Just take a look at the diagram which describes the main idea:
CA states that source code dependencies can only point inwards, the inner circles must be unaware of the outer ones and that they should communicate by passing simple Data Transfer objects. Let's take all the about literally and start writing some code.
Business rules
If we take a closer look at the algorithm we've defined at planning phase it seems like it's a perfect example of a use case. So let's implement right away:
import Template from "entities/Template"
export default class ScaffoldTemplateIntoPath {
constructor(
private templatesRepository: ITemplatesRepository,
private fs: IFileSystemAdapter,
private io: IInputOutputAdapter,
) {}
public exec = async (path: string, name: string) => {
if (!name) throw new Error("Template name is required");
// 2. Get template data by <template_name>
const dto = this.templatesRepository.getTemplateByName(name);
if (!dto) throw new Error(`Template "${name}" was not found`);
// 3. If template was found prompt user to fill the variable
const template = Template.make(dto).setPath(path);
const variables = await this.io.promptInput(template.getRequiredVariables());
// 4. Interpolate template filenames and contents
const files = template.interpolateFiles(values);
// 5. Write modified files
return this.fs.createFiles(files);
};
}
As you can see Template
is the only direct dependency while ITemplatesRepository
, IFileSystemAdapter
and IInputOutputAdapter
are injected (inverted) which means they don't break the dependency rule. This fact gives us a lot of flexibility since their possible changes won't affect ScaffoldTemplateIntoPath
usecase and we can easily mock them out in test environment.
Let's now implement the Template
entity:
class Template implements ITemplateInstance {
constructor(private dto: ITemplate) {
this.dto = { ...dto };
}
public getPath = () => {
return this.dto.path;
};
public getRequiredVariables = () => {
return this.dto.config.variables;
};
public getFiles = () => {
return this.dto.files;
};
public interpolateFiles = (variables: Record<string, string>) => {
return this.dto.files.map((file) => {
// private methods
file = this.interpolateFile(file, variables);
file.path = this.toFullPath(file.path);
return file;
});
};
public setPath = (newPath: string) => {
this.dto.path = newPath;
return this;
};
public toDTO = () => {
return this.dto;
};
// ...
}
With this in place we already have our basic business rules ready to be used within basically any javascript environment. It may be a CLI tool, REST API, frontend app, browser extension etc.
Feel free to check out the source code for the outer layers implementations:
Publish to npm
To make the project globally available as an executable npm
package we need to do the following in package.json
:
{
"name": "bystro",
"version": "0.1.0",
"bin": "./dist/index.js", // compiled code
// ...
}
By setting bin
field npm
should treat our project as an executable by putting a symlink named bystro
into ./node_modules/.bin
on install.
Lastly after compiling our .ts
code we will use np package to help us to efficiently publish our package.
"scripts": {
"build": "tsc -p .",
"release": "yarn build && np",
// ...
}
Running npm run release
or yarn release
should check our files, run tests, bump version, and finally publish our project to npm registry.
🎉🎉🎉
Outro
I'm pretty excited to bring Clean Architecture into my project, but I want you not to take everything I say for granted. I'm learning as well so I might be wrong in understanding some CA concepts and would love to hear some feedback.
BTW this is my first article ever as well as the first open source project and I would be happy to hear from the community. Any feedback, pull requests, open issues would be great. Let me know what you think.
Thanks for reading 🙏
Top comments (1)
Awesome, thanks for sharing!