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:
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.
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.
… 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.
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:
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.
And there we go. The tag-line is now rendered with the p-tag, inheriting its layout. No css changes required.
Sources: