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:
max: integer maximum level to apply numbering to (default is 3, maximum is 6 but won’t warn or error, 0 or 1 disable the numbering).default: boolean whether or not to enable autonum by default (default is false).force: boolean whether or not the globaldefaultshould take precedence over the pageautonumvariable (default is false).
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 }
Comments