Brian McCaffrey

Creating a Tagging System With gray-matter

When I began maintaining my website, making it easy to navigate was a priority. I followed a tutorial [1] to initially create the site which provided a way for me to write posts as markdown files with metadata and sort the posts by date. The tutorial code naturally left something to be desired. I wanted a way to organize similar topics together. To address this, I implemented a tagging system. The tagging system seamlessly integrated with my current setup. It allows users to explore specific topics and does not interfere with the default sorting of posts in chronological order. Let's delve into the details of what it took to enhance the user experience on my website!

Requirements

I wanted the tagging system to adhere to these requirements.

  • The system must not interfere with the default sorting of posts by date on the post list page.
  • The blog page must include a list of selected tags
  • Each post will store its tags in the front matter metadata
  • The blog page must start with all tags selected. When the first tag is clicked or tapped, all other tags become unselected.
  • When a tag is clicked or tapped, only that tag is activated, displaying posts that include that tag in its tag list
  • If a user has already selected one tag and chooses another, it is added to the list of selected tags and posts that include either tag are listed
  • If a user clicks on a tag that is already selected, the system should remove that tag from the group of selected tags

Notes

I opted to write my own solution rather than utilizing any existing packages to implement my tagging system. My decision stemmed from a desire to deepen my understanding and sharpen my skills. Currently, my website is dependent on Next.js, a popular React framework, for its functionality. Additionally, I use gray-matter for parsing metadata from each of my blog posts. The metadata is stored as front matter[2], which is also stored in the markdown files alongside the content of my blog posts. If you're interested in implementing a tagging system using gray-matter for front matter parsing, feel free to follow along!

Steps

Step 1 - Store tags in the document and parse the tags in the code

I first needed to decide how we should represent the tags in our blog posts. To do so, I tried writing my tags like this.

front matter example

This approach did not work. When I checked the results of parsing the front matter seen above in the code, it seemed that the array I defined after the 'tags' key would simply be passed as a string; brackets, quote-ticks, commas, and all. The other front matter is also just a string. Therefore, I decided to implement a simple function to convert a comma-delineated string into an array and utilize this function to parse the data. This way, I could store my tags in the front matter as such a sting. I decided that my tags would look like this:

front matter example

and that I would parse this part of the front matter like this:

import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

...

/**
 *
 * @param {string} the post id
 * @returns {string[]} a list oftags
 */
export function getTags(id) {
  const fullPath = path.join(postsDirectory, `${id}.md`); //code to retrieve the post
  const fileContents = fs.readFileSync(fullPath, 'utf8');

  const matterResult = matter(fileContents); // Use gray-matter to parse the post metadata section

  return matterResult.data.tags.split(',');
}

Step 2 - Display the array at the top of the post page

To display the tags corresponding to each post on the post page itself, I called the getTags function in the component's getStaticProps function. Then, I could pass the results into the component's props.

getting tags of posts

Next, in the component function, I created a constant called tagsList which mapped each value of my tags prop to a li element.

generating tag html

Finally, in the returned template, I included the following at the top of the page:

 <ul style={PostStyles.tagList}>{tagsList}</ul>

These steps result in the tags being rendered at the top of every blog post page on my website as an unordered list.

Code

...

export async function getStaticProps({ params }) {
  const tags = getTags(params.id);
  const postData = await getPostData(params.id);
  return {
    props: {
      postData,
      tags,
    },
  };
}

...

export default function Post({ postData, tags }) {
  const tagsList = tags.map((tag) => (
    <li className={PostStyles.tagText} key={tag}>
      {tag}
    </li>
  ));

  return (
    <Layout post backHref={'/blog'}>
    ...
     <!-- Rendered list of tags -->.
        <ul className={PostStyles.tagList}>{tagsList}</ul>
    ...
    </Layout>
  );
}

Step 3 - Styling the tags

At that point, the tags looked like this:

tags POC

I wanted the tags to be a comma-separated list of grey text at the top of the post. To that end, I decided to use CSS to style the <ul> and <li> elements. In my stylesheet, I utilized the CSS :not(:last-child):after pseudo-class selector to select the end of the element for all of the li elements except for the final one. Then I applied the CSS content property to insert a comma and space after each element of the list. Tip: it turns out that to insert a space using the :after selector, you must escape it like this.

list element styling

This styling results in rendering the tags like this.

tags final state

Step 4 - Sorting by tags

