【Hugo】.TableOfContents が使えないけど目次を追加したいときの対処法

には、記事内の h タグから自動で目次を出力してくれる機能があり、

通常は.TableOfContentsで呼び出すことができます。

しかし、テーマによっては何らかの事情で.TableOfContentsが使えない可能性があるので、

今回は Hugo のテンプレート文だけでがんばって実装してみたいと思います。


.TableOfContentsの構造

HTML を見てみると、.TableOfContentsが呼び出された部分は次のようになっています。

1
<nav id="TableOfContents">
2
<ul>
3
<li>
4
<a href="#見出し1">見出し1</a>
5
<ul>
6
<li>
7
<a href="#見出し2">見出し2</a>
8
</li>
9
<li>
10
<a href="#見出し3">見出し3</a>
11
</li>
12
</ul>
13
</li>
14
<li>
15
<a href="#見出し4">見出し4</a>
16
</li>
17
</ul>
18
</nav>

この場合、記事のマークダウンファイルは、

1
## 見出し1
2
3
...
4
5
### 見出し2
6
7
...
8
9
### 見出し3
10
11
...
12
13
## 見出し4
14
15
...

となり、

HTML では

1
<h2 id="#見出し1">見出し1</h2>
2
<p>...</p>
3
...
4
<h3 id="#見出し2">見出し2</h3>
5
<p>...</p>
6
...
7
<h3 id="#見出し3">見出し3</h3>
8
<p>...</p>
9
...
10
<h2 id="#見出し4">見出し4</h2>
11
<p>...</p>
12
...

となります。

ところで、記事の HTML は.Contentで参照できるので、

記事の中から h タグを探して、それらをもとに目次を作ることができます。

  関連記事【Hugo】ブログの記事ページのサイドバーに固定目次を追加
【Hugo】ブログの記事ページのサイドバーに固定目次を追加

テンプレートのコーディング

目次を作成するテンプレートファイルを作りましょう。

目次の項目は h2 から始まることにします。

h タグを検索する

記事内容は.Contentで参照できますが、テーマ独自で何か改変が加えられていることもあります。

最終的な出力内容は、例えばlayouts/partials/post.htmlなどを確認してください。

$Contentのようになっていれば、直前に{{ $Content := .Scratch.Get "Content" }}などと書かれていると思います。

❓.Scratchって?

Hugo では変数は{{ $var := value }}のように定義できますが、$がつく形の変数はローカルにしか参照できません。

別のファイルに持ち込んだり、rangewithなどの関数の外部から参照することができないので、

このような場合には.Scratchを使います。

{{ .Scratch.Set "name" $inner_var }}とすると、外部からも{{ $outer_var := .Scratch.Get "name" }}とすることで同じ値を呼び出すことができるようになります。

  関連記事【Hugo】.Scratch とは?
【Hugo】.Scratch とは?

ここでは、ある程度の処理が加えられた後の.Contentのデータが.Scratch.Get "Content"で呼び出すことができ、それが最終的に出力されているものとして進めていきます。

layouts/partials/custom/toc.html
1
{{- /* Content を呼び出す */ -}}
2
{{- $Content := .Scratch.Get "Content" -}}

次にfindREを使って h タグを探します。

toc.html
1
{{- /* Content を呼び出す */ -}}
2
{{- $Content := .Scratch.Get "Content" -}}
3
{{- /* 検索に使用する正規表現パターン */ -}}
4
{{- $pattern := `<h[2-9] id="(.+?)".*?>((?:.|\n)*?)</h[2-9]>` -}}
5
{{- /* 正規表現により h タグを検索 */ -}}
6
{{- $headers := findRE $pattern $Content -}}

正規表現の部分について簡単に説明しておくと、

[2-9]は「2-9のうちのどれか一文字」を表し、*?は「前の文字の任意の0回以上の繰り返しで最短のもの」を表しています。

.は「改行以外の任意の一文字」、\nは「改行」を表します。

つまりここでは、id 属性以外の

の有無を問わずレベル2から9の h タグがマッチします。

()で囲った部分はグループ化され、後から$1$2、…という形で参照することができますが、

(?:)で囲った部分は無視されます。

h タグの要素を取り出す

$headersにはパターンにマッチした要素がリストとして渡されています。

それぞれの要素を取り出していきますが、ここでrangeを使います。

