keith is
📨 coding... for fun?

Writing a CLI tool for fun

I’m trying to write more, especially now that I just left my job. And instead of writing, I usually make excuses. This time instead of writing, I’m going to get rid of those excuses. And then hope writing comes. 🤞

An easy excuse to not even get started is

I’ll have to copy an old blog post and update all of the metadata, remember where I put images and I’ll do that later 😴

And I realized that when using a larger library or platform (or really the first thing you do at work when you need to do something a few times) has is a CLI. So, I’m building a little CLI tool for fun.

I’m using Inquirer.js (a new package to me!) to make managing a CLI easier, especially inputs. Below is my full cli/index.ts that I run with bun bun run cli/index.ts. You don’t need the content of emojiChoices, it’s all of the emojis that have an illustrated counterpart in the GT-Maru Emoji font.

I only need it to do a few things (for now):

  • Create a new post, or link as a smaller than blog post type
  • Automatically create the file in the correct directory with the correct front matter
  • If it’s a link, fetch the title and description from the URL
  • Add tags to the front matter
import {existsSync, mkdirSync, writeFileSync} from 'fs';
import {join} from 'path';
import inquirer from 'inquirer';
import emojiChoices from './emojiChoices';

type PostData = {
  title: string;
  headerSubtitle: string;
  description: string;
  emoji: string;
  tags: string[];
};

type LinkData = {
  url: string;
  emoji: string;
  summary: string;
  tags: string[];
};

async function newPost(): Promise<void> {
  const {title, headerSubtitle, description, emoji, 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: 'list',
        name: 'emoji',
        message: 'Select an emoji for the post:',
        choices: emojiChoices
      },
      {
        type: 'input',
        name: 'tags',
        message: 'Enter the tags (comma-separated):'
      }
    ]);

  const date = new Date().toISOString().slice(0, 10);
  const tagList = tags.split(',').map((tag: string) => tag.trim());

  const frontMatter = `---
title: "${title}"
headerSubtitle: "${headerSubtitle}"
description: "${description}"
date: ${date}
emoji: ${emoji}
tags:
${tagList.map((tag: string) => `  - ${tag}`).join('\n')}
---
`;

  const year = new Date().getFullYear();
  const postsDir = join('src', 'posts', year.toString());
  if (!existsSync(postsDir)) {
    mkdirSync(postsDir, {recursive: true});
  }

  const fileName = `${date}-${title.toLowerCase().replace(/\s+/g, '-')}.md`;
  const filePath = join(postsDir, fileName);
  writeFileSync(filePath, frontMatter);

  console.log(`New post created: ${filePath}`);
}

async function newLink(): Promise<void> {
  const {url, emoji, summary, tags} = await inquirer.prompt<LinkData>([
    {
      type: 'input',
      name: 'url',
      message: 'Enter the link URL:'
    },
    {
      type: 'list',
      name: 'emoji',
      message: 'Select an emoji for the link:',
      choices: emojiChoices,
      default: '🔗'
    },
    {
      type: 'input',
      name: 'summary',
      message: 'Enter a summary for the link:'
    },
    {
      type: 'input',
      name: 'tags',
      message: 'Enter the tags (comma-separated):',
      default: ''
    }
  ]);

  try {
    const response = await fetch(url);
    const html = await response.text();

    const titleMatch = html.match(/<title>(.*?)<\/title>/i);
    const title = titleMatch ? titleMatch[1].trim() : '';

    const descriptionMatch = html.match(
      /<meta[^>]*name=["']description["'][^>]*content=["'](.*?)["']/i
    );
    const description = descriptionMatch ? descriptionMatch[1].trim() : '';

    const date = new Date().toISOString().slice(0, 10);
    const tagList =
      tags.length > 0 ? tags.split(',').map((tag: string) => tag.trim()) : [];

    const frontMatter = `---
title: "${title}"
headerSubtitle: "collecting links"
description: "${description}"
url: ${url}
date: ${date}
emoji: ${emoji}
${tagList.length > 0 ? `tags:\n${tagList.map((tag: string) => `  - ${tag}`).join('\n')}` : ''}
---
${summary}
`;

    const year = new Date().getFullYear();
    const month = new Date().getMonth() + 1;
    const linksDir = join(
      'src',
      'posts',
      'links',
      year.toString(),
      month.toString().padStart(2, '0')
    );

    if (!existsSync(linksDir)) {
      mkdirSync(linksDir, {recursive: true});
    }

    const fileName = `${date}-${title.toLowerCase().replace(/\s+/g, '-')}.md`;
    const filePath = join(linksDir, fileName);
    writeFileSync(filePath, frontMatter);

    console.log(`New link created: ${filePath}`);
  } catch (error) {
    console.error('Error fetching URL:', error);
  }
}

async function main(): Promise<void> {
  const {action} = await inquirer.prompt<{action: 'post' | 'link'}>([
    {
      type: 'list',
      name: 'action',
      message: 'What do you want to create?',
      choices: ['post', 'link']
    }
  ]);

  if (action === 'post') {
    await newPost();
  } else if (action === 'link') {
    await newLink();
  }
}

main();
bun cli/index.ts
? What do you want to create? link
? Enter the link URL:
https://wingolog.org/archives/2023/11/24/tree-shaking-the-horticulturally-misguided-algorithm
? Select an emoji for the link: 🔗
? Enter a summary for the link: The difficulty of getting adoption with WASM and languages like python that
 currently ship in the 10s of MBs. WASM GC is coming, but will require lots of tree shaking work in the
toolchains.
? Enter the tags (comma-separated): WASM
New link created: src/posts/links/2024/04/2024-04-15-tree-shaking,-the-horticulturally-misguided-algorithm-—-wingolog.md