Building this site #3: Better tags and backlinks

I've been spending a lot of time with React lately, which prompted me to come back to work on this site after a few months. Last time I enabled tags, which didn't take much effort but I didn't like that I had to write tags in slug form like this

tags:
  - "building-this-site"
  - "javascript"
  - "react"
  - "nextJS"

if I wanted them to be accessible as pages of their own. The solution to this is also simple but first I had to go back and rewrite some of the code that handles retrieving and converting posts from markdown. That code is now in helpers/content and the function responsible for posts now looks like this:

// Get reference to all post data.
const posts = (() => {
  return getPostPaths()
    .map((file) => {
      const rawContent = fs.readFileSync(`${POSTS_DIR}/${file}`, OPTIONS)
      const { data, content } = matter(rawContent)
      if (data.isDraft) return null // Exclude drafts.

      return {
        title: data.title,
        slug: slugify(file),
        description: data.description || null,
        date: data.date,
        tags: formatTags(data.tags) || [],
        links: getLinks(content),
        content: md.render(content)
      }
    })
    .filter((post) => post)
    .reverse()  // Reverse chronological order.
})()

export const getPosts = () => posts

So each post now has the following properties: title, slug, description, date, tags, links, and content. A few of these properties depend on outside utility functions or libraries in order to compute them. Notably I've adopted markdown-it to handle all my markdown parsing, making it incredibly concise: just md.render(content). As for tags, I simply pass the raw tags into a function that looks like this:

export function formatTags(tags) {
  return tags.map((tag) => {
    return {
      label: tag,
      slug: tag.toLowerCase().split(" ").join("-")
    }
  })
}

This way we get each tag as a pair of 1) a user-facing label, and 2) a slug. So now, in markdown, I can write my tags like so and each would have a slug automatically assigned to it. Pretty neat.

tags:
  - "Building this site"
  - "JavaScript"
  - "React"
  - "NextJS"

While I was at it, I also took the opportunity to add internal backlinks to my posts. A backlink is a link to any given post page to another post page within my domain. To implement this, I needed an easy way to reference all links contained in one post. I wrote a function to parse links from markdown (markdown links have this format: [text](url)), giving us an array that can then be assigned to a post's links property.

export function getLinks(string) {
  const links = []

  while (string.includes("](")) {
    const startIdx = string.indexOf("](") + 2
    string = string.slice(startIdx)
    const endIdx = string.indexOf(")")
    let link = string.slice(0, endIdx).trim()
    if (link.startsWith("/")) link = link.replace("/", "")
    if (link.endsWith("/")) link = link.slice(0, -1)
    string = string.slice(endIdx + 1)
    if (!links.includes(link)) links.push(link)
  }

  return links
}

This function doesn't guard against certain edge cases. For example, thanks to the above code snippets it will parse url and " from this post as links. But for our purposes at this time it's perfectly serviceable, as long as it catches actual links that could point to another page on this site.

In pages/[slug].js, which represents an individual blog post page, Next.js's getStaticProps function is responsible for finally handing over props to our page before rendering. Here's what it now looks like:

export async function getStaticProps(context) {
  const slug = context.params.slug
  const posts = getPosts()
  const i = posts.findIndex((post) => post.slug === slug)
  const { title, description, date, tags, content } = posts[i]

  const getAdjacentPost = (cond, i) => cond ? {
    title: posts[i].title,
    slug: posts[i].slug
  } : null

  const backlinks = posts
    .filter((post) => post.links.includes(slug))
    .map(({ title, date, slug }) => {
      return { title, date, slug }
    })

  return {
    props: {
      post: { title, description, date, tags, content },
      next: getAdjacentPost(i - 1 >= 0, i - 1),
      prev: getAdjacentPost(1 + i < posts.length, i + 1),
      backlinks
    }
  }
}

So for each post, we are able to get backlinks by looking at every other post's links property to see if there's any item that matches the current post's slug. And finally, everything gets passed to the BlogPostPage component:

export default function BlogPostPage({ post, backlinks, prev, next }) {
  return (
    <Container className="page-container">
      <PageHead title={post.title} />
      <PageBody
        data={post}
        footer={(
          <p className="small text-muted">
            Comments are not enabled (yet?).
            Please <Link href="mailto:joshua@cerdenia.com">email me</Link> if
            you see anything that interests you.
          </p>
        )}
      />
      {backlinks.length > 0 && <div className="mt-5 mb-3">
        <PostList heading="Backlinks" posts={backlinks} />
      </div>}
      <BottomBlogNav prev={prev} next={next}/>
    </Container>
  )
}

Between the PageBody and BottomBlogNav components, I simply inserted a condition: if the backlinks array contains more than 0 items, then render a PostList component (inside a div for proper distancing), passing the array into it. And that's done: in the future, when one or more posts link to this one, it will show up below in a nicely formatted list.