TypeScript + React で i18n (国際化/多言語) 対応を楽して続けるためのアレコレ

こんにちは！ [LOB のアドベントカレンダー](https://qiita.com/advent-calendar/2018/lob-inc) の4日目は、またもや僕だよ！

[前回の LOB での TypeScript + React 選定小話](https://qiita.com/mojibakeo/items/227e7371e9f6d9c6d711) に引き続き、またも TypeScript + React ネタです。

今回はちゃんとコードを添えた記事でお送りいたします。

# これはなに
- i18n (国際化/多言語) 対応ってめんどくさいよね！
    - 翻訳 id 覚えるのめんどくさいし
    - typo するし
    - その割に何度も書かされるし
    - たまに翻訳漏れしたりするし
    - おれはちゃんとやってるのに誰かがいつも忘れて事故る

という様々なつらみを解決するやつです。(上の例の一部は TypeScript 使っただけで補完きいて解決してるものも含んでますけど)
超しょぼいサンプルアプリを作りながらお送りします。

そのへんの過程はどうでもええんや！まとまったコードをみせろや！！という漢スタイル方のためにリポジトリごと公開しておりますので [あとがき](#あとがき)までジャンプしてしまってください。

# やっていく
## 準備編

なにはともあれ翻訳するためのアプリケーションが必要ですな。さすがにプロダクトのコードを公開できないので、さくっと Next.js で組み立てていきます。
そんなんどうでもえーわっていう方は[本題](#本題)まで飛んでいってください。

なお、以下は `yarn` がインストールされていること前提です。必要に応じて脳内変換してください。

### App 作成

```sh
yarn init
```

いろいろ言われるので適当に設定してください

### Next.js つかって TypeScript + React をシュッと用意

```sh
yarn add next react react-dom @zeit/next-typescript typescript
```

next にビルド系をおまかせしたいので `package.json` に以下の scripts を追加しましょう。

```package.json
// 略
"scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  }
```

お次は `tsconfig.json` ですね。内容はおまかせしますが、今回はこんな感じで設定してみます。

```tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist/",
    "target": "es2017",
    "module": "commonjs",
    "jsx": "react",

    "strictNullChecks": true,
    "noImplicitAny": false,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "alwaysStrict": true,

    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "removeComments": true
  }
}
```

すぐ忘れそうになりますが、 `tsconfig.json` に加えて `.babelrc` 経由でも typescript 使う宣言が必要になります。
以下のように。

```.babelrc
{
  "presets": [
    "next/babel",
    "@zeit/next-typescript/babel"
  ]
}
```


`next.config.js` を編集して `next` と一緒に `yarn add` した `@zeit/next-typescript` を使うようにします。


```next.config.js
module.exports = require('@zeit/next-typescript')()
```

ここは個人の自由というか思想次第ですが、 tslint と prettier を入れないとやる気がでないので、ここでいれちゃいます。

```sh
yarn add tslint tslint-config-prettier tslint-eslint-rules tslint-plugin-prettier tslint-react prettier @types/next
 ```

無事インストールが完了したら `tslint.json` と `.prettierrc` を用意してあげましょう。 `.prettierrc` は yaml 派です。
このへんも個人やら会社によってポリシーあると思うので、適宜加筆修正削除なりして設定してください。

```tslint.json
{
  "defaultSeverity": "error",
  "extends": ["tslint:recommended", "tslint-react", "tslint-eslint-rules", "tslint-config-prettier"],
  "rulesDirectory": ["tslint-plugin-prettier"],
  "rules": {
    "prettier": true,
    "object-literal-sort-keys": true,
    "no-namespace": true,
    "no-console": true,
    "radix": true,
    "interface-name": [true, "never-prefix"],
    "jsx-no-lambda": true
  }
}
```

```.prettierrc.yml
printWidth: 120
bracketSpacing: true
singleQuote: true
semi: false
trailingComma: es5
jsxBracketSameLine: false
arrowParens: avoid
requirePragma: false
```

ここまでできたら一旦動作確認してみましょう。たくさんページ作る気もないので `pages/index.tsx` 一枚だけでいいでしょう。

```sh
mkdir pages
touch pages/index.tsx
```

```pages/index.tsx
import * as React from 'react'

export default () => <div>It's works</div>
```

`yarn dev` で開発サーバーを立ち上げ、 `http://localhost:3000` (デフォルトでポート 3000番になってるはず)にアクセスして、`It's works` と表示されたらここまでは準備編は完了です。

まだまだ続きますよ！
 
## 本題

さて、TypeScript + React な環境が用意できたということで、本題にはいっていきましょう。

がしかし、`It's works` だけ翻訳させてもつまらないので、「新年まであと何日カウンター」でもつくりましょう。たいしておもしろくないけど。
加えてちゃんと翻訳機構が動作しているかも確認したいので、デフォルト日本語、クエリパラメータに言語指定がはいっていればそれを使う、という仕様にしてみましょう。

 

というわけでまずはベースの実装から。日付処理といえばおなじみの moment をいれて楽をします。

```sh
yarn add moment @types/moment
```

日本語でカウンターが動くようにちょろっと書き換えてみます。どうせなら残りの日数に応じたコメントも表示させてみましょう。

```pages/index.tsx 
import * as React from 'react'
import * as Moment from 'moment'

const getRemain = (): number =>
  Moment()
    .endOf('year')
    .diff(Moment(), 'days')

const getComment = (remain: number): string => {
  if (remain < 31) {
    return 'もう師走、よい一年でしたか？'
  } else if (remain < 92) {
    return '残り3ヶ月を切りましたね！冬がはじまるよ！'
  } else if (remain < 183) {
    return '早いもので今年も後半戦のはじまりです！はりきっていきましょう！'
  }
  return 'こんにちは！まだまだ今年は終わらないよ！'
}

export default () => {
  const remain = getRemain()
  const comment = getComment(remain)
  return (
    <>
      <h1>今年がおわるまであと{remain}日</h1>
      <p>{comment}</p>
    </>
  )
}
```

こんな感じでしょうか。

<img width="391" alt="スクリーンショット 2018-12-04 11.32.17.png" src="https://qiita-image-store.s3.amazonaws.com/0/28612/0af748bd-e138-5bd2-d18d-4adac67bd2ad.png">
動いているようにみえます。ではこれを i18n やっていきましょう。ようやくっすね。


### 翻訳系のパッケージの導入

定番の `react-intl` を使います。自前でやると数値のカンマ位置とか、通貨とか、そのへんに向き合わないといけないので皆様全力で Yahoo 様に感謝と祈りをささげましょう。

```sh
yarn add react-intl @types/react-intl
```

今回は `intl` ディレクトリを掘って、その中に翻訳系の処理やらファイルを突っ込んでいきます。
以下のような構成でつくっていく方向で進めます。


```sh
intl
├── index.ts // 共通処理はこちらで
├── locales // ここに翻訳ファイルいれる
└── messages
    └── index.ts // 翻訳 id 管理するやつ
```


まずは `intl/index.ts` の実装から開始します。
こいつに期待するのは、現在の言語設定を渡すと、それに応じた翻訳済み文字列を返却してくれること、と於いてみましょう。

```intl/index.ts
import { addLocaleData, IntlProvider } from 'react-intl'
import * as en from 'react-intl/locale-data/en'
import * as ja from 'react-intl/locale-data/ja'

addLocaleData([...ja, ...en])

export const locales = {
  en: require('./locales/en.json'),
  ja: require('./locales/ja.json'),
}

export default class IntlMessage {
  protected intl: ReactIntl.InjectedIntl
  constructor(locale?: string) {
    if (locale) {
      this.intl = this.makeIntlInstance(locale)
    }
  }
  public format(
    messageDescriptor: ReactIntl.FormattedMessage.MessageDescriptor,
    values?: { [key: string]: ReactIntl.MessageValue }
  ): string {
    return this.intl.formatMessage.bind(this.intl)(messageDescriptor, values)
  }
  public setLanguage(locale: string) {
    this.intl = this.makeIntlInstance(locale)
  }
  protected makeIntlInstance(locale: string): ReactIntl.InjectedIntl {
    const intlProvider = new IntlProvider({
      locale,
      messages: locales[locale],
    })
    return intlProvider.getChildContext().intl
  }
}
``` 

コンストラクタ、もしくは public な口で用意してある `setLanguage` 経由で言語設定を渡すと、インスタンス内に `intlProvider` の `InjectedItl` 型を `this.intl` にセットする実装です。

`format` には　`id` (上の実装でいうところの `messageDescriptor`) と `values` を渡すと、 `InjectedIntl` を使って formatMessage を実行、文字列を返却するようにしてみました。

突貫工事なのでコンストラクタに引数渡さず、`setLanguage` も呼ばないまま `format` しようとしたときのエラーハンドリングが用意されておりませんが、今回はご愛嬌ということで…。

### 実際に翻訳を試してみましょう

では、実際に翻訳されるように書き換えていきましょう。

タイトルに該当する `tiltles`, 残日数に応じたテキストの `comments`, 言語切替ボタン用の `links` にオブジェクトを分割してみます。

```intl/messages/index.ts
import { defineMessages } from 'react-intl'

export const titles = defineMessages({
  index: {
    id: 'titles.index',
    defaultMessage: '{remain} days left until the New Year',
  },
})

export const comments = defineMessages({
  lessThanOrEqualTo31days: {
    id: 'comments.lessThanOrEqualTo31days',
    defaultMessage: 'It is already December. Was it a good year?',
  },
  lessThanOrEqualTo92days: {
    id: 'comments.lessThanOrEqualTo92days',
    defaultMessage: '3 months to go again this year. Winter will begin!',
  },
  lessThanHalf: {
    id: 'comments.lessThanHalf',
    defaultMessage: "It's the beginning of the second half of the year! Let's go do our best!",
  },
  moreThanHalf: {
    id: 'comments.moreThanHalf',
    defaultMessage: 'Hi, this year has just begun!',
  },
})

export const links = defineMessages({
  switchLanguage: {
    id: 'links.switchLanguage',
    defaultMessage: 'Switch Language',
  },
})
```

こんなかんじですね。英語は苦手なのでツッコミを入れられると涙が出てしまうの。

次に `intl/locales` の下に翻訳ファイルを用意します。 `en`, `ja` ごとに実際に表示される文字列のベースとなるテキストデータを `json` で設置します。

```intl/locales/en.json
{
  "titles.index": "{remain} days left until the New Year",
  "comments.lessThanOrEqualTo31days": "It is already December. Was it a good year?",
  "comments.lessThanOrEqualTo92days": "3 months to go again this year. Winter will begin!",
  "comments.lessThanHalf": "It's the beginning of the second half of the year! Let's go do our best!",
  "comments.moreThanHalf": "Hi, this year has just begun!",
  "links.switchLanguage": "Switch Language"
}
```


```intl/locales/ja.json
{
  "titles.index": "年越しまであと {remain} 日！",
  "comments.lessThanOrEqualTo31days": "もう師走、よい一年でしたか？",
  "comments.lessThanOrEqualTo92days": "残り3ヶ月を切りましたね！冬がはじまるよ！",
  "comments.lessThanHalf": "早いもので今年も後半戦のはじまりです！はりきっていきましょう！",
  "comments.moreThanHalf": "こんにちは！まだまだ今年は終わらないよ！"
  "links.switchLanguage": "言語切替"
}
```

そして `pages/index.tsx` も `intl` 以下を使って文字列を呼び出すようにします。

```pages/index.tsx
import * as React from 'react'
import * as Moment from 'moment'
import Link from 'next/link'
import { RouterProps, withRouter } from 'next/router'

import IntelMessage, { locales } from './../intl'
import { titles, comments, links } from './../intl/messages'

const defaultLanguage = 'ja' // デフォルトの言語設定は日本語
const intl = new IntelMessage(defaultLanguage)

const getRemain = (): number =>
  Moment()
    .endOf('year')
    .diff(Moment(), 'days')

const getComment = (remain: number): string => {
  let message = comments.moreThanHalf
  if (remain < 31) {
    message = comments.lessThanOrEqualTo31days
  } else if (remain < 92) {
    message = comments.lessThanOrEqualTo92days
  } else if (remain < 183) {
    message = comments.lessThanHalf
  }
  return intl.format(message)
}

const getCurrentLanguage = (router?: RouterProps) => {
  if (!router || !router.query) {
    return defaultLanguage
  }
  const lang = router.query.lang as string
  return Object.keys(locales).includes(lang) ? lang : defaultLanguage
}

const getNextLanguage = (lang: string) => (lang === 'ja' ? 'en' : 'ja')

export default withRouter(({ router }) => {
  const lang = getCurrentLanguage(router)
  intl.setLanguage(lang)

  const remain = getRemain()
  const comment = getComment(remain)
  return (
    <>
      <h1>{intl.format(titles.index, { remain })}</h1>
      <p>{comment}</p>
      <Link href={{ pathname: '/', query: { lang: getNextLanguage(lang) } }}>
        <a>{intl.format(links.switchLanguage)}</a>
      </Link>
    </>
  )
})
```

`intl.format` を使用するようにした以外の目立つ修正は、以下2点でしょうか。

- 言語切替ボタンを設置した
- `next/router` の withRouter を使い、クエリパラメータを取得できるようにした

うまく実装できていると以下のような動きをするはずです。

[![Image from Gyazo](https://i.gyazo.com/e159b45748b5dd5e9cb8215aac563758.gif)](https://gyazo.com/e159b45748b5dd5e9cb8215aac563758)

### やったね！これで君も i18n 対応マスターやで！！！

とはいかず、このままだと運用上しんどいことばかりです。

ざっと目につくめんどくさい部分は以下でしょう。

- json を手で書かないといけない
- 翻訳 id を手で書いてるのに同じものを json にも書かないといけない
- 言語ごとに json を用意する必要があるので、特定の言語にしか加筆修正してなかった/特定の言語だけ加筆修正してなかった問題が起きやすい
- git pull して手元で動かしてみるまで翻訳漏れがないのか確信が持てない

一つずつ潰していきましょう。
以下のようになるとだいぶ楽そうです。

- json ではなく yaml
- messages/*.ts を更新したら自動で locales/{ja|en}.yml が生成される
    - デフォルトメッセージな英語が最初から埋められた状態で生成
- `.git/hooks` の下に `pre-push` ファイルを用意して、翻訳漏れをチェック、パスしないと `push` できない

### yaml で Go

何はともあれ yaml のほうがベター、ローダーをいれて対応しちゃいます。

```sh
yarn add json-loader yaml-flat-loader
```

`next.config.js` を編集して yaml を `require` したら `yaml-flat-loader` が使われるようにします。

```next.config.js
module.exports = require('@zeit/next-typescript')({
  webpack(config, options) {
    config.module.rules.push({
      test: /\.(yml|yaml)$/,
      use: [{ loader: 'json-loader' }, { loader: 'yaml-flat-loader' }],
    })
    return config
  },
})
```

これで yaml の読み込みができるようになりました。 `intl/index.ts` の `locales` も yaml を読むようにします。

```itnl/index.ts
export const locales = {
  en: require('./locales/en.yml'),
  ja: require('./locales/ja.yml'),
}
```
.json を .yml にしただけですね。

このまま yml を用意してないですが、この際手で書くのではなく自動生成させてしまいましょう。
というわけでいつものパッケージの追加です。

```sh
yarn add extract-react-intl-messages
```

ものすごい万能な神パッケージでして、特にコードを書く必要もなく、 `package.json` の `scripts` に書くだけで動きます。
以下のようなコマンドを追加してみましょう。

```package.json
"intl:build": "extract-messages -l=ja,en -o intl/locales 'intl/messages/**/*.ts' -f yaml --flat",
```

`intl/messages` 以下の ts ファイルを読み込み、yaml 形式で `intl/locales` 以下に ja, en を用意する、という一行でございます。
実行してみましょう。

<img width="679" alt="スクリーンショット 2018-12-04 16.15.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/28612/cbe49049-aafe-5b3d-5ea7-30fca255839f.png">

あっけなく完了しました。

<img width="156" alt="スクリーンショット 2018-12-04 16.16.14.png" src="https://qiita-image-store.s3.amazonaws.com/0/28612/6ed364b5-a1d8-72a1-e2f3-7c3494b1c40f.png">

なんということでしょう。言語ごとに yaml ファイルが生成されているようです！

- `intl/locales/en.yml`
<img width="773" alt="スクリーンショット 2018-12-04 16.17.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/28612/97b31aa5-7500-8246-fa9d-09e1452325e1.png">

- `intl/locales/ja.yml`
<img width="350" alt="スクリーンショット 2018-12-04 16.17.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/28612/eb224597-5f7c-1850-7764-a693d51fd8fb.png">

かんぺきですね。 `extract-react-intl-messages` を公開してくれている @akameco 氏に足を向けてねむれません。

ここまで来たら `intl/messages` を watch するのと、 `intl:check` 的なスクリプトを用意してあげれば運用面でもバッチリです。

おなじみのパッケージ追加からはじめます。

```sh
yarn add watch colors path 
```

`intl/messages` の更新検知はこれだけ。かんたん。

```package.json
"intl:watch": "watch 'yarn intl:build' ./intl/messages"
```

最後に翻訳もれチェックスクリプトを用意しましょう。
ビルド処理の CPU がもったいないので生の JavsScript でやります。

```scripts/intlChecker.js
require('colors')
const path = require('path')
const fs = require('fs')
const loader = require('yaml-flat-loader')

fs.readFile(path.resolve(__dirname, './../intl/locales/ja.yml'), (err, raw) => {
  if (err) {
    return console.error('Not found ja.yml'.white.bgRed)
  }
  const errors = []
  const messages = JSON.parse(loader(raw))
  Object.keys(messages).forEach(key => {
    if (messages[key] && messages[key].length > 0) {
      return
    }
    errors.push(key)
  })
  if (errors.length === 0) {
    console.log('Translation has completed:)'.green)
    process.exit(0)
  }
  console.log('\nOh, these ids has no messages for ja_JP;'.red)
  console.log('\n======================================\n')
  errors.forEach(e => {
    console.log(`${e}`.yellow)
  })
  console.log('\n======================================\n')
  process.exit(1)
})
```

難しいことはしてません。単純に `ja.yml` を読み込み、翻訳データが設定されてないキーを収集し、
1つでも翻訳漏れがあれば `process.exit(1)` で終了させます。


```package.json
"intl:check": "node scripts/intlChecker.js",
```

<img width="700" alt="スクリーンショット 2018-12-04 16.40.15.png" src="https://qiita-image-store.s3.amazonaws.com/0/28612/44fec268-4b5e-e803-88fe-feeb7be8d4a0.png">

ちゃんと怒ってもらえました。これで漏れ知らずですね！
仕上げに以下のようなスクリプトを `.git/hooks/pre-push` として登録しておけば、 `git push` するときに自動実行されます。

```sh
#!/bin/sh

# technical copypasta from https://github.com/angular/material2/wiki/Pre-commit-hook-for-linters

pass=true
RED='\033[1;31m'
GREEN='\033[0;32m'
NC='\033[0m'

echo "Running Linters:"

# Run tslint and get the output and return code
intlCheck=$(yarn intl:check)
ret_code=$?

# If it didn't pass, announce it failed and print the output
if [ $ret_code != 0 ]; then
	printf "\n${RED}intl:check failed:${NC}"
	echo "$intlCheck\n"
	pass=false
else
	printf "${GREEN}intl:check passed.${NC}\n"
fi

# If there were no failures, it is good to commit
if $pass; then
	exit 0
fi

exit 1
```

<img width="790" alt="スクリーンショット 2018-12-04 16.49.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/28612/fc4fe047-c8c2-6c75-1d5b-5aac91b0b43f.png">

これで治安を維持するために必要なコミュニケーションは「pre-push つかってくれ」だけでよくなるので楽になりましたね。

# あとがき
LOB の実プロダクトでも `extract-react-intl-messages` が大活躍しているのですが、　TypeScript のまま渡しても動くことを知りませんでした。
`intl:watch` コマンドでファイル更新ごとに JavaScript へ変換、その変換後のファイルを `extract-react-intl-messages` に食べさせていたのですが、記事作成中、なにも考えず `*.ts` を指定したら普通に飲み込んでくれて驚愕しました。

今回のサンプルアプリケーションについては、 Github においておきました。興味がある方はぜひこちらも御覧ください。

https://github.com/mojibakeo/ts-react-intl-sample-app


明日は再び @hatchinee による[業務で書いたコードはチャンスさえあればパッケージに切り出して公開すべきである理由](https://qiita.com/advent-calendar/2018/lob-inc)です。引き続き LOB のアドベントカレンダーをおたのしみください。

ではでは。