Internationalization for GitLab

原文:https://docs.gitlab.com/ee/development/i18n/externalization.html

Internationalization for GitLab

在 GitLab 9.2 中引入 .

为了使用国际化(i18n),使用了GNU gettext ,因为它是该任务最常用的工具,并且有许多应用程序可以帮助我们使用它.

Setting up GitLab Development Kit (GDK)

为了能够在GitLab 社区版项目上工作,您必须通过GDK下载并配置它.

准备好 GitLab 项目后,就可以开始进行翻译了.

Tools

使用以下工具:

  1. gettext_i18n_rails :这个 gem 使我们可以转换模型,视图和控制器中的内容. 此外,它还使我们可以访问以下 Rake 任务:
    • rake gettext:find :解析 Rails 应用程序中的几乎所有文件,以查找已标记为要翻译的内容. 最后,它将使用找到的新内容更新 PO 文件.
    • rake gettext:pack :处理 PO 文件并生成二进制的 MO 文件,最终由应用程序使用.
  2. gettext_i18n_rails_js :此 gem 对于使翻译在 JavaScript 中可用非常有用. 它提供以下 Rake 任务:
    • rake gettext:po_to_json :从 PO 文件中读取内容,并生成包含所有可用翻译的 JSON 文件.
  3. PO 编辑器:有多个应用程序可以帮助我们处理 PO 文件, Poedit是一个不错的选择,可用于 macOS,GNU / Linux 和 Windows.

Preparing a page for translation

我们基本上有 4 种类型的文件:

  1. Ruby 文件:基本上是模型和控制器.
  2. HAML 文件:这些是视图文件.
  3. ERB 文件:用于电子邮件模板.
  4. JavaScript 文件:我们主要需要使用 Vue 模板.

Ruby files

例如,如果存在使用原始字符串的方法或变量,则:

  1. def hello
  2. "Hello world!"
  3. end

Or:

  1. hello = "Hello world!"

您可以轻松地将该内容标记为要翻译的内容:

  1. def hello
  2. _("Hello world!")
  3. end

Or:

  1. hello = _("Hello world!")

在类或模块级别转换字符串时要小心,因为在类加载时它们只会被评估一次.

例如:

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

当加载该类时,这将被翻译,并导致错误消息始终位于默认语言环境中.

Active Record’s :message option accepts a Proc, so we can do this instead:

  1. validates :group_id, uniqueness: { scope: [:project_id], message: -> (object, data) { _("already shared with this group") } }

注意: API( lib/api/app/graphql )中的消息无需外部化.

HAML files

考虑到 HAML 中的以下内容:

  1. %h1 Hello world!

您可以使用以下方式将该内容标记为要翻译的内容:

  1. %h1= _("Hello world!")

ERB files

考虑到 ERB 中的以下内容:

  1. <h1>Hello world!</h1>

您可以使用以下方式将该内容标记为要翻译的内容:

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

JavaScript files

在 JavaScript 中,我们添加了__() (双下划线括号)函数,您可以从~/locale文件中导入该函数. 例如:

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

为了测试 JavaScript 翻译,您必须将 GitLab 本地化更改为英语以外的其他语言,并且必须使用bin/rake gettext:po_to_jsonbin/rake gettext:compile生成 JSON 文件.

Dynamic translations

有时,运行bin/rake gettext:find时,解析器无法找到一些动态转换. 对于这些情况,可以使用N_方法 .

还有另一种方法可以转换来自验证错误的消息 .

Working with special content

Interpolation

