🍣

react-intlとTransifexでi18n

2021/04/03に公開

今やってるプロジェクトで突如多言語対応が必要になったので、やったことのまとめ。

やりたいこと

日本語で作ってきたReactAppを急遽英語化する必要が発生しました。その後中国語化も入ってくるということで、突貫工事で多言語化する必要に迫られました。

翻訳作業はクライアント側で手配してくれるとのことですが、今プロジェクトのクライアントはそこそこレガシー寄り(このプロジェクトが冒険というかパイロットになるはず)なので、翻訳テキストがExcelファイルで提供される可能性に震えました。
今の時代にExcelからぽちぽちコピペでjsonファイルに移植する作業はやりたくありません。Excelからjsonを吐き出すマクロのメンテも気が重いです。

というか、一週間で英語サイトをステージング環境に公開しないといけないので、そんなことをしている時間はないです。

なので、クライアントの了解を取りつつ、React側のi18nライブラリと連携して作業できる翻訳ツールを導入し、CIで同期を取ってステージング環境にデプロイすることにしました。
ステージング環境でクライアントの翻訳チェックが完了したら、リリースタグを切って本番環境にデプロイします。

突貫作業なので、まずできる部分をやっつけつつ、次回以降のイテレーションで完成度を上げていくことも合意をいただきました。

Reactのi18n化

react-I18nextreact-intl(formatjs)で迷いましたが、react-intlに決定。

Hello <strong title={t('nameTitle')} />

的にシンプルに書けるreact-i18nextにだいぶ引かれましたが、今回は時間がないのと冒険したくないので実績のあるformatjsに決定。
今のところこの選択で困ったことは発生していない&タグ内にdescriptionを必須にしていくスタイルは翻訳するときに随分助かりました。

導入

インストールしてProviderを設定します。

公式Doc通りにやれば大抵の場合では大丈夫と思われますが、今回はリポジトリが変な構成になっていたりライブラリが古かったりして、フロントエンドエンジニアの方をてこずらせてしまった。

インストール

yarn add react-intl
yarn add -D @formatjs/ts-transformer

TypeScriptの設定が必要なので、rollup.config.jsに書きます。

rollup.config.js
// Compile TypeScript files
typescript({
  transformers: [
    () => ({
      before: [
        transform({
          overrideIdFn: '[sha512:contenthash:base64:6]',
        }),
      ],
      after: [],
    }),
  ],
  useTsconfigDeclarationDir: true,
}),

自分のところではrollupのバージョンが古かったので普通にはまりました。

0.64.1から2.42.1まで一気に上げたのであっちも上げこっちも上げ、i18nと関係ないところで四苦八苦しました。react-intlの設定自体は上記のtypescript設定のみなんですが。

rollup ver0.64.1ではrollup-plugin-typescript2に@formatjs/ts-transformerは適用できないみたいです。古すぎるからあたりまえですね。ライブラリはこまめにアップデートしましょう。。。

なおこの設定は、後述するTransifexとの連携用なので、翻訳ツール使わない場合は不要です。

コンテキスト注入

Componentにreact-intlのコンテキストを渡してやります。

import React, { FC, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RouteComponentProps, Router } from '@reach/router';
import styled from '@emotion/styled';
import {createIntl, createIntlCache, RawIntlProvider} from 'react-intl'
//...
import * as sourceOfTruth from '../../../compiled-lang/ja.json'
import ja from '../../../compiled-lang/ja.json'
import en from '../../../compiled-lang/en.json'

const locale = navigator.language;

function selectMessages(locale: string) {
  switch(locale) {
    case 'en':
      return en
    case 'ja':
      return ja
    default:
      return ja
  }
}

const cache = createIntlCache()
export const intl = createIntl({
  locale,
  defaultLocale: "ja",
  messages: selectMessages(locale)
}, cache)


const App: FC = () => {

  //...

  return (
    <RawIntlProvider value={intl}>
      <Wrapper>
        <Navigation />
        <Container>
          <Router>
            <DashBoard path={Path.dashboard} />
            <NewPage path={Path.newPage} />
            <ContentList path= {Path.contentList}>
          </Router>
        </Container>
      </Wrapper>
    </RawIntlProvider>
  );

};

export default App;

const Wrapper = styled.div`
  padding: 30px ${CONTAINER_SIDE_PADDING}px;
  min-height: calc(100vh - ${FOOTER_HEIGHT}px);
`;

//.....

こんな感じで書いておくと、以降Component内で以下のように書けます。

