Blog CLI tool: my secret weapon
This is a growing collection of notes about the TypeScript CLI tool I built to make publishing to my digital garden as frictionless as possible. I’ll be adding more insights and improvements as I continue to build on top of it.
Why I Built a CLI for My Blog
I love writing and sharing what I learn, but I noticed a pattern: the more “steps” involved in creating a new post, the less likely I was to actually publish something. Each little bit of friction—opening the right directory, creating a file in the right place, filling out metadata fields, creating files in the right directories—added up to a significant mental barrier.
The solution? A simple command-line interface that lets me start writing in seconds… which I’m sure will open me up to writing more often… right?
How It Works
With a single command in my terminal, I can:
- Choose what type of content I want to create (post, link, “Today I Learned”, growing post, or daily link)
- Answer a few quick prompts for metadata
- Select an emoji from my curated list (or search for one)
- Get a pre-formatted Markdown file with frontmatter already set up
- Immediately start writing in my code editor
Behind the scenes, the CLI:
- Creates the file in the right directory structure
- Formats all the metadata consistently
- Generates the appropriate scaffolding based on content type
- Runs build steps to preview the post
- Commits the changes to Git, if I want it to
- Git pushes the changes if it’s a link post (easiest to just push up)
- Opens the file in VS Code so I can start writing immediately
Here is the full code, so you can take a look through it yourself:
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'fs';
import {join} from 'path';
import inquirer from 'inquirer';
import emojiChoices from './emojiChoices';
import {exec} from 'child_process';
// Define types for different post formats
type PostData = {
title: string;
headerSubtitle: string;
description: string;
tags: string;
};
type TilData = {title: string; description: string; topic: string; tags: string};
type GrowingData = {
title: string;
headerSubtitle: string;
description: string;
topic: string;
tags: string;
};
type LinkData = {url: string; summary: string; tags: string};
type DailyLinkEntry = {url: string; name: string; description: string};
// Database entry types
type GameData = {
title: string;
headerSubtitle: string;
description: string;
rating: number;
tags: string;
};
type MovieData = {
title: string;
headerSubtitle: string;
description: string;
rating: number;
tags: string;
};
type MusicData = {
title: string;
artist: string;
headerSubtitle: string;
description: string;
rating: number;
tags: string;
};
type ShowData = {
title: string;
headerSubtitle: string;
description: string;
rating: number;
tags: string;
};
/**
* Executes a shell command and returns a promise
* Makes it easier to chain commands with async/await
*/
function runCommand(command: string): Promise<void> {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`Error executing command: ${command}\n`, stderr);
reject(error);
} else {
console.log(`Command executed: ${command}\n`, stdout);
resolve();
}
});
});
}
/**
* Converts a title into a URL-friendly slug
* Used for generating filenames from post titles
*/
function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.trim()
.replace(/\s+/g, '-'); // Replace spaces with hyphens
}
/**
* Gets the current date in YYYY-MM-DD format
* Used throughout the post creation process
*/
function getCurrentDate(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Gets the current year as a string
* Used for directory organization
*/
function getCurrentYear(): string {
return new Date().getFullYear().toString();
}
/**
* Interactive emoji selection process
* Allows for direct entry or searching by term
*/
async function selectEmoji(): Promise<string> {
const maxAttempts = 2;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const {emoji} = await inquirer.prompt<{emoji: string}>({
type: 'input',
name: 'emoji',
message: 'Enter an emoji for the post/link:'
});
if (emojiChoices.includes(emoji)) {
return emoji;
} else {
console.log('This emoji is not in the approved list. Please try again.');
}
}
// If direct entry failed, allow searching
const {searchTerm} = await inquirer.prompt<{searchTerm: string}>({
type: 'input',
name: 'searchTerm',
message: 'Enter a text to search for matching emojis:'
});
const matchingEmojis = emojiChoices.filter(emoji =>
emoji.toLowerCase().includes(searchTerm.toLowerCase())
);
if (matchingEmojis.length === 0) {
console.log('No matching emojis found. Using default emoji: 📝');
return '📝';
}
const {selectedEmoji} = await inquirer.prompt<{selectedEmoji: string}>({
type: 'list',
name: 'selectedEmoji',
message: 'Select an emoji from the matching list:',
choices: matchingEmojis
});
return selectedEmoji;
}
/**
* Ensures a directory exists before trying to write a file
* Creates it recursively if it doesn't exist
*/
function ensureDirectoryExists(dirPath: string): void {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, {recursive: true});
}
}
/**
* Formats tag list from comma-separated string to YAML format
*/
function formatTags(tagsString: string): string {
const tagList = tagsString
.split(',')
.map(tag => tag.trim())
.filter(tag => tag);
if (tagList.length === 0) return '';
return `tags:
${tagList.map(tag => ` - ${tag}`).join('\n')}`;
}
/**
* Common post-creation workflow steps
* Builds the site, commits changes (if requested), and opens the file
*/
async function postCreationWorkflow(
filePath: string,
commitMessage: string,
pushToRemote = false
): Promise<void> {
await runCommand('bun run build:11ty');
// Ask if user wants to commit changes
const {shouldCommit} = await inquirer.prompt<{shouldCommit: boolean}>({
type: 'confirm',
name: 'shouldCommit',
message: 'Do you want to commit these changes to git?',
default: true
});
if (shouldCommit) {
await runCommand(`git add ${filePath}`);
// For certain post types, we need to add the generated OG images
if (filePath.includes('links') || filePath.includes('daily')) {
await runCommand(`git add src/assets/og-images/*`);
}
// Ask for commit message or use default
const {customCommitMessage} = await inquirer.prompt<{customCommitMessage: string}>({
type: 'input',
name: 'customCommitMessage',
message: 'Enter a commit message (or press enter for default):',
default: commitMessage
});
await runCommand(`git commit -m "${customCommitMessage}"`);
// Ask if user wants to push (if not already specified)
if (!pushToRemote) {
const {shouldPush} = await inquirer.prompt<{shouldPush: boolean}>({
type: 'confirm',
name: 'shouldPush',
message: 'Do you want to push these changes to remote?',
default: false
});
if (shouldPush) {
await runCommand('git push');
}
} else {
await runCommand('git push');
}
}
// Open the file in VS Code for editing
await runCommand(`code ${filePath}`);
}
/**
* Creates a new standard blog post
*/
async function newPost(): Promise<void> {
const {title, headerSubtitle, description, tags} = await inquirer.prompt<PostData>([
{type: 'input', name: 'title', message: 'Enter the post title:'},
{type: 'input', name: 'headerSubtitle', message: 'Enter the header subtitle:'},
{type: 'input', name: 'description', message: 'Enter the post description:'},
{type: 'input', name: 'tags', message: 'Enter the tags (comma-separated):'}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
const formattedTags = formatTags(tags);
const frontMatter = `---
title: "${title}"
headerSubtitle: "${headerSubtitle}"
description: "${description}"
date: ${date}
emoji: ${emoji}
${formattedTags}
---
`;
// Create post directory for the current year if it doesn't exist
const year = getCurrentYear();
const postsDir = join('src', 'posts', year);
ensureDirectoryExists(postsDir);
const fileName = `${date}-${slugify(title)}.md`;
const filePath = join(postsDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New post created: ${filePath}`);
await postCreationWorkflow(filePath, `New post: ${title}`);
}
/**
* Creates a new link post (for sharing interesting URLs)
*/
async function newLink(): Promise<void> {
const {url, summary, tags} = await inquirer.prompt<LinkData>([
{type: 'input', name: 'url', message: 'Enter the link URL:'},
{type: 'input', name: 'summary', message: 'Enter a summary for the link:'},
{
type: 'input',
name: 'tags',
message: 'Enter the tags (comma-separated):',
default: ''
}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
try {
// Fetch the webpage to extract title and description
const response = await fetch(url);
const html = await response.text();
// Extract title from HTML
let titleMatch = html.match(/<title>(.*?)<\/title>/i);
let title = titleMatch ? titleMatch[1].trim() : '';
// If no title found, ask for manual input
if (!title) {
const {manualTitle} = await inquirer.prompt<{manualTitle: string}>({
type: 'input',
name: 'manualTitle',
message: 'Could not fetch a title. Please enter a title for the link:'
});
title = manualTitle;
}
// Extract description from meta tags
const descriptionMatch = html.match(
/<meta[^>]*name=["']description["'][^>]*content=["'](.*?)["']/i
);
const description = descriptionMatch ? descriptionMatch[1].trim() : '';
const formattedTags = formatTags(tags);
const frontMatter = `---
title: "${title}"
headerSubtitle: "collecting links"
description: "${description}"
url: ${url}
date: ${date}
emoji: ${emoji}
${formattedTags}
---
${summary}
`;
// Organize links by year and month
const year = getCurrentYear();
const month = (new Date().getMonth() + 1).toString().padStart(2, '0');
const linksDir = join('src', 'posts', 'links', year, month);
ensureDirectoryExists(linksDir);
const fileName = `${date}-${slugify(title)}.md`;
const filePath = join(linksDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New link created: ${filePath}`);
// Ask if user wants to push to remote after creating a link
const {pushToRemote} = await inquirer.prompt<{pushToRemote: boolean}>({
type: 'confirm',
name: 'pushToRemote',
message: 'Do you want to push this link to remote after committing?',
default: true
});
await postCreationWorkflow(filePath, `New link: ${url}`, pushToRemote);
} catch (error) {
console.error('Error fetching URL:', error);
}
}
/**
* Creates a new "Today I Learned" post
*/
async function newTil(): Promise<void> {
const {title, description, topic, tags} = await inquirer.prompt<TilData>([
{type: 'input', name: 'title', message: 'Enter the TIL title:'},
{type: 'input', name: 'description', message: 'Enter the TIL description:'},
{type: 'input', name: 'topic', message: 'Enter the topic (e.g., css, javascript):'},
{type: 'input', name: 'tags', message: 'Enter the tags (comma-separated):'}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
// Make sure 'til' tag is always included
let tagList = tags.split(',').map(tag => tag.trim());
if (!tagList.includes('til')) {
tagList.push('til');
}
const formattedTags = `tags:
${tagList.map(tag => ` - ${tag}`).join('\n')}`;
// TIL post template with scaffolding
const frontMatter = `---
title: '${title}'
description: '${description}'
date: ${date}
status: 'til'
topic: '${topic}'
emoji: ${emoji}
headerSubtitle: 'today I learned'
${formattedTags}
---
# ${title}
Today I learned about ${title.toLowerCase()}.
## The problem it solves
[Describe the problem or challenge that this solves]
## Basic usage
\`\`\`${topic}
// Example code here
\`\`\`
## Practical example
\`\`\`${topic}
// A real-world example
\`\`\`
`;
const year = getCurrentYear();
const postsDir = join('src', 'posts', year);
ensureDirectoryExists(postsDir);
const fileName = `${date}-${slugify(title)}.md`;
const filePath = join(postsDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New TIL post created: ${filePath}`);
await postCreationWorkflow(filePath, `New TIL: ${title}`);
}
/**
* Creates a new "Growing" post (digital garden style post that evolves over time)
*/
async function newGrowing(): Promise<void> {
const {title, headerSubtitle, description, topic, tags} =
await inquirer.prompt<GrowingData>([
{type: 'input', name: 'title', message: 'Enter the growing post title:'},
{type: 'input', name: 'headerSubtitle', message: 'Enter the header subtitle:'},
{type: 'input', name: 'description', message: 'Enter the post description:'},
{type: 'input', name: 'topic', message: 'Enter the topic (e.g., css, javascript):'},
{type: 'input', name: 'tags', message: 'Enter the tags (comma-separated):'}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
const formattedTags = formatTags(tags);
// Growing post template with scaffolding
const frontMatter = `---
title: '${title}'
headerSubtitle: '${headerSubtitle}'
description: '${description}'
date: ${date}
updatedAt: ${date}
status: 'growing'
topic: '${topic}'
emoji: ${emoji}
${formattedTags}
---
# ${title}
This is a growing collection of ${title.toLowerCase()} that I'm building over time. I'll be adding more examples and explanations as I learn and experiment.
## Basic Example
\`\`\`${topic}
// Basic example code here
\`\`\`
`;
const year = getCurrentYear();
const postsDir = join('src', 'posts', year);
ensureDirectoryExists(postsDir);
const fileName = `${date}-${slugify(title)}.md`;
const filePath = join(postsDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New growing post created: ${filePath}`);
await postCreationWorkflow(filePath, `New growing post: ${title}`);
}
/**
* Adds a new link to today's daily post, or creates the post if it doesn't exist
*/
async function newDailyLink(): Promise<void> {
const today = new Date();
const formattedDate = today.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
const date = getCurrentDate();
const year = getCurrentYear();
const postsDir = join('src', 'posts', 'daily', year);
const fileName = `${date}-daily-post.md`;
const filePath = join(postsDir, fileName);
// Check if today's daily post already exists
let existingContent = '';
let frontMatter = '';
if (existsSync(filePath)) {
// If it exists, extract the front matter and body
existingContent = readFileSync(filePath, 'utf-8');
const [, existingFrontMatter, existingBody] = existingContent.split('---', 3);
frontMatter = existingFrontMatter.trim();
existingContent = existingBody.trim();
} else {
// Otherwise, create new front matter
frontMatter = `title: "Daily Post: ${formattedDate}"
headerSubtitle: "daily thoughts and links"
description: "A collection of thoughts, ideas, and interesting links for the day"
date: ${date}
emoji: 📝
tags:
- daily
layout: daily`;
}
// Get the new link details
const {url, name, description} = await inquirer.prompt<DailyLinkEntry>([
{type: 'input', name: 'url', message: 'Enter the link URL:'},
{type: 'input', name: 'name', message: 'Enter a name for the link:'},
{
type: 'input',
name: 'description',
message: 'Enter a short description for the link:'
}
]);
const newEntry = `- [${name}](${url}): ${description}`;
// Either add to existing content or create new content
let updatedContent;
if (existingContent) {
updatedContent = `${existingContent}\n${newEntry}`;
} else {
updatedContent = `This is a daily post containing stream of thought ideas and interesting links I've found throughout the day.
## Links:
${newEntry}`;
}
const fullContent = `---
${frontMatter}
---
${updatedContent}
`;
// Ensure directory exists and write the file
ensureDirectoryExists(postsDir);
writeFileSync(filePath, fullContent);
console.log(`Daily post updated: ${filePath}`);
await postCreationWorkflow(filePath, `Update daily post: ${formattedDate}`, true);
}
/**
* Creates a new game entry for the database
*/
async function newGame(): Promise<void> {
const {title, headerSubtitle, description, rating, tags} =
await inquirer.prompt<GameData>([
{type: 'input', name: 'title', message: 'Enter the game title:'},
{type: 'input', name: 'headerSubtitle', message: 'Enter the header subtitle:'},
{type: 'input', name: 'description', message: 'Enter a description:'},
{type: 'number', name: 'rating', message: 'Enter your rating (0-5):', default: 3},
{type: 'input', name: 'tags', message: 'Enter the tags (comma-separated):'}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
const formattedTags = formatTags(tags);
const frontMatter = `---
title: "${title}"
headerSubtitle: "${headerSubtitle}"
emoji: ${emoji}
layout: db
date: ${date}
rating: ${rating}
${formattedTags}
featuredImage: /assets/images/db/games/${slugify(title)}.jpg
---
${description}
`;
const dbDir = join('src', 'db', 'games');
ensureDirectoryExists(dbDir);
const fileName = `${slugify(title)}.md`;
const filePath = join(dbDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New game entry created: ${filePath}`);
await postCreationWorkflow(filePath, `New game entry: ${title}`);
}
/**
* Creates a new movie entry for the database
*/
async function newMovie(): Promise<void> {
const {title, headerSubtitle, description, rating, tags} =
await inquirer.prompt<MovieData>([
{type: 'input', name: 'title', message: 'Enter the movie title:'},
{type: 'input', name: 'headerSubtitle', message: 'Enter the header subtitle:'},
{type: 'input', name: 'description', message: 'Enter a description:'},
{type: 'number', name: 'rating', message: 'Enter your rating (0-5):', default: 3},
{type: 'input', name: 'tags', message: 'Enter the tags (comma-separated):'}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
const formattedTags = formatTags(tags);
const frontMatter = `---
title: "${title}"
headerSubtitle: "${headerSubtitle}"
emoji: ${emoji}
layout: db
date: ${date}
rating: ${rating}
${formattedTags}
featuredImage: /assets/images/db/movies/${slugify(title)}.jpg
---
${description}
`;
const dbDir = join('src', 'db', 'movies');
ensureDirectoryExists(dbDir);
const fileName = `${slugify(title)}.md`;
const filePath = join(dbDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New movie entry created: ${filePath}`);
await postCreationWorkflow(filePath, `New movie entry: ${title}`);
}
/**
* Creates a new music entry for the database
*/
async function newMusic(): Promise<void> {
const {title, artist, headerSubtitle, description, rating, tags} =
await inquirer.prompt<MusicData>([
{type: 'input', name: 'artist', message: 'Enter the artist name:'},
{type: 'input', name: 'title', message: 'Enter the album/song title:'},
{type: 'input', name: 'headerSubtitle', message: 'Enter the header subtitle:'},
{type: 'input', name: 'description', message: 'Enter a description:'},
{type: 'number', name: 'rating', message: 'Enter your rating (0-5):', default: 3},
{type: 'input', name: 'tags', message: 'Enter the tags (comma-separated):'}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
const formattedTags = formatTags(tags);
const fullTitle = `${artist} - ${title}`;
const frontMatter = `---
title: '${fullTitle}'
headerSubtitle: "${headerSubtitle}"
emoji: ${emoji}
layout: db
date: ${date}
genre:
${formattedTags}
rating: ${rating}
featuredImage: /assets/images/db/music/${slugify(fullTitle)}.jpg
---
${description}
`;
const dbDir = join('src', 'db', 'music');
ensureDirectoryExists(dbDir);
const fileName = `${slugify(fullTitle)}.md`;
const filePath = join(dbDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New music entry created: ${filePath}`);
await postCreationWorkflow(filePath, `New music entry: ${fullTitle}`);
}
/**
* Creates a new TV show entry for the database
*/
async function newShow(): Promise<void> {
const {title, headerSubtitle, description, rating, tags} =
await inquirer.prompt<ShowData>([
{type: 'input', name: 'title', message: 'Enter the show title:'},
{type: 'input', name: 'headerSubtitle', message: 'Enter the header subtitle:'},
{type: 'input', name: 'description', message: 'Enter a description:'},
{type: 'number', name: 'rating', message: 'Enter your rating (0-5):', default: 3},
{type: 'input', name: 'tags', message: 'Enter the tags (comma-separated):'}
]);
const emoji = await selectEmoji();
const date = getCurrentDate();
const formattedTags = formatTags(tags);
const frontMatter = `---
title: "${title}"
headerSubtitle: "${headerSubtitle}"
emoji: ${emoji}
layout: db
date: ${date}
rating: ${rating}
${formattedTags}
featuredImage: /assets/images/db/shows/${slugify(title)}.jpg
---
${description}
`;
const dbDir = join('src', 'db', 'shows');
ensureDirectoryExists(dbDir);
const fileName = `${slugify(title)}.md`;
const filePath = join(dbDir, fileName);
writeFileSync(filePath, frontMatter);
console.log(`New show entry created: ${filePath}`);
await postCreationWorkflow(filePath, `New show entry: ${title}`);
}
/**
* Main function that handles the initial user choice and routes to the appropriate function
*/
async function main(): Promise<void> {
const {action} = await inquirer.prompt<{
action:
| 'post'
| 'link'
| 'daily-link'
| 'til'
| 'growing'
| 'game'
| 'movie'
| 'music'
| 'show';
}>([
{
type: 'list',
name: 'action',
message: 'What do you want to create?',
choices: [
'post',
'link',
'daily-link',
'til',
'growing',
new inquirer.Separator('--- Database Entries ---'),
'game',
'movie',
'music',
'show'
]
}
]);
// Route to the appropriate function based on user choice
switch (action) {
case 'post':
await newPost();
break;
case 'link':
await newLink();
break;
case 'daily-link':
await newDailyLink();
break;
case 'til':
await newTil();
break;
case 'growing':
await newGrowing();
break;
case 'game':
await newGame();
break;
case 'movie':
await newMovie();
break;
case 'music':
await newMusic();
break;
case 'show':
await newShow();
break;
}
}
main();
Implementation Details
The CLI is built with Inquirer.js for interactive prompts