Customizing Hugo Tables of Contents

12 Nov 2024

Table of Contents


fall leaves

A Basic Hugo Table of Contents

It’s easy to generate a table of contents (ToC) using Hugo.

Here is a basic example:

My config in hugo.toml:

TOML
1
2
3
4
5
[markup]
  [markup.tableOfContents]
    startLevel = 1
    endLevel = 4
    ordered = false

A startLevel of 1 refers to headings using a single hash (#) for a <h1> tag.

Any heading outside the startLevel and endLevel range is not added to the rendered ToC.

The ordered option controls whether the items in the HTML ToC are numbered or not. This is the difference between Hugo generating <ul> (unordered list) elements and <ol> (ordered list) elements.

My example Markdown page - yes, this is deliberately messy, with heading levels used in a very inconsistent way, reflecting my real-world usage in my own posts, over the years:

markdown
 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
30
31
32
33
34
35
36
37
38
+++
date = '2024-11-12T00:00:00-05:00'
draft = true
summary = 'Testing TOC levels'
tags = []
title = '[draft] Testing TOC levels'
toc = true
+++

Lorem ipusm.

### Heading A

Lorem ipusm.

## Heading B

Lorem ipusm.

### Heading C

Lorem ipusm.

## Heading D

Lorem ipusm.

#### Heading E

Lorem ipusm.

##### Heading F

Lorem ipusm.

# Heading G

Lorem ipusm.

Below is the Hugo code to generate the ToC. Place this code in the relevant template, such as layouts/_default/single.html - or whichever template you are targeting for a ToC:

Go Template
1
2
3
4
{{ if .Params.Toc }}
  <h4>Table of Contents</h4>
  {{ .TableOfContents }}
{{ end }}

This code generates the following ToC for the above Markdown:

Ugly-Looking Gaps in my ToC Levels

As you can see, Hugo generates a bullet list for me. But because my first heading (### Heading A) is a level-3 heading, Hugo has to fill in the “missing” level-1 and level-2 headings, so it can generate valid bullets for Heading A.

That is why you see those two seemingly random dots at the start of the ToC. They are not actually random at all. Look closely and you will see their styles match those of a level-1 and level-2 bullet.

But, overall, it’s not a pleasing result. I have often started my headings with a level-3 heading, since the default font size for level-1 (and level-2) was a bit too large for my liking. I never adjusted those styles - so here we are, with many posts of mine containing headings which start at level-3…


Numbered Headings

Digression…

If I change ordered = false to ordered = true in my config file, then I get this:

That’s no better. I’m not going to discuss numbered headings, here at all. I don’t know of a clean, simple way to generate sane numbered HTML headings from Markdown. There are probably libraries which can help - but anyway, this is off-topic here.


Hugo Fragments

Instead of using {{ .TableOfContents }}, I can customize my ToC by using Hugo’s .Page.Fragments instead.

When Hugo converts Markdown to HTML it adds HTML IDs to the relevant headings tags.

So, this:

markdown
1
### Some Blurb Here

becomes this:

HTML
1
<h3 id="some-blurb-here">Some Blurb Here</h3>

Those ID attributes added by Hugo are important. They can be used to create intra-page links - in our case, to the section headings listed in the ToC:

HTML
1
<a href="#some-blurb-here">Some Blurb Here</a>

The .Page.Fragments method gives you access to all of these elements.

It’s structured as an array of objects. Each object represents one of the top-level headings, in the order in which they appear in your Markdown file. Each object contains properties describing the heading (its HTML ID, the heading Title and its Level in the heading hierarchy).

Each object also contains an array of Headings - which contains all of the subheadings which are immediate children of the heading. And each child object contains a list of its immediate children.

This continues recursively down through the entire document and the entire nested structure of the document’s headings.

Here is an example, generated for the Markdown file shown above:

1
<pre>{{ debug.Dump .Fragments.Headings }}</pre>

The above command placed in my page template generated the following output in the web page:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
[
  {
    "ID": "",
    "Level": 0,
    "Title": "",
    "Headings": [
      {
        "ID": "",
        "Level": 0,
        "Title": "",
        "Headings": [
          {
            "ID": "heading-a",
            "Level": 3,
            "Title": "Heading A",
            "Headings": null
          }
        ]
      },
      {
        "ID": "heading-b",
        "Level": 2,
        "Title": "Heading B",
        "Headings": [
          {
            "ID": "heading-c",
            "Level": 3,
            "Title": "Heading C",
            "Headings": null
          }
        ]
      },
      {
        "ID": "heading-d",
        "Level": 2,
        "Title": "Heading D",
        "Headings": [
          {
            "ID": "",
            "Level": 0,
            "Title": "",
            "Headings": [
              {
                "ID": "heading-e",
                "Level": 4,
                "Title": "Heading E",
                "Headings": [
                  {
                    "ID": "heading-f",
                    "Level": 5,
                    "Title": "Heading F",
                    "Headings": null
                  }
                ]
              }
            ]
          }
        ]
      }
    ]
  },
  {
    "ID": "heading-g",
    "Level": 1,
    "Title": "Heading G",
    "Headings": null
  }
]

Notice that in my case, this structure starts with two objects at "Level": 0, which have no IDs and no titles. These are added by Hugo to build a structure with the correct nesting levels for my situation where I started with my first Markdown heading at level-3.

You can see another “level 0” entry in between Heading D (level 2) and Heading E (level 4) - which is needed to bridge the “missing child” gap.

So, overall, Hugo has used dummy objects to fill in the all gaps I left when I threw together my Markdown headings.

Using Hugo Fragments for a ToC

Now, instead of using {{ .TableOfContents }} I can do this in my Hugo page template:

Go Template
 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
30
31
32
33
34
35
36
<!-- table of contents (toc) -->
{{ if .Params.Toc }}
  <h4>Table of Contents</h4>
  {{ with .Fragments }}
    <!-- toc level 1 -->
    {{ range .Headings }}
      {{ if gt .Level 0 }}
        <div><a class="toc-level-{{ .Level }}"
                href="#{{ .ID }}">{{ .Title | htmlUnescape }}</a></div>
      {{ end }}
      <!-- toc level 2 -->
      {{ range .Headings }}
        {{ if gt .Level 0 }}
          <div><a class="toc-level-{{ .Level }}"
                  href="#{{ .ID }}">{{ .Title | htmlUnescape }}</a></div>
        {{ end }}
        <!-- toc level 3 -->
        {{ range .Headings }}
          {{ if gt .Level 0 }}
            <div><a class="toc-level-{{ .Level }}"
                    href="#{{ .ID }}">{{ .Title | htmlUnescape }}</a></div>
          {{ end }}
          <!-- toc level 4 -->
          {{ range .Headings }}
            {{ if gt .Level 0 }}
              <div><a class="toc-level-{{ .Level }}"
                      href="#{{ .ID }}">{{ .Title | htmlUnescape }}</a></div>
            {{ end }}
          {{ end }}
          <!-- end toc level 4 -->
        {{ end }}
      {{ end }}
    {{ end }}
  {{ end }}
  <hr>
{{ end }}

This code mirrors the nested structure of my document’s headings. Each {{ range .Headings }} action iterates over the subheadings for the current heading, down through the heading hierarchy.

The code includes {{ if gt .Level 0 }} to ignore those “filler” levels at the start, and anywhere else in the body of the fragments structure.

This approach is hard-coded to only iterate four levels down through the hierarchy of my headings.

It should be noted: the above code does not use any of the config data in [markup.tableOfContents]. What gets included in (and excluded from) the ToC is wholly controlled by the above code.

info
Note the use of the HTMLUnescape function in the above code (documented here). This ensures characters are displayed as expected. For example, an ellipsis is displayed as “…” instead of its HTML character reference “&hellip;”.

The above code also uses CSS styles, such as class="toc-level-{{ .Level }}", which I use to control indentation and font size. I create one CSS rule for each heading level. Something like this, just to get some very basic styling in place:

CSS
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.toc-level-1 {
    margin-left: 0;
    font-size: 1.1rem;
}

.toc-level-2 {
    margin-left: 1.0rem;
    font-size: 1.0rem;
}

.toc-level-3 {
    margin-left: 2.0rem;
    font-size: 1.0rem;
}

.toc-level-4 {
    margin-left: 3.0rem;
    font-size: 0.9rem;
}

.toc-level-5 {
    margin-left: 4.0rem;
    font-size: 0.9rem;
}

The output looks like this:

That is a better fit for my needs. No stray bullets. I can also see where I might want to go and take a closer look at my shoddy headings Markdown, to tidy it up.


Handling Recursion

The above code is full of repetition. It’s quite unpleasant in that regard. Can we do anything about that?

Yes we can.

I can create a Hugo partial - let’s call it toc_level.html:

Go Template
1
2
3
4
5
6
7
{{ range .Headings }}
  {{ if gt .Level 0 }}
    <div><a class="toc-level-{{ .Level }}"
            href="#{{ .ID }}">{{ .Title | htmlUnescape }}</a></div>
  {{ end }}
  {{ partial "toc_level.html" . }}
{{ end }}

The most important thing about this partial is that it calls itself at the end of the partial. It is recursive.

I can use this partial to greatly simplify my original code in my page template:

Go Template
1
2
3
4
5
6
7
8
<!-- table of contents (toc) -->
{{ if .Params.Toc }}
  <h4>Table of Contents</h4>
  {{ with .Fragments }}
    {{ partial "toc_level.html" . }}
  {{ end }}
  <hr>
{{ end }}

That is much cleaner - much less code!

The end result:


Controlling Heading Levels in my ToC

We are not quite finished.

My recursive code does not generate the same result as my non-recursive code.

The non-recursive code was literally hard-coded to stop at four levels of depth. The recursive code just keeps going through the entire depth of the nested headings.

You can see this in the above screenshot: we now have Heading F showing up in the ToC.

To fix this, we can change this line of code, in the partial:

1
{{ if gt .Level 0 }}

We will first create our own ToC parameters in our hugo.toml, since we will need these - and we can’t access the Hugo-specific [markup.tableOfContents] config values (which we can actually delete, since we are no longer using them):

TOML
1
2
3
4
[params]
  [params.toc]
    startLevel = 1
    endLevel = 4

And we can use those values as follows in our recursive partial:

1
2
{{ if and (ge .Level site.Params.toc.startLevel)
          (le .Level site.Params.toc.endLevel) }}

The above logic reads: if the current heading’s level is greater than or equal to 1, and also less than or equal to 4 then show it in the ToC.

Note the use of site.Params in the above code. That site is a global function, which works regardless of the current context. If I tried to use .Site in my partial, my code would fail to compile (due to an inappropriate Hugo context!).

Now, with this adjusted logic, we no longer see Heading F (a level-5 heading) in our ToC.

Finally

I don’t mind that all my level-3 headings are indented - for example, in the ToC at the top of this page. I’m fine with that. But if I were not fine with that, at least I have a way, now, to do something about it.

So that is that.

Final Finally

A couple of days after writing I’m fine with that, I realized I wasn’t fine with that.

My goal was therefore to outdent (opposite of indent?) the ToC listing, so that the headings were left-aligned.

I chose to implement this by:

a) Perform a first pass over the headings to determine the lowest non-zero level in the ToC. (“Lowest” means the outermost level, here.)

b) Perform my original code as a second pass over the headings, but using that lowest value to fudge the CSS classes I use to control indentation for each heading.

