
Интернет приносит в нашу жизнь глобальность. И многие веб-ресурсы не ограничиваются аудиторией, живущей в одной стране и разговаривающей на одном языке. Однако, поддержка нескольких языковых версий сайта вручную — затея малоприятная и, начиная с определённого масштаба, вряд ли реальная.
Например, в REG.RU на сегодня в словарях более 15000 фраз, из которых порядка 200 используют склонение, и более 2000 используют подстановку переменных. Каждый день добавляется не менее 10 фраз. И это при том, что мы пока только начали локализацию сайта и впереди планы на новые языки.
Хотя задачи интернационализации и локализации программного обеспечения (в том числе в веб) не новы, и, в целом, довольно стандартны, хороших универсальных инструментов для их решения не так много. И подобрать такой инструмент для конкретного стека клиентских и серверных технологий не всегда просто, особенно если хочется использовать один и тот же инструмент и там, и там.
DON'T PANIC.
Недавно был опубликован пакет BabelFish 1.0, предназначенный для интернационализации JavaScript-приложений.
Идеи, лежащие в его основе, настолько пришлись нам по душе, что мы даже перенесли их на Perl в виде CPAN-модуля Locale::Babelfish, и используем это для Perl-приложений. Но вернёмся к JavaScript-реализации.
Обзор

В чём же особенности этой библиотеки?
- Очень удобный и компактный синтаксис для склонений и подстановок.
- Возможность работы как на сервере, так и на клиенте (для старых браузеров потребуется пакет поддержки 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' ]. Это нам пригодится чуть позже.Преобразования фраз локализации
Обычно нам требуется перед компиляцией фраз выполнить дополнительные преобразования над ними. В их число у нас входят такие, как:- преобразование из формата 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.
