Back

Auto-Number Headings in Hugo

Introduction to Markdown Render Hooks and 2 techniques for structuring your content painlessly

Render Hooks is a great feature of Hugo which allows us to customize the rendering of some markdown elements thanks to goldmark, the default markdown renderer since v0.62.0 (released in December 2019).

Currently 4 elements are supported: images, links, code blocks and headings, which we are going to tackle right now. But first let’s see how it actually works.

1. Render Hooks #

Physically, the render-heading hook is a regular Go Text Template located under layouts/<section>/_markup/, where <section> can be any section of your website (or content type).

The _default section is used to set a default behavior that will be applied to all the sections unless another hook is defined for them and thus takes precedence (refer to Lookup Order for more details on Hugo’s logic for template selection).

So it’s possible to define different render-heading hooks based on the context. Here we are just going to target the main section only which is where I store my posts (as I don’t want the customization to propagate elsewhere).

Under the hood, the render-heading hook is a call to the HeadingRender interface which has access to the HeadingContext containing accessors to predefined attributes that are available for us to customize the rendering.

The most important attributes for headings are of course .Text and .Level and the default template could look like:

1<h{{ .Level }} id="{{ .Anchor }}">{{ .Text | safeHTML }}</h{{ .Level }}>

rendering:

1## Sample Heading

into:

1<h2 id="sample-heading">Sample Heading</h2>

2. Desired behavior #

So we want to add the number to the heading in the form of <level>.<level>. to all headings from levels 2 to 6.

Indeed, we reserve the first level (<h1>) for the page title so we start from level 2 within our markup.

As well, for my personal use, I want to only target the first two levels not to clutter the content to much. A parameter should be available to easily set the limit.

From this markdown:

1## Level 1 heading
2### Level 2 heading
3#### Level 3 heading
4### Level 2 heading

We want this output:

1. Level 1 heading
1.1 Level 2 heading
1.1.1 Level 3 heading
1.2 Level 2 heading

3. Implementation #

From the headings’ absolute levels that we are provided with, we have to find their relative levels (relatively to their predecessors or parents) in terms of <level>.<level>...

When .ContentMap.Headings will be available, this will probably be less tedious. Anyways, has I have no knowledge in Go, let’s just do it with what we have.

Because the HeadingContext interface also provides access to the .Page context, we can use the Scratch method to push and get back some data in its scope.

file: layouts/partials/heading_autonum.html

 1{{ $default := site.Params.autonum.default | default false }}
 2{{ $enabled := or .Page.Params.autonum $default }}
 3{{ $force := site.Params.autonum.force | default false }}
 4{{ if $force }}
 5  {{ $enabled = $default }}
 6{{ end }}
 7
 8{{ $limit := site.Params.autonum.max | default 3 }}
 9{{ with .Page.Params.autonum }}