<FormattedMessage
  description="Greeting to welcome the user to the app"
  defaultMessage="Hello, {name}!"
  values={{
    name: 'Eric',
  }}
/>

なお実際のプロジェクトではちょっと微妙な構成だったのでカスタムフック&コンポーネントを作ってもらって対応しました。

コンテンツのi18n化

表示される単語や文章の多言語化はざっくり三種類あります。

ReactComponent

react-intlが用意してくれるコンポーネントがあります。

<FormattedMessage
  description="表紙イメージ入力フォーム:説明文"
  defaultMessage="表紙を作成します。必要な情報を以下に入力してください。"
/>

descriptionは、テキストがどの部分に使われるのか、画面と見比べて特定できるように記載しておきます。descriptionの書き方の標準を決めてガイド化しておくといいでしょう。
Transifexなどの翻訳ツールと連携した時、"Developers Note"などとして参考用に表示されるので、翻訳する人がどんなコンテキストで使われるテキストを訳せばいいのか判断する手がかりになります。

defaultMessageはそのまま日本語の表示テキストを入れます。最初に日本語でアプリを作って後から多言語化すると、デフォルトメッセージが日本語になってることでしんどい思いをしそうです。してます。

useIntl Hook

placeholderのテキストなどはReact Componentをそのまま突っ込むわけにはいかないので、Hookが用意されています。

const MyComponent:FC<myComponentProps> = ({ myProp })=> {

  const { formatMessage } = useIntl();

  return (
    //...

    <Field
      name="email"
      component={WhiteInputField}
      normalize={onlyEngNums}
      placeholder={formatMessage({
        description: '登録フォーム:メール入力フィールド:プレースホルダ',
        defaultMessage: 'メールアドレス(必須)',
      })}
    />

  //...
  );
}

こんな感じで使います。

formatMessageが長くてだるいという場合は const { formatMessage as t } = useIntl(); などとして、 t({description: "省略", defaultMessage: "メールアドレス"}) などのようにも書けます。

intl Object

当たり前ですが、上記の<FormattedMessage>コンポーネントやuseIntlフックは<IntlProvider>のツリー内でしか使えません。

前もってcreateしておいたintlオブジェクトを直接使うことで、Reactコンポーネントの外部やAppツリーの外側でreact-intlを使うことができます。

特にFormのvalidation functionの内部でエラーメッセージを多言語化したい時や、表示ラベルの切替をutility functionに切り出す時などに使います。

validation.ts
import { intl } from '../App';

export const statusLabel = (status: PageStatus) => {

  return intl.formatMessage(
    {
      description: 'ステータスラベル',
      defaultMessage: `{status, select,
        draft {下書き}
        closed {非公開}
        published {公開}
        archived {アーカイブ}
        }`,
    },
    { status },
  );
};

複数形、人称、etc

そのうち書きたい。。。

公式ドキュメント
https://formatjs.io/docs/intl-messageformat

selectがTransifexの画面上で見づらいのは何か対策ないのだろうか
(それともあまりselect使わない方がいいのだろうか)

日付とか

formatjsが豊富な日付・時刻・数値・通貨などの表示オプションを用意していくれています。

const App = ({importantDate}) => (
  <div>
    <FormattedDate
      value={importantDate}
      year="numeric"
      month="long"
      day="numeric"
      weekday="long"
    />
  </div>
)

ReactDOM.render(
  <IntlProvider locale={navigator.language}>
    <App importantDate={new Date(1459913574887)} />
  </IntlProvider>,
  document.getElementById('container')
)

とすると以下のように表示される

<div>mardi 5 avril 2016</div>

らしいのですが、正直使いこなせていないので時間ができたらもっと真面目に使ってみたいです…

Storybook

Storybookのwebpackとpreview.tsxはrollupとは別途設定が必要です。

.storybook/main.js に追記。

const { transform } = require('@formatjs/ts-transformer');

module.exports = {
  stories: ['../src/components/Introduction.stories.mdx', '../src/**/*.stories.(tsx|mdx)'],
  addons: [
    ...

    {
      name: '@storybook/preset-typescript',
      options: {
        tsLoaderOptions: {
          transpileOnly: false,
          getCustomTransformers: () => ({
            before: [
              transform({
                overrideIdFn: '[sha512:contenthash:base64:6]',
                ast: true,
              }),
            ],
          }),
        },
      },
    },
  ...
    'storybook-addon-intl',
  ],

    return config;
  },
};

.storybook/preview.tsx に追記

