Adding a useful Table of Contents to Hugo sites

It’s probably apparent by now, but I’m pretty particular so using the simple Table of Contents that is already part of Hugo felt too easy. I am special and so my Table of Contents will be too.

If you do not care to read (or about) all the words and explanations, feel free to skip to the SHORT VERSION


BACKGROUND

Site navigatability (what, that’s not a word?) is important to me. I know that, personally, I hate scrolling just to get to the top or bottom or that place I read that thing (scrolling while reading isn’t bad). I also have a bad tendency to be wordy, so some pages get long.

I initially thought I would combat this with a “back to top” arrow like I see on a lot of sites (and I furrow my brow at sites that don’t have that), but I realized that I don’t always want to go all the way to the VERY top. So a Table of Contents (I’m already tired of typing all of that out, so I will use “TOC” from now on) is going to be essential.


CHALLENGE

Lucky for me, the TOC functionality is already provided by Hugo. Awesome! But here are a few things I immediately realized I didn’t like about this version of a TOC:

  1. this TOC can easily be slapped in an aside section and largely not effect any other layout…but this TOC just stays at the top (or wherever I put it) and so is only accessible while that section of the site is in view (FULL DISCLOSURE: it is very likely that this can be configured, but I didn’t find it quickly, so I decided to try other things)
  2. this approach would require me to add toc: true to the frontmatter of all of my pages and while that is fine for new pages, I’m dragging along a lot of old and archived pages that I don’t want to have to add that to (ANOTHER FULL DISCLOSURE: I am able to work around this in my “solution” so this is cheating to put as a negative of this approach)

I stumbled upon another approach and this creates a “floating” TOC which is more in line with what I want…but it effects the layout of the page. By that I mean that when a TOC is displayed, the rest of the page is shifted to the left, off center. This isn’t a very big deal, but the difference between a page that doesn’t have a TOC and one that does is not compatible with my OCD (I’ve never been diagnosed so I’m generalizing the term). I suspect that this has everything to do with “bootstrap” but I have not taken the time to learn about that.

So if I had to boil down the challenges, here they come:

  • consistency: I would like all pages to look the same regardless of whether they display a TOC or not
  • retroactive: probably the wrong term, but I just mean that I do not want to have to add anything to old pages in order for them to display a TOC
  • predictability: I do not want a TOC on every page, just pages over an arbitrary number of words…so if a page has more than 500 words, show a TOC, but less than 500 probably doesn’t need one so don’t show it
  • avoidability: this might contradict “retroactive” but I wasn’t using markdown when I wrote the posts in my archives so it is silly to have an empty TOC there…so I’d like to avoid a whole subset of pages
  • resilience: I want whatever changes I make to withstand a theme update, so no changes withing the “theme” directory structure (I didn’t mention this above, but this needs to be addressed in everything I do)

SOLUTION

I will discuss my solution at a high level, and then drill down into the actual files/changes.

