はじめに
k8s.ioのサイト構築などにも使われているHugoを使っていくつかの静的サイトを構築しています。
静的とはいっても時系列によって変化するデータをD3.jsを使ってプロットしたり、項目立てに外部のREST APIを参照するなどHugoのData Source機能をよく利用しています。
v0.123から従来の機能が廃止予定(Deprecated)扱いになってから、Minorバージョンが6進んだv0.129.0からWarningとなり、12進んだ最新版のv0.135.0からはErrorになって処理が停止します。
最新版を利用したいので移行の顛末をまとめておくことにしました。
従来の機能 (GetCSV, GetJSON)
名前のとおりCSV形式のデータソース、 JSON形式のデータソースをHugoの内部データ構造に変更してくれるHugo Data Functionの機能です。
サンプルに記載されている利用例は以下のようになっていてネットワーク経由以外に、静的コンテンツとしてローカルに配置するファイルを読み込むことも出来るようになっています。
{{ $opts := dict "Authorization" "Bearer abcd" }}
{{ $data := getCSV "," "https://example.org/pets.csv" $opts }}
実際の利用ではcontent/ディレクトリに配置する各ファイルのFront-matterにデータソースとなるRESET- APIのURLを記載しています。
そしてlayouts/ディレクトリに置いたsingle.htmlからGetJSONにURLを渡しています。
...
{{ $feed_url := .Params.rss_url }}
{{ $feed_data := getJSON $feed_url }}
{{ $feed_items := index $feed_data "items" }}
...
こんな感じのコードを新しい記法に変更していきます。
新しいデータ参照方法
GetCSVの説明に追加されているドキュメントをみると、resources.Get
とtransform.Unmarshal
を利用する利用例が紹介されています。
{{ $data := dict }}
{{ $p := "pets.csv" }}
{{ with .Resources.Get $p }}
{{ $opts := dict "delimiter" "," }}
{{ $data = . | transform.Unmarshal $opts }}
{{ else }}
{{ errorf "Unable to get resource %q" $p }}
{{ end }}
この利用例はPageオブジェクトの配下から.Resources.Get
を利用していてpets.csv
を獲得しています。
ここでResource functionsを眺めてみると、URLを指定したい場合にはresources.GetRemote
が紹介されています。
おそらく柔軟にリクエストヘッダーを設定できるようにしたいニーズに対応するための対応だと思いますが、いくらか面倒になりつつもエラー処理は楽になっていると思います。
data.GetCSVの説明には、transform.Unmarshalをglobal
, page
, remote
リソースなどと一緒に使うように記載されています。
データ参照方法の変更
GetJSON等はcontent/以下に配置されたファイルも読み込んでくれましたが、resources.Getを利用することで少し面倒になりました。
content/ディレクトリにあるファイルはおよそ自動的にpublic/ディレクトリにコピーされますが、全てがリソースとして認識されているわけではありません。
Hugoのトップディレクトリからみて、data/やassets/以下にファイルを配置すればファイルパスを指示してJSON/CSVファイルにアクセスすることができます。
content/以下に配置した場合は明示的にPageオブジェクトの配下に置けば参照できますが、任意のpathから読み込めたGetJSON/GetCSVとは挙動が少し異なります。
このため複数のページから参照して外部にも公開するCSVファイル群は、assets/の配下とcontent/配下の2箇所に配置するよう変更しました。
.
├── archetypes
│ ├── default.adoc
├── assets
│ ├── js
│ │ └── default.js
│ ├── json
│ │ ├── data.20240101.json
| ...
├── content
│ ├── _index.adoc
│ ├── summary
│ │ ├── _index.adoc
| | ├── data.20240101.json
| | ├── ...
│ │ ├── summary.adoc
_index.{en,ja}.adocに対応するlayoutから直下のJSONファイルにアクセスできますが、summary.{en,ja}.adocからはアクセスできません。summary/の中に配置すれば参照可能です。
データソースとして複数のコンテンツファイルから参照する用途のためにassets/json/ディレクトリを準備して同じ内容を配置しています。
多言語化している場合にも同様の問題は発生していて、外部に公開するファイルをページの配下に配置すると日本語ページにはコピーされないといった現象が発生します。Pageのリソースの一部にしないため、あえて_index.adocなどと同じディレクトリに配置していました。
transform.Unmarshal (a.k.a. unmarshal)
このtransform.Unmarshal
は上の利用例でも使用されていて、CSVファイルから取得した生のデータを内部構造に落し込んでいます。
global
やpage
はGlobal functionsのpage
, site
を指していると思われます。remote
というカテゴリはないので、Resource functionsを指しているのだと思われます。
対応しているフォーマットは CSV, JSON, TOML, YAML, XML となっていて、CSVの場合だけデリミタなどを指定する利用例が紹介されています。
{{ $csv := "a;b;c" | transform.Unmarshal (dict "delimiter" ";") }}
ネットワーク経由でのデータソースの取得
Hugoでは主にJSONとCSV形式のデータをネットワーク経由で取得しています。
これまでのコードはローカルファイルかURLかの違いしかなかったのですが、with
を使った書き換えは少し煩わしく感じます。
ただ変更によってErrorで処理を停止したり、Warningで処理を継続したりといった制御についてコントロールできるようになった点はあるべき姿になったのかなという印象でした。
{{- $url := printf "%s%s" $baseurl $scopus_id }}
{{- $csv_data := getCSV "," $url }}
{{- $csv_data_first := index $csv_data 0 }}
{{- $url := printf "%s%s" $baseurl $scopus_id }}
{{- $csv_data := dict }}
{{- with resources.GetRemote $url }}
{{- $csv_opt := dict "delimiter" "," "comment" "#" "lazyQuotes" true }}
{{- $csv_data = . | transform.Unmarshal $csv_opt }}
{{- else }}
{{- errorf "Failed to get %s" $url }}
{{- end }}
データソースに対する変更点
resources.GetRemote
はGetJSON
と違い、MIMEヘッダーを要求します。
以前作成した古いRESET APIはMIMEヘッダーを指定していなかったのでデフォルトの"text/html"を返すようになっていたことが原因でresources.GetRemote
がエラーを返すようになりました。
ERROR render of "section" failed: "...layouts/newsfeed/list.html:11:28": execute of template failed: template: newsfeed/list.html:11:28: executing "main" at <transform.Unmarshal>: error calling Unmarshal: MIME "text/html" not supported
参照していた自前のREST APIはruby3.2ベースだったので、この機会にruby-3.3ベースに変更するついでにcontent-typeを正しく設定するようにアプリケーションを再度デプロイすることで解決することができました。
content-typeはapplication/json
を利用することで問題は解決しました。なおCSVの場合はtext/csv
であれば問題なく動作しています。
閑話休題: Hugoで久しぶりにはまった点
.Site.LangaugeCode
を取得しようとして、rangeの内部では参照できない状況に陥りました。
ループの外で別の変数に代入しておけば問題なく利用できることが分かり解決したのですが、データを個別表示しようとデータソースをrangeで取得したら期待どおりに画面が表示されず困りました。
本番環境にデプロイした後に発覚した問題
テスト系ではここまでで問題なく、hugo server
でもちゃんと画面が表示できている状態でリポジトリに反映しました。
本番環境はk8s上で動作するCronJobオブジェクトにコンテナが登録されていて、定期的にhugoコマンドを実行し、生成した静的コンテンツを本番サーバーにpushするようにしています。
unmarshal が MIME "text/plain" でエラーになる
似たようなメッセージはコードを変更する過程でも遭遇していましたが、"text/plain" はREST APIサーバー側では設定しない値だったのでコード側の問題だろうという想定で原因を調べてみました。
ERROR render of "page" failed: ".../layouts/data/single.html:201:34": execute of template failed: template: data/single.html:201:34: executing "main" at <transform.Unmarshal>: error calling Unmarshal: MIME "text/plain" not supported
Total in 4369 ms
本番環境ではalpineでコンパイルしたバイナリを利用していて、試しにバイナリをコピーしてlibc6-compatなどを加えて実行させていましたが、同様の現象が発生しています。
fmt.Printlnを埋め込んで実行してみる
hugoのコードを確認すると、FromContent()の判定結果が環境によって違うようです。
func FromContent(types Types, extensionHints []string, content []byte) Type {
t := strings.Split(http.DetectContentType(content), ";")[0]
正常な環境ではこのt
が一旦text/plain
になってから最終的に正しい値が格納されます。
alpineコンテナではContent-Typeがtext/csv;charset=utf-8
の場合のみ、FromContent関数によって080822
や080845
といった謎の数値になります。
application/json
の場合には正しくjson
が識別されています。
コンテナで実行した時にmimeパッケージの関数の挙動が異なることが分かりました。
Alpineコンテナ上で検証用コードを実行する
この処理を行っているのは標準のmimeパッケージなので簡単なコードで挙動を確認します。
$ podman run -it --rm docker.io/library/golang:1.23-alpine sh
/go #
この状態で簡単なコードを作成します。
/go # cat > main.go
package main
import (
"fmt"
"mime"
)
func main() {
fmt.Println("test")
exts, _ := mime.ExtensionsByType("text/csv;charset=utf-8")
fmt.Println("exts of text/csv;charset=utf-8: ", exts)
}
このmain.go
をビルドして実行します。
/go # go build main.go
/go # ./main
test
exts of text/csv;charset=utf-8: []
Go公式ドキュメントのmime#TypeByExtensionを確認すると、/etc/mime.typesの他にapache2などのMIME設定をロードするようになっています。
/etc/mime.typesが存在しないことが原因だったので、mailcap
パッケージを追加することで無事に解決しました。
最初に環境変数の可能性も少し考えたのですが、手元で$ env -i hugo
を実行しても挙動に変化がなかったので、この可能性は早々になくなりました。
最終的な解決策
Gnome系のglobs2ファイルはサイズが大きくなるであろうことは想像できたので、mailcapパッケージを加えることで解決しました。
Alpineはminimumな環境だとは理解していましたが、mime.typesは標準ファイルだと思っていたので意外な発見でした。