RL

Related articles with Gatsby

20.02.2019

This website is created with Gatsby, Reasonml and a little bit of typescript. In this post I will explain how I built the "Related articles" feature that can be seen on the bottom of some of the articles. I have kept all the examples in this post in javascript so it will be helpful to more people.

First of we need to define what makes two posts or articles related. On this site and for this article I am defining it as articles that has the same tags. In the frontmatter I have a comma separated list of tags. Below is an example:

title: Related articles with Gatsby
slug: related-articles-with-gatsby
tags: gatsby,javascript
date: 2019-02-20

This blog is based on a setup with gatsby-transformer-remark which has the following in gatsby-node.js to generate the pages. The first part fetches all the articles and the necessary metadata with Graphql. The second part loops over all the articles and then creates pages for them with the Gatsby createPage function.

exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions;
  const templatePath = path.resolve('./src/templates/');

  const response = await graphql(
    `
      query {
        allMarkdownRemark {
          edges {
            node {
              frontmatter {
                layout
                slug
                date(formatString: "DD.MM.YYYY")
              }
            }
          }
        }
      }
    `
  );

  const postEdges = response.data.allMarkdownRemark.edges

  postEdges.forEach(({ node }) => {
    const { slug, layout, tags } = node.frontmatter;
    createPage({
      path: `/${slug}`,
      component: path.join(templatePath, `${layout}.tsx`),
      context: { slug },
    });
  });

In order to populate the data for related articles we will add the data as context parameters in the createPage call. However, first we need to find those articles. The code below will build a map of tags and articles with those tags.

const articlesByTags = postEdges.reduce((lastValue, { node }) => {
  const tags = node.frontmatter.tags || "";
  tags.split(/, ?/).forEach(tag => {
    if (lastValue[tag]) {
      lastValue[tag].push(node.frontmatter);
    } else {
      lastValue[tag] = [node.frontmatter];
    }
  });
  return lastValue;
}, {});

The result will be something like the following, where {...} is the frontmatter of a post. This we can use to look up all the related articles for one post.

{
  gatsby: [{...}, {...}],
  cli: [{...}, {...}]
}

The articlesByTags is useful for looking up all the articles that has a list of tags. Let us look at a possible implementation for that.

const _ = require("lodash/fp");

function findRelatedArticles(slug, tags) {
  return _.flow([
    _.split(/, ?/),
    _.map(tag => articlesByTags[tag]),
    _.flatten,
    _.filter(node => node.slug !== slug),
    _.uniqBy(node => node.slug)
  ])(tags);
}

In this function the _ is the functional programming(fp) version of lodash, which has this neat utility called flow for grouping actions together. Flow returns a function when called will perform all the operations in the array. Each operation will have to be a function and the return value of each of them will be passed as the argument to the next one. The function above will first split the comma separated string to a list and then perform a map on that list of tags to a list of lists of articles. Since we now have a list of lists it is necessary to flatten it, which will result in a list of articles. The next step is to filter out the current post since we only want other articles than the one that is currently viewed. In the last step we want to remove any duplicate articles and can do that by selecting unique elements based on the slug.

To finish the data setup it is necessary to add the related articles to the context in the createPage call.

createPage({
  path: `/${slug}`,
  component: path.join(templatePath, `${layout}.tsx`),
  context: {
    slug,
    relatedArticles: findRelatedArticles(slug, tags),  },
});

The gatsby-node.js file should look something like the following now. In the code below the changes is highlighted.

exports.createPages = async ({ graphql, actions }) => {
  const { createPage } = actions;
  const templatePath = path.resolve('./src/templates/');

  const response = await graphql(
    `
      query {
        allMarkdownRemark {
          edges {
            node {
              frontmatter {
                layout
                slug
                date(formatString: "DD.MM.YYYY")
              }
            }
          }
        }
      }
    `
  );

  const postEdges = response.data.allMarkdownRemark.edges;

  const articlesByTags = postEdges.reduce((lastValue, { node }) => {    const tags = node.frontmatter.tags || '';    tags.split(/, ?/).forEach(tag => {      if (lastValue[tag]) {        lastValue[tag].push(node.frontmatter);      } else {        lastValue[tag] = [node.frontmatter];      }    });    return lastValue;  }, {});
  function findRelatedArticles(slug, tags) {    return _.flow([      _.split(/, ?/),      _.map(tag => articlesByTags[tag]),      _.flatten,      _.filter(node => node.slug !== slug),      _.uniqBy(node => node.slug),    ])(tags);  }
  postEdges.forEach(({ node }) => {
    const { slug, layout, tags } = node.frontmatter;
    createPage({
      path: `/${slug}`,
      component: path.join(templatePath, `${layout}.tsx`),
      context: {
        slug,
        relatedArticles: findRelatedArticles(slug, tags),      },
    });
  });

At this point all the data needed is in place. However, it still has to be shown to give some value. Let us look at a component to add this to the site.

export function RelatedArticles(props) {
  if (!props.articles || props.articles.length === 0) {
    return <div />;
  }
  return (
    <section id="related-articles">
      <header>Related articles</header>
      <ul>
        {props.articles.map(article => (
          <li key={article.slug}>
            <Link to={`/${article.slug}`}>{article.title}</Link>
            {' '}
            <em>{article.date}</em>
          </li>
        ))}
      </ul>
    </section>
  );
}

This component will render an empty div if there are no related articles. In the case of related articles it will render a list with links to the articles. Remember that the data available here is defined by the graphql query in gatbsy-node.js. In order to glue these parts together and make it work the component needs to be added in the template used for the articles.

<RelatedArticles articles={props.pageContext.relatedArticles} />

The page component will get the context attribute from createPage as pageContext. Thus, the relatedArticles attribute we created earlier will be accessible on that object.

This is all that is needed to have related articles with a Gatsby site with the transformer-remark plugin. To see have it looks have a look at the bottom of the Testing simple GraphQL services article.