Pull to refresh
48.05
REG.RU
Домены, хостинг, серверы

BabelFish — полиглот в мире JavaScript

Reading time 8 min
Views 11K
BabelFish


Интернет приносит в нашу жизнь глобальность. И многие веб-ресурсы не ограничиваются аудиторией, живущей в одной стране и разговаривающей на одном языке. Однако, поддержка нескольких языковых версий сайта вручную — затея малоприятная и, начиная с определённого масштаба, вряд ли реальная.

Например, в REG.RU на сегодня в словарях более 15000 фраз, из которых порядка 200 используют склонение, и более 2000 используют подстановку переменных. Каждый день добавляется не менее 10 фраз. И это при том, что мы пока только начали локализацию сайта и впереди планы на новые языки.

Хотя задачи интернационализации и локализации программного обеспечения (в том числе в веб) не новы, и, в целом, довольно стандартны, хороших универсальных инструментов для их решения не так много. И подобрать такой инструмент для конкретного стека клиентских и серверных технологий не всегда просто, особенно если хочется использовать один и тот же инструмент и там, и там.

DON'T PANIC.

Недавно был опубликован пакет BabelFish 1.0, предназначенный для интернационализации JavaScript-приложений.

Идеи, лежащие в его основе, настолько пришлись нам по душе, что мы даже перенесли их на Perl в виде CPAN-модуля Locale::Babelfish, и используем это для Perl-приложений. Но вернёмся к JavaScript-реализации.

Обзор

image
В чём же особенности этой библиотеки?

  • Очень удобный и компактный синтаксис для склонений и подстановок.
  • Возможность работы как на сервере, так и на клиенте (для старых браузеров потребуется пакет поддержки es5-shim).
  • Автоматическое приведение структур с данными к «плоскому» виду.
  • Возможность хранения и отдачи сложных структур вместо текста.

Рассмотрим возможности модуля на примерах. Типичная фраза выглядит так:

В небе #{cachalotes_count} ((кашалот|кашалота|кашалотов)):cachalotes_count.

Также поддерживается точное совпадение и возможность вложенной интерпретации вхождений переменных. Типичный пример — когда мы вместо «0 кашалотов» хотим написать «нет кашалотов», вместо «1 кашалот» просто «кашалот», при этом оставив написание «21 кашалот»:

