Hugoでセクション機能を利用してカテゴリを階層化する方法です。 いろいろ詰まりながらもおおむね目標どおりになったのでまとめました。

初心者なので理解が足りず歯切れが悪いところもありますが、そのへんは割り引いて見てください。 変なところがありましたら、ご指摘いただければうれしいです。

環境

  • Hugoのバージョン: 0.54.0
  • 使用テーマ: Robust(2019年3月10日時点の最新バージョン)
  • 筆者のスペック: HTML/CSS、C#、Python の入門書を1冊づつ読んだことがある程度

なおRobustテーマをカスタマイズする場合には、/themes/hugo_theme_robust 内のlayoutsフォルダをルートフォルダ直下のlayoutsフォルダへコピーして、コピー元のファイルではなくコピーしたファイルを編集します。

ただしすでにlayoutsフォルダ内の一部のファイルやフォルダをコピー済みの場合には、まだ存在していないファイルのみをコピーしてください。

またすでにlayoutsフォルダをまるごとコピーして編集している場合には、上書きコピーしないでください。

taxonomie機能を使っても階層化できるらしいが……

Hugoでは taxonomy機能 にデフォルトでカテゴリ分類が用意されているものの、そのままでは階層化できません。

どうにかしてカテゴリを階層化できないかと調べると、『Tranquilpeak』というテーマが taxonomy機能 を拡張させる形で実装しているようです。 しかしGitHubでソースを覗いてみると、複雑で何をどうやっているのかよくわかりませんでした。

kakawait/hugo-tranquilpeak-theme

セクション機能をつかう場合の利点と欠点

ところでHugoには section機能 というものもあり、 content/ の下にフォルダを作ってその中に _index.html ファイルを作るとセクションとして認識されるようです。 つまり、 content/ 内のフォルダ構成がそのままセクションの構成となります。

利点

カテゴリ構造

セクションをカテゴリとして扱うことで、画像のようにローカルのフォルダ構造がそのままカテゴリ構造として反映されるので、わかりやすいです。あとでカテゴリを変更するときもファイルを移動するだけで済みます。

ちなみに content/ の直下に置いた記事ファイルはカテゴリ無しとなります。

欠点

Hugoのデフォルトではひとつの記事に対してカテゴリを複数設定できますが、 section機能 を使う場合はひとつだけしかカテゴリを設定できません。 記事が入っているフォルダをカテゴリとして扱うからです。

もっとも逆に考えれば、ひとつのカテゴリの範囲に収まるように書くことで焦点を絞った記事になりやすいともいえます。

目標

  1. カテゴリを階層化する
  2. サイドバーとカテゴリ一覧ページにカテゴリツリーを表示する
  3. パンくずリストにカテゴリ構造を反映させる
  4. そのカテゴリ内の記事リストを表示するページには、そのカテゴリ直下の記事だけでなく、子孫カテゴリ内の記事もリストに含めて表示する
  5. カテゴリ内の記事リストは投稿日順ソートし、かつページネーションも適用する(トップページの記事リストと同じ表示にする)
  6. ついでにカテゴリ内の記事数も表示する
  7. 記事リストで、その記事が直接所属しているカテゴリ名を表示する

やったこと

カテゴリの階層化

  1. content/ 内にフォルダ(例:web/)を作る
  2. _index.mdを web/ 内に作る

これだけでwebセクションができます。 同様にして、 _index.md 入りのフォルダを content/web 内に作れば子セクションができます。

_index.md は、最低限でも最深部のフォルダに作っておけば、その上にあるフォルダは _index.md を持たなくてもすべてセクションとして認識されます。 つまり web/hugo/robust/_index.md があれば、web/ や hugo/ フォルダに _index.md が無くても web, hugo, robust のすべてがセクション扱いとなります。

また、_index.md を web/ 以下のどこにも作らなかった場合は、 ルート直下にある web/ だけが認識されます(記事ファイルが入っていれば)。

_index.mdがないフォルダは、セクション名が先頭が大文字で、複数形になります。 カテゴリ名の表示を変更するにはそのフォルダに _index.md をつくり、フロントマター内で title に好きなカテゴリ名を設定します。

例 : web/_index.md のフロントマターで下のようにtitleを設定すれば、Web/内の記事のカテゴリの表示も「Webs」→「Web制作」に変わる

---

title: "Web制作"

---

カテゴリツリーの表示

{{define}}{{template}}による再帰呼出しとかいうやり方で何階層あっても自動でカテゴリツリーが生成される方法です。

