Tables of content
Overview
Insert a table of contents (TOC) into pages on your Hugo site using one of four methods:
- Use the
.Page.TableOfContents
method - Use the
.Page.Fragments.ToHTML
method - Build your own by recursively walking
.Page.Fragments.Headings
on a page - Build your own by parsing the content after Hugo has rendered the page
Each method has advantages and disadvantages. See the feature and performance comparisons at the end of this article.
Method 1: TableOfContents
The simplest approach is to use .Page.TableOfContents
method in a template, partial, or shortcode. For example, in a single page template:
<h1>{{ .Title }}</h1>
{{ .TableOfContents }}
{{ .Content }}
Method 2: Fragments to HTML
In a URL, whether absolute or relative, the fragment links to an id
attribute of an HTML element on the page.
/articles/article-1#section-2
------------------- ---------
path fragment
Hugo assigns an id
attribute to each heading on a page, which you can override with Markdown attributes as needed. This creates the relationship between an entry in the TOC and a heading on the page.
Hugo introduced .Page.Fragments
in v0.111.0. This structure provides the following methods:
.Headings
- (
map
) A nested map of all headings on the page. Each map contains the following keys:ID
,Level
,Title
andHeadings
. .HeadingsMap
- (
slice
) A slice of maps of all headings on the page, with first-level keys for each heading. Each map contains the following keys:ID
,Level
,Title
andHeadings
. .Identifiers
- (
slice
) A slice containing theid
of each heading on the page. .Identifiers.Contains
- (
bool
) Returnstrue
if one or more headings on the page has the givenid
, useful for validating fragments within a link render hook. .Identifiers.Count
- (
int
) The number of headings on a page with the givenid
attribute, useful for detecting duplicates. .ToHTML
- (
string
) Returns a TOC as a nested list, either ordered or unordered, identical to the HTML returned by.Page.TableOfContents
. This method take three arguments: the start level (int
), the end level (int
), and a boolean (true
to return an ordered list,false
to return an unordered list).
To use the .Identifiers.Contains
or .Identifiers.Count
methods:
{{ .Fragments.Identifiers.Contains "section-2" }}
{{ .Fragments.Identifiers.Count "section-2" }}
To examine the .Fragments
data structure on a page, place this in a template:
<pre>{{ jsonify (dict "indent" " ") .Fragments }}</pre>
To build a TOC using the .ToHTML
method:
{{ $startLevel := 2 }}
{{ $endLevel := 3 }}
{{ $ordered := true }}
{{ .Fragments.ToHTML $startLevel $endLevel $ordered | safeHTML }}
This detailed example allows you to:
- Set start and end levels in site configuration and/or front matter
- Define a threshold to display the TOC based on the number of headings that would appear in the TOC, set in site configuration and/or front matter
- Detect duplicate heading
ids
layouts/partials/toc-fragments-to-html.html
{{- /* Last modified: 2024-08-09T14:24:24-07:00 */}}
{{- /*
Copyright 2023 Veriphor, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}
{{- /*
Renders a table of contents from .Page.Fragments.ToHTML.
In site configuration, set the default start level, end level, and the minimum
number of headings required to show the table of contents:
[params.toc]
startLevel = 2 # default is 2
endLevel = 3 # default is 3
minNumHeadings = 2 # default is 2
To display the table of contents on a page:
+++
title = 'Post 1'
toc = true
+++
To display the table of contents on a page, and override one or more of the
default settings:
+++
title = 'Post 1'
[toc]
startLevel = 2 # default is 2
endLevel = 3 # default is 3
minNumHeadings = 2 # default is 2
+++
Start with these basic CSS rules to style the table of contents:
.toc li {
list-style-type: none;
}
.toc ol {
padding: 0 0 0 1em;
}
.toc > ol {
padding-left: 0;
}
@context {page} .
@returns {template.HTML}
@example {{ partial "toc-fragments-to-html.html" . }}
*/}}
{{- /* Initialize. */}}
{{- $partialName := "toc-fragments-to-html" }}
{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
{{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}
{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .File }}
{{- $contentPath = .Path }}
{{- else }}
{{- $contentPath = .Path }}
{{- end }}
{{- /* Check for duplicate heading IDs. */}}
{{- $duplicateIDs := slice }}
{{- range .Fragments.Identifiers }}
{{- if gt ($.Fragments.Identifiers.Count .) 1 }}
{{- $duplicateIDs = $duplicateIDs | append . }}
{{- end }}
{{- end }}
{{- with $duplicateIDs | uniq }}
{{- errorf "The %q partial detected duplicate heading IDs (%s) in %s" $partialName (delimit . ", ") $contentPath }}
{{- end }}
{{- /* Render. */}}
{{- if .Params.toc }}
{{- $startLevel := or (.Param "toc.startLevel" | int) 2 }}
{{- $endLevel := or (.Param "toc.endLevel" | int) 3 }}
{{- $minNumHeadings := or (.Param "toc.minNumHeadings" | int) 2 }}
{{- $toc := .Fragments.ToHTML $startLevel $endLevel true | safeHTML }}
{{- $numHeadings := $toc | findRE `<a href=".+">.+</a>` | len }}
{{- if ge $numHeadings $minNumHeadings }}
{{- $toc }}
{{- end }}
{{- end }}
To use this in a template or shortcode:
{{ partial "toc-fragments-to-html.html" . }}
Method 3: Walk headings
Build a TOC by recursively walking .Page.Fragments.Headings
, using .ID
, Level
, and .Title
to create each entry.
This detailed example allows you to:
- Set start and end levels in site configuration and/or front matter
- Define a threshold to display the TOC based on the number of headings that would appear in the TOC, set in site configuration and/or front matter
- Detect duplicate and missing heading
ids
- Customize HTML elements and attributes
- Create site-relative instead of page-relative link
layouts/partials/toc-walk-headings.html
{{- /* Last modified: 2024-08-09T14:24:24-07:00 */}}
{{- /*
Copyright 2023 Veriphor, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}
{{- /*
Renders a table of contents by walking .Page.Fragments.Headings.
In site configuration, set the default start level, end level, and the minimum
number of headings required to show the table of contents:
[params.toc]
startLevel = 2 # default is 2
endLevel = 3 # default is 3
minNumHeadings = 2 # default is 2
To display the table of contents on a page:
+++
title = 'Post 1'
toc = true
+++
To display the table of contents on a page, and override one or more of the
default settings:
+++
title = 'Post 1'
[toc]
startLevel = 2 # default is 2
endLevel = 3 # default is 3
minNumHeadings = 2 # default is 2
+++
Change or localize the title with a "toc_title" key in your i18n file(s).
Start with these basic CSS rules to style the table of contents:
.toc li {
list-style-type: none;
}
.toc ol {
padding: 0 0 0 1em;
}
.toc > ol {
padding-left: 0;
}
.toc-title {
font-weight: bold;
}
@context {page} .
@returns {template.HTML}
@example {{ partial "toc-walk-headings.html" . }}
*/}}
{{- /* Initialize. */}}
{{- $partialName := "toc-walk-headings" }}
{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
{{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}
{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .File }}
{{- $contentPath = .Path }}
{{- else }}
{{- $contentPath = .Path }}
{{- end }}
{{- /* Check for duplicate heading IDs. */}}
{{- $duplicateIDs := slice }}
{{- range .Fragments.Identifiers }}
{{- if gt ($.Fragments.Identifiers.Count .) 1 }}
{{- $duplicateIDs = $duplicateIDs | append . }}
{{- end }}
{{- end }}
{{- with $duplicateIDs | uniq }}
{{- errorf "The %q partial detected duplicate heading IDs (%s) in %s" $partialName (delimit . ", ") $contentPath }}
{{- end }}
{{- /* Render. */}}
{{- if .Params.toc }}
{{- with .Fragments.Headings }}
{{- $startLevel := or ($.Param "toc.startLevel" | int) 2 }}
{{- $endLevel := or ($.Param "toc.endLevel" | int) 3 }}
{{- $numHeadings := where (sort $.Fragments.HeadingsMap) "Level" "in" (seq $startLevel $endLevel) | len }}
{{- if ge $numHeadings (or ($.Param "toc.minNumHeadings" | int) 2) }}
<nav class="toc">
<div class="toc-title">
{{ or (T "toc_title") "Table of contents" | safeHTML }}
</div>
<ol>
{{- $ctx := dict
"page" $
"contentPath" $contentPath
"partialName" $partialName
"startLevel" $startLevel
"endLevel" $endLevel
"headings" .
}}
{{- partial "inline/toc/walk.html" $ctx }}
</ol>
</nav>
{{- end }}
{{- end }}
{{- end }}
{{- /* Recursively walk the headings. */}}
{{- define "partials/inline/toc/walk.html" }}
{{- $ctx := . }}
{{- range $ctx.headings }}
{{- if and (ge .Level $ctx.startLevel) (le .Level $ctx.endLevel) }}
<li>
{{- if not .ID }}
{{- errorf "The %q partial detected that the %q heading has an empty ID attribute. See %s" $ctx.partialName .Title $ctx.contentPath }}
{{- end }}
{{- $href := printf "%s#%s" $ctx.page.RelPermalink .ID }}
<a href="{{ $href }}">{{ .Title | plainify | safeHTML }}</a>
{{- with and (lt .Level $ctx.endLevel) .Headings }}
<ol>
{{- $ctx = merge $ctx (dict "headings" .) }}
{{- partial "inline/toc/walk.html" $ctx }}
</ol>
{{- end }}
</li>
{{- else }}
{{- $ctx = merge $ctx (dict "headings" .Headings) }}
{{- partial "inline/toc/walk.html" $ctx }}
{{- end }}
{{- end }}
{{- end }}
To use this in a template or shortcode:
{{ partial "toc-walk-headings.html" . }}
Method 4: Parse content
This approach uses regular expressions to parse the page content after Hugo has rendered the page. This allows you to capture headings generated by deeply nested shortcodes1 regardless of the calling notation, {{< >}}
or {{% %}}
.
You cannot call this partial from a shortcode. The content we need to parse includes the call to the shortcode—an infinite loop.
layouts/partials/toc-parse-content.html
{{- /* Last modified: 2024-08-09T14:24:24-07:00 */}}
{{- /*
Copyright 2023 Veriphor, LLC
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.
*/}}
{{- /*
Renders a table of contents by parsing rendered content.
In site configuration, set the default start level, end level, and the minimum
number of headings required to show the table of contents:
[params.toc]
startLevel = 2 # default is 2
endLevel = 3 # default is 3
minNumHeadings = 2 # default is 2
To display the table of contents on a page:
+++
title = 'Post 1'
toc = true
+++
To display the table of contents on a page, and override one or more of the
default settings:
+++
title = 'Post 1'
[toc]
startLevel = 2 # default is 2
endLevel = 3 # default is 3
minNumHeadings = 2 # default is 2
+++
Change or localize the title with a "toc_title" key in your i18n file(s).
Start with these basic CSS rules to style the table of contents:
a.toc-item {
display: block;
}
a.toc-level-1 {
margin-left: 0em;
}
a.toc-level-2 {
margin-left: 1em;
}
a.toc-level-3 {
margin-left: 2em;
}
a.toc-level-4 {
margin-left: 3em;
}
a.toc-level-5 {
margin-left: 4em;
}
a.toc-level-6 {
margin-left: 5em;
}
@context {page} .
@returns {template.HTML}
@example {{ partial "toc-parse-content.html" . }}
*/}}
{{- /* Initialize. */}}
{{- $partialName := "toc-parse-content" }}
{{- /* Verify minimum required version. */}}
{{- $minHugoVersion := "0.125.6" }}
{{- if lt hugo.Version $minHugoVersion }}
{{- errorf "The %q partial requires Hugo v%s or later." $partialName $minHugoVersion }}
{{- end }}
{{- /* Determine content path for warning and error messages. */}}
{{- $contentPath := "" }}
{{- with .File }}
{{- $contentPath = .Path }}
{{- else }}
{{- $contentPath = .Path }}
{{- end }}
{{- /* Get configuration. */}}
{{- $startLevel := or ($.Param "toc.startLevel" | int) 2 }}
{{- $endLevel := or ($.Param "toc.endLevel" | int) 3 }}
{{- $minNumHeadings := or ($.Param "toc.minNumHeadings" | int) 2 }}
{{- /* Get headings. */}}
{{- $headings := slice }}
{{- $ids := slice }}
{{- range findRE `(?is)<h\d.*?</h\d>` .Content }}
{{- $level := substr . 2 1 | int }}
{{- if and (ge $level $startLevel) (le $level $endLevel) }}
{{- $text := replaceRE `(?is)<h\d.*?>(.+?)</h\d>` "$1" . }}
{{- $text = trim $text " " | plainify | safeHTML }}
{{- $id := "" }}
{{- if findRE `\s+id=` . }}
{{- $id = replaceRE `(?is).+?\s+id=(?:\x22|\x27)?(.*?)(?:\x22|\x27)?[\s>].+` "$1" . }}
{{- $ids = $ids | append $id }}
{{- if not $id }}
{{- errorf "The %q partial detected that the %q heading has an empty ID attribute. See %s" $partialName $text $contentPath }}
{{- end }}
{{- else }}
{{- errorf "The %q partial detected that the %q heading does not have an ID attribute. See %s" $partialName $text $contentPath }}
{{- end }}
{{- $headings = $headings | append (dict "id" $id "level" $level "text" $text) }}
{{- end }}
{{- end }}
{{- /* Check for duplicate heading IDs. */}}
{{- $unique := slice }}
{{- $duplicates := slice }}
{{- range $ids }}
{{- if in $unique . }}
{{- $duplicates = $duplicates | append . }}
{{- else }}
{{- $unique = $unique | append . }}
{{- end }}
{{- end }}
{{- with $duplicates }}
{{- errorf "The %q partial detected duplicate heading IDs (%s) in %s" $partialName (delimit . ", ") $contentPath }}
{{- end }}
{{- /* Render */}}
{{- if .Params.toc }}
{{- with $headings }}
{{- if ge (len .) $minNumHeadings }}
<nav class="toc">
<div class="toc-title">
{{ or (T "toc_title") "Table of contents" | safeHTML }}
</div>
{{- range . }}
{{- $attrs := dict "class" (printf "toc-item toc-level-%d" (add 1 (sub .level $startLevel))) }}
{{- with .id }}
{{- $attrs = merge $attrs (dict "href" (printf "%s#%s" $.RelPermalink .)) }}
{{- end }}
<a
{{- range $k, $v := $attrs }}
{{- printf " %s=%q" $k $v | safeHTMLAttr }}
{{- end -}}
>{{ .text }}</a>
{{- end }}
</nav>
{{- end }}
{{- end }}
{{- end }}
To use this in a template or shortcode:
{{ partial "toc-parse-content.html" . }}
Feature comparison
In the table below, references to the methods above are abbreviated M1, M2, M3, and M4.
M1 | M2 | M3 | M4 | |
---|---|---|---|---|
Generate TOC from a shortcode | ✔️ | ✔️ | ✔️ | ❌ |
Generate TOC from a template or partial | ✔️ | ✔️ | ✔️ | ✔️ |
Set start and end levels in site configuration | ✔️ | ✔️ | ✔️ | ✔️ |
Set start and end levels in front matter2 | ❌ | ✔️ | ✔️ | ✔️ |
Set number of headings threshold in site configuration3 | ❌ | ✔️ | ✔️ | ✔️ |
Set number of headings threshold in front matter3 | ❌ | ✔️ | ✔️ | ✔️ |
Detect duplicate heading IDs | ❌ | ✔️ | ✔️ | ✔️ |
Detect missing heading IDs | ❌ | ❌ | ✔️ | ✔️ |
Customize HTML elements and attributes34 | ❌ | ❌ | ✔️ | ✔️ |
Create site-relative instead of page-relative links5 | ❌ | ❌ | ✔️ | ✔️ |
Include headings from deeply nested shortcodes | ❌ | ❌ | ❌ | ✔️ |
Include headings from HTML within Markdown | ❌ | ❌ | ❌ | ✔️ |
Performance comparison
Using the examples above, we tested a 1000 page site with 20 nested headings per page. These build times are the average of 5 runs:
Description | Build time | |
---|---|---|
Method 1 | TableOfContents | 205 ms |
Method 2 | Fragments to HTML | 207 ms |
Method 3 | Walk headings | 224 ms |
Method 4 | Parse content | 334 ms |
Method 4 is the slowest, as expected. It parses the rendered content using regular expressions to capture each of the headings.
This test site was simple. A more realistic site with shortcodes, partials, image processing, JavaScript building, Sass transpilation, CSS purging, and minification would take longer to build. As a percentage of total build time, the difference between the TOC generation methods would be negligible.