I have updated layouts/_default/single.html to use “partials” that will slightly modify the layout of every page, but the logic in the partials will only insert a TOC if (a) the tags do not include “archive,” (b) there are more than 500 words in the post/page, and (c) the page does not explicitely have toc: false in the frontmatter. As a sidenote, I also updated the startLevel and endLevel to make sense for how I write my posts (using “###” and “####” for sections).

In the end, I’ve only modified two files and created an additional two, totally independent of the theme.

Consistency

I’ve achieved consistency by making ALL the pages use the same layout as if a TOC was going to be displayed. In the example that the “CharlieLeee” user provided, there were a set of divs that only got used on the page if the page’s frontmatter contained toc: true. I narrowed down that logic to the very specific section that creates the TOC. I essentially moved the logic “further in” and then made sure that the rest of it was “drawn” for every page.

Instead of the logic being at the top:

...
{{ if .Params.toc }}                   # <--- logic at the top
<div class="container-fluid docs">
    <div class="row flex-xl-nowrap">
      <div class="d-none d-xl-block col-xl-2 docs-toc">
        <ul class="nav toc-top">
          <li><a href="#" id="back_to_top" class="docs-toc-title">{{ i18n "on_this_page" }}</a></li>
        </ul>
        {{ .TableOfContents }}
        {{ partial "docs_toc_foot" . }}
      </div>
      <main class="col-12 col-md-0 col-xl-10 py-md-3 pl-md-5 docs-content" role="main">
{{ end }}
...

I moved it to only contain the actual TOC part:

...
<div class="container-fluid docs">
    <div class="row flex-xl-nowrap">
    {{ if .Params.toc }}               # <--- logic "further in" specific to TOC
      <div class="d-none d-xl-block col-xl-2 docs-toc">
        <ul class="nav toc-top">
          <li><a href="#" id="back_to_top" class="docs-toc-title">{{ i18n "on_this_page" }}</a></li>
        </ul>
        {{ .TableOfContents }}
        {{ partial "docs_toc_foot" . }}
      </div>
    {{ end }}                          # <--- this was moved too
      <main class="col-12 col-md-0 col-xl-10 py-md-3 pl-md-5 docs-content" role="main">
...

Logic

Retroactive

I found that I could key on the presence of toc: false because I’m fairly confident I never added that to the frontmatter of any of my old pages. So this will essentially allow me to stop a TOC from being displayed by adding that explicitely to a page, but I don’t have to add anything for a TOC to show up on older pages if needed.

This logic looks like: ne .Params.toc false

don’t worry, I’ll put it all together in a minute

Predictability

As I described earlier, I do not need nor want a TOC of every page. Luckily Hugo is already able to count words on a page and so I can use this to determine if a TOC will be displayed.

This logic looks like: gt .WordCount 500

Avoidability

In my conversion of old posts, I’ve always used an “archive” tag. I can use the presence or absence of a tag to insert a TOC. I couldn’t figure out how to negate this in a larger “and” statement, so I have added it before the other logic.

This logic looks like: not (in .Params.tags "archive")

Combined

So, to combine that logic, I ended up with the following:

...
<div class="container-fluid docs">
    <div class="row flex-xl-nowrap">
    {{ if not (in .Params.tags "archive") }}
      {{ if and (gt .WordCount 500) (ne .Params.toc false) }}
      <div class="d-none d-xl-block col-xl-2 docs-toc">
        <ul class="nav toc-top">
          <li><a href="#" id="back_to_top" class="docs-toc-title">{{ i18n "on_this_page" }}</a></li>
        </ul>
        {{ .TableOfContents }}
        {{ partial "docs_toc_foot" . }}
      </div>
      {{ end }}
    {{ end }}
      <main class="col-12 col-md-1 col-xl-10 py-md-3 pl-md-5 docs-content" role="main">
...

BONUS - Cleanliness

In an attempt to make this easier to read (and I might have failed), I decided to only minimally change the layouts/_default/single.html file by utilizing “partials.” Unfortunately, because it is clean code to close the HTML tags, I had to create two partials, one that goes before the main content of the page, and one that goes after it.

So I added the layouts/partials/toc_top.html partial with the logic we derived above:

<div class="container-fluid docs">
    <div class="row flex-xl-nowrap">
    {{ if not (in .Params.tags "archive") }}
      {{ if and (gt .WordCount 500) (ne .Params.toc false) }}
      <div class="d-none d-xl-block col-xl-2 docs-toc">
        <ul class="nav toc-top">
          <li><a href="#" id="back_to_top" class="docs-toc-title">{{ i18n "on_this_page" }}</a></li>
        </ul>
        {{ .TableOfContents }}
        {{ partial "docs_toc_foot" . }}
      </div>
      {{ end }}
    {{ end }}
      <main class="col-12 col-md-1 col-xl-10 py-md-3 pl-md-5 docs-content" role="main">

And a layouts/partials/toc_bottom.html partial to close those tags we inserted on every page:

      </main>
    </div>
  </div>

I used those partials in layouts/_default/single.html as:

{{- define "main" -}}
      {{ partial "toc_top" . }}
        <article class="article">
          {{ partial "page_header" . }}
          <div class="article-container">
            <div class="article-style">
              {{ .Content }}
            </div>
            {{ partial "page_footer" . }}
          </div>
        </article>
      {{ partial "toc_bottom" . }}
{{- end -}}

Resilience

I have only modified files in <repo_root>/layouts so this should withstand a theme update. If you can’t tell, this has really bitten me in the past. Hopefully not this time.


SIDE NOTES

  • I also modified config/_default/config.toml for my own personal preference:
    # from the root of my site repository
    $ grep Level config/_default/config.toml 
        startLevel = 3
        endLevel = 5
    
  • I sometimes use page/post/article interchangeably…this can be confusing so I’m sorry
  • I have no idea where the “Contents” text comes from but I might look into changing that
  • I should learn how to take advantage of “bootstrap” so I can center the text on a page, but I haven’t looked into that yet

WHAT I LEARNED

  • there is a lot of great information out there and by combining them I can get most of what I want

REFERENCE

SHORT VERSION

To avoid tl;dr enjoy this instead:

# from the root of my site repository
## create a toc_top.html partial:
$ cat layouts/partials/toc_top.html 
<div class="container-fluid docs">
    <div class="row flex-xl-nowrap">
    {{ if not (in .Params.tags "archive") }}
      {{ if and (gt .WordCount 500) (ne .Params.toc false) }}
      <div class="d-none d-xl-block col-xl-2 docs-toc">
        <ul class="nav toc-top">
          <li><a href="#" id="back_to_top" class="docs-toc-title">{{ i18n "on_this_page" }}</a></li>
        </ul>
        {{ .TableOfContents }}
        {{ partial "docs_toc_foot" . }}
      </div>
      {{ end }}
    {{ end }}
      <main class="col-12 col-md-1 col-xl-10 py-md-3 pl-md-5 docs-content" role="main">
## create a toc_bottom.html partial:
$ cat layouts/partials/toc_bottom.html 
      </main>
    </div>
  </div>
## use them in the default single.html:
$ cat layouts/_default/single.html 
{{- define "main" -}}
      {{ partial "toc_top" . }}
        <article class="article">
          {{ partial "page_header" . }}
          <div class="article-container">
            <div class="article-style">
              {{ .Content }}
            </div>
            {{ partial "page_footer" . }}
          </div>
        </article>
      {{ partial "toc_bottom" . }}
{{- end -}}
Daniel Whitley
Daniel Whitley
Administrator of thisdwhitley.com

My research interests include distributed robotics, mobile computing and programmable matter.

Related