10  {{ if in (seq 2 6) . }}
11    {{ $limit = . }}
12  {{ end }}
13{{ end }}
14
15{{ if and $enabled (gt $limit 1) }}
16  {{ $previous_h2 := .Page.Scratch.Get "h2" | default 0 }}
17  {{ $current_h2 := $previous_h2 }}
18  {{ if eq .Level 2 }}
19    {{ $current_h2 = add $previous_h2 1 }}
20  {{ end }}
21  {{ $var_h2 := sub $current_h2 $previous_h2 }}
22
23  {{ $previous_h3 := .Page.Scratch.Get "h3" | default 0 }}
24  {{ $current_h3 := $previous_h3 }}
25  {{ if eq .Level 3 }}
26    {{ $current_h3 = add $previous_h3 1 }}
27  {{ end }}
28  {{ if $var_h2 }}
29    {{ $current_h3 = 0 }}
30  {{ end }}
31  {{ $var_h3 := sub $current_h3 $previous_h3 }}
32
33  {{ $previous_h4 := .Page.Scratch.Get "h4" | default 0 }}
34  {{ $current_h4 := $previous_h4 }}
35  {{ if eq .Level 4 }}
36    {{ $current_h4 = add $previous_h4 1 }}
37  {{ end }}
38  {{ if $var_h3 }}
39    {{ $current_h4 = 0 }}
40  {{ end }}
41  {{ $var_h4 := sub $current_h4 $previous_h4 }}
42
43  {{ $previous_h5 := .Page.Scratch.Get "h5" | default 0 }}
44  {{ $current_h5 := $previous_h5 }}
45  {{ if eq .Level 5 }}
46    {{ $current_h5 = add $previous_h5 1 }}
47  {{ end }}
48  {{ if $var_h4 }}
49    {{ $current_h5 = 0 }}
50  {{ end }}
51  {{ $var_h5 := sub $current_h5 $previous_h5 }}
52
53  {{ $previous_h6 := .Page.Scratch.Get "h6" | default 0 }}
54  {{ $current_h6 := $previous_h6 }}
55  {{ if eq .Level 6 }}
56    {{ $current_h6 = add $previous_h6 1 }}
57  {{ end }}
58  {{ if $var_h5 }}
59    {{ $current_h6 = 0 }}
60  {{ end }}
61  {{ $var_h6 := sub $current_h6 $previous_h6 }}
62
63  {{ $num := "" }}
64  {{ if le .Level $limit }}
65    {{ $num = printf "%s." (string $current_h2) }}
66  {{ end }}
67  {{ if and (gt .Level 2) (le .Level $limit) }}
68    {{ $num = printf "%s%s." $num (string $current_h3) }}
69  {{ end }}
70  {{ if and (gt .Level 3) (le .Level $limit) }}
71    {{ $num = printf "%s%s." $num (string $current_h4) }}
72  {{ end }}
73  {{ if and (gt .Level 4) (le .Level $limit) }}
74    {{ $num = printf "%s%s." $num (string $current_h5) }}
75  {{ end }}
76  {{ if and (gt .Level 5) (le .Level $limit) }}
77    {{ $num = printf "%s%s." $num (string $current_h6) }}
78  {{ end }}
79
80  {{ .Page.Scratch.Set "h2" $current_h2 }}
81  {{ .Page.Scratch.Set "h3" $current_h3 }}
82  {{ .Page.Scratch.Set "h4" $current_h4 }}
83  {{ .Page.Scratch.Set "h5" $current_h5 }}
84  {{ .Page.Scratch.Set "h6" $current_h6 }}
85
86  {{ with $num }}
87    <span class="autonum">{{ . }}</span>
88  {{ end }}
89
90{{ end }}

We wrapped the whole mechanics in the partial you can then add to the render-hook.

file: layouts/<section>/_markup/render-heading.html

1<h{{ .Level }} id="{{ .Anchor | safeURL }}">
2  {{- partial "autonum" . -}}
3  {{ .Text | safeHTML }}
4  <a class="anchor secondary" href="#{{ .Anchor | safeURL }}">#</a>
5</h{{ .Level }}>

The feature will be activated if the autonum page parameter is set to true in the Page Front Matter.

Furthermore, it is configurable with 3 global variables bundled in autonum:

The 2 later are shortcuts for what could be defined otherwise through the cascade.

In the end, you can add the following to your config.yaml:

1autonum:
2  max: 3 # heading levels from 2 to 6 to apply numbering to (0 or 1 to disable)
3  default: false
4  force: false

4. Pure CSS alternative #

I’d also like to point out Ashish Lahoti’s alternative implementation using only CSS,.

 1body {counter-reset: h2}
 2h2 {counter-reset: h3}
 3h3 {counter-reset: h4}
 4h4 {counter-reset: h5}
 5
 6article[autonumbering] h2:before {counter-increment: h2; content: counter(h2) ". "}
 7article[autonumbering] h3:before {counter-increment: h3; content: counter(h2) "." counter(h3) ". "}
 8article[autonumbering] h4:before {counter-increment: h4; content: counter(h2) "." counter(h3) "." counter(h4) ". "}
 9
10article[autonumbering] .toc__menu ul { counter-reset: item }
11article[autonumbering] .toc__menu li a:before { content: counters(item, ".") ". "; counter-increment: item }
Metadata
PublicationApril 25, 2022
Last editMay 30, 2022
SourcesView source
LicenseCreative CommonsAttribution - Some rights reserved
ContributeSuggest change
Comments