Jan 2019

Generate navigation with Gatsby

This post will show you how you can generate one level deep sidebar navigation out of markdown pages in a Gatsby project.

Setup

To create a new project, use one of the starters that Gatsby offers. I used the gatsby default starter.

We would need the following two plugins installed:

  • gatsby-source-filesystem

      npm install --save gatsby-source-filesystem
    

    This plugin helps us transform files into Gatsby nodes. If you have used the default starter to create your project, this one should be already installed.

  • gatsby-transformer-remark

      npm install --save gatsby-transformer-remark
    

    This plugin helps us parse Markdown into HTML.

Next let's add these two plugins in the gatsby-config.js file:

  {
    resolve: `gatsby-source-filesystem`,
    options: {
      name: `docs`,
      path: `${__dirname}/src/docs/`,
    },
  },
  `gatsby-transformer-remark`

Now, the gatsby-source-filesystem is sourcing the files from within a docs folder, but we do not have this one just yet. Let's go ahead, create it and add some Mardown files in it, but group them in category folders. This is how my folder structure looks like:

  .
  ├── api
  │   └── useful-mixins.md
  ├── contributing
  │   ├── how-to-contribute.md
  │   ├── code-of-conduct.md
  │   └── community.md
  └── guides
      ├── preparing-your-environment.md
      └── deploying-and-hosting.md

I have also added some content in each file and frontmatter containing a page title.

The challenges ahead of us:

  • how to order pages in an arbitrary way (not by date of creation or alphabetical order)?
  • how to extract page category?

Ordering by priority

One possibility to solve page order is to add another frontmatter field and use it to sort pages by it. The difficulty arises if this is an arbitrary field that determines order relative to other pages, such as priority. As the number of pages increases, it will get harder and harder to manage the prioritarization within a category.

To solve this, I have added a priority number within the filename separated with a delimiter of my choice (double underscore in this case).

  .
  ├── api
  │   └── 1__useful-mixins.md
  ├── contributing
  │   ├── 1__how-to-contribute.md
  │   ├── 2__code-of-conduct.md
  │   └── 3__community.md
  └── guides
      ├── 1__preparing-your-environment.md
      └── 2__deploying-and-hosting.md

We can now get an overview of the priority pages with a glance, as files are already ordered in the editor.

We do not want this priority numbering to appear in the URL. It must be removed from the path, but kept somewhere so it can be used to sort the pages by it.

For this purpose, we can make use of onCreateNode. It allows us to transform nodes when they are created. We will extract the priority out of the file name and assign it to a node field instead:

const path = require(`path`);
const slash = require(`slash`);
const { createFilePath, createNodeField } = require(`gatsby-source-filesystem`);
const isIndex = (name) => name === `index` || name.indexOf("__index") !== -1;

exports.onCreateNode = ({ node, getNode, actions }) => {
  if (node.internal.type === `MarkdownRemark`) {
    const { createNodeField } = actions;
    const fileNode =
      node.parent && node.parent !== "undefined" ? getNode(node.parent) : node;
    const { dir = ``, name } = path.parse(fileNode.relativePath);
    let fileName = ``;
    let priority = 1;

    if (!isIndex(name)) {
      fileName = name.split("__");
      priority = parseInt(fileName[0], 10);
      fileName = fileName[fileName.length - 1];
    }

    createNodeField({
      node,
      name: `priority`,
      value: priority,
    });
  }
};

Next we would need to create a slug which is the file name without the priority number and delimiter together with the category folder name:

createNodeField({
  node,
  name: `slug`,
  value: path.posix.join(`/docs`, dir, fileName),
});

For example the slug for the file 1__useful-mixins.md in the api folder will now be /docs/api/useful-mixins.

Page category

Last we would need the page category. We could try again and make use of frontmatter by adding another field called category. This method is error prone and could turn to be misleading. We might move a file from one folder to another but forget to rename the category field and end up with an inaccurate folder structure. To avoid this, let's make use of the folder structure instead.

The field relativeDirectory contains already the path to the file. Since the file is nested in only one folder, it is easy to extract the category and save it in a new node field with the same name.

