Nested Taxonomies with Hugo

March 9, 2020

I was looking into a WordPress to Hugo site migration recently. One problem I noticed was around Hugo's incomplete support for hierarchical taxonomies – something that WordPress offers with its notion of categories. I spent a while trying to adapt the built-in taxonomy conventions, but eventually I decided it wasn't practical. Instead, I switched to using regular content sections and some extra templating magic to support these more complex taxonomies. The following are general strategies that I found to work for me.

Starting with Content

The first type in this example is the taxonomy-equivalent type which I call a collection. To maintain the hierarchy, each collection must be its own section. For example, the following represents the Confections category (which itself is a subcategory of Sweets):

---
title: "Confections"
---

Desserts, candies, and sweet breads

The next type is a product, and these are regular pages which can include a list of the collections they belong to. For example:

---
title: "Schoggi Schokolade"
brand: "heli-susswaren-gmbh-co-kg"
collections:
- "sweets/confections"
- "sales/2020-11"
---

100 - 100 g pieces

Listing Pages

When it comes to rendering collections, I primarily use the section.html template. One of the traditional views is to show all the products in the collection. For this, a straightforward where function with an intersect can be used:

<dt>Products</dt>
{{- range where .Site.Pages ".Params.collections" "intersect" ( slice ( .File.Path | strings.TrimPrefix ( printf "%s/" .Type ) | strings.TrimSuffix "/_index.md" ) ) }}
  <dd><a href="{{ .Permalink }}">{{ .Title }}</a></dd>

The result for a collection with two products then looks something like:

<dt>Products</dt>
  <dd><a href="/product/schoggi-schokolade/">Schoggi Schokolade</a></dd>
  <dd><a href="/product/teatime-chocolate-biscuits/">Teatime Chocolate Biscuits</a></dd>

Listing Nested Pages

A more complicated view (which I was not able to reproduce with built-in Hugo taxonomies) was to list all items in this taxonomy collection and sub-collections. To support this, I created a template helper function to recursively capture all the sub-collections into a .Scratch variable:

{{ define "_nested_sections_slice" -}}
  {{ $.Scratch.Add "nested_sections_slice" ( slice ( .Section.File.Path | strings.TrimPrefix ( printf "%s/" .Section.Type ) | strings.TrimSuffix "/_index.md" ) ) -}}
  {{ range .Section.Sections -}}
    {{ template "_nested_sections_slice" ( dict "Scratch" $.Scratch "Section" . ) -}}
  {{ end -}}
{{ end -}}

Then, after executing it, I use it with another where/intersect lookup (and I could add Paginate for long lists):

<dt>Nested Products</dt>
{{- template "_nested_sections_slice" ( dict "Scratch" .Scratch "Section" . ) }}
{{- range where .Site.Pages ".Params.collections" "intersect" ( $.Scratch.Get "nested_sections_slice" ) }}
  <dd><a href="{{ .Permalink }}">{{ .Title }}</a></dd>
{{- else }}
  <dd>none</dd>
{{- end }}

The result for a collection containing two sub-collections with three total products then looks like:

<dt>Nested Products</dt>
  <dd><a href="/product/grandmas-boysenberry-spread/">Grandma&#39;s Boysenberry Spread</a></dd>
  <dd><a href="/product/schoggi-schokolade/">Schoggi Schokolade</a></dd>
  <dd><a href="/product/teatime-chocolate-biscuits/">Teatime Chocolate Biscuits</a></dd>

Page Navigation

For the product pages, a common view is to show breadcrumbs for the taxonomy collections it is in. To help with that, I created a template function that I can call with a collection to show the hierarchy:

{{ define "taxonomy-hierarchy" }}
  {{- if and .Parent ( eq .Parent.Type .Type ) }}
    {{- template "taxonomy-hierarchy" .Parent }} / {{ end -}}
  <a href="{{ .Permalink }}">{{ .Title }}</a>
{{- end }}

Then, on the individual product pages, I can show a set of breadcrumbs for each collection. I use the .GetPage function to load the collection before passing it to the template function (with errorf helping to avoid frontmatter typos):

<dt>Collections</dt>
{{- range .Params.collections }}
  {{- with $.Site.GetPage ( printf "collection/%s" . ) }}
    <dd>{{ template "taxonomy-hierarchy" . }}</dd>
  {{- else }}
    {{ errorf "unable to find collection: %s" . }}
  {{- end }}
{{- end }}

The result for a product in the Confections and On Sale collections then looks like:

<dt>Collections</dt>
    <dd><a href="/collection/sweets/">Sweets</a> / <a href="/collection/sweets/confections/">Confections</a></dd>
    <dd><a href="/collection/sales/">On Sale</a> / <a href="/collection/sales/2020-11/">This Week&#39;s Deals</a></dd>

Still, a taxonomy

One last thing: in order to avoid confusion of the collection/ root directory being a collection itself, I explicitly give it a type of taxonomy. There's nothing magic about the name taxonomy here – it is just another content type – but it does help simplify some of the earlier template functions and make it easier to have a different theme template at that path.

---
type: "taxonomy"
title: "Collections"
---

Note: the sample site for examples from this post is available if you want more details or to run hugo serve yourself.