翻译后的文字中的占位符应与相应源文件的代码样式匹配. 例如使用%{created_at}在 Ruby,但%{createdAt}在 JavaScript. 添加链接时,请确保避免拆分句子 .

  • 在 Ruby / HAML 中:

    1. _("Hello %{name}") % { name: 'Joe' } => 'Hello Joe'
  • 在 Vue 中:

    请参见有关Vue 分量插值的部分.

  • 在 JavaScript 中(无法使用 Vue 时):

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

    如果要在翻译中使用标记并且正在使用 Vue,则必须使用gl-sprintf组件. 如果由于某种原因您不能使用 Vue,请使用sprintf并通过将false用作第三个参数来阻止其转义占位符值. 您必须使用自己逃避任何插值动态值,例如escapelodash .

    1. import { escape } from 'lodash';
    2. import { __, sprintf } from '~/locale';
    3. let someDynamicValue = '<script>alert("evil")</script>';
    4. // Dangerous:
    5. sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>`, false);
    6. // => 'This is <strong><script>alert('evil')</script></strong>'
    7. // Incorrect:
    8. sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>` });
    9. // => 'This is &lt;strong&gt;&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;&lt;/strong&gt;'
    10. // OK:
    11. sprintf(__('This is %{value}'), { value: `<strong>${escape(someDynamicValue)}</strong>`, false);
    12. // => 'This is <strong>&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;</strong>'

Plurals

  • 在 Ruby / HAML 中:

    1. n_('Apple', 'Apples', 3)
    2. # => 'Apples'

    使用插值:

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

    避免在单个字符串中使用%d或计数变量. 这样可以更自然地翻译某些语言.

  • 在 JavaScript 中:

    1. n__('Apple', 'Apples', 3)
    2. // => 'Apples'

    使用插值:

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

n_方法仅应用于获取同一字符串的多个转换,而不能控制针对不同数量显示不同字符串的逻辑. 某些语言具有不同数量的目标复数形式-例如,中文(简体)在我们的翻译工具中仅具有一种目标复数形式. 这意味着翻译者将不得不选择只翻译一个字符串,而翻译在另一种情况下的表现将不符合预期.

例如,更喜欢使用:

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

而不是:

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

Namespaces

命名空间是一种将属于在一起的翻译进行分组的方法. 它们通过在前缀后面加上条形符号( | )为我们的翻译人员提供上下文. 例如:

  1. 'Namespace|Translated string'

命名空间具有以下优点:

  • 它解决了词义上的歧义,例如: Promotions|Promote vs Epic|Promote
  • 它使翻译人员可以专注于翻译属于相同产品区域而不是任意产品区域的外部化字符串.
  • 它提供了语言环境以帮助翻译.

在某些情况下,例如,对于无处不在的 UI 单词和短语(如”取消”)或短语(如”保存更改”)而言,名称空间可能会适得其反.

命名空间应为 PascalCase.

  • 在 Ruby / HAML 中:

    1. s_('OpenedNDaysAgo|Opened')

    如果找不到翻译,它将返回Opened .

  • 在 JavaScript 中:

    1. s__('OpenedNDaysAgo|Opened')

注意:应从转换中删除名称空间. 有关更多详细信息,请参见翻译指南 .

Dates / times

  • 在 JavaScript 中:
  1. import { createDateTimeFormat } from '~/locale';
  2. const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
  3. console.log(dateFormat.format(new Date('2063-04-05'))) // April 5, 2063

这利用了Intl.DateTimeFormat .

  • 在 Ruby / HAML 中,我们有两种向日期和时间添加格式的方法:

    1. 通过l帮助器 ,即l(active_session.created_at, format: :short) . 我们有一些预定义的日期时间格式. 如果您需要添加新格式,因为代码的其他部分可能会从中受益,则需要将其添加到en.yml文件中.
    2. 通过strftime ,即milestone.start_date.strftime('%b %-d') . 如果在en.yml 上定义的格式均不符合我们所需的日期/时间规范,并且由于非常特殊而无需将其添加为新格式的情况(例如,仅在单个视图中使用),则我们将使用strftime . .

Best practices

Keep translations dynamic

在某些情况下,将翻译内容保持在数组或哈希中是有意义的.

Examples:

  • 下拉列表的映射
  • 错误讯息

要存储此类数据,使用常数似乎是最佳选择,但这不适用于翻译.

不好,请避免:

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

首次加载该类时,将调用翻译方法( _ ),并将文本翻译为默认语言环境. 无论用户的语言环境是什么,这些值都不会再次转换.

将类方法与备注一起使用时,也会发生类似的情况.

不好,请避免:

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

此方法将使用用户的语言环境来记住翻译,该用户首先”调用”此方法.

为避免这些问题,请保持翻译动态.

Good:

  1. class MyPresenter
  2. def self.my_list
  3. {
  4. key_1: _('item 1'),
  5. key_2: _('item 2'),
  6. key_3: _('item 3')
  7. }.freeze
  8. end
  9. end

Splitting sentences

请不要拆分句子,因为这会假定句子的语法和结构在所有语言中都是相同的.

例如,以下内容:

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

应该外部化如下:

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

Avoid splitting sentences when adding links

当在翻译的句子之间使用链接时,这也适用,否则这些文本在某些语言中不可翻译.

  • 在 Ruby / HAML 中,而不是:

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

    将链接的开始和结束 HTML 片段设置为变量,如下所示:

    1. - zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones'
    2. - zones_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: zones_link_url }
    3. = 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 中,而不是:

    1. <template>
    2. <div>
    3. <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{link}')">
    4. <template #link>
    5. <gl-link
    6. href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
    7. target="_blank"
    8. >zones</gl-link>
    9. </template>
    10. </gl-sprintf>
    11. </div>
    12. </template>

    将链接的开始和结束 HTML 片段设置为占位符,如下所示:

    1. <template>
    2. <div>
    3. <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
    4. <template #link="{ content }">
    5. <gl-link
    6. href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
    7. target="_blank"
    8. >{{ content }}</gl-link>
    9. </template>
    10. </gl-sprintf>
    11. </div>
    12. </template>
  • 在 JavaScript 中(无法使用 Vue 时),而不是:

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

    将链接的开始和结束 HTML 片段设置为占位符,如下所示:

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

其背后的原因是,在某些语言中,单词会根据上下文而变化. 例如,日语将は添加到句子的主语中,将を添加到宾语的主语中. 如果我们从句子中提取单个单词,则无法正确翻译.

