Hugo の記事をヘッドレスCMS で管理できる! Content adapters 入門

2024/06/04に公開

概要

静的サイトジェネレーターである Hugo では、v0.126.0 にして遂にビルド時に動的に記事を生成する機能がリリースされました。
それが Content adapters です。

https://gohugo.io/content-management/content-adapters/

今回は Content adapters を使い、microCMS で作成した記事データを用いたブログを作ってみます。

  • 環境
    • hugo v0.126.2
  • 完成品
  • 説明すること
    • Content adapters で microCMS のデータを元に Page と Page Resources を生成する手順と実装内容
  • 説明しないこと
    • Hugo や microCMS 自体の使い方、インストール方法、デプロイ方法などの周辺知識

ブログを作ってみる

microCMS でコンテンツを作る

まず microCMS でコンテンツを作ります。
本記事では "articles" と "tags" というコンテンツ API を作成しました。
スキーマは以下の通りです。以降、このスキーマを前提として進めます。

articles
{
    "apiFields": [
        {
            "idValue": "lrk4WAjnR8",
            "fieldId": "title",
            "name": "タイトル",
            "kind": "text",
            "required": true,
            "isUnique": false
        },
        {
            "fieldId": "content",
            "name": "内容",
            "kind": "richEditorV2",
            "required": true
        },
        {
            "fieldId": "tags",
            "name": "Tags",
            "kind": "relationList"
        },
        {
            "fieldId": "coverImage",
            "name": "カバーイメージ",
            "kind": "media"
        }
    ],
    "customFields": []
}
tags
{
    "apiFields": [
        {
            "idValue": "iGhXhpUpAy",
            "fieldId": "name",
            "name": "タグ名",
            "kind": "text",
            "isUnique": true
        }
    ],
    "customFields": []
}

スキーマを定義できたら適当にコンテンツを追加しておきましょう。

Hugo でブログを用意する

Hugo の Quick start に従い、下記のコマンドで ananke というテーマを利用したブログを作ります。

hugo new site quickstart
cd quickstart
git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml
hugo server

この時点ではコンテンツが 1 件も無いのでトップページだけ表示されます。

ビルド時にデータを取得する

Content adapters を使って記事を追加していきます。

ananke テーマの仕様に従って /post というパスで記事を公開することにします。

content/post/_index.md は適当に作ってください。今回はタイトルだけ設定しておきます。

_index.md
+++
title = 'Articles'
+++

content/post/_content.gotmpl が今回作っていきたい物になります。

まずAPIからデータを取得する部分ですが、下記のように作りました。(シンタックスハイライトが .gotmpl に対応しておらず見づらいですがご了承ください。)

_content.gotmpl
{{ $limit := 100 }}
{{ $endpoint := "https://PUT_YOUR_SERVICE_ID.microcms.io/api/v1/PUT_YOUR_ARTICLE_ENDPOINT_NAME" }}
{{ $opts := dict
  "headers" (dict "X-MICROCMS-API-KEY" "PUT_YOUR_API_KEY")
}}

{{ $dataList := slice }}

{{/* 無限ループを作る構文が無いので十分大きなループ回数で代用 */}}
{{ range seq 0 1999 }}
  {{ $offset := math.Mul $limit . }}
  {{ $data := dict }}
  {{ $url := print $endpoint "?orders=createdAt&limit=" $limit "&offset=" $offset }}

  {{ with resources.GetRemote $url $opts }}
    {{ with .Err }}
      {{ errorf "Unable to get remote resource %s: %s" $url . }}
    {{ else }}
      {{ $data = . | transform.Unmarshal }}
      {{ $dataList = $dataList | append $data }}
    {{ end }}
  {{ else }}
    {{ errorf "Unable to get remote resource %s" $url }}
  {{ end }}

  {{ if lt (len $data.contents) $limit }}
    {{ break }}
  {{ end }}
{{ end }}

全件取得したいので作成日時昇順[1]でデータを 100 件ずつ取得して $dataList へ追加し、取得数が 100 件未満ならループを抜けます。

