home

Blog


Creating customizable and reusable project templates with Node


At some point in your life you end up with a repo that is the starting point. It has the CI pipeline you like, your preferred linting and formatting configs and the Terraform layout that works like a charm. Then a new project shows up, you clone the base template and spend the next twenty minutes doing find-and-replace on the project name, ids, the domain name and whatever else is hardcoded in there. It works, until you miss a thing and find out your project is failing - but is it because of the template or because you missed something!? However, a small script and two npm packages solve this problem pretty well.

Prompt and replace

Instead of hardcoding values, you create a template with placeholders, using whatever delimiter is unlikely to show up naturally in your code. Something like {{% project_slug %}} sitting in your package.json, Terraform files, README.md or any other file.

Then a single script asks for each value and rewrites every occurrence across the repo with two libraries doing the heavy lifting: @inquirer/prompts for the questions and replace-in-file for the replacements.

The script

import process from "node:process";

import { input } from "@inquirer/prompts";
import { replaceInFileSync } from "replace-in-file";

const config = {
  files: "**/*",
  ignore: [
    "node_modules/**/*",
    ".env",
    "scaffolder.ts",
    ".git/**/*",
  ],
  from: [
    /{{% project_slug %}}/g,
    /{{% domain_name %}}/g,
  ],
  to: ["", ""],
  glob: {
    dot: true,
  },
};

const answers = [];
const parameters = config.from;
for (const parameter of parameters) {
  answers.push(
    await input({
      message: `Specify the ${String(parameter).replace(/(\[|{|}|%|\s|\/g|\/)/g, "")} `,
      required: true,
    }),
  );
}

config.to = answers;

const results = replaceInFileSync(config);
const changes = results.filter((result) => result.hasChanged);

console.info(`${changes.length} files were changed`);
console.info(changes.map((changed) => changed.file));

process.exit(0);

The interesting part is that the list of regexes in from is the only place you declare anything. The loop turns each pattern into a question by stripping the delimiters out of the regex source, so /{{% domain_name %}}/g becomes "Specify the domain_name". Adding a new parameter to your template means adding one line to that array, and the prompt comes for free.

The to array is a placeholder that gets fully overwritten by the answers, and since the nth answer maps to the nth pattern, order matters. Two other details are worth pointing out: ignore has to include the scaffolder itself, otherwise the script rewrites its own regexes mid-run and you get a fun debugging session; glob.dot is what makes **/* actually reach the dotfiles, which is where half of your config lives anyway (.github, .gitlab-ci.yml and friends).

Of course, the script and everything else is customizable, so if you have a different idea, set it up in any way you prefer.

What about Yeoman and Cookiecutter?

This problem is old and there are two well-established tools that solve it, so it's fair to ask why you'd write thirty lines instead of using them.

Yeoman

Yeoman is the Node ecosystem's answer and it's a proper framework. You write a generator as a separate package, it renders templates through EJS, handles conflict resolution when files already exist, composes generators together and can run install steps afterwards. That's genuinely more powerful, but it also means your template stops being a repository you can clone and run, and becomes a package you have to publish, version and install globally before anyone can use it.

Cookiecutter

Cookiecutter is the Python one and it's closer in spirit: a cookiecutter.json declares the variables and Jinja renders the tree. It renames directories, which is exactly the thing the script above can't do, and it works on any project regardless of language. The trade-off is that your Node template repo now needs Python in every environment that touches it.

Limitations

The package replace-in-file does what its name says, it replaces content in files. Paths are out of scope, as I mentioned, so a directory called {{% project_slug %}}/ stays exactly as it is after the script runs, quietly, with no error to tell you about it.

However, there are a few ways to work around it, in increasing order of effort:

  • Keep folder names generic, if possible (lol);
  • Rename folders by hand afterwards. Fine for one or two paths, terrible as a convention because it's the exact manual step you were trying to delete;
  • Add a rename pass to the script. Walk the tree with node:fs, apply the same answers to path names and renameSync the matches. Just remember to go depth-first, otherwise you rename a parent directory and invalidate every path you were about to visit.

You may also have noticed that the replacements themselves are not transactional. If the process dies halfway through, part of your repo is substituted and part isn't, so run it on a fresh clone with a clean git status and let git diff be your review step. The script printing which files changed helps a lot here, because a parameter that shows up in zero files is almost always a typo in your template rather than an unused variable.

Wrap-up

This isn't a replacement for Yeoman or Cookiecutter and it doesn't try to be. It's a single file you drop in a repo to turn it into a template, with no framework to learn, no package to publish and no second language to install. If you ever outgrow it, the placeholders you already wrote may help with migrating to another tool.

Powered by Simple Blog API