createNodeField({
  node,
  name: `category`,
  value: fileNode.relativeDirectory,
});

Creating pages

So far so good. Next we will hook into createPages which is called whenever a new page is being created.

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

  return new Promise((resolve, reject) => {
    graphql(`
      {
        allMarkdownRemark {
          edges {
            node {
              fields {
                slug
              }
            }
          }
        }
      }
    `).then((result) => {
      result.data.allMarkdownRemark.edges.forEach(({ node }) => {
        createPage({
          path: node.fields.slug,
          component: path.resolve(`./src/templates/docs.js`),
          context: {
            slug: node.fields.slug,
          },
        });
      });
      resolve();
    });
  });
};

We fetch the markdown nodes, and use their slugs to create page paths. This way the priority numbers stay out of the URL (since we cleaned them up earlier).

In the code snippet above, we also assign a template for the markdown pages. If you haven't created this a template file already, go ahead and do it.

In this template I have used the Layout component that comes out of the box with the default starter, and I have added a Sidebar component to display the navigation.

Display navigation

In the sidebar navigation, we query the pages:

  query {
    docs: allMarkdownRemark(sort: {fields: fields___priority})
      group(field: fields___category) {
        edges {
          node {
            id
            fields {
              slug
              priority
              category
            }
            frontmatter {
              title
            }
          }
        }
      }
    }
  }

The query results are grouped by category and sorted by priority. The category name though is the name of the folder. We could do some normalizing of the folder names, like capitalizing the first letter or removing dashes if we were using any. This still might not be ideal, but luckily we have choices here.

One way to go, is to add frontmatter field with category name. This will lead to needless repeat of this field in every new page created. Also it would be nice to keep pages and categories set independant from each other.

So I opted out for creating a categories.js file which contains a list of the available categories. If you have a relatevely small number of categories this could be a convenient way to manage them. We could even add category priority so we can order categories by it.

This list currently only contains the category id (we could use it as key when rendering the data) and category display name. The keys are corresponding to the folder names.

const categories = {
  docs: {
    name: "Documentation",
    id: 34567,
  },
  contributing: {
    name: "Contribute",
    id: 43567,
  },
  guides: {
    name: "Guides",
    id: 456789,
  },
  api: {
    name: "API",
    id: 9456,
  },
};

export default categories;

Now after fetching the data, let's transform it so it is more convenient to iterate over. We can grab the category key from the first page in the group list and use it to get the related category data from the categories list (id and display name in this case).

const getPagesByCategory = function (pages, categories) {
  const pagesByCategory = {};

  pages.group.forEach((pageGroup) => {
    // Grab the category key from the 1st item in the pages list
    // they all have the same category key, so no need to iterate each one
    const categoryKey = pageGroup.edges[0].node.fields.category;
    pagesByCategory[categoryKey] = {};
    pagesByCategory[categoryKey].pages = pageGroup.edges;
    pagesByCategory[categoryKey].categoryName = categories[categoryKey].name;
    pagesByCategory[categoryKey].categoryId = categories[categoryKey].id;
  });

  return pagesByCategory;
};

Next render the navigation:

<nav className="sidebar">
  <ul className="sidebar__category-list">
    {Object.keys(pagesByCategory).map(function (categoryKey, i) {
      return (
        <li className="sidebar__category-list__item" key={i}>
          <span className="sidebar__category">
            {pagesByCategory[categoryKey].categoryName}
          </span>

          <ul className="sidebar-links-list">
            {pagesByCategory[categoryKey].pages.map(function (p) {
              return (
                <li className="sidebar-links-list__item" key={p.node.id}>
                  <Link
                    to={p.node.fields.slug}
                    className="sidebar-links-list__link"
                    activeClassName="sidebar-links-list__link--active"
                  >
                    {p.node.frontmatter.title}
                  </Link>
                </li>
              );
            })}
          </ul>
        </li>
      );
    })}
  </ul>
</nav>

Conclusion

Thanks to Gatsby's powerful node API we were able to generate a sidebar navigation out of our markdown files. This solution is not perfect and it can definitely be improved! It would be interesting to see the possibilities of creating more deeply nested navigation rather than just one level.