Table of Contents

Generating a recursive menu in Hugo

Everyone tries recursion at least once. It was my turn.

29 Jun 2022. 738 words.


Motivation

Hugo lets you generate menus from the menu parameters of individual pages. For example, if you have a Menu example-menu with a top page, Projects 1 and 2 directly beneath it, two examples under Project 1, it will have the following structure:

example-menu
└── Top
    ├── Project 1
    │   ├── Example 1
    │   └── Example 2
    └── Project 2

This menu can be created using the following frontmatter structure:

---
menu:
  example-menu:
    name: "Top"
---
---
menu:
  example-menu:
    parent: "Top"
    name: "Project 1"
    weight: 10
---
---
menu:
  example-menu:
    parent: "Project 1"
    name: "Example 1"
    weight: 10
---
---
menu:
  example-menu:
    parent: "Project 1"
    name: "Example 2"
    weight: 20
---
---
menu:
  example-menu:
    parent: "Top"
    name: "Project 2"
    weight: 20
---

So, how do we render this? The Hugo documentation shows some good examples of a menu template, but it also has a few shortcomings:

You can definitely see the result of this from the official Hugo website as well, just have a look at how many pages there are in the Functions section!

Inspired by this Vue example case, I decided to design a recursive menu template that can:

Partials

Let’s start with having a look at the overall structure first. This is where the menu list sits inside <aside>.

layouts/partials/sidenav.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{{ $current_page := . }}
{{ $menu_id := "" }}
{{ range $key, $value := .Params.menu }}
  {{ $menu_id = $key }}
{{ end }}

<aside class="sidenav">
  {{/* ...rest of code */}}
  <div class="menu">
    <ul>
      {{ partial "menu-item" (dict
        "menu" $menu_id
        "list" (index .Site.Menus $menu_id)
        "current" $current_page) }}
    </ul>
  </div>
</aside>

You can see that the partial is called with three variables, menu, list, and current.

Let’s look inside what the partial looks like.

layouts/partials/menu-item.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{{ $menu_id := .menu }}
{{ $list := .list }}
{{ $current_page := .current }}
{{ range $list }}
  {{- $is_current := $current_page.IsMenuCurrent $menu_id . -}}
  <li>
    {{- if .URL -}}
      <a {{ if $is_current }}class="current" {{ end }} href="{{ .URL }}">{{ .Name }}</a>
    {{- else -}}
      <span {{ if $is_current }}class="current" {{ end }}>{{ .Name }}</span>
    {{- end -}}

    {{/* only render the child elements of the current page and
        the ancestors of the current page. */}}
    {{- if and
      .HasChildren
      (or
        ($current_page.IsMenuCurrent $menu_id .)
        ($current_page.HasMenuCurrent $menu_id .)
      ) -}}
      <ul>
        {{ partial "menu-item" (dict
          "menu" $menu_id
          "list" .Children
          "current" $current_page) }}
      </ul>
  {{- end -}}
  </li>
{{ end }}{{/* end range $list */}}

It definitely wasn’t as tricky as it sounded!

Here would be the result of rendering example-menu from the Project 2 page.

public/posts/project-2.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<aside>
  ...
  <div class="sidenav-menu">
    <ul>
      <li><a href="/posts/">Top</a></li>
      <ul>
        <li><a href="/posts/project-1/">Project 1</a></li>
        <li class="current"><a href="/posts/project-2/">Project 2</a></li>
      </ul>
    </ul>
  </div>
</aside>

Update The menu template was originally developed from the example template in the Hugo documentation, which renders like this:

<ul>
  <li>Item 1</li>
  <ul>
    <li>Subitem 1</li>
    <li>Subitem 2</li>
  </ul>
  <li>Item 2</li>
</ul>

However, according to MDN and HTML Standard, nested lists should look like this instead:

<ul>
  <li>
    Item 1
    <ul>
      <li>Subitem 1</li>
      <li>Subitem 2</li>
    </ul>
  </li>
  <li>Item 2</li>
</ul>

Hugo documentation is now fixed as well.