本当は無限ループを回したいところですが、残念ながら無限ループを書ける構文が無いので range seq 0 1999 で 2000 回[2]のループを作って代用しています。

microCMS は最も高額なプランでもコンテンツ数の上限は 10 万件なので、大抵の場合 2000 回のループで十分足ります。

もし「コンテンツが重くて容量制限にひっかかるので少数ずつ取らざるを得ない」といった事情でループ回数を増やしたい場合、多重ループにすればいくらでも増やせるので問題ないです。

{{ $limit := 100 }}

{{ range seq 0 1 }}
  {{ $i := . }}
  {{ range seq 0 1999 }}
    {{ $offset := math.Mul $limit . | math.Add (math.Mul $i $limit) }}
    {{/* これで 4000 回のループができる */}}
  {{ end }}
{{ end }}

この時点ではまだ記事を生成していないので、hugo server で起動しても未だに記事は表示されないはずです。

取得したデータを元に記事を追加する

取得したデータを元に記事を追加します。
一旦カバーイメージの取り扱いは脇に置いておいて、記事の内容を追加していきましょう。
_content.gotmpl に下記のように追記します。

_content.gotmpl
{{/* データ取得部分は省略 */}}

{{/* 記事追加 */}}
{{ range $dataList }}

  {{ range .contents }}

    {{ $articlePath := .id }}
    {{ $title := .title }}

    {{/* https://gohugo.io/content-management/content-adapters/#page-map に従って整形 */}}
    {{ $content := dict "mediaType" "text/html" "value" .content }}

    {{ $dates := dict "date" (time.AsTime .publishedAt) "lastmod" (time.AsTime .revisedAt) }}

    {{/* タグ名の配列でないと taxonomy として認識されないので配列にする */}}
    {{ $tags := slice }}
    {{ range .tags }}
      {{ $tags = $tags | append .name}}
    {{ end }}
    
    {{ $params := dict "id" .id "title" .title "tags" $tags }}
    {{ $page := dict
      "content" $content
      "dates" $dates
      "kind" "page"
      "params" $params
      "path" $articlePath
      "title" $title
    }}
    {{ $.AddPage $page }}

  {{ end }}

{{ end }}

Hugo の機能を生かすために幾つか注意点があります。

  • Page map に従ってデータを整形しなければならない
    • ただし dates.date には作成日ではなく公開日を割り当てている。理由は ananke テーマの記事ソート順が dates.date 降順であるため。
  • Taxonomy(tag や category)は文字列の配列でなければならない

上記を守って記事を追加すると、hugo server で起動したとき記事が表示されるはずです。

記事の中身も描画されていると思います。

更にただ記事を追加しただけで Taxonomy ページも自動で生成されます。

便利ですね!

なおリッチテキスト内のコードブロックのシンタックスハイライトにつきましては、結構頑張って検討したのですが、現在の Content adapters の範疇では難しそうでした。
ハイライトを適用する関数は存在します[3]が、そもそも HTML をパースしてハイライト対象を抽出することが困難[4]だと思われます。
(良い方法をご存じの方はぜひ教えてください。)

よってサーバーサイドでハイライトを当ててからデプロイしたいならば、ビルド後の HTML ファイルを Node.js 環境等で読み込んでシンタックスハイライトを当て、ファイルを上書きする感じになるかなと思います。

一方開発中はホットリロードがありますので、Hugo のビルドプロセスに組み込めないならクライアントサイドでハイライトしたいですね。

取得したデータを元に Page resources を追加する

脇に置いておいたカバーイメージと向き合います。
ananke テーマの README に Change the hero background という項目があります。

ananke テーマで hero background を変える方法が書いてあるわけですが、リモートの画像ファイルを指定することはできません。
そこでリモートから画像をダウンロードして Page resources に配置することで解決します。

ananke テーマでは "featured_image" という名前の画像が Page resources にあれば利用してくれるので、カバーイメージを "featured_image" という名前で配置しましょう。

{{ range .contents }} のループの中に下記のように追加します。

_content.gotmpl
{{/* データ取得部分は省略 */}}