{{define}}{{template}}は、関数のように{{define}}で一連の処理を定義して{{template}}で呼び出して使います。

{{define}}内に終了条件をつけて同名の{{template}}を組み込むと、終了条件を満たすまで{{define}}の処理を繰り返します(再帰呼出し)。

このブログではカテゴリ一覧ページやサイドバーのCATEGORIESに適用しました。 次のコードはサイドバーで使ったものです。

{{ define "categoryTree" }}
    {{ range .Sections.ByWeight}}
            <li>
                {{ if gt (len .Sections) 0}}
                    <details open>
                        <summary>
                            <a href="{{.Permalink}}">{{ .Title }}</a><span>({{template "numArticle" .}})</span>
                        </summary>
                        <ul>
                            {{ if gt (len .Sections) 0}}
                                {{ template "categoryTree" . }}
                            {{end}}
                        </ul>
                    </details>
                {{ else }}
                    <i class="fas fa-folder-open  fa-fw"></i><a
                        href="{{.Permalink}}">{{ .Title }}</a><span>({{template "numArticle" .}})</span>
                {{ end }}
            </li>
    {{ end }}
{{ end }}

<aside class="sidebar">
    <header><a href="{{ .Site.BaseURL }}categories">CATEGORIES</a></header>
    {{ $home := .Site.Home }}
    <nav class="category-tree-side">
        <ul>
            {{ template "categoryTree" $home }}
        </ul>
    </nav>
</aside>

{{ range .Sections.By* }}で、カテゴリの各階層ごとに表示する順番をコントロールできます。 今回は.ByWeightにしているので、表示順を指定するには、同じ階層ごとに各カテゴリの_index.mdのフロントマターでweightの値をweight:1のように設定します(下図参照)。

weightは小さいほうが先に表示されるようですが、0が一番前というわけではなかったので、1以上の数値で小さい順のようです。 また、weight値の指定がないカテゴリはweight値の指定があるカテゴリより後ろになります。

また他のソート方法としては、.ByDate.ByTitleはちょっと試した限りうまく機能するようです。

.ByDateは_index.mdで_dateが設定されていれば古いほうから先に表示されます。 archetype/default.md でdate:{{date}}としておいて、カテゴリのフォルダの作成と同時にhugo newで _index.md を作るようにしておけば、カテゴリの作成順に並べられることになります。 なお、新しいほうから先に表示したい場合は.ByDate.Reverseとします。

.ByTitleは _index.md に設定したtitle:""の、アルファベット順>ひらがな>漢字といった順序みたいです。

ほかの方法は試してませんが、Lists of Content in Hugo - Order Contentに載ってます。

カテゴリツリーの表示
カテゴリツリーの表示。「Webカテゴリ」はweight:1、「未分類」はweight未設定。

なおコード内の{{template "numArticle" .}}は記事数表示(後述)です。

カテゴリ内の記事リストに子孫カテゴリ内の記事も含めて表示する

{{$indexScratch := newScratch}}
{{$indexScratch.Set "articleList" .Pages}}

{{if not .IsHome }}

    {{ range .Sections.Reverse}}
    {{ $indexScratch.Add "articleList" .Pages}}
    
    {{ if ge (len .Section) 0}}
        {{ range .Sections.Reverse}}
        {{ $indexScratch.Add "articleList" .Pages}}
        
        {{ if ge (len .Section) 0}}
            {{ range .Sections.Reverse}}
            {{ $indexScratch.Add "articleList" .Pages}}
            
            {{ if ge (len .Section) 0}}
                {{ range .Sections.Reverse}}
                    {{ $indexScratch.Add "articleList" .Pages}}
                {{ end }}
            {{ end }}
        
            {{ end }}
        {{ end }}
        
        {{ end }}
    {{ end }}
    
    {{ end }}
{{ end }}

{{ $paginator := .Paginate (where ($indexScratch.Get "articleList") "Type" "!=" "page").ByDate.Reverse }}

{{ range $paginator.Pages}}
    <div class="mcol c6">{{ .Render "li" }}</div>
{{ end }}

{{ $indexScratch.Delete "articleList"}}

はじめに、再帰呼出しの方法で何層あっても自動で記事リストが生成される方法を試してみたのですが、うまくいきませんでした。 しかたないので、必要な階層分だけ入れ子状にしてみたところ、うまくいきました。 もっといいやり方ないかなあ。

