GitLabの国際化

GitLab 9.2 で導入されました

国際化(i18n)を作業するために、GNU gettextが使われます。GNU gettextはこの作業のために最も使われているツールで、それを使って作業するのに役立つアプリケーションがたくさんあるからです。

GitLab Development Kitのセットアップ(GDK)

GitLab Community Editionプロジェクトで作業するためには、GDKを通してダウンロードし、設定する必要があります。

GitLab プロジェクトの準備ができたら、翻訳作業を始めましょう。

ツール

以下のツールを使用します:

  1. gettext_i18n_railsこのgemを使うことで、モデル、ビュー、コントローラのコンテンツを翻訳できるようになります。 また、以下のRakeタスクにアクセスできるようになります:
    • rake gettext:find: Railsアプリケーションのほぼすべてのファイルを解析して、翻訳の対象となるコンテンツを探します。 最後に、見つかった新しいコンテンツでPOファイルを更新します。
    • rake gettext:packPOファイルを処理し、最終的にアプリケーションで使用されるバイナリファイルであるMOファイルを生成します。
  2. gettext_i18n_rails_js: このgemはJavaScriptで翻訳を利用できるようにするのに便利です。 以下のRakeタスクを提供します:
    • rake gettext:po_to_jsonPOファイルから内容を読み取り、利用可能なすべての翻訳を含むJSONファイルを生成します。
  3. POエディター:POファイルを扱うのに役立つアプリケーションは複数ありますが、macOS、GNU/Linux、Windowsで利用できるPoeditがよいでしょう。

翻訳用ページの準備

基本的に4種類のファイルがあります:

  1. Rubyファイル:基本的にモデルとコントローラ。
  2. HAMLファイル: これはビューファイルです。
  3. ERBファイル:電子メールのテンプレートに使用されます。
  4. JavaScriptファイル: 私たちは主にVueテンプレートを扱う必要があります。

Rubyファイル

例えば、生の文字列を扱うメソッドや変数がある場合:

def hello
  "Hello world!"
end

または:

hello = "Hello world!"

そのコンテンツを翻訳用にマークするのは簡単です:

def hello
  _("Hello world!")
end

または:

hello = _("Hello world!")

クラスやモジュールのレベルで文字列を翻訳する場合は注意が必要です。

使用例:

validates :group_id, uniqueness: { scope: [:project_id], message: _("already shared with this group") }

これはクラスがロードされるときに翻訳され、エラーメッセージが常にデフォルトのロケールで表示されることになります。

Active Recordの:message オプションはProc

validates :group_id, uniqueness: { scope: [:project_id], message: -> (object, data) { _("already shared with this group") } }
注:API(lib/api/ またはapp/graphql)内のメッセージは、外部化する必要はありません。

HAMLファイル

HAMLに次のような内容があるとします:

%h1 Hello world!

でそのコンテンツを翻訳用にマークすることができます:

%h1= _("Hello world!")

ERBファイル

ERBに次のような内容があります:

<h1>Hello world!</h1>

でそのコンテンツを翻訳用にマークすることができます:

<h1><%= _("Hello world!") %></h1>

JavaScriptファイル

JavaScriptでは、~/locale ファイルからインポートできる__() (二重アンダースコア括弧)関数を追加しました。 インスタンス:

import { __ } from '~/locale';
const label = __('Subscribe');

JavaScriptの翻訳をテストするには、GitLabのローカライズを英語以外の言語に変更し、bin/rake gettext:po_to_json またはbin/rake gettext:compileを使ってJSONファイルを生成する必要があります。

ダイナミック翻訳

時々、bin/rake gettext:findを実行してもパーサが見つけられない動的な翻訳があります。 このようなシナリオでは、N_ メソッドを使うことができます。

バリデーションエラーのメッセージを翻訳する方法もあります。

特別なコンテンツでの作業

補間