((=0 нет кашалотов|=1 кашалот|#{count} кашалот|#{count} кашалота|#{count} кашалотов))

Отметим, что если используется переменная с именем count, то её имя через двоеточие в конце фразы можно опустить.

Babelfish API предлагает метод t(локаль, ключ, параметры) для разрешения ключа в конкретной локали в готовый текст или структуру данных. Вызов выглядит так:

babelfish.t( 'ru-RU', 'some.complex.key', { a: "test" } );
babelfish.t( 'ru-RU', 'some.complex.key', 17 ); // переменные count и value будут равны 17

Чтобы упростить читаемость кода и меньше печатать обычно создается метод такого вида (coffee):

    window.t = t = (key, params, locale) ->
        locale = _locale  unless locale?
        babelfish.t.call babelfish, locale, key, params

Здесь локаль перемещается в конец списка аргументов и становится опциональной. Теперь можно писать кратко:

t( 'some.complex.key', { a: "test" } );

// обе записи ниже равнозначны:
t( 'some.complex.key', 17 );
t( 'some.complex.key', { count => 17, value => 17 } );

Обратная сторона лаконичности синтаксиса — переводчикам (персоналу, работающему со словарями и шаблонами) к синтаксису нужно привыкать, хоть он и несложен.

Решением проблемы является предоставление интерфейса для переводчиков, где, помимо фразы для перевода, предлагаются сразу контекст фразы, фикстуры с типичными данными, используемыми при её формировании, и область просмотра результатов.

Также полезно предоставление сниппетов, которые вставляют уже готовые конструкции для склонения и подстановки переменных.

Рассмотрим процесс интеграции Babelfish в ваше приложение на стороне браузера.

Установка


Babelfish доступен как в виде пакета npm, так и в виде пакета bower. Если вам нужно работать одновременно и с Node.JS, и с браузерами, рекомендуем использовать npm-пакет + browserify (пример есть в babelfish demo), но большинству разработчиков проще будет использовать bower.

Здесь мы предполагаем, что текущая локаль определена как window.lang:

# assets/coffee/babelfish-init.coffee
do (window) ->
    "use strict"

    BabelFish = require 'babelfish'

    locale = switch window.lang
        when 'ru' then 'ru-RU'
        when 'en' then 'en-US'
        else window.lang

    window.l10n = l10n = BabelFish()

    l10n.setFallback 'be-BY', [ 'ru-RU', 'en-US' ]

    window.t = t = (args...) ->
        l10n.t.apply l10n, [ locale ].concat(args)

    null


Хранение и компиляция словарей


Внутренний формат


Словари формируются во внутреннем формате Babelfish, который позволяет привязать к ключу не только текст, но и другие структуры данных. Механизм сериализации и десериализации словарей в JSON прилагается (stringify/load).

Фактически, можно добавлять фразы в словари так:

babelfish.addPhrase( 'ru-RU', 'some.complex.key', 'текст ключа' );
babelfish.addPhrase( 'ru-RU', 'some.complex.anotherkey', 'текст другого ключа' );

Или так:

babelfish.addPhrase( 'ru-RU', 'some', {
    complex: {
        key: 'текст ключа',
        anotherkey: 'текст другого ключа'
    }
});

При добавлении сложных структур данных можно указать параметр flattenLevel (false или 0), после:

babelfish.addPhrase( 'ru-RU', 'myhash', {
    key: 'текст ключа',
    anotherkey: 'текст другого ключа'
}, false);

И тогда при вызове t('myhash') мы получим объект с ключами key и anotherkey. Это очень удобно при локализации внешних библиотек (например, для предоставления конфигураций для плагинов jQuery UI).

Единственное требование при сериализации таких данных — возможность их представления в формате JSON.

Обратите внимание, что для разбора синтаксиса Babelfish использует ленивую (отложенную) компиляцию. То есть для фраз с параметрами при первом использовании будут сгенерированы функции, а при следующих вызовах результат получится быстро. С одной стороны это сильно упрощает сериализацию, с другой — может стать проблемой, если вы используете параноидальные CSP-политики (запрещающие выполнение eval и Function() в браузере). Автор пакета не против реализовать режим совместимости, так что если Вам это действительно потребуется — просто создайте тикет в трекере проекта.

Формат YAML


Для большинства применений больше подходит формат YAML, который также поддерживается «из коробки». Я бы рекомендовал хранить данные в этом формате, компилируя их во внутренний формат перед использованием. В частности, словари можно комбинировать друг с другом и отдавать клиенту в виде обычного JavaScript.

При этом вложенные ключи YAML преобразуются в плоскую структуру:

some:
    complex:
        key: "Some text at least of #{count}"

преобразуется в ключ some.complex.key.

Кстати, Babelfish умеет автоматически, без прямого указания, распознавать в словарях не просто фразы, но и списки (как сложные структуры данных). Так, если указать

mylist:
    - british
    - irish

То при вызове t('mylist') мы получим [ 'british', 'irish' ]. Это нам пригодится чуть позже.


Преобразования фраз локализации


imageОбычно нам требуется перед компиляцией фраз выполнить дополнительные преобразования над ними. В их число у нас входят такие, как:

  • преобразование из формата Markdown в HTML;
  • типографика;
  • добавление классов и атрибутов, специфичных для нашей реализации БЭМ.

Автоматическое типографирование полезно всем, а использование формата Markdown упрощает как чтение текста, так и взаимодействие с переводчиками.

Оригинальные словари мы кладём в каталог assets/locales, преобразуя их далее в готовые к использованию в config/locales.

Понятно, что ваш стек преобразований скорее всего будет отличаться от нашего.

А вот пример компиляции словарей в формате YAML во внутренний формат Babelfish с преобразованием через Markdown-процессор (grunt):

 # Gruntfile.coffee
# нужны пакеты glob, marked, traverse
marked = require 'marked'
traverse = require 'traverse'

  grunt.registerTask 'babelfish', 'Compile config/locales/*.<locale>.yaml to Babelfish assets', ->
    fs = require 'fs'
    Babelfish = require 'babelfish'
    glob = require 'glob'
    files = glob.sync '**/*.yaml', { cwd: 'config/locales' }
    reFile = /(^|.+\/)(.+)\.([^\.]+)\.yaml$/

    # do not wrap each line with <p>
    renderer = new marked.Renderer()
    renderer.paragraph = (text) ->
      text

    for file in files
      m = reFile.exec(file)
      continue  unless m
      [folder, dict, locale] = [m[1], m[2], m[3], '']
      b = Babelfish locale
      translations = grunt.file.readYAML "config/locales/#{folder}#{file}"

      # md
      traverse(translations).forEach (value) ->
        if typeof value is 'string'
          @update marked( value, { renderer: renderer } )

      b.addPhrase locale, dict, translations

      res =  "// #{file} translation\n"
      res += "window.l10n.load("
      res += b.stringify locale
      res += ");\n"
      resPath = "assets/javascripts/l10n/#{folder}#{dict}.#{locale}.js"
      grunt.file.write resPath, res
      grunt.log.writeln "#{resPath} compiled."

Теперь готовые скрипты можно склеивать и подключать к вашему приложению любым удобным вам образом.


Выбор локали


Для выбора локали на серверной стороне наиболее корректным способом является парсинг заголовка Accept-Language. В этом нам поможет npm-модуль locale. Также можете посмотреть исходный код nodeca.core.

Откат на другую локаль


Babelfish поддерживает список правил отката на другие локали в случае, если нужной фразы нет в текущей локали.

Например, мы хотим, чтобы для белорусской локали данные брались в порядке приоритета из белорусской, русской и английской локалей:

babelfish.setFallback( 'be-BY', [ 'ru-RU', 'en-US' ] );


Локализация


Помимо интернационализации перед нами стоит также задача по локализации приложения. В частности, мы должны уметь, например, форматировать валюты, даты, диапазоны времени с учётом локали.

Локализация дат


Воспользуемся слегка модифицированными данными для форматирования дат из Rails:

# config/locales/formatting.ru-RU.yaml
date:
    abbr_day_names:
        - Вс
        - Пн
        - Вт
        - Ср
        - Чт
        - Пт
        - Сб
    abbr_month_names:
        -
        - янв.
        - февр.
        - марта
        - апр.
        - мая
        - июня
        - июля
        - авг.
        - сент.
        - окт.
        - нояб.
        - дек.
    day_names:
        - воскресенье
        - понедельник
        - вторник
        - среда
        - четверг
        - пятница
        - суббота
    formats:
        default: '%d.%m.%Y'
        long: '%-d %B %Y'
        short: '%-d %b'
    month_names:
        -
        - января
        - февраля
        - марта
        - апреля
        - мая
        - июня
        - июля
        - августа
        - сентября
        - октября
        - ноября
        - декабря
    order:
        - day
        - month
        - year
time:
    am: до полудня
    formats:
        default: '%a, %d %b %Y, %H:%M:%S %z'
        long: '%d %B %Y, %H:%M'
        short: '%d %b, %H:%M'
    pm: после полудня


# assets/coffee/babelfish-init.coffee
    strftime  = require 'strftime'

    l10n.datetime = ( dt, format, options ) ->
        return null  unless dt && format

        dt = new Date(dt * 1000)  if 'number' == typeof dt

        m = /^([^\.%]+)\.([^\.%]+)$/.exec format
        format = t("formatting.#{m[1]}.formats.#{m[2]}", options)  if m

        format = format.replace /(%[aAbBpP])/g, (id) ->
            switch id
                when '%a'
                    t("formatting.date.abbr_day_names", { format: format })[dt.getDay()] # wday
                when '%A'
                    t("formatting.date.day_names", { format: format })[dt.getDay()] # wday
                when '%b'
                    t("formatting.date.abbr_month_names", { format: format })[dt.getMonth() + 1] # mon
                when '%B'
                    t("formatting.date.month_names", { format: format })[dt.getMonth() + 1] # mon
                when '%p'
                    t((if dt.getHours() < 12 then "formatting.time.am" else "formatting.time.pm"), { format: format }).toUpperCase()
                when '%P'
                    t((if dt.getHours() < 12 then "formatting.time.am" else "formatting.time.pm"), { format: format }).toLowerCase()

        strftime.strftime format, dt

Теперь мы имеем хелпер:

window.l10n.datetime( unix timestamp or Date object, format_string_or_config ).

Аналогично можно построить хелперы для валют и других локализуемых значений.

Другие реализации


Парсер Babelfish построен на PEG.js. С некоторыми доработками можно использовать его грамматику и в других PEG-парсерах. Учитывая отсутствие привязки синтаксиса к JavaScript и удобство использования, можно полагать, что будут опубликованы реализации Babelfish и для других платформ.

Как я уже упоминал выше, мы реализовали диалект Babelfish 1.0 для языка Perl.

Заключение


Для иллюстрирования возможностей Babelfish мы опубликовали небольшой демонстрационный проект с использованием marked и jade.

Надо сказать, что в процессе использования в нашем проекте некоторые возможности Babelfish существенно расширились именно в результате наших запросов. Например, хранение сложных структур данных фактически перекочевало в Babelfish из нашего Perl-проекта.

Как это обычно и бывает у nodeca, они выпустили продуманную, качественную и перспективную библиотеку. Просто напомню, что ими были разработаны такие хиты, как js-yaml, mincer, argparse, pako и markdown-it.

Особая благодарность автору модуля Vitaly Puzrin (@puzrin). Статья подготовлена при активном участии отдела разработки REG.RU, в частности: IgorMironov, dreamworker, nugged TimurN.
Tags:
Hubs:
+21
Comments 25
Comments Comments 25

Articles

Information

Website
www.reg.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия
Representative
Рег.ру