thoughts.sort()

Setting up tags for Gatsby pages

April 07, 2020

Tags: javascript, gatsby, this site

I want to be able to tag my posts and to maintain a central hub for browsing through the tags. This is in order to get an overview of my content, and to improve navigation between related articles.

This is the starting point:

No tags

We follow the official docs for implementing tags on blog posts. To start off, we insert a tags field in the front-matter of a post. This is going to serve as our example data.

20-04-07|13:16|~/projects/casperlehmann$ git diff
diff --git a/content/blog/setting-up-tags-for-gatsby-pages/index.md b/content/blog/setting-up-tags-for-gatsby-pages/index.md
index 7c10728..b9fc75d 100644
--- a/content/blog/setting-up-tags-for-gatsby-pages/index.md
+++ b/content/blog/setting-up-tags-for-gatsby-pages/index.md
@@ -2,7 +2,6 @@
 title: Setting up tags for Gatsby pages
 date: "2020-04-07T12:59:00.000Z"
 description: "Next step in improving my blog is tag navigation."
+tags: ["javascript", "gatsby", "this site"]
 ---

 I want to be able to tag my posts, and maintain a central hub for browsing through the tags. In order to get an overview of my content, and to improve navigation between related articles.

We’ll then make some changes to gatsby-node.js. gatsby-node.js is responsible for generating our pages, and it does so by combining our data with our templates.

The main thing happening here is illustrated in the GraphQL query. It gets expanded to include the complete set of all tags across all posts. In order to create the tag pages, we loop over the tagsGroup part of the GraphQL result. As this is the complete set of tags from across the site, this means that we run the createPages function once for each of the tags in the set in order to get a complete set of pages.

diff --git a/gatsby-node.js b/gatsby-node.js
index a6e7f96..9309655 100644
--- a/gatsby-node.js
+++ b/gatsby-node.js
@@ -1,13 +1,10 @@
 const path = require(`path`)
+const _ = require("lodash")
 const { createFilePath } = require(`gatsby-source-filesystem`)

exports.createPages = async ({ graphql, actions, reporter }) => {
   const { createPage } = actions

   const blogPostTemplate = path.resolve("./src/templates/blog-post.js")
+  const tagTemplate = path.resolve("./src/templates/tags.js")

   const result = await graphql(
     `
       {
@@ -22,22 +19,15 @@ exports.createPages = async ({ graphql, actions, reporter }) => {
               }
               frontmatter {
                 title
               }
             }
           }
         }
+        tagsGroup: allMarkdownRemark(limit: 2000) {
+          group(field: frontmatter___tags) {
+            fieldValue
+          }
+        }
       }
     `
   )

   if (result.errors) {
     reporter.panicOnBuild(`Error while running GraphQL query.`)
     throw result.errors
   }

@@ -50,28 +40,13 @@ exports.createPages = async ({ graphql, actions, reporter }) => {

     createPage({
       path: post.node.fields.slug,
       component: blogPostTemplate,
       context: {
         slug: post.node.fields.slug,
         previous,
         next,
       },
     })
   })
+
+  // Create tag pages.
+  const tags = result.data.tagsGroup.group
+
+  tags.forEach(tag => {
+    createPage({
+      path: `/tags/${_.kebabCase(tag.fieldValue)}/`,
+      component: tagTemplate,
+      context: {
+        tag: tag.fieldValue,
+      },
+    })
+  })
 }

In the script above, we are also resolving ./src/templates/tags.js into the tagTemplate variable. The purpose of this template is to be the blueprint for the html representation of the list of articles tagged with a specific tag. E.g. https://casperlehmann.com/tags/this-site/. This is a new file, adapted from the Gatsby example. Main changes are the use of the Layout component in order to match the design of the other pages on the site.

