🌏

リアルタイムにUIデザインを確認しながら翻訳できる仕組みを作った話

2022/12/28に公開

Webサービスを多言語対応するに当たっては、翻訳者の方とのコミュニケーションがとても大切になります。特に日本語から他の言語に訳す場合、日本語は言葉が短い言語なので多言語化したときにUIが崩れやすいです。

そのため、翻訳者の方にもUIを確認して頂きながら、UIのコンテキストに沿った形でなるべく短い文章に訳してもらう必要があります。

今回、Storybook と Locize というサービスを使って多言語開発の仕組みを整え、その結果とてもスムーズに タイ語版スペイン語版 をリリースすることができたので、やったことをブログにまとめました。

何も仕組みがない場合

多言語の辞書を開発者が git 管理している場合の、開発フローの一例は以下のようになると思います(日本語 → タイ語に翻訳する場合を想定しています)。

  1. 開発者: スプレッドシートで翻訳者に辞書のキーと日本語の文言の一覧を共有
  2. 翻訳者: 日本語の文言を見てタイ語に翻訳し、スプレッドシートに記入
  3. 開発者: 翻訳されたタイ語をスプレッドシートから辞書に記入する

このフローで翻訳を進めると、大きく2つの問題が発生します。実際に 英語版を開発した時 は大変な思いをしました。

  • 文言がUIに収まらないほど長くなってしまう
  • どういうページで使われる文言か分からないため、文言の意味が微妙にUIと合わない

日本語は数多ある言語の中でもかなり短い方で、短い文章に多くの意味を込められてしまいます。そのため、そのまま直訳してしまうとUIが許容できる文字数より長くなってしまい、UIが崩れます。

また、UIを見ずに文字だけで訳すこと自体がなかなか難しく、微妙にUIの文脈とずれてしまうことが多いです。例えば「完了」という日本語を英語に訳す時、何かを選択した後の完了ボタンなのか、完了というステータスなのかによって英語の訳が変わってきます。これは、実際にUIを見ないと判断することが難しいです。

しかし、サービスについて熟知していない翻訳者が、訳したい文字を使っているUIに自分でたどり着くのは至難の業です。

作った仕組み

そこで、UIを見ながら翻訳を進められるような仕組みを作りました。

全体のフローは以下の通りです。

翻訳フロー

  1. エンジニアが Storybook のページを用意し、翻訳ページのリストを作成しておく
  2. Storybook を見ながら文言をタイ語に翻訳し、Locize に入力
    1. Storybook 上に翻訳対象の文言が表示される仕組みを作成
  3. Locize に入力したら即座に Storybook に反映されるので、デザイン崩れをチェック
  4. デザイン崩れが発生したらエンジニアに修正依頼する
    1. 実際はだいたい終わったタイミングでまとめて共有してもらいました
  5. デザイン崩れがなかったらそのページの翻訳完了、次のページへ

このフローの中で、特に2番目の部分が今回作成した仕組みのメインの部分になります。以下で詳しく説明します。

Storybook と Locize を使って、リアルタイムにUIデザインを確認しながら翻訳できる仕組み

Storybook と Locize の連携

Locize は多言語化の辞書を管理する SaaS です。i18next という JavaScript の多言語ライブラリを作っている会社が運営しています。

このサービス、若干UIが独特でとっつきにくいのですが、他のサービスにはない「辞書を更新するとCDNで配信している辞書もリアルタイムに更新される」という特徴があります。

辞書を管理できるサービスは他にも CrowdinLocalazy などがあり、UIの使いやすさはそれらの方が良かったのですが、CDNがリアルタイムで更新できないと今回作りたい仕組みが作れなかったため最終的に Locize を選びました。

Storybook 上では Locize が CDN で配信する「最新のCDN」を用いて文言を表示するようにしています。そのため、Locize 上で文言を変更すると即座に Storybook に反映されます。

Locize を辞書にした時の i18next の設定は以下の通りです。通常のUI表示用の設定と Storybook 用の設定を分けているため、.storybook/i18n.ts を追加しています。

.storybook/i18n.ts
import i18n from 'i18next'
import HttpBackend from 'i18next-http-backend'
import intervalPlural from 'i18next-intervalplural-postprocessor'
import { initReactI18next } from 'react-i18next'

export const locizeCdnUrl = (lng: string) =>
  `https://api.locize.app/b85f786b-54d5-4531-b2b7-5fd94663f0f8/latest/${lng}/latest`

i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .use(intervalPlural)
  .init({
    fallbackLng: 'en', // デフォルトの言語を設定する
    returnEmptyString: false, // 空文字での定義を許可しない
    detection: {
      lookupQuerystring: ''
    },
    debug: true,
    interpolation: {
      escapeValue: false
    },
    react: {
      transKeepBasicHtmlNodesFor: ['br', 'strong', 'i', 'span'],
      useSuspense: true
    },
    backend: {
      loadPath: locizeCdnUrl('{{lng}}')
    },
    postProcess: ['consoleLog']
  })

export default i18n

次に、i18next の設定を Storybook の preview.tsx で読み込むようにします。 I18nextProvider に先ほど作成した i18n を設定しています。私たちは Next.js や Material UI を利用しているためその設定がありますが、適宜読み替えてください。

.storybook/preview.tsx
import { MINIMAL_VIEWPORTS } from '@storybook/addon-viewport'
import { ComponentStory } from '@storybook/react'
import { initialize, mswDecorator } from 'msw-storybook-addon'
import { RouterContext } from 'next/dist/shared/lib/router-context'
import { I18nextProvider } from 'react-i18next'
import { MuiThemeProvider, CssBaseline } from '@material-ui/core/'
import theme from '../src/ui/styles/theme'
import '@lib/utils/dayjsConfig'
import { setDayjsGlobalLocale } from '@lib/utils/localeTime'
import i18n from './i18n'
import { StorybookContext } from '@core/interfaces/storybook'
import { Suspense } from 'react'

