Populating and then Iterating Over a Hugo Map

25 Nov 2024

Table of Contents


“dunkers”

Introduction

This website uses tags, as provided by Hugo’s built-in taxonomy support. The site includes a page which lists my tags. The page includes an alphabetical listing of all the tags, grouped by initial letter:

“tags in order”

I build this list in two steps:

  1. Step 1: collect all the tags into a map. The keys are the first letters of the tag, and the value for each key is an array of all the tags starting with that letter.

  2. Step 2: iterate over the map to write out each array of tags grouped by letter. The key becomes the heading of the group.

As with many things in Hugo, there is more than one way to do this. Here are two variations on one possible way:

  • Use a scratch variable to collect the data, then iterate over the backing map of the scratch.

  • Use a map instead of a scratch variable.

tip
You could also create the HTML output in only one pass, by tracking the first letter of each tag and comparing that letter to the first letter of the previous tag - so you know when you start processing a new letter. But for the sake of this post, I want to only look at the first two approaches.

Using NewScratch to Collect Our Data

Here is the code which populates the newScratch variable:

Go Template
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{{ $s := newScratch }}
{{ range .Data.Terms.Alphabetical }}
  {{ $key := substr .Name 0 1 }}
  {{ $val := .Name }}
  {{ if ($s.Get $key) }}
    {{ $s.Add $key $val }}
  {{ else }}
    {{ $s.Set $key (slice $val) }}
  {{ end }}
{{ end }}

The list of tags is accessed using .Data.Terms.Alphabetical. You can see the structure of this object by using dump:

1
2
3
{{ range .Data.Terms.Alphabetical }}
  <pre>{{ debug.Dump . }}</pre>
{{ end }}

You will see something like the following JSON for each dumped sub-object:

JSON
 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
{
  "Name": "3d",
  "WeightedPages": [
    {
      "Weight": 0,
      "Page": {
        "Date": "2024-07-28T11:05:16-04:00",
        ...
        "LinkTitle": "Displaying a 3D Model in a Web Page",
        "IsNode": false,
        "IsPage": true,
        "Path": "/post/displaying-a-3d-model-in-a-web-page",
        "Slug": "",
        "Lang": "en",
        "IsSection": false,
        "Section": "post",
        ...
        "Type": "post",
        "Weight": 0
      }
    },
    {
      ...
    },
    ...
  ]
}

As well as the tag itself ("Name": "3d"), there is an array of all of the pages which use that tag. We don’t use that array here - we only access the Name of the tag.

NewScratch is a collection of key-value pairs. It comes with various methods, including Get, Set and Add. My code uses these methods to add new entries to the scratch, or to add a new tag to an existing entry.

So, for example, for the letter u, we will end up with:

JSON
1
{ "u": [ "uml",  "unicode", "upgrade", "url", "utf-8" ] }

We check if the key already exists in our scratchpad. If it doesn’t, then the first entry is .Set as an array, using slice:

1
{{ $s.Set $key (slice $val) }}

Otherwise, we .Add to the existing entry:

1
{{ $s.Add $key $val }}

As noted in the documentation:

If the first Add for a key is an array or slice, the following adds will be appended to that list.

This is why subsequent entries don’t need to be placed in an array.


Iterating Over The Data to Write the HTML

Here is the code for step 2 (writing the HTML):

Go Template
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{{ range $letter, $tags := $s.Values }}
  <h3>{{ $letter }}</h3>
  <div class="taxon-container taxon-smaller">
    {{ range $tag := $tags }}
      <span class="taxon-term">
        <a href="{{- $tag | safeURL -}}">{{- lower $tag | htmlUnescape -}}</a>
      </span>
    {{ end }}
  </div>
{{ end }}

$s.Values gives us “the raw backing map” for our scratch data.

Because it’s a map, we can assign variables to the key and value of each entry in the map as part of the range statement:

Generically, this is:

1
{{ range $key, $val := $theMap }}

This capability is actually part of the underlying Go language.

In our specific code, we do this:

1
{{ range $letter, $tags := $s.Values }}