上記のコードでカテゴリ内の記事リストに、5層目までの記事をまとめて日付順で表示できます。

表示する階層を増やしたいor減らしたい場合は、

{{ if ge (len .Section) 0 }}
    {{ range .Sections.Reverse }}
        {{ $indexScratch.Add "articleList" (.Paginate .Data.Pages).Pages }}
    {{ end }}
{{ end }}

を、入れ子状を保ったまま増減させればOKです。 私は多分3層くらいまでしか使わないと思うのでとりあえず3層分にしています。

さてカテゴリ内の記事リストはこんな感じになりました。

カテゴリ内の記事リスト

日付順ソート

.ByDate.Reverseをつけることで投稿日が新しい記事から順に表示されます。

ページネーション

またページネーションについては機能の解説がよくわからなくてすごく苦労したんですが、試行錯誤の結果、上記のコードでカテゴリ内の記事リストでもページ分けできるようになりました。

scratchでセクションの記事を"articleList"に一層分づつ追加していき、全部溜まったところで日付順でページネートして変数$paginatorに渡し、それをrangeで順番にリスト出力している、という感じでしょうか。 (理解があやふやなので違ってたらすいません)

画像はサイズに収めるために3記事ごとにページ分けしていますが、 config.toml に例えばPaginate = 5と書き込めば5記事づつになります。なお指定しなければ10記事づつになります。

記事数の取得

Scratchで"articleList"に記事を追加していって最後にまとめて表示することができたので、同様のやり方でそのカテゴリ内の総記事数も表示できるようになりました。

{{define "numArticle"}}
    {{$indexScratch := newScratch}}
    {{$indexScratch.Set "numArticle" (len .Pages)}}

    {{if not .IsHome }}

        {{ range .Sections.Reverse}}
            {{ $indexScratch.Add "numArticle" (len .Pages)}}

            {{ if ge (len .Section) 0}}
                {{ range .Sections.Reverse}}
                    {{ $indexScratch.Add "numArticle" (len .Pages)}}

                    {{ if ge (len .Section) 0}}
                        {{ range .Sections.Reverse}}
                            {{ $indexScratch.Add "numArticle" (len .Pages)}}

                            {{ if ge (len .Section) 0}}
                                {{ range .Sections.Reverse}}
                                   {{ $indexScratch.Add "numArticle" (len .Pages)}}
                                {{ end }}
                            {{ end }}

                        {{ end }}
                    {{ end }}

                {{ end }}
            {{ end }}

        {{ end }}
    {{ end }}

    {{ $indexScratch.Get "numArticle"}}
    {{ $indexScratch.Delete "numArticle"}}
{{end}}

.Pages(len .Pages)に変えることで、記事ではなく「記事の数」を追加していき、{{ $indexScratch.Get "numArticle" }}で合計を表示するようにしました。

これをhtmlファイルにして partials/ に置きます。 あとは記事数を表示したいところに{{ template "numArticle" . }}を入れればOKです。 リストページのタイトルにも使いました。

(追記)

“numArticle”の読み込みエラーでnetlifyへのデプロイがちょくちょく失敗するようになりました。 何度かやれば通るので、デプロイのたびにファイルを読み込む順番が変わっているのかもしれません。

エラーメッセージによると、{{ template "numArticle" . }}があるファイルで”numArticle”の読み込みエラーが起こっていました。 別のhtmlファイルからtemplateを読み込むやり方がよくないのかな?

そこで上記の{{define "numArticle"}}をpartials/ にhtmlファイルとして独立させるのをやめ{{ template "numArticle" . }}を使っているすべてのファイルにコピペしてみたら、安定してデプロイが通るようになりました。

できれば1か所にまとめてから呼び出した方がスッキリするんですけどね。

カテゴリ内の記事数が表示できた
カテゴリ内の記事数が表示できた

パンくずリストにカテゴリ階層を反映

{{- define "breadcrumb" }}

  {{- if .IsHome}}
    <li><a href="{{ .Site.BaseURL }}"><i class="fas fa-home fa-fw"></i>TOP</a></li>
  {{- else if .Parent }}
    {{- template "breadcrumb" .Parent }}
    <li><a href="{{.Permalink}}">{{ .LinkTitle }}</a></li>
  {{ else }}
    <li><a href="{{ .Site.BaseURL }}"><i class="fas fa-home fa-fw"></i>TOP</a></li>
    <li><a href="{{.Permalink}}">{{- .LinkTitle }}</a></li>
  {{- end }}