如有疑问,请尝试遵循此Mozilla Developer 文档中描述的最佳做法.

Vue components interpolation

在 Vue 组件中翻译 UI 文本时,您可能希望在转换字符串中包括子组件. 您不能使用仅 JavaScript 的解决方案来呈现翻译,因为 Vue 不会意识到子组件并将其呈现为纯文本.

对于此用例,应使用在GitLab UI 中维护的gl-sprintf组件.

gl-sprintf组件接受message属性,该属性是可翻译的字符串,并且为字符串中的每个占位符公开一个命名的插槽,这使您可以轻松地包含 Vue 组件.

假设您要打印Pipeline %{pipelineId} triggered %{timeago} by %{author}的可翻译字符串Pipeline %{pipelineId} triggered %{timeago} by %{author} . 要将%{timeago}%{author}占位符替换为 Vue 组件,以下是使用gl-sprintf

  1. <template>
  2. <div>
  3. <gl-sprintf :message="__('Pipeline %{pipelineId} triggered %{timeago} by %{author}')">
  4. <template #pipelineId>{{ pipeline.id }}</template>
  5. <template #timeago>
  6. <timeago :time="pipeline.triggerTime" />
  7. </template>
  8. <template #author>
  9. <gl-avatar-labeled
  10. :src="pipeline.triggeredBy.avatarPath"
  11. :label="pipeline.triggeredBy.name"
  12. />
  13. </template>
  14. </gl-sprintf>
  15. </div>
  16. </template>

有关更多信息,请参见gl-sprintf文档.

Updating the PO files with the new content

现在,新内容已标记为要翻译,我们需要使用以下命令更新locale/gitlab.pot文件:

  1. bin/rake gettext:regenerate

该命令将使用新外部化的字符串更新locale/gitlab.pot文件,并删除不再使用的任何字符串. 您应该检入此文件.一旦将更改保存在主文件中,它们将由CrowdIn 提取并显示以进行翻译.

我们不需要签入对locale/[language]/gitlab.po文件的任何更改. 当来自 CrowdIn 的翻译合并时,它们会自动更新.

如果gitlab.pot文件中存在合并冲突,则可以删除该文件并使用同一命令重新生成它.

Validating PO files

为了确保我们的翻译文件保持最新, static-analysis工作中有一个运行在 CI 上的 lint.

要在本地rake gettext:lint PO 文件中的调整,可以运行rake gettext:lint .

短绒将考虑以下因素:

  • 有效的 PO 文件语法
  • 可变用法
    • 仅一个未命名( %d )变量,因为变量的顺序可能会以不同的语言更改
    • 消息 ID 中使用的所有变量都在转换中使用
    • 转换中不应使用消息 ID 中没有的变量
  • 翻译时出错.

错误按文件和消息 ID 分组:

  1. Errors in `locale/zh_HK/gitlab.po`:
  2. PO-syntax errors
  3. SimplePoParser::ParserErrorSyntax error in lines
  4. Syntax error in msgctxt
  5. Syntax error in msgid
  6. Syntax error in msgstr
  7. Syntax error in message_line
  8. There should be only whitespace until the end of line after the double quote character of a message text.
  9. 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?"]}'
  10. SimplePoParser filtered backtrace: SimplePoParser::ParserError
  11. Errors in `locale/zh_TW/gitlab.po`:
  12. 1 pipeline
  13. <%d 條流水線> is using unknown variables: [%d]
  14. Failure translating to zh_TW with []: too few arguments

在此输出中, locale/zh_HK/gitlab.po具有语法错误. locale/zh_TW/gitlab.po具有转换中使用的变量,这些变量不在 ID 1 pipeline的消息中.

Adding a new language

假设您想添加一种新语言的翻译,例如法语.

  1. 第一步是在lib/gitlab/i18n.rb注册新语言:

    1. ...
    2. AVAILABLE_LANGUAGES = {
    3. ...,
    4. 'fr' => 'Français'
    5. }.freeze
    6. ...
  2. 接下来,您需要添加语言:

    1. bin/rake gettext:add_language[fr]

    如果要为特定区域添加新语言,则命令类似,您只需要用下划线( _ )分隔区域即可. 例如:

    1. bin/rake gettext:add_language[en_GB]

    请注意,您需要在大写字母中指定地区部分.

  3. 现在已经添加了该语言,已经在以下路径下创建了一个新目录: locale/fr/ . 现在,您可以开始使用 PO 编辑器来编辑位于locale/fr/gitlab.edit.po的 PO 文件.

  4. 更新完翻译后,您需要处理 PO 文件以生成二进制 MO 文件,最后更新包含翻译的 JSON 文件:

    1. bin/rake gettext:compile
  5. 为了查看翻译后的内容,我们需要更改首选语言,该语言可以在用户的设置/profile )下找到.

  6. 在确认更改没问题之后,您可以继续提交新文件. 例如:

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