翻訳されたテキストのプレースホルダーは、それぞれのソースファイルのコードスタイルに合わせる必要があります。 例えば、Rubyでは%{created_at} 、JavaScriptでは%{createdAt}リンクを追加するときは、文章が分割されないように注意してください。

  • Ruby/HAMLで:

     _("Hello %{name}") % { name: 'Joe' } => 'Hello Joe'
    
  • ヴューで:

    Vue コンポーネントの補間のセクションを参照してください。

  • JavaScriptの場合(Vueが使えない場合):

     import { __, sprintf } from '~/locale';
    
     sprintf(__('Hello %{username}'), { username: 'Joe' }); // => 'Hello Joe'
    

    Vueを使用していて、翻訳内でマークアップを使用したい場合は、gl-sprintf コンポーネントを使用する必要があります。何らかの理由でVueを使用できない場合は、sprintf を使用し、false を第3引数として渡すことで、プレースホルダの値をエスケープしないようにします。補間された動的な値は、lodashからescape を使用するなど、自分でエスケープする必要があります。

     import { escape } from 'lodash';
     import { __, sprintf } from '~/locale';
    
     let someDynamicValue = '<script>alert("evil")</script>';
    
     // Dangerous:
     sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>`, false);
     // => 'This is <strong><script>alert('evil')</script></strong>'
    
     // Incorrect:
     sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>` });
     // => 'This is &lt;strong&gt;&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;&lt;/strong&gt;'
    
     // OK:
     sprintf(__('This is %{value}'), { value: `<strong>${escape(someDynamicValue)}</strong>`, false);
     // => 'This is <strong>&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;</strong>'
    

複数形

  • Ruby/HAMLで:

     n_('Apple', 'Apples', 3)
     # => 'Apples'
    

    補間を使って

     n_("There is a mouse.", "There are %d mice.", size) % size
     # => When size == 1: 'There is a mouse.'
     # => When size == 2: 'There are 2 mice.'
    

    単数形の文字列では、%d やカウント変数の使用を避けてください。 これにより、言語によってはより自然な翻訳が可能になります。

  • JavaScriptで:

     n__('Apple', 'Apples', 3)
     // => 'Apples'
    

    補間を使って

     n__('Last day', 'Last %d days', x)
     // => When x == 1: 'Last day'
     // => When x == 2: 'Last 2 days'
    

n_ メソッドは、同じ文字列の複数形の翻訳を取得するためだけに使われるべきで、異なる文字列を異なる量で表示するロジックを制御するために使うべきではありません。 言語によっては、対象となる複数形の量が異なるものがあります。例えば、中国語(簡体字)の場合、翻訳ツールでは対象となる複数形は1つだけです。 つまり、翻訳者は文字列のうちの1つだけを翻訳するように選択しなければならず、他のケースでは翻訳が意図したとおりに動作しないことになります。

例えば

if selected_projects.one?
  selected_projects.first.name
else
  n__("Project selected", "%d projects selected", selected_projects.count)
end

よりも:

# incorrect usage example
n_("%{project_name}", "%d projects selected", count) % { project_name: 'GitLab' }

名前空間

名前空間は、翻訳をグループ化するためのもので、プレフィックスにバー記号 (|) を付加することで、翻訳者にコンテキストを提供します:

'Namespace|Translated string'

名前空間には以下のような利点があります:

  • 例えば、Promotions|Promote vs. 。Epic|Promote
  • これにより、翻訳者は任意の文字列ではなく、同じ製品分野に属する外部化された文字列の翻訳に集中することができます。
  • 言語的な背景がわかるので、翻訳者の助けになります。

例えば、”Cancel “のようなどこにでもあるUIの単語やフレーズや、”Save changes “のようなフレーズでは、名前空間は逆効果になる可能性があります。

名前空間は PascalCase でなければなりません。

  • Ruby/HAMLで:

     s_('OpenedNDaysAgo|Opened')
    

    翻訳が見つからない場合はOpenedを返します。

  • JavaScriptで:

     s__('OpenedNDaysAgo|Opened')
    

注:名前空間は翻訳から削除する必要があります。詳細は翻訳ガイドラインを参照してください。

日時

  • JavaScriptで:
import { createDateTimeFormat } from '~/locale';

const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
console.log(dateFormat.format(new Date('2063-04-05'))) // April 5, 2063

これはIntl.DateTimeFormatを利用しています。

  • Ruby/HAMLでは、日付や時刻に書式を追加する2つの方法があります:

    1. l ヘルパー を通して、すなわち を使ってください。l(active_session.created_at, format: :short)日付と 時刻については、いくつかの定義済み書式が用意されています。 コードの他の部分に新しい書式を追加する必要がある場合は、en.ymlファイルに追加する必要があります。
    2. strftime en.ymlで定義されている書式がどれも必要な日付/時刻の指定にマッチしない場合、また、非常に特殊な書式であるため新しい書式として追加する必要がない場合 (つまり、単一のビューでしか使用されない場合) に、strftime、つまりmilestone.start_date.strftime('%b %-d')を使用します。

ベストプラクティス

翻訳をダイナミックに

翻訳を配列やハッシュの中にまとめておくことに意味がある場合もあります。

例:

  • ドロップダウンリストのマッピング
  • エラーメッセージ

この種のデータを保存するには、定数を使用するのが最適のように思えますが、これは翻訳には使えません。

まずい、避けましょう:

class MyPresenter
  MY_LIST = {
    key_1: _('item 1'),
    key_2: _('item 2'),
    key_3: _('item 3')
  }
end

翻訳メソッド (_) は、クラスが初めてロードされたときにコールされ、テキストをデフォルトのロケールに翻訳します。 ユーザーのロケールにかかわらず、これらの値が再び翻訳されることはありません。

同様のことは、クラスメソッドをメモ化で使用する場合にも起こります。

まずい、避けましょう:

class MyModel
  def self.list
    @list ||= {
      key_1: _('item 1'),
      key_2: _('item 2'),
      key_3: _('item 3')
    }
  end
end

このメソッドは、最初にこのメソッドを “呼び出した “ユーザのロケールを使って翻訳をメモします。

このような問題を避けるためには、翻訳をダイナミックに保ちましょう。

よかったです:

class MyPresenter
  def self.my_list
    {
      key_1: _('item 1'),
      key_2: _('item 2'),
      key_3: _('item 3')
    }.freeze
  end
end

文の分割

文の文法や構造がどの言語でも同じだと思い込んでしまうので、決して文を分割しないでください。

インスタンスンス:

{{ s__("mrWidget|Set by") }}
{{ author.name }}
{{ s__("mrWidget|to be merged automatically when the pipeline succeeds") }}

は次のように外部化する必要があります:

{{ sprintf(s__("mrWidget|Set by %{author} to be merged automatically when the pipeline succeeds"), { author: author.name }) }}

これは、翻訳された文章の間にリンクを使用する場合にも適用されます。そうでない場合は、これらのテキストは、特定の言語で翻訳することはできません。

  • Ruby/HAMLでは、代わりに:

     - zones_link = link_to(s_('ClusterIntegration|zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer')
     = s_('ClusterIntegration|Learn more about %{zones_link}').html_safe % { zones_link: zones_link }
    

    このように、リンクの開始と終了のHTMLフラグメントを変数として設定します:

     - zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones'
     - zones_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: zones_link_url }
     = s_('ClusterIntegration|Learn more about %{zones_link_start}zones%{zones_link_end}').html_safe % { zones_link_start: zones_link_start, zones_link_end: '</a>'.html_safe }
    
  • Vueでは

     <template>
       <div>
         <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{link}')">
           <template #link>
             <gl-link
               href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
               target="_blank"
             >zones</gl-link>
           </template>
         </gl-sprintf>
       </div>
     </template>
    

    このように、リンクの開始と終了のHTMLフラグメントをプレースホルダーとして設定します:

     <template>
       <div>
         <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
           <template #link="{ content }">
             <gl-link
               href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
               target="_blank"
             >{{ content }}</gl-link>
           </template>
         </gl-sprintf>
       </div>
     </template>
    
  • JavaScriptの場合(Vueが使えない場合)、代わりに

     {{
         sprintf(s__("ClusterIntegration|Learn more about %{link}"), {
             link: '<a href="https://cloud.google.com/compute/docs/regions-zones/regions-zones" target="_blank" rel="noopener noreferrer">zones</a>'
         })
     }}
    

    このように、リンクの開始と終了のHTMLフラグメントをプレースホルダーとして設定します:

     {{
         sprintf(s__("ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}"), {
             linkStart: '<a href="https://cloud.google.com/compute/docs/regions-zones/regions-zones" target="_blank" rel="noopener noreferrer">',
             linkEnd: '</a>',
         })
     }}
    

その理由は、言語によっては文脈によって単語が変化するためです。 たとえば日本語では、文の主語には「」が付き、目的語には「」が付きます。 文から個々の単語を抜き出しても、正しく翻訳することは不可能です。

疑わしい場合は、このMozilla Developer ドキュメントに記載されているベストプラクティスに従ってください。

Vueコンポーネント補間

VueコンポーネントでUIテキストを翻訳する場合、翻訳文字列の中に子コンポーネントを含めたいと思うかもしれません。 Vueは子コンポーネントを認識せず、プレーンテキストとしてレンダリングしてしまうため、JavaScriptのみのソリューションで翻訳をレンダリングすることはできません。

このユースケースでは、GitLab UIでメンテナーされているgl-sprintf コンポーネントを使うべきです。

gl-sprintf コンポーネントは、翻訳可能な文字列であるmessage プロパティを受け入れ、文字列内のプレースホルダごとに名前付きスロットを公開します。

翻訳可能な文字列Pipeline %{pipelineId} triggered %{timeago} by %{author}を表示したいとします。%{timeago}%{author} のプレースホルダを Vue コンポーネントに置き換えるには、gl-sprintfを使用します:

<template>
  <div>
    <gl-sprintf :message="__('Pipeline %{pipelineId} triggered %{timeago} by %{author}')">
      <template #pipelineId>{{ pipeline.id }}</template>
      <template #timeago>
        <timeago :time="pipeline.triggerTime" />
      </template>
      <template #author>
        <gl-avatar-labeled
          :src="pipeline.triggeredBy.avatarPath"
          :label="pipeline.triggeredBy.name"
        />
      </template>
    </gl-sprintf>
  </div>
</template>

詳しくは、gl-sprintf のドキュメントをご覧ください。

新しい内容でPOファイルを更新

新しいコンテンツが翻訳用にマークされたので、次のコマンドでlocale/gitlab.pot ファイルを更新する必要があります:

bin/rake gettext:regenerate

このコマンドは、locale/gitlab.pot ファイルを新しく外部化された文字列で更新し、使われなくなった文字列を削除します。 このファイルをチェックインする必要があります。変更がマスターに反映されると、CrowdInに取り込まれ、翻訳のために提示されます。

CrowdInからの翻訳がマージされると自動的に更新されるため、locale/[language]/gitlab.po ファイルに変更を加える必要はありません。

gitlab.pot ファイルにマージ・コンフリクトがある場合、同じコマンドを使用してファイルを削除し、再生成することができます。

POファイルの検証

翻訳ファイルを常に最新の状態に保つために、static-analysis ジョブの一部として CI で実行されている linter があります。

POファイルの調整を内部でlintするには、rake gettext:lint.

リンターは以下を考慮します:

  • 有効なPOファイル構文
  • 変数
    • 言語によって変数の順序が変わる可能性があるため、無名 (%d) 変数は1つだけ。
    • メッセージIDで使用されている変数はすべて翻訳で使用されます。
    • メッセージIDにない変数が翻訳に使われてはいけません。
  • 翻訳中のエラー。

エラーはファイルごと、メッセージIDごとにグループ化されます:

Errors in `locale/zh_HK/gitlab.po`:
  PO-syntax errors
    SimplePoParser::ParserErrorSyntax error in lines
    Syntax error in msgctxt
    Syntax error in msgid
    Syntax error in msgstr
    Syntax error in message_line
    There should be only whitespace until the end of line after the double quote character of a message text.
    Parsing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
    SimplePoParser filtered backtrace: SimplePoParser::ParserError
Errors in `locale/zh_TW/gitlab.po`:
  1 pipeline
    <%d 條流水線> is using unknown variables: [%d]
    Failure translating to zh_TW with []: too few arguments

この出力では、locale/zh_HK/gitlab.po に構文エラーがあります。locale/zh_TW/gitlab.po には、ID1 pipeline、メッセージにはない変数が翻訳で使われています。

新しい言語の追加

新しい言語、例えばフランス語の翻訳を追加したいとしましょう。

  1. 最初のステップは、新しい言語をlib/gitlab/i18n.rbに登録することです:

    ...
    AVAILABLE_LANGUAGES = {
      ...,
      'fr' => 'Français'
    }.freeze
    ...
    
  2. 次に言語を追加します:

    bin/rake gettext:add_language[fr]
    

    特定の地域に新しい言語を追加したい場合もコマンドは同様で、地域をアンダースコア(_)で区切るだけです。 例えば、以下のようになります:

    bin/rake gettext:add_language[en_GB]
    

    地域部分は大文字で指定してください。

  3. これで言語が追加され、パスの下に新しいディレクトリが作成されました:locale/fr/. これでPOエディタを使って、locale/fr/gitlab.edit.poにあるPOファイルを編集することができます。

  4. 翻訳の更新が終わったら、バイナリの MO ファイルを生成するために PO ファイルを処理し、最後に翻訳を含む JSON ファイルを更新する必要があります:

    bin/rake gettext:compile
    
  5. 翻訳されたコンテンツを見るためには、ユーザーの設定(/profile)にある優先言語を変更する必要があります。

  6. 変更に問題がないことを確認したら、新しいファイルをコミットします。 たとえば、以下のようにします:

    git add locale/fr/ app/assets/javascripts/locale/fr/
    git commit -m "Add French translations for Value Stream Analytics page"