BabelFish — полиглот в мире JavaScript
Интернет приносит в нашу жизнь глобальность. И многие веб-ресурсы не ограничиваются аудиторией, живущей в одной стране и разговаривающей на одном языке. Однако, поддержка нескольких языковых версий сайта вручную — затея малоприятная и, начиная с определённого масштаба, вряд ли реальная.
Например, в 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 '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/*.
# do not wrap each line with
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 ('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.