Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Multi-select, filterable command-line interface with inquirer

In this blog, I have a feature that lets me connect multiple blog posts to each other. I then show them as “Related posts” in the right sidebar of this blog (if read on large enough viewport).

Technically, this is maintained in the front matter of each post under related_posts key:

---
related_posts:
  - title: "Another post"
    slug: another-post
  - title: "Second related post"
    slug: second-related-post
---

This works very well but there’s one big issue and it’s something I’ve been struggling to figure out for a long time. Managing these connections is cumbersome. If I write a new post and want to connect it with three other, existing posts, I need to manually copy-paste the titles and slugs of each and add to multiple files and so on.

I’ve built so many prototypes to try and figure it out but now I finally got the right inspiration.

Desired workflow

I started designing this with my desired workflow.

I already use a bunch of custom Javascript scripts to manage different parts of my workflow (like downloading posts from Notion, fetching popular posts from analytcis and so on) for this website so I decided to make this as one.

The key reason why I use this approach is that it keeps the tooling in the repository.

After I’ve written a new post and downloaded it from Notion, I want to run

npm run related

and want it to start an interactive CLI session where I could choose all the posts I want to connect with each other and I want it to be smart enough to add or update the connections, as needed.

Building CLI tool with inquirer

I have previously used commander.js to build CLI tooling for this project and it’s very nice for building non-interactive command-line interfaces. This time, I needed to build something more interactive so I decided to go with inquirer that I’ve used before for building similar tooling.

I started with the multi-select feature and used inquirer’s checkbox prompt which provides it out of the box:

const posts = listBlogPosts();
import("@inquirer/checkbox").then(async ({ default: checkbox }) => {
  const selections = await checkbox({
    message: "Choose posts to connect",
    choices: posts,
    pageSize: 15,
    loop: true,
  });
});

My listBlogPosts() reads in all the blog post titles and slugs from the repository.

Multiselect checkbox view on the command line, showing a list of 15 posts, some of them selected

When I now run this script, I get a paginated list of 15 blog post titles per page and I can select and unselect them. Once I submit the selection, selections becomes a list of the slugs of selected posts.

At the time of building this, I had almost 400 blog posts so going through them in alphabetical order one by one wasn’t gonna work.

Next, I wanted to add a way to filter the list by text queries. As I researched it further, I found inquirer-checkbox-search that does exactly what I want. It’s a 7-year old project but it works like a charm.

To implement it into my project, I added this as my main CLI code:

import("inquirer").then(async ({ default: inquirer }) => {
  inquirer.registerPrompt(
    "checkbox-search",
    require("inquirer-checkbox-search")
  );
  const slugs = await inquirer.prompt({
    type: "checkbox-search",
    name: "posts",
    message: "Choose posts to connect",
    source: filterPosts,
  });

  connectPosts(slugs.posts);
});

Two custom pieces of code are filterPosts and connectPosts.

function filterPosts(answers, input) {
  input = input || "";
  const inputArray = input.split(" ");

  const posts = listBlogPosts();

  return new Promise((resolve) => {
    resolve(
      posts.filter((post) => {
        let shouldInclude = true;

        inputArray.forEach((inputChunk) => {
          // if any term to filter by doesn't exist, exclude
          if (!post.title.toLowerCase().includes(inputChunk.toLowerCase())) {
            shouldInclude = false;
          }
        });

        return shouldInclude;
      })
    );
  });
}

I picked up the code from the repository’s example and adjusted it to use my posts instead of the list of states. It takes in an input, splits it into multiple queries and checks which posts they all match to and returns that filtered list to inquirer.

Screenshot of filtered list of posts. Query is rss and it shows two posts out of of which the first one is selected.

The workflow is so nice. I can quickly search and select the posts I want to connect with each other. connectPosts then takes those posts, reads their existing frontmatter, adds missing relations into related_posts key in frontmatter and saves the information back to the files.

Big thanks to Simon and Lauren for the amazing tooling you’ve built! ❤️