For example, if a post uses all level-3 headings, then I treat these all as if they are level-1 headings when building the ToC.

For (a) I perform a first pass over the Fragments collection. I use newScratch to capture the data, because dicts are immutable.

In my single.html template:

Go Template
1
2
3
4
5
{{ $s := newScratch }}
{{ $s.Add "minLevel" 99 }}
{{ with .Fragments }}
  {{ partial "toc_min_level.html" ( dict "ctx" . "s" $s ) }}
{{ end }}

And the toc_min_level.html partial:

Go Template
1
2
3
4
5
6
7
{{ range .ctx.Headings }}
  {{ if and (gt .Level 0) 
            (lt .Level ($.s.Get "minLevel")) }}
    {{ $.s.Set "minLevel" .Level }}
  {{ end }}
  {{ partial "toc_min_level.html" ( dict "ctx" . "s" $.s ) }}
{{ end }}

To pass data to the partial from single.html, I first put the context . and the scratch $s into a dict. That seems to be the canonical way to pass data to a partial.

My minLevel variable in $s is what I will use to track the lowest level I encounter as I visit each heading. This is initially set to an artificially high value (99), to ensure a valid comparison during the first iteration.

Whenever I find a new “lowest value”, I replace the previous “lowest value”:

1
$.s.Set "minLevel" .Level