toc.html
1
{{- /* Content を呼び出す */ -}}
2
{{- $Content := .Scratch.Get "Content" -}}
3
{{- /* 検索に使用する正規表現パターン */ -}}
4
{{- $pattern := `<h[2-9] id="(.+?)".*?>((?:.|\n)*?)</h[2-9]>` -}}
5
{{- /* 正規表現により h タグを検索 */ -}}
6
{{- $headers := findRE $pattern $Content -}}
7
{{- range $headers -}}
8
{{- /* ヘッダーへのリンクアドレス */ -}}
9
{{- $id := . | replaceRE $pattern `$1` -}}
10
{{- /* ヘッダー名 */ -}}
11
{{- $header := . | replaceRE $pattern `$2` | safeHTML -}}
12
{{- end -}}

9行目の.というのは$headersの要素のうちのひとつを表しています(ここでは扱いませんがwith関数でも同じような省略が起こります)。

9行目は、「$headers内の各要素について、$patternにマッチする部分をパターンの第一グループに置き換える」という意味になります。

第一グループというのは$patternで id 属性の後に書いた(.+?)のことです。

つまり、この一連の処理によって、例えば

<h2 id="ミダシ">見出し</h2>$id:ミダシ, $header:見出し

となって、$id$headerに格納されるわけですね。

safeHTMLというのは HTML 文がエスケープされないようにするということです。

ところで、このままでは h2 タグと h3 タグは区別できていないので、

階層構造のある目次にはなりません。

階層を区別する

h タグの数字の部分を取り出しましょう。

正規表現の部分に修正を加えます。

そして新たに$depthに階層の深さの情報を入れます。

toc.html
1
{{- /* Content を呼び出す */ -}}
2
{{- $Content := .Scratch.Get "Content" -}}
3
{{- /* 検索に使用する正規表現パターン */ -}}
4
{{- $pattern := `<h([2-9]) id="(.+?)".*?>((?:.|\n)*?)</h[2-9]>` -}}
5
{{- /* 正規表現により h タグを検索 */ -}}
6
{{- $headers := findRE $pattern $Content -}}
7
{{- range $headers -}}
8
{{- /* ヘッダーの深さ */ -}}
9
{{- $depth := . | replaceRE $pattern `$1` -}}
10
{{- /* ヘッダーへのリンクアドレス */ -}}
11
{{- $id := . | replaceRE $pattern `$2` -}}
12
{{- /* ヘッダー名 */ -}}
13
{{- $header := . | replaceRE $pattern `$3` | safeHTML -}}
14
{{- end -}}

かっこを後ろにつけて...</h([2-9])>としても大丈夫です。

さて、これで.TableOfContentsをつくる準備は整いました。

あとはこれをもとに HTML を組んでいけばOKです。

  関連記事【Hugo】findRE・replaceRE の使い方
【Hugo】findRE・replaceRE の使い方

HTML のコーディング

さきほど示した.TableOfContentsの構造をまねて、 HTML を書いていきます。

ここはもうパズルです笑

toc.html
1
...
2
{{- /* ループで直前に調べたものの階層の深さ */ -}}
3
{{- $scratch := newScratch -}}
4
{{- $scratch.Set "prev_depth" 0 -}}
5
<nav id="TableOfContents">
6
<ul>
7
{{- range $headers -}}
8
...
9
{{- $prev_depth := $scratch.Get "prev_depth" -}}
10
{{- /* h2 のとき */ -}}
11
{{- if eq $depth `2` -}}
12
{{- /* 直前の h タグが h2 のとき */ -}}
13
{{- if eq $prev_depth `2` -}}
14
</li>
15
{{- /* 直前の h タグが h3 のとき */ -}}
16
{{- else if eq $prev_depth `3` -}}
17
</ul></li>
18
{{- end -}}
19
<li><a href="#{{ $id }}">{{ $header }}</a>
20
{{- /* h3 のとき */ -}}
21
{{- else if eq $depth `3` -}}
22
{{- /* 直前の h タグが h2 のとき */ -}}
23
{{- if eq $prev_depth `2` -}}
24
<ul>
25
{{- end -}}
26
<li><a href="#{{ $id }}">{{ $header }}</a></li>
27
{{- end -}}
28
{{- $scratch.Set "prev_depth" $depth -}}
29
{{- end -}}
30
{{- $prev_depth := $scratch.Get "prev_depth" -}}
31
{{- /* 直前の h タグが h2 のとき */ -}}
32
{{- if eq $prev_depth `2` -}}
33
</li>
34
{{- /* 直前の h タグが h3 のとき */ -}}
35
{{- else if eq $prev_depth `3` -}}
36
</ul></li>
37
{{- end -}}
38
</ul>
39
</nav>

