Scaffold Projects from Templates with Variable Substitution
Build a template directory with delimited placeholders, substitute values safely without regex injection, and verify the result compiles.
Task
Turn a hand-copied starter folder into a parameterized template with safe substitution and a post-scaffold smoke check.
When to use this
Every new service starts as a copy of the last one: find-and-replace on the old service name, one missed occurrence per repository, and a broken build discovered at 11 p.m.
Prerequisites
- Node.js 18+ (or any language with a template engine)
- A known-good starter project to templatize
- A smoke command that proves a scaffolded project works
Steps
1. Extract every occurrence of the varying values.
grep -rn "acme-orders" starter/ --include="*" -l | head -10; grep -ro "acme-orders" starter/ | wc -l; grep -rn "AcmeOrders" starter/ -l | head -5Expected output: File lists and counts print for each varying token: the service slug appears 47 times across 12 files, the PascalCase name 23 times across 8.
2. Replace tokens with delimited placeholders.
find starter -type f -exec sed -i "s/acme-orders/{{service_slug}}/g; s/AcmeOrders/{{service_name_pascal}}/g" {} + && grep -rn "{{" starter/ | wc -lExpected output: Every varying value is now a delimited placeholder, and the count confirms the transformation covered all occurrences.
3. Substitute with a string-splitting replacer, not regex.
// scaffold.mjs
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
function fill(text, vars) {
let out = text;
for (const [k, v] of Object.entries(vars)) {
// split/join: no regex metacharacters in v can break the substitution
out = out.split(`{{${k}}}`).join(v);
}
return out;
}
const vars = {
service_slug: process.argv[2],
service_name_pascal: pascal(process.argv[2]),
year: String(new Date().getFullYear()),
};
function walk(dir, outDir) {
mkdirSync(outDir, { recursive: true });
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name === '.git') continue;
const src = join(dir, e.name);
const dst = join(outDir, fill(e.name, vars)); // filenames too
if (e.isDirectory()) walk(src, dst);
else writeFileSync(dst, fill(readFileSync(src, 'utf8'), vars));
}
}
walk('starter', vars.service_slug);
Expected output: A new directory appears with every file and filename substituted; replacement values containing dots or dollars cannot corrupt the output because no regex is involved.
4. Refuse invalid names before writing anything.
const VALID = /^[a-z][a-z0-9-]{2,30}$/;
const slug = process.argv[2];
if (!slug || !VALID.test(slug)) {
console.error(`invalid service slug "${slug}": must match ${VALID}`);
process.exit(2);
}
// also refuse collisions
import { existsSync } from 'node:fs';
if (existsSync(slug)) {
console.error(`refusing to overwrite existing ${slug}/`);
process.exit(2);
}
Expected output: Bad names and existing directories are rejected before any file is written, so a typo never half-creates a project.
5. Run the smoke check as the last scaffold step.
node scaffold.mjs billing-api && cd billing-api && npm install --silent && npm test --silent 2>&1 | tail -3 && npm run build --silent && echo "SCAFFOLD OK"Expected output: The generated project installs, tests, and builds end to end, proving the template substitution produced a working service rather than a plausible-looking one.
Troubleshooting
Problem
Substitution corrupts files with special characters
Solution
A regex-based replace treats $ and backslashes in values as metacharacters; switch to split/join or replaceAll with a string needle, which is literal by construction.
Problem
Binary files get mangled by the text pass
Solution
Skip known binaries by extension, or detect NUL bytes in the buffer and copy those files verbatim instead of decoding as utf8.
Problem
Template drifts from reality as the starter evolves
Solution
Generate a canary project from the template in CI weekly and run its smoke test; when the starter changes meaningfully, regenerate the template instead of hand-editing placeholders.
Limitations
- Templates encode one blessed starting point; divergent service shapes need either multiple templates or post-scaffold flags, not more placeholders.
- The smoke check proves the scaffold works today; dependency drift still needs the weekly canary run.
Copy-paste-as-a-service is how names leak
The hand-copied starter guarantees an eventual missed rename: in a package name, a container label, a log prefix. Delimited placeholders plus a literal substitution pass turn the starter into a template, and a smoke check turns every scaffold into proof that the copy works.
# templatize the known-good starter
find starter -type f -exec sed -i \
"s/acme-orders/{{service_slug}}/g; \
s/AcmeOrders/{{service_name_pascal}}/g" {} +
# scaffold with validation + smoke
node scaffold.mjs billing-api
cd billing-api && npm install && npm test && npm run build
# -> SCAFFOLD OK
# grep for leaks: should be zero
grep -rn "acme" billing-api/ | wc -l # 0
Literal substitution is a security property
Regex-based templates are an injection surface: a service name containing $& or backslashes rewrites unrelated parts of the output, and hostile input can smuggle code into generated files. split/join is literal by construction, which makes the scaffold safe for any name that passes validation.
The smoke check is the template test suite
A template nobody executes is a guess wearing a directory structure. Install, test, build: three commands that catch a placeholder typo in package.json, a missed rename in an import, or a broken script path. Run them on every scaffold and on a weekly canary in CI, and template drift becomes visible within days instead of quarters.