Интернет приносит в нашу жизнь глобальность. И многие веб-ресурсы не ограничиваются аудиторией, живущей в одной стране и разговаривающей на одном языке. Однако, поддержка нескольких языковых версий сайта вручную — затея малоприятная и, начиная с определённого масштаба, вряд ли реальная.
Например, в
Хотя задачи интернационализации и локализации программного обеспечения (в том числе в веб) не новы, и, в целом, довольно стандартны, хороших универсальных инструментов для их решения не так много. И подобрать такой инструмент для конкретного стека клиентских и серверных технологий не всегда просто, особенно если хочется использовать один и тот же инструмент и там, и там.
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 'by-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} translationn"
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( 'by-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 и remarked.
Автор: akzhan