Back to the blog page. Each of my blog posts are displayed on the blog page as a list of links ordered by date. Per the requirements I set out for myself, I wanted to be able to click a tag button and show only the posts with that tag. I broke the problem into 2 tasks:

  1. Get a list of tags from all the existing posts, so the site can dynamically create the tag buttons at the top of the blog page
  2. When a tag is selected, filter posts by selected tags

Step 4a - Create a list unique tags to create the buttons

In my blog component, I populated a constant allPostsData, which includes the ID of every post. Therefore, I can leverage the getTags() function, which takes a post id, to create a getAllTags function.

This function returns an array of every tag used on a post in my blog. Each tag is represented in this list only once, therefore we can use the list to render our tags as buttons at the top of the blog page.

getAllTags function

I called the new function in the blog component's getStaticProps.

getStaticProps

Finally, we can use the array of tags to create an array of button elements (and of course add some nice CSS).

export default function Blog({ allPostsData, allTags }) {
  const [selectedTags, setSelectedTags] = React.useState(allTags);

  //we create this constant which contains the html for our tag buttons
  const tagButtons = allTags.map((tag) => {
    return (
      <button
        onClick={() =>
          setSelectedTags(
            getToggleTagState(tag, selectedTags, allTags)
          )
        }
        key={tag}
        value={selectedTags.includes(tag) ? 'true' : 'false'}
        className={utilStyles.toggleButton}
      >
        {tag}
      </button>
    );
  });

  return (
    <Layout>
      <Head>
        <title>Bri's Blog</title>
      </Head>
      ...
        <div>
          <p>Choose tags: </p>
          {tagButtons}
          <button onClick={() => setSelectedTags([...allTags])}>
            Reset all tags
          </button>
        </div>
      ...
    </Layout>
  );
}

Step 4b - Filter the posts by selected tag

The next step was to only show the posts which have a tag in the selectedTags array. I used the useState hook from React and passed the array with all tags as the initialState param. This initialized an array called selectedTags with all tags as an element that I could update using the setSelectedTags set function. I knew that I could pass an array of tags to the setSelectedTags function to update the state of the selectedTags array, but I needed to figure out which tags to pass when a tag was clicked. I settled on using this logic to determine the array of tags that I would pass to setSelectedTags.

function getToggleTagState(clickedTag, currentTags, allTags) {
  let localCurrentTags = Object.assign([], currentTags);
  let localAllTags = Object.assign([], allTags);
  //Case 1 - all tags are selected, so we want to unselect all tags and toggle the clicked tag as selected
  if (
    localCurrentTags.sort().join(',') ===
    localAllTags.sort().join(',')
  ) {
    return [clickedTag];
    //Case 2 - the clicked tag is already selected, we want to toggle it as unselected
  } else if (currentTags.includes(clickedTag)) {
    const newArray = localCurrentTags.filter(
      (selectedTag) => selectedTag != clickedTag
    );
    return newArray;
    //Case 3 - the clicked tag is not selected, so we want to toggle it as selected
  } else {
    const newArray = [...currentTags, clickedTag];
    return newArray;
  }
}

Then, I enhanced the html to conditionally show each post if and only if at least one tag on the post is included in the array of selected tags. I could certainly improve this section of the code... but it works for now! :P

        <ul className={utilStyles.list}>
          {allPostsData.map(({ id, date, title, postTags }) =>
            selectedTags.some((tag) => postTags.includes(tag)) ? (
              <li className={utilStyles.listItem} key={id}>
                <Link href={`/posts/${id}`}>{title}</Link>
                <br />
                <small className={utilStyles.lightText}>
                  <Date dateString={date} />
                </small>
              </li>
            ) : (
              <div></div>
            )
          )}
        </ul>

Finally, I added one extra button as an extra utility, which simply resets the selected tag array to the starting state.

<button onClick={() => setSelectedTags([...allTags])}>
  Reset all tags
</button>

With that, I implemented my own tagging system based off of grey-matter front matter!

Step 5 - Next steps

I should implement the following features:

  • Click on a tag on the post page, be brought back to the blog page with only that tag selected (i.e., see more posts like this).

Conclusion

This was a great project to implement! The hardest part of this entire endeavor was writing about the development! I have never written a coding guide before, so please bear with me as I improve this post over time. If you found this guide while trying to implement your own tagging system, I hope it was at least of some help!

[1] https://next-learn-starter.vercel.app [2] https://dev.to/dailydevtips1/what-exactly-is-frontmatter-123g