Grouping a List by First Letter in Hugo

5 Jul 2021 | Updated on 11 Sep 2022

Introduction

Hugo provides a powerful content management tool called taxonomies. In addition to default taxonomies such as categories and tags, you can define one on your own and customise as you like (see this repo for a cool example!).

The Problem

One important feature of taxonomies is that you can show the list of terms (keywords) across the site, such as the list of tags. By default, the list looks like a regular branch bundle of a section because it follows the same layout:

The list of all tags in this blog using the default list layout.

Now, how can we improve this page? We can certainly reduce the spacing between terms using flexbox and sort them alphabetically.

The list of all tags in this blog using a custom layout with alphabetical sorting.

It looks much better than before, but you can expect this list will grow to a messy clutter of words as the number of tags increases over time. It would look much better if we could divide them into smaller chunks based on the first letter.

The list of all tags in this blog grouped by the first letter.

The Solution(s)

I want to clarify that this is not the first solution to this problem. There was a discussion on this matter a while ago, and you can also find a different solution. These methods basically sort the titles alphabetically, track the first letter, and start the <ul> element again whenever it detects a change. I wanted to try a more conventional approach, where you first build a dictionary of tags that look like this:

{ 
  "A": [
    "absolute value",
    "algebra"
  ],

  "D": [
    "decimal",
    "distributive law"
  ],

  // ...
}

and build the document using this information.

Classifying the items

We will first build the dictionary, which we call $pages_by_letters, where the keys are the letters of the alphabet, and the values are the list of tags that start with that letter. Here is the full code:

> layouts/_default/terms.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{{ define "main" }}

{{- $letters := split "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "" -}}
{{- $pages := .Pages.ByTitle -}}
{{- $pages_by_letters := dict -}}
{{ range $pages }}
  {{- $page := . -}}
  {{- $first_letter := upper ( substr $page.Name 0 1 ) -}}
  {{- if not (in $letters $first_letter) }}
    {{ $first_letter = "#" }}
  {{ end }}
  {{- $new_list := slice -}}
  {{ with index $pages_by_letters $first_letter }}
    {{- $new_list = . | append $page -}}
  {{ else }}
    {{- $new_list = slice $page -}}
  {{ end }}
  {{- $pages_by_letters = merge $pages_by_letters (dict $first_letter $new_list) -}}
{{ end }}

<!-- ...rest of page -->

{{ end }}
Explanation
{{- $letters := split "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "" -}}
{{- $pages := .Pages.ByTitle -}}
{{- $pages_by_letters := dict -}}
{{ range $pages }}
  {{- $page := . -}}
  {{- $first_letter := upper ( substr $page.Name 0 1 ) -}}
  {{- if not (in $letters $first_letter) }}
    {{ $first_letter = "#" }}
  {{ end }}
  {{- $new_list := slice -}}
  {{ with index $pages_by_letters $first_letter }}
    {{- $new_list = . | append $page -}}
  {{ else }}
    {{- $new_list = slice $page -}}
  {{ end }}
  {{- $pages_by_letters = merge $pages_by_letters (dict $first_letter $new_list) -}}
{{ end }}

Let’s first define some useful variables. Here, $letters is the slice (list) of the English alphabet, and $pages is the slice of all terms, sorted alphabetically. Then, we will loop over $pages to look at individual terms.

{{- $letters := split "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "" -}}
{{- $pages := .Pages.ByTitle -}}
{{- $pages_by_letters := dict -}}
{{ range $pages }}
  {{- $page := . -}}
  {{- $first_letter := upper ( substr $page.Name 0 1 ) -}}
  {{- if not (in $letters $first_letter) }}
    {{ $first_letter = "#" }}
  {{ end }}
  {{- $new_list := slice -}}
  {{ with index $pages_by_letters $first_letter }}
    {{- $new_list = . | append $page -}}
  {{ else }}
    {{- $new_list = slice $page -}}
  {{ end }}
  {{- $pages_by_letters = merge $pages_by_letters (dict $first_letter $new_list) -}}
{{ end }}

Because we need to change the scope, we first need to define $page. Then, $first_letter, as the name suggests, is the (capitalised) first letter of the name of $page. A term can start with numbers or non-alphabetic letters, so we need to classify them separately.

{{- $letters := split "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "" -}}
{{- $pages := .Pages.ByTitle -}}
{{- $pages_by_letters := dict -}}
{{ range $pages }}
  {{- $page := . -}}
  {{- $first_letter := upper ( substr $page.Name 0 1 ) -}}
  {{- if not (in $letters $first_letter) }}
    {{ $first_letter = "#" }}
  {{ end }}
  {{- $new_list := slice -}}
  {{ with index $pages_by_letters $first_letter }}
    {{- $new_list = . | append $page -}}
  {{ else }}
    {{- $new_list = slice $page -}}
  {{ end }}
  {{- $pages_by_letters = merge $pages_by_letters (dict $first_letter $new_list) -}}
{{ end }}

Then, we will try to search the dictionary with $first_letter. If there is an entry, we can attend the current $page to the entry. Otherwise, we need to make a new slice. The loop ends after we update the dictionary.

Printing the items

We can then make the list from the dictionary. Since we will make use of the CSS grid, the keys can just sit inside a <span>.

> layouts/_default/terms.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- ...rest of page -->

<section class="section-pages-tag">
{{ range $key, $items := $pages_by_letters }}
  <span class="key">{{ $key }}</span>
  <ul>
  {{- range $items -}}
    <li>
      <a href="{{ .RelPermalink }}">{{ .Name }}</a><sup>{{ len .Pages }}</sup>
    </li>
  {{- end -}}
  </ul>
{{ end }}
</section>

{{ len .Pages }} calculates the number of posts for a single tag.

CSS

Finally, we can put everything together using (S)CSS.

> assets/scss/main.scss
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
.section-pages-tag {
  display: grid;
  grid-template-columns: 3rem auto;
  align-items: baseline;

  ul {
    display: flex;
    flex-wrap: wrap;
    list-style: none;
    line-height: 1.5;
  }

  li {
    margin-right: 1em;
  }

  .key {
    font-style: italic;
    font-size: 1.7rem;
  }
}