Getting Started With Hugo: Series taxonomy and navigation

I wanted to introduce a support for “series” ‒ an ordered collection of posts about the same topic. For example, this post is the 4th post in the series “Getting Started with Hugo”. And I wanted to enable navigation between different posts from the same series instead of just going to “next” or “previous” post in the blog. I looked on internet and found many articles about how to do it using Hugo. The posts that I found used older versions of Hugo because of configuration parameters and methodology that they used. I learned from these posts and came with my own implementation.

Taxonomies

The way to introduce a new thing such as “series” with Hugo is to use taxonomies. Tags are implemented this way. One thing to remember when defining new taxonomies in Hugo is that the new taxonomy configuration replaces the default one. I still wanted to keep tags so my new configuration (in hugo.yaml) looks like:

taxonomies:
  series: series
  tag: tags

Now I have two taxonomies: tags and series. And I can utilize Hugo functions to navigate around my new series taxonomy.

Like the tags, defining a serie for a post is a matter of simple definition of the serie in the post metadata. In opposite to the tags, one post can belong to only one serie. I know that it is not necessary so, but this is how I intend to use it in this blog. This post metadata looks similar to this:

---
title: "Getting Started With Hugo: Series taxonomy and navigation"
date: "2024-04-13T12:00:00-07:00"
tags: ["hugo"]
series: "getting_started_with_hugo"
---

Build a pager for series

Next I wanted to enable a reader to navigate between posts of the same series. The idea is to take the current pager implementation and customize it to work for the series taxonomy. The default navigation in the Mainroad theme shows the previous and next posts in the same category (i.e. folder or type). To customize the behavior of the theme, I followed the Hugo instructions.

(A) Customize single.html template

I copied the theme’s single.html from /themes/mainroad/layouts/_default/ to my blog’s layouts/_default folder. Then I copied the theme’s partial template for page, pager.html from /themes/mainroad/layouts/partials/ to layouts/partials folder. And I renamed the file to be single_pager.html to distinguish it from the original pager of the theme. If you use the same theme and do not need other customizations, you can simply copy the pager.html to layouts/partials and customize it. I went to customize the default template for display of the post because I wanted to do additional customizations. Then I modified the line (line 26 in my case) like this:

remove <<<< {{ partial "pager.html" . }}
insert >>>> {{ partial "series_pager.html" . }}

Running it with hugo serve I could see that I still get the default pager behavior when I am browsing a post in my blog.

(B) Implement series pager

There is no built-in method for navigating between posts of the same taxonomy in Hugo. At least I did not find something like NextInSection function. Instead, I utilized the extensive template support based on Golang template. The implementation does it in ??? steps:

  1. Get all posts with the same series taxonomy as the current post.
  2. Order the posts according to their creation date since it suppose to be the natural order of the posts in the series.
  3. Find the postion (aka index) of the current post within the ordered posts.
  4. Show “Prev” link, pointing to the index - 1 post if index is greater than 0.
  5. Show “Next” link, pointing to the index + 1 post if index is less than the total number of posts minus 1.

Get all posts in the series (1)

There are several methods to collect the posts that belong to the same taxonomy. I went with the method from the listing posts with the same taxonomy example that uses get method:

{{ $series := (.Site.Taxonomies.series.Get .Params.series) }}

Order the posts by creation date (2)

Hugo has sort method that sorts the collection by the property. Combined with the call to get it returns the ordered list of the series posts:

{{ $series := (sort (.Site.Taxonomies.series.Get .Params.series) "Date") }}

Finding the position of the current post (3)

Using the template script I can traverse the collection of the posts from (2) and compare the permalink property of each post vs. the current post’s value. The template reminds a simple loop in any of the programming languages. Because the context in the loop changes, I need to “remember” the permalink of my current page first:

{{ $my_permalink := .Permalink }}
{{ $my_index := -1 }}
{{ range $index, $page := $series }}
  {{ if eq $my_permalink $page.Permalink }}
    {{ $my_index = $index }}
    {{ break }}
  {{ end }}
{{ end }}

Afer finding the position of the current post in the collection I could reuse the rendering logic from the original pager.html implementation to display the internationalized “Prev” and “Next” links. The following snippet shows the template script that renders “Prev” link.

{{ if gt $my_index 0 }}
  {{ $page := (index $series (sub $my_index 1)) }}
<div class="pager__item pager__item--prev">
    <a class="pager__link" href="{{ $page.RelPermalink }}" rel="prev">
        <span class="pager__subtitle">«&thinsp;{{ T "post_nav_prev" }}</span>
        <p class="pager__title">{{ $page.Title }}</p>
    </a>
</div>
{{ end }}

Rendering “Next” link is implemented very similar.

The full implementation of the series pager

This captures the full implementation of the series pager. The pager is rendered only if the post has .Params.series metadata.

{{ if .Params.series }}
<!-- Find page index in the  -->
  {{ $my_permalink := .Permalink }}
  {{ $my_index := -1 }}
  {{ $series := (sort (.Site.Taxonomies.series.Get .Params.series) "Date") }}
  {{ range $index, $page := $series }}
    {{ if eq $my_permalink $page.Permalink }}
      {{ $my_index = $index }}
      {{ break }}
    {{ end }}
  {{ end }}
<!-- Render -->
<nav class="pager flex">
  {{ if gt $my_index 0 }}
    {{ $page := (index $series (sub $my_index 1)) }}
 <div class="pager__item pager__item--prev">
  <a class="pager__link" href="{{ $page.RelPermalink }}" rel="prev">
   <span class="pager__subtitle">«&thinsp;{{ T "post_nav_prev" }}</span>
   <p class="pager__title">{{ $page.Title }}</p>
  </a>
 </div>
  {{ end }}
  {{ if lt (add $my_index 1) ($series | len) }}
    {{ $page := (index $series (add $my_index 1)) }}
 <div class="pager__item pager__item--next">
  <a class="pager__link" href="{{ $page.RelPermalink }}" rel="next">
   <span class="pager__subtitle">{{ T "post_nav_next" }}&thinsp;»</span>
   <p class="pager__title">{{ $page.Title }}</p>
  </a>
 </div>
  {{ end }}
</nav>
{{ end }}

Voilà, I have the navigation between the posts in the same series now.