3行目のnewScratchというのはローカルなスクラッチだそうです。

.Scratchを使うと範囲が広すぎて他のものと名前衝突を起こし上書きされてしまう危険性があるので、転ばぬ先の杖です。

eqは等号で、eq a ba == bの意味です。

注意しなくてはならないのは、取得した数字は文字列なので、文字列として扱わなければifにひっかからないということです。

このコードはループで直前に調べたものの階層の深さとループ中のものの階層の深さを利用して、

どこで何のタグをどれくらい開いたり閉じたりするかを指定しています。

h2 と h3 しか使わないよという人なら、これで十分です(といっても全部あわせると五十文あります…)。

もっといえば、さきほどの正規表現の[2-9][23]に書き換えてしまってOKです。

目次なら二階層もあれば大丈夫だと思うんですが、もし h4 も使いたいなら、脳内シミュレーションしつつ同じようにコーディングしていけばいいと思います。

コード全文(h2 h3 しか使わない場合)
toc.html
1
{{- /* Content を呼び出す */ -}}
2
{{- $Content := .Scratch.Get "Content" -}}
3
{{- /* 検索に使用する正規表現パターン */ -}}
4
{{- $pattern := `<h([23]) id="(.+?)".*?>((?:.|\n)*?)</h[23]>` -}}
5
{{- /* 正規表現により h タグを検索 */ -}}
6
{{- $headers := findRE $pattern $Content -}}
7
{{- /* ループで直前に調べたものの階層の深さ */ -}}
8
{{- $scratch := newScratch -}}
9
{{- $scratch.Set "prev_depth" 0 -}}
10
<nav id="TableOfContents">
11
<ul>
12
{{- range $headers -}}
13
{{- /* ヘッダーの深さ */ -}}
14
{{- $depth := . | replaceRE $pattern `$1` -}}
15
{{- /* ヘッダーへのリンクアドレス */ -}}
16
{{- $id := . | replaceRE $pattern `$2` -}}
17
{{- /* ヘッダー名 */ -}}
18
{{- $header := . | replaceRE $pattern `$3` | safeHTML -}}
19
{{- $prev_depth := $scratch.Get "prev_depth" -}}
20
{{- /* h2 のとき */ -}}
21
{{- if eq $depth `2` -}}
22
{{- /* 直前の h タグが h2 のとき */ -}}
23
{{- if eq $prev_depth `2` -}}
24
</li>
25
{{- /* 直前の h タグが h3 のとき */ -}}
26
{{- else if eq $prev_depth `3` -}}
27
</ul></li>
28
{{- end -}}
29
<li><a href="#{{ $id }}">{{ $header }}</a>
30
{{- /* h3 のとき */ -}}
31
{{- else if eq $depth `3` -}}
32
{{- /* 直前の h タグが h2 のとき */ -}}
33
{{- if eq $prev_depth `2` -}}
34
<ul>
35
{{- end -}}
36
<li><a href="#{{ $id }}">{{ $header }}</a></li>
37
{{- end -}}
38
{{- $scratch.Set "prev_depth" $depth -}}
39
{{- end -}}
40
{{- $prev_depth := $scratch.Get "prev_depth" -}}
41
{{- /* 直前の h タグが h2 のとき */ -}}
42
{{- if eq $prev_depth `2` -}}
43
</li>
44
{{- /* 直前の h タグが h3 のとき */ -}}
45
{{- else if eq $prev_depth `3` -}}
46
</ul></li>
47
{{- end -}}
48
</ul>
49
</nav>
  関連記事【Hugo】ブログにマウスオーバーでふわっと表示する吹き出しを追加
【Hugo】ブログにマウスオーバーでふわっと表示する吹き出しを追加

これだけ(これ以上)のことをたった一行.TableOfContentsでやってくれるのはありがたいですね笑

読んでくださりありがとうございました!

それでは~👋