Blog CLI tool: my secret weapon

Updated:
This is a growing post that's being actively updated. Check the updated date for the latest changes.
Topic: blog

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:

  1. Choose what type of content I want to create (post, link, “Today I Learned”, growing post, or daily link)
  2. Answer a few quick prompts for metadata
  3. Select an emoji from my curated list (or search for one)
  4. Get a pre-formatted Markdown file with frontmatter already set up
  5. 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