Because I am calling toc_min_level.html recursively, I have to be careful to pass the equivalent parameters in the recursive call as I passed in the initial call from single.html. So, in the partial I have:

1
{{ partial "toc_min_level.html" ( dict "ctx" . "s" $.s ) }}

Note the use of $.s here. The $ takes me to the initial context prior to entering the range function in my partial; the .s is how I access the scratch variable s in the partial (equivalent to how I used .ctx to access the context variable passed to the partial).

It’s a little bit cryptic (to me, anyway), but it works.

Once I am back in the single.html template, I now know what the lowest level heading is.

Now I can use the original toc_level.html partial in much the same way as I did earier - but with a small adjustment to use minLevel.

I can perform a small bit of arithmetic for each level I process in my original toc_level.html partial:

1
$adjustedLevel := ( sub .Level ($.s.Get "minLevel") -1 )

This subtracts my overall minLevel from the actual level I am currently processing (as I travel through all headings to build the ToC). My arithmetic then subtracts -1 (it adds 1) to the result.

Example: If the minLevel for my overall ToC is 3 - and if the current heading I am processing is level 4, then my calculation gives me an adjustedLevel of 4 - 3 - (-1) = 2 for this heading in the ToC.

I’ll use that value when assigning my CSS class to this ToC heading in toc_level.html:

1
class="toc-level-{{ $adjustedLevel }}" /* --> toc-level-2 */

Now I have a properly indented ToC, which starts at the left hand side of the web page, as I realized I wanted after all.