【Hugo】Content adapters で年月別アーカイブページを作る

投稿日:

カテゴリ:デジタル

Hugo 製のこのブログでは、設定ファイル hugo.toml に以下のように記述し、投稿のパーマリンクを /[yyyy]/[MM]/[slug]/ としている。

[permalinks]
posts = "/:year/:month/:slug/"

Hugo に引っ越す前に使っていた Blogger のパーマリンク /[yyyy]/[MM]/[slug].html と近い形式にしたかったからだ。

Blogger では親ディレクトリの /[yyyy]/ で年別、/[yyyy]/[MM]/ で月別のアーカイブが表示できた。だが Hugo の場合、上記の設定を行っても見かけ上のパーマリンクの形式が変わるのみで、これらのディレクトリにアクセスしても 404 エラーになってしまう。

またアーカイブに関しては、以下の記事にも書いた通り 1 ページで全投稿を表示させる方法で妥協していた。

しかし、どうしても Blogger と同じ形式のアーカイブを諦めきれず、最終的に Hugo の Content adapters を利用する方法に辿り着いた。

本記事では、Content adapters を用いた年別 /[yyyy]/ および月別 /[yyyy]/[MM]/ のアーカイブページの実装方法について説明する。

この記事の目次

Content Adapters とは

Content adapters(コンテンツアダプター)は、サイトのビルド時に動的にページを生成できる機能だ。

通常、Hugo でページを増やすには content/ ディレクトリ内に .md ファイルを用意する必要がある。しかしこの Content Adapters を使えば、JSON、TOML、YAML、XML などのリモートデータソースやローカルのファイルからデータを読み込み、動的にページを生成できる。

これを利用することで、わざわざ親ディレクトリを手動で作らなくても、/[yyyy]/ や /[yyyy]/[MM]/ といった年別・月別のアーカイブページを自動で生成してくれる。

Content Adapters で年月別アーカイブを作る

_content.gotmpl の作成

まず、ルート直下の content/ に _content.gotmpl を作成する。

前提として、このブログの content/ 配下は以下のようなディレクトリ構造になっている。記事データは Leaf bundles 形式で配置している。

content/
├── _content.gotmpl  ← ここに作成
└── posts/
    ├── article-1/
    │   └── index.md
    └── article-2/
        └── index.md

次に、作成した _content.gotmpl に以下のコードを記述する。

{{ $data := slice }}

{{ range readDir "content/posts" }}
  {{ if .IsDir }}
    {{ $md := printf "content/posts/%s/index.md" .Name }}

    {{ with os.ReadFile $md }}
      {{ range findRE `(?s)^\+\+\+\r?\n(.*?)\r?\n\+\+\+` . }}
        {{ $isDraft := false }}

        {{ if findRE `(?i)draft\s*=\s*["']?true["']?` . }}
          {{ $isDraft = true }}
        {{ end }}

        {{ if or hugo.IsServer (not $isDraft) }}
          {{ $match := findRESubmatch `(?i)date\s*=\s*["']?(\d{4}-\d{2})` . }}

          {{ if $match }}
            {{ $ym := replace (index $match 0 1) "-" "/" }}
            {{ $data = $data | append $ym }}
          {{ end }}
        {{ end }}
      {{ end }}
    {{ end }}
  {{ end }}
{{ end }}

{{ $months := $data | uniq }}

{{ range $months }}
  {{ $.AddPage (dict
    "title" (time.Format "2006年1月" (printf "%s-01" (replace . "/" "-")))
    "path" .
    "kind" "section"
    "layout" "archive-list"
  ) }}
{{ end }}

{{ $years := apply $months "substr" "." 0 4 | uniq }}

{{ range $years }}
  {{ $.AddPage (dict
    "title" (printf "%s年" .)
    "path" .
    "kind" "section"
    "layout" "archive-list"
  ) }}
{{ end }}

まず os.readDir で content/posts 配下の各投稿のディレクトリを巡回し、os.ReadFile で index.md の Front Matter を読み込んでいる。

上記のコードは TOML 形式 の Front Matter を前提としているため、YAML 形式などで運用している場合は適宜正規表現を書き換える必要がある。

投稿日 date から年月 [yyyy]/[MM] だけを抽出し、配列 $data に格納していく。なお、本番ビルド時には draft = true の下書きを除外する一方、ローカル開発時 hugo server では下書き分のアーカイブもプレビューできるよう hugo.IsServer で条件を分岐させている。

次に、取得した年月データの重複を uniq で削除して $months を作成し、$.AddPage で月別アーカイブページを動的に生成する。

最後に、月別データから年(yyyy)の部分だけを抽出し、同様に $.AddPage で年別アーカイブページを追加生成すれば完了だ。すでにあるページとパスが被ると競合が発生するため、重複がないかはビルド時に --printPathWarnings フラグで確認しておくと安心かもしれない。

