Building a simple CLI to scaffold pattern templates with Clack
I’ve been busy at work building a small pattern library for our boilerplate monorepo. We have used Storybook in the past, which is a very comprehensive industry-standard solution. At the time of writing this, I’m not aware of support for .astro components, which was a pretty pressing requirement, and our needs are often much simpler than everything bundled with Storybook.
Built on Astro (naturally), this pattern library includes support for:
- Theme toggling and custom backgrounds for previews.
- Internal documentation generated directly from design tokens.
- Multi-framework support for Astro, JSX, and TSX components.
- Client-side hydration where needed.
- And more!
I’m using data files as the basis to inform the pattern library which UI components to glob, a bit like the Storybook .stories files, as well as adding supplementary markdown files for each pattern as documentation.
For consistency and enforcing governance (sorry, not sorry), it made sense to include some type of template scaffolding tool. This ensures that every new pattern adheres to the same structure, contains the required metadata, and includes documentation by default.
After some research, I leaned towards using Clack for the CLI prompts and Node.js to handle the file system and infrastructure changes based on the user’s selections.
This isn’t something I have previous experience building, but the documentation for Clack made it really easy to get the prompts in place.
Using the group function, I combined a bunch of questions needed to determine what the scaffolded template files should include.
scaffold-pattern.mjs
import * as p from '@clack/prompts';
async function main() {
p.intro('🧪 Pattern Scaffolder');
// https://bomb.sh/docs/clack/packages/prompts/#group
const project = await p.group(
{
patternName: () =>
p.text({
message: 'Enter the Pattern Name (PascalCase):',
placeholder: 'MyPattern',
validate: (value) => {
if (!value) return 'Name is required!';
if (!/^[A-Z][a-zA-Z]*$/.test(value)) {
return 'Must use PascalCase (e.g. ButtonGroup) and match your component name.';
}
},
}),
includeDocs: () =>
p.confirm({
message: 'Create a markdown file for documentation?',
initialValue: true,
}),
},
{
onCancel: () => {
p.cancel('Operation cancelled.');
process.exit(0);
},
}
);
}
main().catch(console.error);
Obviously this is just a small sample of the prompts posed, but based on the newly acquired project data, I can now shape the output of the scaffolded files.
As the boilerplate is a monorepo, I need to set up the path(s) for where the scaffolded files need to be placed:
import path from 'node:path';
// The current working directory
const REPO_ROOT = process.cwd();
// Pattern lib directories
const PATTERN_LIB_ROOT = path.join(REPO_ROOT, 'apps', 'pattern-lib', 'src');
const PATTERN_LIB_DATA_DIR = path.join(PATTERN_LIB_ROOT, 'data', 'patternData');
const PATTERN_LIB_DOCS_DIR = path.join(PATTERN_LIB_ROOT, 'data', 'patternMarkdown');
-
process.cwd()This grabs the current working directory of the Node process (the directory the script was executed from). In my setup, that’s the repository root because I run the scaffolding task from there.
-
path.join()This safely joins path segments together into a single file path. Instead of manually concatenating strings with
/, it handles the correct path separators automatically, which is super important if working across different operating systems.
Now that I have the paths defined, I want to ensure the new files I generate don’t already exist as a basic safety check, since the scaffolder is not intended to overwrite existing files.
I handle this by gathering the target paths into an array and filtering them using fs.existsSync:
import fs from 'node:fs';
async function main() {
// ...
// Calculate the target path for the data and documentation
const targetPath = path.join(PATTERN_LIB_DATA_DIR, `${project.patternName}.js`);
const docsTargetPath = path.join(PATTERN_LIB_DOCS_DIR, `${project.patternName}.md`);
// Define the files to check
const filesToCheck = [targetPath];
if (project.includeDocs) filesToCheck.push(docsTargetPath);
// Find any files that already exist
const existingFiles = filesToCheck.filter((f) => fs.existsSync(f));
// Bail out if needed
if (existingFiles.length > 0) {
p.cancel(`Aborting! The following file(s) already exist:\n ${existingFiles.join('\n')}`);
process.exit(0);
}
}
main().catch(console.error);
-
if (project.includeDocs) filesToCheck.push(docsTargetPath);Since documentation is optional, I don’t want to check on this unnecessarily. I start with an
arraycontaining the mandatory data file only and then conditionally push the documentation file path if needed. -
filesToCheck.filter((f) => fs.existsSync(f));For each of the file paths, I run
fs.existsSync(). This returnstrueorfalsedepending on whether the file exists. Thefiltermethod here iterates through the array and only keeps the items that meet the condition (returnstrue).
It’s worth noting that while fs.existsSync() is synchronous (meaning the script waits for it to finish before continuing), in this use case, that’s exactly what we need, since we must check whether the files exist before proceeding.
Right, now we have some basic safety checks in place, we can finally create the files!
Well almost. We should create some template content for the files.
In my use case, I structured the template files based on the project data returned from Clack. Let’s keep things simple for now and set them up as basic files.
async function main() {
// ...
// Define a template string for the pattern data file.
const fileContent = `${project.patternName} data file.`;
let docsContent = '';
if (project.includeDocs) {
// Define a template string for the pattern documentation file.
docsContent = `${project.patternName} documentation file.`;
}
}
main().catch(console.error);
Finally we can:
async function main() {
// ...
// Create the data file at targetPath using the generated fileContent string.
fs.writeFileSync(targetPath, fileContent, 'utf-8');
if (project.includeDocs) {
// Create the file at docsTargetPath using the generated docsContent string.
fs.writeFileSync(docsTargetPath, docsContent, 'utf-8');
}
p.outro(`Scaffolding complete for ${project.patternName}! 🎉`);
}
main().catch(console.error);
-
fs.writeFileSyncThis is another synchronous function in Node that can be used to create or overwrite files. It takes three parameters: the file path, the data to write, and an options argument which I am using to pass the encoding.
Building this feature got me thinking about other areas where a similar approach could make tedious tasks less of a chore.
For now, though, it’s one less manual step.