{{/* 記事追加 */}}
{{ range $dataList }}

  {{ range .contents }}

    {{/* 中略 */}}

    {{/* coverImage があれば Page Resource にタイトル背景画像を保存する */}}
    {{ with $url := .coverImage.url }}
      {{ with resources.GetRemote $url }}
        {{ with .Err }}
          {{ errorf "Unable to get remote resource %s: %s" $url . }}
        {{ else }}
          {{ $content := dict "mediaType" .MediaType.Type "value" .Content }}
          {{ $params := dict "alt" $title }}
          {{ $resource := dict
            "content" $content
            "params" $params
            "path" (printf "%s/featured_image.%s" $articlePath .MediaType.SubType)
          }}
          {{ $.AddResource $resource }}
        {{ end }}
      {{ else }}
        {{ errorf "Unable to get remote resource %s" $url }}
      {{ end }}
    {{ end }}

  {{ end }}

{{ end }}

これで再度 hugo server で起動すると、CoverImage を記事に反映することができます。

これで説明は終わりです。
不明点があれば、_content.gotmpl の全文を含めた完成品である下記をご参照ください。

https://github.com/k1350/hugo-microcms-sample

ex. トラブルシューティング

Q. microCMS 上で記事を更新してから Hugo でビルドしなおしても更新が反映されない

A. たぶんディスクキャッシュのせいです。

ここまで見てきたように Content adapters では resources.GetRemote を使用してデータを取得します。

この関数の結果はディスクにキャッシュされるのですが、デフォルトではキャッシュの有効期限が無期限です。
そのためローカル環境で開発中の場合、デフォルト設定のままでは最初にビルドしたときのデータが永遠に返ってくることになり、microCMS 上で何をどう変更しても反映されない状況に陥ります。

よって Caching を見て対策しておくことがほぼ必須です。

  • グローバルなキャッシュ設定を更新する
  • resources.GetRemote を実行する際のキャッシュキーを変更する

のどちらかで有効期限を設定します。

グローバルなキャッシュ設定を変更する場合は Configure file cachesgetresourcemaxAge を変えます。
私はこの方法で対策しており、基本的にビルドごとに最新の値を取得したいので maxAge = "1s" にしています。

なおキャッシュについては改善計画があるようですので、その内変わるかもしれません。
https://github.com/gohugoio/hugo/issues/12502

Q. キャッシュ設定はしたが、microCMS 上で記事を公開してから Hugo でビルドしなおしても反映されない

A. たぶんビルド環境の時刻が合っていません。

特に Windows マシンで WSL 環境をお使いの場合、PC がスリープから復帰したときに時刻が狂うことがあります。(というか私の環境では毎回時刻が狂います。)

Hugo のデフォルトの動作仕様で、dates.date がビルド時刻より未来の場合は記事を生成しません。
ビルド環境の時刻を正確に合わせてください。

終わりに

Content adapters のリリースによって Hugo を利用できる領域が広がったと思います。
先月リリースされたばかりの機能なので今すぐ採用するには早すぎるかもしれませんが、ご興味がある方はぜひ試してみてください!

脚注
  1. 作成日時が全く同一の記事が複数存在する場合、ソートの指定を増やして順序が一意に定まるようにする必要があります。 ↩︎

  2. なぜ 2000 回かというと seq で作れる要素数の最大が 2000 個だからです。 ↩︎

  3. https://gohugo.io/functions/transform/highlight/ ↩︎

  4. 当初 (?s)<pre><code(?: class="language-(.+)")?>(.*?)</code></pre> というような正規表現で抽出できる気がしてて、実際「コードブロックが一つしか無い」といった特定の状況ではシンタックスハイライトを当てることに成功したのですが、コードブロックが連続で並んでいるような状況で開始タグと閉じタグの組み合わせを正しく抽出できなくて断念しました。
    <pre><code> の抽出だけを目的とするなら HTML パーサーを作るのはそう難しくないのでは?と思いましたが、template 記法の制約も厳しいですし、沼にはまりそうなので止めました。 ↩︎

chot Inc. tech blog

Discussion