なお、$.AddPage 内で指定している layout: "archive-list" は、このあと作成するアーカイブ用レイアウトを指している。

アーカイブ用レイアウトの作成

続いて、Content Adapters で動的生成したページにその月の投稿一覧を表示するためのレイアウトを作成していく。

/layouts/archive-list.htmlを作成し、以下のコードを記述する。

{{ define "main" }}

<h1 class="list-heading">{{ .Title }}</h1>

{{ $posts := where .Site.RegularPages "Section" "posts" }}

<select name="archives" class="archive-select" onchange="location.href=this.value;">
  {{ range $year := $posts.GroupByDate "2006" }}
    {{ $yearName := $year.Key }}
    {{ $yearUrl := printf "/%s/" $yearName }}

    <option value="{{ $yearUrl }}"{{ if eq $.RelPermalink $yearUrl }} selected{{ end }}>{{ $yearName }}年({{ len $year.Pages }})</option>

    {{ range $month := $year.Pages.GroupByDate "1" }}
      {{ $monthName := int $month.Key }}
      {{ $monthUrl := printf "/%s/%s/" $yearName (printf "%02d" $monthName) }}

      <option value="{{ $monthUrl }}"{{ if eq $.RelPermalink $monthUrl }} selected{{ end }}> {{ $yearName }}年{{ $monthName }}月({{ len $month.Pages }})</option>
    {{ end }}
  {{ end }}
</select>

<ul class="archive-list">
  {{ range where $posts ".RelPermalink" "like" (printf "^%s" $.RelPermalink) }}
    <li><time>{{ .Date.Format "2006/01/02" }}</time>:<a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
  {{ end }}
</ul>

{{ end }}

まず $posts で対象を content/posts ディレクトリ内の通常ページに絞り込んでいる。

.GroupByDate を使って全投稿を年・月の順でループ処理し、各アーカイブページへ移動できるドロップダウンを生成している。現在開いているページの URL と一致した場合は selected を付与するようにした。

投稿一覧の出力では、where 関数と like 演算子の正規表現(先頭一致 ^)を使い、現在の URL $.RelPermalink から始まる投稿だけを絞り込んでいる。

具体的には printf "^%s" $.RelPermalink と指定することで、/2026/09/ のページではその月の投稿だけが、/2026/ のページではその年の投稿すべてが自然と抽出される仕組みだ。

2026年のアーカイブページ

あとは .Title や .Date などを用いて好きなデザインで投稿一覧を作っていけばオッケー。

おまけ:カレンダー風ナビゲーション

ちなみに、ドロップダウンを採用する前はカレンダー形式のナビゲーションを使用していた。せっかくなので備忘録としてここに残しておく。

<nav class="archive-nav">
  {{ range $posts.GroupByDate "2006" }}
    {{ $year := .Key }}
    {{ $yearUrl := printf "/%s/" $year }}
    {{ $activeMonths := slice }}

    {{ range .Pages }}
      {{ $activeMonths = $activeMonths | append (.Date.Format "/2006/01/") }}
    {{ end }}

    <div class="archive-nav-year">
      <a href="{{ $yearUrl }}" class="year-link{{ if eq $.RelPermalink $yearUrl }} is-active{{ end }}">{{ $year }}年</a>
      <ul class="month-list">
        {{ range seq 12 1 }}
          {{ $monthUrl := printf "/%s/%s/" $year ( printf "%02d" .) }}

          <li>
            {{ if in $activeMonths $monthUrl }}
              <a href="{{ $monthUrl }}" class="month-link{{ if eq $.RelPermalink $monthUrl }} is-active{{ end }}">{{ . }}月</a>
            {{ else }}
              <span class="month-link is-disabled">{{ . }}月</span>
            {{ end }}
          </li>
        {{ end }}
      </ul>
    </div>
  {{ end }}
</nav>

年ごとのループ内で seq 12 1 を使って12ヶ月分(12月〜1月)のリストを動的に生成し、投稿が存在する月だけリンクを有効化(非存在する月は is-disabled クラスで非活性化)する処理を行っている。

年が増えていくにつれかさばってしまうため最終的には採用を見送ったが、デザインの好みに応じてこちらのカレンダー形式に差し替えて使うことも可能だ。

アーカイブへアクセスできるようにする

せっかく Content Adapters でアーカイブページを動的生成したからには誰かには見てもらいたいので、ブログ内からそれらのページへアクセスできる導線を整えておく。

パンくずリスト

今見ているページがどの階層にあるかを示すパンくずリストを設置することにした。

パンくずリスト用のテンプレート /layouts/partials/breadcrumb-list.html を作成し、以下のコードを記述する。

