Fortes


Hierarchical Tags in Hugo

Making your posts a dessert topping and a floor wax

Santuário do Sagrado Coração de Jesus

Santuário do Sagrado Coração de Jesus Viana do Castelo, Portugal

Once again, if you’re not using Hugo to build a static site, your time is better spent elsewhere. Or maybe it’s not? I won’t pretend to know what’s going on with your life.

Hugo has a taxonomy system that is quite flexible. By default, the tag field is already enabled, so you can quickly organize posts by adding tag values into the post frontmatter like:

---
title: "Edan - Beauty and the Beat"
tags:
  - experimental-hip-hop
  - psychedelic-rock
---

Now that post will show up in both /tags/experimental-hip-hop and /tags/psychedelic-rock. But what if we want it to also appear in tags/hip-hop? One option is to create the following directory structure:

content/
├── tags/
    ├── hip-hop/
        ├── experimental-hip-hop/
            ├── _index.md
    ├── rock/
        ├── psychedelic-rock/
            ├── _index.md

But then, instead of tagging with just experimental-hip-hop, you’ll have to use hip-hop/experimental-hip-hop which is wordy and error-prone. Also, each tag will only be able to have a single parent.

Instead, we’ll try something different: we’ll add tags to the tag pages themselves. For example, create a file at content/tags/experimental-hip-hop/_index.md and add the following frontmatter:

---
title: "Experimental Hip-Hop"
tags:
  - hip-hop
---

This by itself won’t do much, we still won’t see Beauty and the Beat when visiting /tags/hip-hop. The next step is to change the layout of our tag pages in order to also pull all pages from any child tags. Typically, the template for a tag page will look something like:

{{ range .Pages }}
  <h2>{{ .Title }}</h2>
  {{/* Post rendering logic goes here... */}}
{{ end }}

The .Pages collection will only grab pages with the specific tag (e.g. hip-hop), it does not include any child tags. We can adjust the code to include child tags by first adding a new partial:

{{/*
  Returns a slice of pages matching the given tag, including all pages from child tags.

  Usage:
    {{ $pages := partial "partials/get_descendant_tag_pages" (dict "Page" .) }}
*/}}
{{ define "partials/get_descendant_tag_pages" }}
  {{ $matches := or .Matches slice }}
  {{ $all_tag_pages = (where (where site.Pages ".Kind" "eq" "term") ".Type" "eq" "tags") }}
  {{ range where $all_tag_pages ".Params.Tags" "intersect" (slice .Page.Data.Term) }}
    {{ if not (in $matches .) }}
      {{ $matches = $matches | append . }}
      {{ $child_tag_pages := partial "partials/get_descendant_tag_pages" (dict "Page" . "Matches" $matches) }}
      {{ $matches = $matches | union $child_tag_pages }}
    {{ end }}
  {{ end }}
  {{ return $matches }}
{{ end }}

Now, update the template for the tag page to use the result of this partial instead of .Pages:

{{ $pages := partial "partials/get_descendant_tag_pages" (dict "Page" .) }}
{{ range $pages }}
  <h2>{{ .Title }}</h2>
  {{/* Post rendering logic goes here... */}}
{{ end }}

Now, when you visit /tags/hip-hop you’ll see Beauty and the Beat listed along with any other pages tagged with experimental-hip-hop. One nice thing about this technique is that add multiple parent tags to a single tag page, so you can make nursery-rhymes a child of both children and death-metal in order to confuse your headbanging friends.