{{- end }}

<ol class="breadcrumb">
  {{- template "breadcrumb" . }}
</ol>    

パンくずリストはこんな感じの再帰呼出しになりました。

{{- else if .Parent }}までは、 まず最初に、現在のぺージの名称を、フロントマターのlinktitle > title の優先順位で表示します。 さらに上位セクションがあるときはそのセクション名(=カテゴリ名)を表示する、という処理を上位セクションがなくなるまで繰り返し、トップページまで辿ったら「TOP」と表示します。

ちなみに、{{- template "breadcrumb" . }}.が現在のページのコンテキストを参照するという意味らしいので、{{- template "breadcrumb" .Parent }}は定義した処理を親セクションのコンテキストで行うという意味じゃないかなーと理解しています。つまり親セクションがあればそのlinktitleを前に表示する、というのが{{- else if .Parent }}内の処理です。

{{ else }}のところは、{{- else if .Parent }}までの処理ではカテゴリ一覧、タグ一覧、アーカイブ一覧のようなページにはパンくずリストが表示されなかったので追加しました。 おそらくトップページではないが上位セクションも存在しないページ、という扱いになっているんじゃないかと思いますが、ここではトップページを親にもつページとして表示させました。

パンくずリストにカテゴリ階層を反映することができた
パンくずリストにカテゴリ階層を反映することができた

記事リストで、その記事が直接所属しているカテゴリ名を表示

例えば記事ファイルが web/hugo/ にあるなら、hugoフォルダの_index.mdに設定したカテゴリ名が表示されるようにします。

{{- define "articleCategory" }}

    {{- if not .IsHome}}
        {{- template "articleCategory" .Site.Home}}
        <a class="card-category-link" href="{{.Permalink}}">{{ .Title }}</a>
    {{- end }}

{{- end }}

これをhtmlファイルにして partials/ に置きます。 あとは記事数を表示したいところに{{ template "articleCategory" . }}を入れればOKです。

カテゴリ名表示
(画像A) li.html に適用
カテゴリ名表示
(画像B) li_sm.html に適用

他のテーマはわかりませんがRobustテーマだと、{{ template "articleCategory" . }}を、 li.html に入れればトップページ、カテゴリ内の記事リストページ、タグのリストページ、および月間アーカイブページに(画像A)、 li_sm.html に入れれば前後記事リンクや新着記事リストに(画像B)、それぞれ表示されます。

おまけ1:記事リストから固定ページを除く

私はAboutページとお問い合わせページを/content 直下に置いて固定ページとしています。 これらはセクションであるフォルダに入っていないため、カテゴリー内の記事リストには当然現れませんが、トップページの総記事リストには表示されてしまいます。

これらの固定ページは投稿記事とは区別したいので、総記事リストにも表示されないようにしました。

これは上記の『カテゴリ内の記事リストに子孫カテゴリ内の記事も含めて表示する』のコードのなかの次の部分で実現しています。

{{ $paginator := .Paginate (where ($indexScratch.Get "articleList") "Type" "!=" "page").ByDate.Reverse }}

Hugoでは、/content 直下に置いた記事ファイルの”Type”は”page”となります(フロントマターで上書きは可能)。

私の場合、固定ページのファイルを /content 直下に配置しているので、固定ページの”Type”は”page”となります。 一方、固定ページでない記事のファイルは /content 直下に置かないようにしているので、固定ページ以外の記事の”Type”は「”page”ではない」となります。

したがってwhere ($indexScratch.Get "articleList") "Type" "!=" "page" と条件づけすることで”Type”が「”page”ではない」”articleList”が取得されます。 これにより記事リストの表示から固定ページが取り除かれます。

参考ページ:

おまけ2:すべての記事に次の記事・前の記事へのリンクを表示する(ただし固定ページに関しては非表示にする)

上記のように階層化したセクションで記事を管理すると、多分Robustテーマのデフォルトでは次の記事・前の記事へのリンクは同じカテゴリ内の記事同士でしか表示されないんじゃないかと思います。

この問題に対応する別の記事を書きましたので参考にしてください。

Hugoで固定ページに次の記事・前の記事のリンクを表示しない方法(セクションを階層化している場合)

感想

なんといってもhugoの機能を理解するのが大変でした。 公式のドキュメントをみてもなかなかよく理解できなくてずいぶん時間かかりました。 もっといいやり方があるかもしれませんが、とりあえず希望通りの機能が実装できたのでよかったです。

参考サイト