<nav aria-label="パンくずリスト">
  <ol class="breadcrumbs">
    <li><a href="/">Home</a></li>

    {{ if and (eq .Section "posts") (ne .RelPermalink "/posts/")}}
      <li><a href="{{ .Date.Format "/2006/" }}">{{ .Date.Format "2006年" }}</a></li>
      <li><a href="{{ .Date.Format "/2006/01/" }}">{{ .Date.Format "2006年1月" }}</a></li>
    {{ else }}
      {{ range $i, $a := .Ancestors.Reverse }}
        {{ if gt $i 0 }}
          <li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>
        {{ end }}
      {{ end }}
    {{ end }}

    <li aria-current="page">{{ .LinkTitle }}</li>
  </ol>
</nav>

祖先ページを取得する .Ancestors を通常の投稿に対して使うと親の階層が /posts/ になる。そのため投稿のときだけ条件分岐を行い、投稿日から年 /[yyyy]/ と月 /[yyyy]/[MM]/ の URL を直接組み立ててパンくずの途中に挟むようにしている。

テンプレートを作成したら、/layouts/baseof.html のヘッダーの下辺りに以下のコードを追加しておく。

{{ if not .IsHome }}
  {{ partial "breadcrumb-list.html" . }}
{{ end }}

こうすることで、トップページ以外の全ページにパンくずリストが反映されるようになる。例えば本記事の場合、以下のような階層で表示される。

Home > 2026年 > 2026年9月 > 【Hugo】Content adapters で年月別アーカイブページを作る

全投稿一覧ページからのリンク

ブログの全投稿一覧ページから各年月アーカイブへ遷移できるようリンクを追加しておく。

全投稿一覧用のレイアウト /layouts/archives.html を作成し、以下のコードを記述する。

{{ define "main" }}
<div class="single">
  <div class="single-header">
    <h1 class="single-title">{{ .Title }}</h1>
  </div>
  <div class="single-body">
  {{ $posts := where .Site.RegularPages "Section" "posts" }}

  <p>全 {{ len $posts }} 件の投稿</p>

  <nav id="TableOfContents">
    <ul>
    {{ range $year := $posts.GroupByDate "2006" }}
      <li><a href="#{{ $year.Key }}">{{ $year.Key }}年({{ len $year.Pages }})</a></li>
    {{ end }}
    </ul>
  </nav>

  {{ range $year := $posts.GroupByDate "2006" }}
    {{ $yearName := $year.Key }}
    {{ $yearUrl := printf "/%s/" $yearName }}

    <h2 id="{{ $year.Key }}"><a href="{{ $yearUrl }}">{{ $yearName }}年({{ len $year.Pages }})</a></h2>

    <ul>
      {{ range $month := $year.Pages.GroupByDate "1" }}
        {{ $monthName := int $month.Key }}
        {{ $monthUrl := printf "/%s/%s/" $yearName (printf "%02d" $monthName) }}

        <li><a href="{{ $monthUrl }}">{{ $month.Key }}月({{ len $month.Pages }})</a>
          <ul>
          {{ range $month.Pages }}
            <li>{{ .Date.Format "01/02" }}:{{ if .Draft }}[draft] {{ end }}<a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
          {{ end }}
          </ul>
        </li>
      {{ end }}
    </ul>

    <div class="back-to-toc"><a href="#TableOfContents">↑ 目次に戻る</a></div>
  {{ end }}
  </div>
</div>
{{ end }}

GroupByDate を使って年 → 月の階層リストを作成し、それぞれの見出し <h2> と <li> に対して printf で生成したアーカイブ URL /[yyyy]/ と /[yyyy]/[MM]/ を設定している。

あとがき

Content Adapters を用いた年別 /[yyyy]/ および月別 /[yyyy]/[MM]/アーカイブページの実装方法について紹介した。

実はこの方法に辿り着くまでにかなりの寄り道をしている。

最初は全投稿の日付やタイトル、URL を持たせた JSON から、次に投稿のある年月だけを配列にまとめた JSON から、アーカイブページを生成しようとしていた。しかし JSON を経由するやり方は、ビルド(JSON 生成)→ 再ビルド(アーカイブ生成)という手順を踏むため、新しく投稿を公開したときにどうしてもタイムラグが発生してしまう。

JSON を使うからラグが出る。使わない方法を模索していたある日、ビルド時にローカルファイルを直接読み込むメソッドが Hugo にあると知った。そこからはもうトントン拍子だった。と言いたいところだが、普通に苦労して Gemini に質問しまくっていた。どうしても Blogger ライクなアーカイブを作りたいという執念だけで最後まで作り切ることができた。

少しマニアックなカスタマイズではあるが、この記事が誰かの参考になればいいなと思う。

タグ:Web 制作, Hugo