And then because our $tags variable is an array, we iterate over that array:

1
{{ range $tag := $tags }}

This builds our output.


Using a Map instead of NewScratch

It’s convenient to use NewScratch because it comes with those Get, Set and Add functions. But this is all (probably) just syntactic sugar on top of the underlying map used by NewScratch.

We could perform step 1 using only a map. But it’s a bit more involved, because a map (or rather a dictionary in Go-speak) is immutable. Instead of updating values in-place (as we did with our scratchpad), here we are always handed a new object which results from some change we make to an existing object. The new object may be referenced by a variable which has the same name as the old object - but it is, nonetheless, a new object. We have not changed (or “mutated”) the original (“immutable”) object. Instead, we have discarded it in favor of the new object.

Why does any of this matter? Because it affects the code we need to write…

Here is that code for our revised step 1:

Go Template
1
2
3
4
5
6
{{ $tagsMap := dict }}
{{ range .Data.Terms.Alphabetical }}
  {{ $key := substr .Name 0 1 }}
  {{ $val := .Name }}
  {{ $tagsMap = merge $tagsMap (dict $key (append $val (index $tagsMap $key))) }}
{{ end }}

We start with an empty map using $tagsMap := dict.

But what is going on in the line of code below?

1
{{ $tagsMap = merge $tagsMap (dict $key (append $val (index $tagsMap $key))) }}

Working from the innermost nested command, outwards:

1
index $tagsMap $key

The index function will return null (when dumped to JSON - which is perhaps Go’s nil - I don’t actually know for sure) the very first time a key is used. Otherwise it will return the array of tags created so far.

The append function…

1
append $val (index $tagsMap $key)

…will append the tag name to the end of the existing array, and returns the new array. If the array is null (see above) then a new array will be created containing the tag.


That last point kind of surprised me, but it works. Yes, you can append a value to the end of a null (or nil) array in Hugo:

Go Template
1
2
3
4
5
6
7
{{ $myMap := dict }}
{{ $key := "x" }}
{{ $lookup := index $myMap $key }}
<pre>{{ debug.Dump $lookup }}</pre>
{{ $val := "xanadu" }}
{{ $surprise := append $val (index $myMap $key) }}
<pre>{{ debug.Dump $surprise }}</pre>

The first debug.dump prints null.

The second one prints [ "xanadu" ]. Surprise! Other languages I am familiar with would have thrown in the towel at this point (e.g. Java’s null pointer exceptions).

info
Turns out there is such a thing as a nil slice in Go. And you can append data to it. The following is Go code, not Hugo code.
Go
1
2
3
4
var s []int
fmt.Println(s, len(s), cap(s)) // a nil slice (s == nil)
s = append(s, 123)
fmt.Println(s, len(s), cap(s)) // [123]

Back to our one-liner:

1
{{ $tagsMap = merge $tagsMap (dict $key (append $val (index $tagsMap $key))) }}

The dict function…

1
dict $key (append $val (index $tagsMap $key))

…creates a new dictionary (map) containing our key and value-as-an-array.

Finally, the merge function merges our new map into the existing $tagsMap. If the key of the new map does not yet exist in $tagsMap, then the entry (the key and its value) is just added to $tagsMap.

But if the key does already exist then the new value (the new array) replaces the old value (the old array).

A new dict is returned at the end of all this - and is assigned back to our $tagsMap variable, replacing the old map.

(This is the “immutable” nature of Go collections in action - we always return a new object after performing some modification to the existing object - in contrast to NewScratch, where we are always operating on the same scratch object.)

That completes step 1 of our revised 2-step process.

To complete step 2, instead of iterating over a NewScratch, we iterate over our $tagsMap:

1
2
3
{{ range $letter, $tags := $tagsMap }}
  ...
{{ end }}

Final Thought

I prefer the readability of the NewScratch code - although behind the scenes, Hugo is probably just doing something similar to what my dict code does.

And if you really care about performance, it might be ever so slightly faster to implement all this as a one-pass approach (as mentioned near the start of this post).