20-04-07|15:07|~/projects/casperlehmann$ git diff 882a87e6485abc771703072da7fc2b70bd4cc76b 24a582178de95de72e1e62298a41f02e2f0c7909 src/templates/tags.js
diff --git a/src/templates/tags.js b/src/templates/tags.js
new file mode 100644
index 0000000..bd690c5
--- /dev/null
+++ b/src/templates/tags.js
@@ -0,0 +1,88 @@
+import React from "react"
+import PropTypes from "prop-types"
+import Layout from "../components/layout"
+
+// Components
+import { Link, graphql } from "gatsby"
+
+const Tags = ({ pageContext, data, location }) => {
+  const { tag } = pageContext
+  const { edges, totalCount } = data.allMarkdownRemark
+  const tagHeader = `${totalCount} post${
+    totalCount === 1 ? "" : "s"
+  } tagged with "${tag}"`
+
+  return (
+    <Layout location={location} title={"data.site.siteMetadata.title"}>
+      <h1>{tagHeader}</h1>
+      <ul>
+        {edges.map(({ node }) => {
+          const { slug } = node.fields
+          const { title } = node.frontmatter
+          return (
+            <li key={slug}>
+              <Link to={slug}>{title}</Link>
+            </li>
+          )
+        })}
+      </ul>
+      {/*
+              This links to a page that does not yet exist.
+              You'll come back to it!
+            */}
+      <Link to="/tags">All tags</Link>
+    </Layout>
+  )
+}
+
+Tags.propTypes = {
+  pageContext: PropTypes.shape({
+    tag: PropTypes.string.isRequired,
+  }),
+  data: PropTypes.shape({
+    allMarkdownRemark: PropTypes.shape({
+      totalCount: PropTypes.number.isRequired,
+      edges: PropTypes.arrayOf(
+        PropTypes.shape({
+          node: PropTypes.shape({
+            frontmatter: PropTypes.shape({
+              title: PropTypes.string.isRequired,
+            }),
+            fields: PropTypes.shape({
+              slug: PropTypes.string.isRequired,
+            }),
+          }),
+        }).isRequired
+      ),
+    }),
+  }),
+}
+
+export default Tags
+
+export const pageQuery = graphql`
+  query($tag: String) {
+    allMarkdownRemark(
+      limit: 2000
+      sort: { fields: [frontmatter___date], order: DESC }
+      filter: { frontmatter: { tags: { in: [$tag] } } }
+    ) {
+      totalCount
+      edges {
+        node {
+          fields {
+            slug
+          }
+          frontmatter {
+            title
+          }
+        }
+      }
+    }
+    site {
+      siteMetadata {
+        title
+      }
+    }
+  }

Another new file is ./src/pages/tags.js. In contrast to the template file, this is a single page that is designed to be the overview of the list of tags across the site. Like the template file above, it comes from the Gatsby examples, and has been fitted to use the Layout component to match the rest of the site.

diff --git a/src/pages/tags.js b/src/pages/tags.js
deleted file mode 100644
index 2be35d1..0000000
--- /dev/null
+++ a/src/pages/tags.js
@@ -1,72 +0,0 @@
+import React from "react"
+import PropTypes from "prop-types"
+
+// Utilities
+import kebabCase from "lodash/kebabCase"
+
+// Components
+import { Helmet } from "react-helmet"
+import { Link, graphql } from "gatsby"
+
+import Layout from "../components/layout"
+
+const TagsPage = ({
+  data: {
+    allMarkdownRemark: { group },
+    site: {
+      siteMetadata: { title },
+    },
+  }, location,
+}) => (
+  <Layout location={location} title={title}>
+    <Helmet title={title} />
+    <div>
+      <h1>Tags</h1>
+      <ul>
+        {group.map(tag => (
+          <li key={tag.fieldValue}>
+            <Link to={`/tags/${kebabCase(tag.fieldValue)}/`}>
+              {tag.fieldValue} ({tag.totalCount})
+            </Link>
+          </li>
+        ))}
+      </ul>
+    </div>
+  </Layout>
+)
+
+TagsPage.propTypes = {
+  data: PropTypes.shape({
+    allMarkdownRemark: PropTypes.shape({
+      group: PropTypes.arrayOf(
+        PropTypes.shape({
+          fieldValue: PropTypes.string.isRequired,
+          totalCount: PropTypes.number.isRequired,
+        }).isRequired
+      ),
+    }),
+    site: PropTypes.shape({
+      siteMetadata: PropTypes.shape({
+        title: PropTypes.string.isRequired,
+      }),
+    }),
+  }),
+}
+
+export default TagsPage
+
+export const pageQuery = graphql`
+  query {
+    site {
+      siteMetadata {
+        title
+      }
+    }
+    allMarkdownRemark(limit: 2000) {
+      group(field: frontmatter___tags) {
+        fieldValue
+        totalCount
+      }
+    }
+  }
+