export const globalTypes = {
  storybookLocale: {
    name: 'storybookLocale',
    description: 'Internationalization locale',
    defaultValue: 'en',
    toolbar: {
      icon: 'globe',
      items: [
        { value: 'en', right: '🇺🇸', title: 'English' },
        { value: 'ja', right: '🇯🇵', title: 'Japan' },
        { value: 'th', right: '🇹🇭', title: 'Thai' },
        { value: 'es', right: '🇪🇸', title: 'Spanish' }
      ]
    }
  }
}

// Prevent MSW warnings when accessing Locize dictionary from Storybook
initialize({
  onUnhandledRequest: 'bypass'
})

export const decorators = [
  mswDecorator,
  (Story: ComponentStory<any>, context: StorybookContext) => {
    i18n.changeLanguage(
      context.globals.storybookLocale,
      setDayjsGlobalLocale // dayjsのlocaleの初期設定
    )

    return (
      <I18nextProvider i18n={i18n}>
        <MuiThemeProvider theme={theme}>
          <Suspense fallback={<p>Loading...</p>}>
            <CssBaseline />
            <Story />
          </Suspense>
        </MuiThemeProvider>
      </I18nextProvider>
    )
  }
]

export const parameters = {
  viewport: {
    viewports: {
      ...MINIMAL_VIEWPORTS
    }
  },
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/
    }
  },
  nextRouter: {
    Provider: RouterContext.Provider
  },
  layout: 'fullscreen'
}

globalTypes の部分は Storybook 公式の例 のやり方で Addon を追加しています。わずか15行程度のコードで Addon を追加できて簡単でした。これにより、以下のgifのように toolbar から言語を切り替えることができます。Locize を更新したら、toolbar の左端にある更新ボタンを押すか、画面をリロードすることで反映されます。

Storybook言語切替え

Storybook の各ページで使われる語句を可視化する

これで Locize と Storybook を連携できたのですが、これだけだと各ページで使われている文言が全ては分かりません。特にモーダルなど、クリックしないと表示されない文言は存在に気づけません。

そこで、ブラウザの devtool のコンソールに各ページで使われてる文言のリストを表示するようにします。

i18next には post prosessor の仕組みがあり、各t関数の実行後に独自の処理を走らせることができます。今回は、Storybook の各ページを開いた時に、そのページのURLをキーにして localStorage にページ内の文言の辞書を保存するようにしました。

ページをレンダリングする時にそのページで使われる文言のt関数のみが実行されるため、ページ毎の辞書が localStorage に保存されます。

.storybook/i18n.ts
// Storybook で開いたページで使われている文言を表示する...ために localStorage に保存する
// コンソールに表示する処理は preview.tsx で行っている
const consoleLogProcessor: PostProcessorModule = {
  type: 'postProcessor',
  name: 'consoleLog',
  process: (
    value: string,
    key: string,
    _options: TOptions,
    translator: any
  ) => {
    const genDictInfo = (
      key: string,
      en: string,
      translation: string,
      lang: string,
      translated: string
    ) => {
      return {
        [key]: {
          en,
          translation,
          lang,
          translated
        }
      }
    }

    // 
    const translated = !!translator.resourceStore.data[translator.language]
      ?.translation[key]
      ? '✅'
      : '❌'
    const enWords: string =
      translator.resourceStore.data['en']?.translation[key]
    const currentDict =
      JSON.parse(localStorage.getItem(location.href) as string) || {}
    Object.assign(
      currentDict,
      genDictInfo(key, enWords, value, translator.language, translated)
    )

    localStorage.setItem(location.href, JSON.stringify(currentDict))
    return value
  }
}

...

i18n
  .use(HttpBackend)
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(intervalPlural)
  .use(consoleLogProcessor) // 追加
...

続いて、preview.tsx で localStorage に保存した辞書をコンソールに表示するようにします。

showDictionaryInfo 関数で console.table を用いて辞書を表示しています。画面の描画が終わるまで localStorage に保存される辞書は更新され続けるため、更新が終わるまで再帰的に関数を実行してチェックするようにしています。

.storybook/preview.tsx
export const globalTypes = {
...
}

export const decorators = [
  mswDecorator,
  withScreenshot,
  (Story: ComponentStory<any>, context: StorybookContext) => {
    ...
    // ../i18n/consoleLogProcessor が localStorage に追加した辞書情報をコンソールに表示する
    const showDictionaryInfo = () => {
      const dict1 = localStorage.getItem(location.href) || '{}'
      setTimeout(() => {
        const dict2 = localStorage.getItem(location.href) || '{}'
        if (dict1 === dict2) {
          console.table(JSON.parse(dict2))
        } else {
          showDictionaryInfo()
        }
      }, 1000)
    }
    showDictionaryInfo()

    ...

    return (

これで以下のように、現在表示している言語の各ページで使われている辞書のリストと、各文言が翻訳済みかどうかをコンソールでチェックできるようになりました。

まとめ

作った仕組みを実際にタイ語/スペイン語の翻訳者に使っていただき、とても進めやすくて助かったと言って頂くことができました。また、翻訳の内容も、この仕組がなかった英語版の時よりもコンテキストに沿った適切な文言にすることができました。

実はこの仕組みをつくる前は Storybook を利用していなかったのですが、開発メンバーがとても積極的に Storybook の追加を行ってくれたおかげでほぼ全ページを網羅することができました。新しく実装したページも随時追加できているため、継続的に翻訳することもできています。

多言語アプリケーションを作る方の参考になれば幸いです。

アルダグラム Tech Blog

Discussion