DEV Community

nikita
nikita

Posted on

Developing template scaffolding CLI util with Typescript and Clean Architecture

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
Enter fullscreen mode Exit fullscreen mode

Then I need to:

  • rename folder and filenames
  • find and replace all occurrences of Button in source code
  • get rid of redundant props, imports, 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:

GitHub logo n1stre / bystro

A CLI utility library for scaffolding code templates and boilerplates.

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.

License NPM Version TravisCI Build Code Coverage

Install

$ npm install -D bystro
Enter fullscreen mode Exit fullscreen mode

Usage

$ bystro <template_name> <path>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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__'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Obtain user input (<template_name> and <path_to_clone_into>) from the command line.

  2. Get template data by <template_name> by checking custom templates folder created by user and if not found fetch it from predefined templates folder inside of the package itself.

  3. If template was found prompt user to fill the variables required in the template.

  4. Interpolate template filenames and contents according to users input.

  5. 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:

CleanArchitecture

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

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

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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:

GitHub logo n1stre / bystro

A CLI utility library for scaffolding code templates and boilerplates.

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
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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", 
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
joelpatrizio profile image
Joel Patrizio

Awesome, thanks for sharing!