Finally, we need to make changes to the actual blog post template. Simply displaying the list of assigned tags on a post is a good starting point. In the GraphQL query we add the tags field to the pageQuery. This gets the parsed list of tags that we assigned at the beginning of this post. We can display this on the page with the toString method.

diff --git a/src/templates/blog-post.js b/src/templates/blog-post.js
index cb6db3c..2be0c7c 100644
--- a/src/templates/blog-post.js
+++ b/src/templates/blog-post.js
@@ -36,7 +36,6 @@ const BlogPostTemplate = ({ data, pageContext, location }) => {
    [...]
    return (
        <Layout location={location} title={siteTitle}>
        <SEO
            title={post.frontmatter.title}
            description={post.frontmatter.description || post.excerpt}
        />
        <article>
            <header>
            <h1
                style={{
                marginTop: rhythm(1),
                marginBottom: 0,
                }}
            >
                {post.frontmatter.title}
            </h1>
            <p
                style={{
                ...scale(-1 / 5),
                display: `block`,
                marginBottom: rhythm(1),
                }}
            >
                {post.frontmatter.date}
            </p>
+           <p>tags: {post.frontmatter.tags.toString()}</p>
            </header>
            <section dangerouslySetInnerHTML={{ __html: post.html }} />
            <hr
            style={{
                marginBottom: rhythm(1),
            }}
            />
            <footer>
            <Bio />
            </footer>
        </article>
        [...]
        </Layout>
    )}

    export default BlogPostTemplate

    export const pageQuery = graphql`
      query BlogPostBySlug($slug: String!) {
        site {
          siteMetadata {
            title
          }
        }
        markdownRemark(fields: { slug: { eq: $slug } }) {
          id
          excerpt(pruneLength: 160)
          html
          frontmatter {
            title
            date(formatString: "MMMM DD, YYYY")
            description
+           tags
          }
        }
      }
    `

This makes the tags show up on the page.

Added tags

However, wouldn’t it be nice to be able to click the tags, and be taken to the list of related articles? One way of doing this could be presenting it in an html ordered list like this.

<ol>
    Tags: {post.frontmatter.tags.map(tag => (
        <li key={tag} style={{ display: "inline-block", margin: "0 10px" }}>
        <p><Link to={`/tags/${tag}/`}>
            {tag}
        </Link></p>
        </li>
    ))}
</ol>

This gives the following result.

Ordered list

… which is fine, but there are a couple of problems. For one, the manual margin doesn’t strike me as a good alternative to a comma and a space. And apart from that, it would just be nice to be able to rely on the styling of the <p>-tag. Mapping to a series of links is on the way to where we want to go.

<p>
    Tags: {post.frontmatter.tags.map(tag => (
        <Link to={`/tags/${tag}/`}>{tag}</Link>
    ))}
</p>

… but we are missing both commas and spaces here as well.

Map to Link

What is interesting is that no manual conversion is taking place from Javascript array to html representation. And after some digging, it turns out that even nested lists can be rendered just fine without any additional work. And therefore this:

<p>
    Tags: {[
        [false, <Link to={`/tags/a/`}>a</Link>],
        [", ", <Link to={`/tags/b/`}>b</Link>],
        [", ", <Link to={`/tags/c/`}>c</Link>]
    ]}
</p>

Renders like this:

Nested list as html

Notice how the false value up and disappears before the Python developers manage to say “Javascript sucks.” That’s pretty nifty, and we can use it. Due to another quirk/feature of Javascript, the Logical AND (&&) does not actually evaluate to false or true, but rather to either false or the value of the second operand. This is exemplified in the final iteration of the code:

<p>
  Tags: {post.frontmatter.tags.map((tag, i) => [
    i > 0 && ", ",
    <Link to={`/tags/${tag}/`}>{tag}</Link>
  ])}
</p>

Here, the map method iterates over the tags array, and returns an array of length 2 for every value. The first value of these inner arrays are the result of the Logical AND working in line 5: i > 0 && ", ". For the first iteration, this will evaluate to false while for all subsequent iterations it will evaluate to ", ", which is straightforward for Node to convert to readable html.

Map to nested list

And there we go. The tag-line is now rendered with the p-tag, inheriting its layout. No css changes required.

Sources: