GraphCMSとHugoを連携してGithub Pagesで公開する(番外編)〜国際化対応

# 概要

前回まででHugoとGraphCMSを連携させてGithu Pagesで公開する方法を解説した。
今回、番外編として国際化対応の実装を行う。

## 国際化対応

ウェブ制作の案件として国際化対応についてはどれほど需要があるかわからないが、HugoもGraphCMSもi18nを簡単に導入できるので説明に加えることにした。

### GraphCMSの設定

GraphCMSはフリープランでは二カ国語まで対応している。
デフォルトが`en`なのでまず日本語の`ja`を加える。

#### ロケールの追加設定

ダッシュボード>`Settings`>`Locales`を開く。
`Dispyal name`に`Japanese`を選び`Add`をクリック。

![FireShot Capture 139 - GraphCMS - app.graphcms.com1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/bc39eb8c-a1e9-5856-eebd-7d8b599aa36b.png)

![FireShot Capture 139 - GraphCMS - app.graphcms.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/ef636db2-9841-704e-d6d2-cfed9e7f5b9e.png)

`Japanese`と`Japanese(Japanese)`と二つあってややこしいが、クエリー結果の出力名称(API名)の違いである。

* `Japanese`: `ja`
* `Japanese(Japanese)`: `ja_JP`

なのでその後のスクリプト内での表記法が異なるだけなので別にどちらをでもよい。
ただし今回はこの名称をHugoの言語別のディレクトリ名に使うので`en`, `ja`となるほうを採用した。

#### スキーマ定義修正

設定にロケールを追加しただけでは何も変わらない。
モデルのフィールド毎にロケールの設定を行う必要がある。
今回作成した`post`モデルの場合は多言語化対象になるのは`title`, `body`, `tag`が対象となる。

> slugやdateなどは言語が関係ないので修正不要である。

まず`title`を修正する。
`title`の`Edit field`をクリックし`Localized field`にチェックをいれる。

![FireShot Capture 136 - GraphCMS - app.graphcms.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/ea8f4c36-7ea0-5770-dde5-5b123a4cf183.png)

同様に`body`や`tag`も設定する。

#### 追加設定

ここで追加の設定を行う。
ブログを二ヶ国語化した場合でも必ずしも二ヶ国語で投稿したいとは限らない。
国際的な話題は二ヶ国語化したいが、基本は日本語だけで十分だと言う場合もあるし。
逆に英語でお知らせしたいが日本語は不要の場合もあると考えられる。
そのためモデルのスキーマ定義に言語設定用のフィールドを追加して出力する言語を選べるようにする。

スキーマ定義の左の`Enumerations`>`Add`をクリック。
`Name`を`locale_list`として`ja`と`en`を加えた。

そして`post`モデルのフィールドに`Dropdown`として追加する。

![FireShot Capture 151 - GraphCMS - app.graphcms.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/71825802-749a-3917-35b7-d899ce5f6d72.png)

最終的なスキーマ定義はこんな感じ。

![FireShot Capture 152 - GraphCMS - app.graphcms.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/399943f7-e98a-fbe3-f450-ac3ca1d40d32.png)

`invalid_locale`は**選択された言語をビルドの対象から外すオプション**だ。
必須項目とはせず、`ja`を選択したら日本語、`en`を選択したら英語を**対象外とする**。
デフォルトでは何も選択しないのでその場合は両方出力となるる。

#### コンテンツ修正

このままではまだ国際化対応はできていない。
そこで既存のコンテンツを修正する。新規に作る場合も要領は同じ。

右側に`LOCALIZATIONS`と表示されているところがあり`English`のみがチェック入っている。
`Japanese`も同様にチェックすると、`Localized`対象のフィールドの`title`などが該当言語ごとに追加される。

![FireShot Capture 145 - GraphCMS - app.graphcms.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/7ba08269-d81d-aaf2-752a-882a80dc4a78.png)

既存のコンテンツはこのままではデフォルトの`en`に日本語が入っているので面倒だが、`ja`のところに移す。
`invalid_locale`で`en`を選択すると英語は不要になるのだけども`title`などのフィールド自体は必須項目なので何か入れる必要がある。

#### 確認

ダッシュボードの`API Playground`から以下のクエリーをなげて確認する。

```graphql
query MyQuery {
  posts(locales: [ja, en], orderBy: updatedAt_DESC) {
    localizations(includeCurrent: true) {
      id
      title
      slug
      date
      eyecatch {
        url
      }
      body
      tag
      locale
      invalidLocale
      updatedAt
    }
  }
}
```

### Hugoの設定

続いてHugoの設定。

config.tomlに言語設定を追加する

```toml
# 省略

[languages]
  [languages.en]
    title = "My blog"
    weight = 2
    contentDir = "content/en"
  [languages.ja]
    title = "私のブログ"
    weight = 1
    contentDir = "content/ja"

# 省略
```

上記でわかる通り出力ディレクトリが変わるので以下のような構成にする。

```
content
├── en
│   ├── _index.md
│   └── post
│       └── _index.md
└── ja
    ├── _index.md
    └── post
        └── _index.md
```

### Pythonの設定

受け入れ体制が整ったのでスクリプトの修正を行う。
細かい解説はしないので完成形をお見せする。

`app/__main__.py`

```python
import datetime
import json
import os
import pathlib
import re
import urllib.request

from dotenv import load_dotenv

APP_DIR = os.path.abspath(os.path.dirname(__file__))
PROJECT_DIR = pathlib.Path(APP_DIR).parent
HUGO_CONTENT_DIR = os.path.join(PROJECT_DIR, 'content')
HUGO_POST_DIRNAME = 'post'
UPDATE_SEC = int(os.getenv('UPDATE_SEC', '300'))

dotenv_path = os.path.join(PROJECT_DIR, '.env')
load_dotenv(dotenv_path)


class GraphcmsManager(object):
    def __init__(self, endpoint, token):
        self.endpoint = endpoint
        self.headers = {'Authorization': f'Bearer {token}'}

    def __format_query(self, s):
        s = re.sub(r'\s+', '' ' ', s).replace('\n', ' ')
        return {'query': s}

    def __query_statement(self):
        return '''\
        {
          posts(locales: [ja, en], orderBy: updatedAt_DESC) {
            localizations(includeCurrent: true) {
              id
              title
              slug
              date
              eyecatch {
                url
              }
              body
              tag
              locale
              validLocale
              updatedAt
            }
          }
        }'''

    def query(self, data=None, is_raw=True):
        if not data:
            data = self.__query_statement()
        if is_raw:
            data = self.__format_query(data)

        req = urllib.request.Request(self.endpoint,
                                     data=json.dumps(data).encode(),
                                     headers=self.headers)
        status_code = 500
        try:
            with urllib.request.urlopen(req) as response:
                payload = json.loads(response.read())
                status_code = response.getcode()
        except urllib.error.HTTPError as e:
            payload = {'error': e.reason}
        except urllib.error.URLError as e:
            payload = {'error': e.reason}
        except Exception as e:
            payload = {'error': str(e)}
        return status_code, payload

    def __time_diff(self, date_str, fmt='%Y-%m-%dT%H:%M:%S.%f+00:00'):
        updatetime = datetime.datetime.strptime(date_str, fmt)
        return (datetime.datetime.now() - datetime.timedelta(hours=9) - updatetime).seconds

    def gen_hugo_contents(self, payload):
        result = list()

        data = (payload.get('data'))
        for model, content_list in data.items():
            for content in content_list:
                for x in content.get('localizations'):
                    data_map = dict()
                    locale = x['locale']
                    if locale == x.get('validLocale'):
                        print(f'Pass language code {locale} for content {x["id"]}')
                        continue
                    front_matter = f'title: "{x["title"]}"\n'
                    front_matter += f'slug: "{x["slug"]}"\n'
                    front_matter += f'date: {x["date"]}\n'
                    eyecatch = x.get('eyecatch')
                    if eyecatch:
                        front_matter += f'featured_image: {eyecatch["url"]}\n'
                    tag = x.get('tag')
                    if tag:
                        front_matter += f'tags: {str(tag)}\n'

                    data_map['front_matter'] = front_matter
                    data_map['body'] = x['body']
                    data_map['filepath'] = f'{x["id"]}.md'
                    data_map['update_sec'] = self.__time_diff(x['updatedAt'])
                    data_map['locale'] = locale

                    result.append(data_map)

        return result

    def write(self, data, update_sec=UPDATE_SEC):
        for x in data:
            fullpath = os.path.join(HUGO_CONTENT_DIR, x['locale'],
                                    HUGO_POST_DIRNAME,  x['filepath'])
            os.makedirs(os.path.dirname(fullpath), exist_ok=True)

            if os.path.exists(fullpath) and x["update_sec"] > update_sec:
                # skip old posts
                continue

            with open(fullpath, 'w') as f:
                text = f'---\n{x["front_matter"]}---\n{x["body"]}'
                f.write(text)


def main():
    endpoint = os.getenv('GRAPHCMS_ENDPOINT', 'http://localhost')
    token = os.getenv('GRAPHCMS_TOKEN', 'my-token')
    G = GraphcmsManager(endpoint=endpoint, token=token)
    status_code, payload = G.query()
    if status_code != 200:
        print(payload)
        return
    data = G.gen_hugo_contents(payload)
    G.write(data)


if __name__ == "__main__":
    main()
```

