HugoのGetJSON, GetCSVがdeprecatedになった件について

# はじめに

k8s.ioのサイト構築などにも使われているHugoを使っていくつかの静的サイトを構築しています。

静的とはいっても時系列によって変化するデータを[D3.js](https://d3js.org/)を使ってプロットしたり、項目立てに外部のREST APIを参照するなど[HugoのData Source](https://gohugo.io/content-management/data-sources/)機能をよく利用しています。

v0.123からGetJSON, GetCSVなど従来からあった機能が廃止予定(Deprecated)扱いになりました。

これらの機能はv0.123からMinorバージョンが6進んだv0.129.0ではWarningとなるだけでしたが、12進んだ最新版のv0.135.0ではErrorになってレンダリング処理が途中で停止します。

最新版を利用したいので移行の顛末をまとめておくことにしました。

# 従来の機能 (GetCSV, GetJSON)

名前のとおりCSV形式のデータソース、 JSON形式のデータソースをHugoの内部データ構造に変更してくれる[Hugo Data Function](https://gohugo.io/functions/data/)の機能です。

サンプルに記載されている利用例は以下のようになっていてネットワーク経由以外に、静的コンテンツとしてローカルに配置するファイルを読み込むことも出来るようになっています。

```hugo:Hugo公式サイトの利用例(旧)
{{ $opts := dict "Authorization" "Bearer abcd" }}
{{ $data := getCSV "," "https://example.org/pets.csv" $opts }}
```

実際の利用ではcontent/ディレクトリに配置する各ファイルの[Front-matter](https://gohugo.io/content-management/front-matter/)にデータソースとなるRESET- APIのURLを記載しています。

そしてlayouts/ディレクトリに置いたsingle.htmlからGetJSONにURLを渡しています。

```hugo:layouts/data/single.html
...
  {{ $feed_url := .Params.rss_url }}
  {{ $feed_data := getJSON $feed_url }}
  {{ $feed_items := index $feed_data "items" }}
...
```
こんな感じのコードを新しい記法に変更していきます。

# 新しいデータ参照方法

GetCSVの説明に追加されているドキュメントをみると、``resources.Get``と``transform.Unmarshal``を利用する利用例が紹介されています。

```hugo:Hugo公式サイトの利用例(新)
{{ $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](https://gohugo.io/functions/resources/)を眺めてみると、URLを指定したい場合には``resources.GetRemote``が紹介されています。

おそらく柔軟にリクエストヘッダーを設定できるようにしたいニーズに対応するための対応だと思いますが、いくらか面倒になりつつもエラー処理は楽になっていると思います。

[data.GetCSV](https://gohugo.io/functions/data/getcsv/)の説明には、[transform.Unmarshal](https://gohugo.io/functions/transform/unmarshal/)を``global``, ``page``, ``remote`` リソースなどと一緒に使うように記載されています。

## .Resouirces.Get を使うところと、resources.Get を使うところ

ピリオド(.)で始まる参照方法は現在のコンテキスト(pageオブジェクト)に束縛されます。例えば content/_index.md ファイルを対象にして、list.htmlなどから``.Resources.Get "a.csv"``を呼び出せば content/a.csv のようなファイルを指定することができます。

layout/single.html で同様に``.Resources.Get "b.csv"``を呼び出すようなコードは次のようなcontent/summary/_index.mdに対しては有効です。

```text:ディレクトリ構造の変更
...
├── content
│   ├── _index.md
│   ├── a.csv
│   ├── summary
│   │   ├── _index.md
|   |   ├── b.csv
│   |   ├── failed.md ## ← ../a.csv は resource として認識されていない
```

しかし single.html で、``.Resources.Get "../a.csv"`` のような指定をして content/failed.md を呼び出そうとしても、failed.mdページには a.csv は束縛されていないため失敗します。

ここら辺はHugoの良い所でもあり、分かりにくいところでもあると思います。

ピリオドで始まらない``resources.Get``は``assets/``や``data/``に配置したデータを認識してくれます。しかし、``contents/``に配置したCSVファイルは参照できません。

```hugo:assets/csv/data.csvを参照するコード例
{{ $data := dict }}
{{ $data_src := "csv/data.csv" }}
{{ with resources.Get $data_src }}
{{ $data = . | transform.Unmarshal }}
{{ $data }}
{{ end }}
```

CSVやJSONファイルを外部にも公開したいし、レンダリングにも使いたい場合には、content/に加えて、assets/ や data/ ディレクトリにも配置した上で``resources.Get``を使う方法が見通しが良さそうです。

## データ参照方法の変更

従来のGetJSON等はcontent/以下に配置されたファイルを相対パスでも読み込んでくれましたが、resources.Getを利用することで少し厳密に処理されるようになりました。

content/ディレクトリにあるファイルはPDFやPNG形式等を含めておよそ自動的にpublic/ディレクトリにコピーされますが、全てがリソースとして認識されているわけではありません。

Hugoのトップディレクトリからみて、data/やassets/以下にファイルを配置すればファイルパスを指示してJSON/CSVファイルにアクセスすることができます。

content/以下に配置した場合は明示的にPageオブジェクトの配下に置けば参照できますが、任意のpathから読み込めたGetJSON/GetCSVとは挙動が少し異なります。

このため複数のページから参照して外部にも公開するCSVファイル群は、assets/の配下とcontent/配下の2箇所に配置するよう変更しました。

```text:ディレクトリ構造の変更
.
├── 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](https://gohugo.io/functions/global/)の``page``, ``site``を指していると思われます。``remote``というカテゴリはないので、[Resource functions](https://gohugo.io/functions/resources/)を指しているのだと思われます。

対応しているフォーマットは CSV, JSON, TOML, YAML, XML となっていて、CSVの場合だけデリミタなどを指定する利用例が紹介されています。

```hugo:公式サイトで紹介されている利用例
{{ $csv := "a;b;c" | transform.Unmarshal (dict "delimiter" ";") }}
```

## ネットワーク経由でのデータソースの取得

ローカルからだけでなくJSONとCSV形式のデータをネットワーク経由でも取得しています。

これまでのコードはローカルファイルかURLかの入力の違いしかなかったのですが、データソースによって処理を分ける必要がでてきました。

また``with``を使った書き換えは少し煩わしく感じます。

ただ変更によってErrorで処理を停止したり、Warningで処理を継続したりといった制御についてコントロールできるようになった点はあるべき姿になり、良い変更だと思います。

```hugo:変更前のコード
{{- $url := printf "%s%s" $baseurl $scopus_id }}
{{- $csv_data := getCSV "," $url }}
{{- $csv_data_first := index $csv_data 0 }}
```

```hugo:書き換え後のコード
{{- $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``がエラーを返すようになりました。

```ruby:
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の場合はcontent-typeを``text/csv``にすれば問題なく動作します。

:::note
閑話休題: Hugoで久しぶりにはまった点

``.Site.LangaugeCode``を取得しようとして、rangeの内部では参照できない状況に陥りました。

ループの外で別の変数に代入しておけば問題なく利用できることが分かり解決したのですが、データを個別表示しようとデータソースをrangeで取得したら期待どおりに画面が表示されず困りました。
:::


# 本番環境にデプロイした後に発覚した問題

テスト系では問題なく、``hugo server``でもちゃんと画面が表示できている状態でリポジトリに反映しました。

本番環境はk8s上で動作するCronJobオブジェクトにコンテナが登録されていて、定期的にhugoコマンドを実行し、生成した静的コンテンツを本番サーバーにpushしています。

## unmarshal が MIME "text/plain" でエラーになる

似たようなメッセージはコードを変更する過程でも遭遇していましたが、"text/plain" はREST APIサーバー側では設定しない値だったのでコード側の問題だろうという想定で原因を調べてみました。

```text:本番環境でのみ出現するエラーメッセージ
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()の判定結果が環境によって違うようです。

```go:hugo/media/mediaType.go
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パッケージなので簡単なコードで挙動を確認します。

```bash:alpineコンテナで挙動を確認する
$ podman run -it --rm docker.io/library/golang:1.23-alpine sh
/go # 
```

この状態で簡単なコードを作成します。

```sh:
/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 # go build main.go
/go # ./main
test
exts of text/csv;charset=utf-8:  []
```

Go公式ドキュメントの[mime#TypeByExtension](https://pkg.go.dev/mime#TypeByExtension)を確認すると、/etc/mime.typesの他にapache2などのMIME設定をロードするようになっています。

/etc/mime.typesが存在しないことが原因だったので、``mailcap``パッケージを追加することで無事に解決しました。

最初に環境変数の可能性も少し考えたのですが、手元で``$ env -i hugo``を実行しても挙動に変化がなかったので、この可能性は早々になくなりました。

## 最終的な解決策

Gnome系のglobs2ファイルはサイズが大きくなるであろうことは想像できたので、mailcapパッケージを加えることで解決しました。

Alpineはminimumな環境だとは理解していましたが、mime.typesは標準ファイルだと思っていたので意外な発見でした。

# さいごに

Hugoは静的コンテンツを生成するツールとして必要な機能は備えていますが、Go言語で静的に埋め込まれている制御機構があり柔軟性にはやや欠けます。

またGo言語のテンプレート規則に従う点は、利用できる構文がRuby言語で開発した時のDSLのような自由度はないため、かなり窮屈に感じることもあります。

とはいえバグと感じる事も減ってきて良いツールだと思うので、Gitなどでコンテンツファイルを管理しつつ、都度Webサーバーにコンテンツをアップしたいという用途には最適だと思います。