import React from 'react'
import { addDecorator, addParameters } from '@storybook/react';
import ja from '../compiled-lang/ja.json';
import en from '../compiled-lang/en.json';
const { setIntlConfig, withIntl } = require('storybook-addon-intl');

const messages = {
  en,
  ja,
};

const getMessages = (locale: keyof typeof messages) => messages[locale];

// Set intl configuration
setIntlConfig({
  locales: ['en', 'ja'],
  defaultLocale: 'ja',
  getMessages,
});

// Register decorator
addDecorator(withIntl);

ここの設定わからなくてはまりまくった上に結局助けてもらったよね・・・

storybook-addon-intlを入れることで、storybook上で言語を切り替えることができるようになります。翻訳チェックに便利。

今のところstorybookは日本語でだけスナップショットを取っている状態なので、そのうち日本語・英語両方でスナップショット取ってTransifexの差分と照合できるようにしたいです。

言語ファイル作成

react-intlでは、埋め込んだFormattedMessageを翻訳用のファイルにパースして出力してくれます。

https://formatjs.io/docs/tooling/cli

package.jsonに以下のように書いておきます。

...
"scripts": {
  "extract": "formatjs extract",
  "compile": "formatjs compile"
}

React App側の必要な部分にコンポーネントを埋め込みおえたら、以下のコマンドで翻訳用のファイルが生成されます。

yarn extract 'src/**/*.ts*' --out-file lang/ja.json --id-interpolation-pattern '[sha512:contenthash:base64:6]' --format transifex

--format transifex でtransifex用にフォーマットしています。

ReactAppを日本語ベースで作成したのでja.jsonファイルを生成していますが、ちゃんと(?)最初から多言語化を前提に作成している場合は、en.jsonを生成するんだと思います。その方が何かと便利だと思います。

言語ファイルコンパイル

先程生成したファイルはあくまで翻訳用で、そのままではreact-intlが読み込めません。

yarn compile lang/ja.json --ast --out-file compiled-lang/ja.json --format transifex

として、ja.jsonをコンパイルすることで読み込み可能になります。

翻訳ツール導入

言語ファイルはJSONとして出力されます。
一応、formatjs extract で出力されるJSONは人間が翻訳に使うことを前提としたファイルで読みにくくはないのですが、非エンジニアのメンバーに翻訳してもらうには、JSONは若干厳しいものがあります。
外部の翻訳サービスに依頼したらJSON壊れた・・・とかならないためにも、何らかのツールを使うべきだと思います。

一回リリースして終わりならともかく、サービスとしてプロジェクトの翻訳をメンテしていくにはまともなツールで管理していないと、gitのバージョン管理だけでは破綻しそうです。というか翻訳サービス会社にPR出してもらうのはそこそこ無理があります(2021年現在)。

react-i18nextであれば、locaiz.comというサービスが標準みたいです。
今回はreact-intlなので、対応しているツールはそこそこ多いみたいです。Transifex, LingoHub, Smartling, Lokalizeといろいろありますが、今回はTransifexを採用しました。
時間がなかったのであまりちゃんと比較検討したわけではないですが、CircleCIとcliで必要十分な連携ができそうだったのと、インターフェースがそこそこ馴染めそうだったので選定しました。
あまり複雑なことをやっている時間はないので、とにかくクライアントに使ってもらえそうなUIを優先しました。

プロジェクト作成

特に何も考えずプロジェクト作成します。GUIの表示に従って選択していけば簡単に作れます。
(スクショとりそびれた)

"Choose project type"は"File-based project"にしましょう。
Live Projectを選択すると、react-intlは使えません。ReactApp内に埋め込まれた文字列に該当する翻訳テキストを、JSONファイルからではなく直接Transifexから取ってきて画面上に表示するみたいです。専用のライブラリが用意されているので、それをReactに組み込むようです。
ドキュメントにはReact Nativeの例もBataとして載っていました。面白そうだなと思いつつ、ベンダーロックインされすぎこわいのでスルー。

publicなプロジェクトとして作成すると、Transifex上でボランティアの翻訳者を募ったりできるみたいです。今回は業務プロジェクトなので当然private。

アサインする翻訳チームとか当然ないので新規作成し、Source Languageは日本語(ja)にします。Target Languageを今回は英語(en)にして作成。多分本当はちゃんとen_USとかで作成した方がよいと思われる。

プロジェクトを作成するとファイルをアップロードしろと言われるので、先程生成したja.jsonを指定します(yarn extractした方)。
ファイルフォーマットは Stractured key-value JSON Files にします。

そうすると、このように、extractした日本語がjaの翻訳として取り込まれます。
Transifex

Englishは0%なのでどんどん翻訳していきます。

使い方

翻訳者側のTransifexの使い方はシンプルです。

こんなかんじの画面が出てくるので、左のテキストを右のボックスにひたすら翻訳してあげるだけです。
Transifex Docs

レビュー機能とか、既に翻訳したコンテンツからのサジェストとか色々機能が充実しているので使いこなせたら楽しそうです。

一定以上のプランでは、自動翻訳による下訳などの機能が利用できます。また、Transifexから有料の翻訳サービスを依頼することもできるようです。この辺は、日本語がsourceになってると利用どうなんでしょうね・・・
日本語→英語はともかく、日本語→中国語とか日本語→フランス語とか英語からの翻訳にくらべて料金上がっちゃいそうです。

CI連携

React Appがアップデートするたびにja.jsonを人間が抽出して翻訳ツールに再設定するのは嫌なので、自動化します。

Transifexにはクライアントツールがあるので、クライアントツール経由でソースファイルをアップデートできます。

以下のようにCircleCIでcommmandを作成しました。

.circleci/config.yml
commands:
  push_to_tx:
    description: "push translate source file to transifex"
    steps:
      - run:
          name: Complie ja.json
          command: |
            yarn
            yarn run extract

      - run:
          name: Install transifex client and push source to transifex
          command: |
            LINES=$(git diff lang/ja.json|wc -l)
            if [ $LINES -gt 0 ]; then
              sudo pip3 install transifex-client
              echo $'[https://www.transifex.com]\napi_hostname = https://api.transifex.com\nhostname = https://www.transifex.com\nusername = 'api$'\npassword = '"$TRANSIFEX_API_TOKEN"$'\n' > ~/.transifexrc
              chmod 0600 ~/.transifexrc
              tx push -s            
            fi

ReactAppが更新されたらこのコマンドをCircleCI上で実行することで、コードが更新されたら自動的にTransifex側を更新します。

また、Transifex側で翻訳ファイルが生成されたら、自動的に翻訳内容をjsonに出力し、PRを立ててくれると嬉しいです。

.circleci/config.yml
commands:
  pull_from_tx_create_pr:
    description: "pull translate file create github pr"
    steps:
      - run:
          name: Complie ja.json
          command: |
            yarn
            yarn run compile

      - run:
          name: pr translate file from tx
          command: |
            sudo pip3 install transifex-client
            echo $'[https://www.transifex.com]\napi_hostname = https://api.transifex.com\nhostname = https://www.transifex.com\nusername = 'api$'\npassword = '"$TRANSIFEX_API_TOKEN"$'\n' > ~/.transifexrc
            chmod 0600 ~/.transifexrc
            rm lang/en.json
            tx pull -l en
            yarn
            yarn run compile
            LINES=$(git diff compiled-lang/en.json|wc -l)
            if [ $LINES -gt 0 ]; then
                git config --global user.email "xxxxx"
                git config --global user.name "xxxxx"
                git checkout -b "transifex-${CIRCLE_SHA1}"
                git add lang/en.json
                git commit -m "[circleci] update compiled-lang/en.json"
                git push origin transifex-${CIRCLE_SHA1}:transifex-${CIRCLE_SHA1}
                GITHUB_TOKEN=${GITHUB_TOKEN} hub pull-request -f -m "[circleci] update transifex " \
                    -h "transifex-${CIRCLE_SHA1}" -b develop
            fi

上記を一日2回実行し、Transifexの変更を取得して自動的にPRを立ててもらいます。
自動的にマージしてステージング環境にデプロイしてもよかったのですが、ステージング環境は開発に協力してくれるエンドユーザーさんも見ているため、急にヘンな文字が表示されたりすると混乱しそうなので、一度人間の目で確認するようにしています。

Transifex単体でもGithubと連携して自動的にpushしたりPR作ってくれたりする機能があるようなのですが、react-intl用にcompileしないといけないのでCIを通しました。

CircleCIの設定周りは私がほとんどわからないので、完全にインフラ担当におんぶにだっこです。感謝!!

おわり

急遽、1週間でプロジェクトを多言語(まず英語)対応しないといけなくなったので突貫でやっつけたのですが、ひとまずこれでデプロイまでもっていきました。がんばってくれたプロジェクトメンバーには感謝しかありません。
あとは次の一週間で本番リリースしないといけない(吐血)

多言語対応はQAや運用、継続的デプロイまで考えるといろいろ難しいなと思いました。

ひとまずこれでまわっていくかどうか様子を見ようと思っています。

Discussion