コンテンツ取得、ビルドおよび開発サーバ起動

```shell
python -m app
hugo serve
```

# 結果

まずトップページはこんな感じになる。
右上に`en`のメニューが追加されていることを確認していただきたい。

![FireShot Capture 146 - 投稿記事 - 私のブログ - localhost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/2c17ff4d-7f64-879e-6736-88e3e8eadcf8.png)

そしてこの`en`をクリックするとページは英語表記に変わり言語メニューは`ja`となる。

![FireShot Capture 147 - Blog posts - My blog - localhost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/035c833e-af10-73bd-9fd5-fe1390e76c0e.png)

ブログ詳細ページも同様に言語メニューが出るのだが、先程日本語のみの出力設定(`invalid_locale`に`en`を設定)を行ったコンテンツはこんなふうになり、日本語でしか表示されない。

![FireShot Capture 153 - オリーブ収穫 - 私のブログ - localhost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/691494/376369b7-8b1f-5178-368b-93055e4ea8b4.png)

そもそも先に紹介したトップページの日本語画面、英語画面のところに表示されるブログの一覧のところで既に違いが出ている。

完成したソース

https://github.com/higebobo/hugo-graphcms-blog

上記で作成したサイト

https://higebobo.github.io/hugo-graphcms-blog

# まとめ

HugoとGraphCMSとの連携の番外編として国際化の説明を行った。

実際の業務案件としてウェブサイトをリニューアルして二ヶ国語対応したいというのがあり、Hugoでやってみたいと思ったのがきっかけだった。
Hugoでやる場合、ネックになるのがMarkdown記法である。私は慣れているが一般的な利用者向きとはいえず、テキストエディタで投稿するのは現実的ではなかった。

そこで目をつけたのがヘッドレスCMS。
最初はContentfulを使ってみた。以前紹介したcontentful-hugoというライブラリを利用すれば連携できることはわかった。
しかし、Node.jsで書かれたcontentful-hugoのカスタマイズに苦労したり、またCotentful側のAPIのi18n対応が思った結果を取得できなかったことからいろいろ模索してGraphCMSにたどり着いた。さらにPythonでスクリプトを書くことで自分の土俵にもってくることができた。

* ヘッドレスCMSのWEB-APIを利用してコンテンツを取得する
* 上記をMarkdown形式で書き出す

というたった二つのことを具象化するだけでHugoとヘッドレスCMSの相乗効果を引き出せるのはすごいことだと思う。

今回紹介したGraphCMS(と以前紹介したContentful)に限らずいろんなヘッドレスCMSで同様のことが実現できると思われるのでもっとこういった活用事例が増えるとみんなハッピーになれるかなと思う。