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 andrenameSyncthe 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.