I18n, l10n and string interpolation JavaScript library

11
4
JavaScript

T.js – библиотека для локализации JS-приложений

T.js берёт на себя все задачи, связанные с выводом локализованного текста:

  • зависимость от числа, пола и падежа
  • правильное склонение русских имён
  • форматирование даты, времени и чисел
  • встраивание одних строк в другие
  • инлайновый перевод
  • (TODO) приведение всех строк в соответствие с типографическими правилами

Ниже — подробнее.

Использование

Для использования библиотеки её необходимо подключить, а затем добавить в неё необходимые локализации.

Добавление локализации производится вызовом T.define() и его имеет смысл вынести в отдельные файлы lang.ru.js, lang.en.js и т.д. (чтобы их можно было подключать по отдельности). Если локализация небольшая, все переводы можно объединить и в один файл (lang.js). Файл с текущим перевод также следует подключить обычным способом (или загрузить и выполнить асинхронно).

После загрузки библиотеки необходимо указать текущий язык:

T.lang('ru');

Пример файла перевода:

T.define({
  ru: {
    $aux: {
      your: {
        ins:        { $plural: { one: 'вашей', other: 'вашими' } },
        acc:        { $plural: { one: 'вашу', other: 'ваших' } },
      },
      uploaded:     { $plural: { one: 'загрузил', other: 'загрузили' } },
      liked:        { $gender: { m: 'оценил', f: 'оценила' } },

      users:        { $plural: { one: '{} пользователь', few: '{} пользователя', other: '{} пользователей' } },
      photos: {
        nom:        { $plural: { one: 'фотография', few: 'фотографии', other: 'фотографий' } },
        gen:        { $plural: { one: 'фотографии', few: 'фотографий', other: 'фотографий' } },
        dat:        { $plural: { one: 'фотографии', few: 'фотографиям', other: 'фотографиям' } },
        acc:        { $plural: { one: 'фотографию', few: 'фотографии', other: 'фотографий' } },
        ins:        { $plural: { one: 'фотографией', few: 'фотографиями', other: 'фотографиями' } },
        abl:        { $plural: { one: 'фотографии', few: 'фотографиях', other: 'фотографиях' } },
      },

      codes: {
        200:        'OK',
        300:        'перенаправление',
        400:        'не существует',
        500:        'ошибка',
      },
    },

    ok:            'OK',
    cancel:        'отмена',
    online:        'онлайн {>users}',

    newsfeed: {
      uploaded:    '{>users users} {>uploaded users} {>photos.acc photos}',
      press_ok:    'нажмите «{>ok}»',
      status:      'статус {code}: {>codes code}',
    },
  },
});

Подробнее его структура рассмотрена ниже.

Перевод строки с ключом newsfeed.uploaded:

el.innerHTML = T('newsfeed.uploaded', { users: 5, photos: 21 });

Результат:

5 пользователей загрузили 21 фотографию

Структура файла перевода

JS-объект, передаваемый в функцию T.define(), должен содержать один или несколько полей, название каждого из которых соответствует двухбуквенному коду языка (ru, en), а значение является объектом, который описывает перевод на указанный язык. Внутри такого объекта содержатся либо непосредственно пары ключ-значение, либо вложенные объекты, либо специальные поля. Вложенные объекты могут быть любой глубины (и к ним можно обращаться через точечную нотацию), но рекомендуется использовать только один уровень вложенности — на верхнем уровне общие для всего приложения строки, остальные сгруппированы по разделам.

Формат подстановок

Каждому ключу в переводе соответствует строка, которая может содержать два вида подстановок: данные и другие строки перевода.

Данные передаются в виде объекта вторым параметром в функцию T. Чтобы подставить это значение целиком, используйте фигурные скобки: {}. Для того, чтобы обратиться к какому-то из внутренних полей, в фигурных скобках пропишите «путь» к нему: {user.name}.

Кроме того, внутрь строк можно подставлять другие строки из того же перевода. Делается это так:
{>key param1 param2 name=param3}
Здесь key – подставляемая строка, param1, param2 и param3 – поля данных, которые нужно передать в неё (при этом значение поля param3 станет доступно как name). По умолчанию в «дочернюю» строку передаются те же данные, что были переданы родительской.

Зависимость от числа

Часто возникает необходимость сделать строку зависящей от переданного ей числа. В этом случае различные варианты строки оборачиваются в структуру $plural:

users: {
  $plural: {
    one: '{} пользователь',
    few: '{} пользователя',
    other: '{} пользователей'
  }
}

Поля zero, one, two, few, many и other являются зарезервированными и соответствуют значениям, описанным в документе
http://www.unicode.org/cldr/charts/27/supplemental/language_plural_rules.html

Кроме того, можно указать варианты и для конкретных значений.

В данный момент в библиотеку встроены правила для русского и английского языка, но для конкретного перевода можно написать собственную функцию определения категории по числу и поместить её в виде поля $plural в корне объекта, описывающего перевод.

При обращении к ключу, содержащему внутри себя структуру $plural, будет автоматически выбран соответствующий вариант на основании либо поля n, если передан объект, либо, если передано одно число, самого числа.

TODO: ordinal + range

Зависимость от пола

Аналогично зависимости от числа, можно помечать строку как зависящую от пола при помощи поля $gender:

liked: {
  $gender: {
    m: 'оценил',
    f: 'оценила'
  }
}

Если у входного объекта поле g (или сам объект) начинается с буквы f или имеет значение 1, будет выбран вариант, соответствующий женскому полу, в противном случае — мужскому.

Зависимость от падежа

Иногда удобно записать одну строку в разных падежах чтобы использовать её внутри других строк (в составе предложений). Для этого можно просто использовать вложенный объект:

your: {
  nom:        { $plural: { one: 'ваша', other: 'ваши' } },
  gen:        { $plural: { one: 'вашей', other: 'ваших' } },
  dat:        { $plural: { one: 'вашей', other: 'вашим' } },
  acc:        { $plural: { one: 'вашу', other: 'ваших' } },
  ins:        { $plural: { one: 'вашей', other: 'вашими' } },
  abl:        { $plural: { one: 'вашей', other: 'ваших' } },
}

Тогда значение, естественно, можно получить как {>your.acc}. Однако иногда возникает ситуация, когда нужно просклонять входные данные, а не строки перевода. Чаще всего это необходимо для имён собственных (например, для фразы «Олегу понравились 2 ваших фотографии»). Для этого случая доступны специальные функции $name, $surname и $patronym. Обращаться к ним следует так, словно это обычные подставляемые строки: {$name.dat user.firstName} {$surname.dat user.lastName}. В данный момент реализована поддержка только русских имён, фамилий и отчеств (разумеется, для английского и многих других языков такой проблемы не стоит).

При желании, функции $name, $surname и $patronym можно переопределить (аналогично $plural) – объявив их в корне объекта, описывающего перевод. Первым параметром в них передается имя, вторым — падеж, указанный после точки.

Кроме того, можно определять и собственные функции, которые будут вызываться аналогичным образом. Если название функции не начинается с $, при обращении к ней нужно ставить знак > в начале (как при подстановке любой другой строки), в противном случае он опционален. Первый параметр это всегда данные, «прокинутые» из родительской строки; второй, третий и так далее — то, что стоит после первой, второй (и так далее) точки за именем функции. То есть, подстановка {>testFunc.optA.optB val1 val2 xxx=val3} превратится в вызов testFunc({ 0: val1, 1: val2, xxx: val3 }, 'optA', 'optB').

Форматирование даты и времени

Для форматирования даты и времени доступно три функции: $date, $time, $rdate.

Функции $date и $time вызываются сходным образом, принимая одну опцию — формат вывода: $date.DDMMYYYY или $time.HHmmss. Всё отличие между ними заключается только в форматах по умолчанию, которые используются, если этот параметр опущен. В этом случае $date выводит день, месяц и год, а $time — часы и минуты.

Поддерживаемые компоненты даты и времени:

  • YYYY – год (полностью)
  • YY – год (две цифры)
  • M – месяц (одна или две цифры)
  • MM – месяц (две цифры с ведущим нулем)
  • MMM – сокращенное название месяца
  • MMMM – полное название месяца
  • D — день месяца (одна или две цифры)
  • DD – день месяца (две цифры с ведущим нулем)
  • d – день недели (0 – воскресенье, 1 – понедельник, 6 — суббота)
  • dd – двухсимвольное название дня недели
  • ddd – сокращенное название дня недели (в русском совпадает с двухсимвольным)
  • dddd – полное название дня недели
  • H – час (одна или две цифры)
  • HH – час (две цифры с ведущим нулем)
  • m – минуты (одна или две цифры)
  • mm – минуты (две цифры с ведущим нулем)
  • s – секунды (одна или две цифры)
  • ss – секунды (две цифры с ведущим нулем)

TODO: добавить Do/Mo/do (ordinals).

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

Функция $rdate предназначена для вывода относительных дат. Она выбирает наиболее подходящий формат в зависимости от того, насколько скоро наступит (или насколько давно наступила) указанная дата:

  • в прошлом году или ранее3 фев 1989 (дата без времени)
  • в этом году, позавчера или ранее1 апр в 19:40 (дата без указания года + время)
  • более 3,5 часов назад (вчера или сегодня)вчера в 7:32 (слово вчера или сегодня + время)
  • от 1 до 3,5 часов назаддва часа назад (число часов словами)
  • от 4 до 59 минут назад37 минут назад (число минут)
  • от 1 до 3 минут назадтри минуты назад (число минут словами)
  • от 4 до 59 секунд назад48 секунд назад (число секунд)
  • от 0 до 3 секунд назадтолько что
  • спустя от 1 до 3 секундчерез несколько секунд
  • спустя от 4 до 59 секундчерез 23 секунды (число секунд)
  • спустя от 1 до 3 минутчерез минуту (число минут словами)
  • спустя от 4 до 59 минутчерез 22 минуты (число минут)
  • спустя от 1 до 3,5 часовчерез три часа (число часов словами)
  • спустя более 3,5 часов (сегодня или завтра)сегодня в 21:45 (слово сегодня или завтра + время)
  • в этом году, послезавтра или позднее19 сен в 8:21 (дата без указания года + время)
  • в следующем году или позднее25 мая 2017 (дата без времени)

Эти строки можно переопределить, указав ключ $rdate (а внутри него — объект $date, который и отвечает за выбор нужной опции в зависимости от входных данных):

$rdate: { $date: {
  in_hours:     { $plural: { 1: 'in an hour', 2: 'in two hours', 3: 'in three hours' } },
  in_minutes:   { $plural: { 1: 'in a minute', 2: 'in two minutes', 3: 'in three minutes', one: 'in {} minute', other: 'in {} minutes' } },
  in_seconds:   { $plural: { one: 'in {} second', other: 'in {} seconds' } },
  in_moment:    'in a moment',
  moment_ago:   'just now',
  seconds_ago:  { $plural: { one: '{} second ago', other: '{} seconds ago' } },
  minutes_ago:  { $plural: { 1: 'a minute ago', 2: 'two minutes ago', 3: 'three minutes ago', one: '{} minute ago', other: '{} minutes ago' } },
  hours_ago:    { $plural: { 1: 'an hour ago', 2: 'two hours ago', 3: 'three hours ago' } },
  tomorrow:     'tomorrow at {$time.Hmm}',
  today:        'today at {$time.Hmm}',
  yesterday:    'yesterday at {$time.Hmm}',
  year:         '{$date.MMMD} at {$time.Hmm}',
  other:        '{$date.MMMDYYYY}',
} },

Обратите внимание, что в строки in_hours, in_minutes, in_seconds (а также hours_ago, minutes_ago, seconds_ago) передается не дата, а целочисленное количество часов, минут и секунд (относительно текущего времени) соответственно. Не забывайте про зависимость от числа в таком случае. Для малых величин рекомендуется прописать числительные словами (а для единицы опустить его полностью, если язык допускает).

Перечисленные выше ключи, входящие в структуру $date, являются наиболее полным списком, но при переопределении нет необходимости указывать их все: единственной обязательной строкой является other. В отсутствие строки in_moment будет взято значение moment_ago — и наоборот (это удобно, если дата заведомо должна находиться в прошлом или в будущем, но из-за неточно выставленного времени может оказаться «по другую сторону» от текущего момента). Если отсутствуют оба варианта — будут выбраны строчки in_seconds и seconds_ago соответственно. В свою очередь, если нет строк in_seconds/in_minutes/in_hours или seconds_ago/minutes_ago/hours_ago — будут использована подходящая строка из today/tomorrow/yesterday. Если подходящей не будет (и дата относится к текущему году) — year. Наконец, если даже этот вариант не указан, алгоритм «откатится» к other.

TODO: форматирование интервалов.

Форматирование чисел

Для форматирования целых и дробных чисел используется внутренняя функция $num (которая, разумеется, может быть переопределена). По умолчанию она доступна для русского (в качестве десятичного разделителя используется запятая, в качестве разделителя разрядов — тонкий пробел) и английского (десятичный разделитель — точка, разделитель разрядов — запятая) языков.

Функция имеет следующие опции: {$num.+t3.5}. Знак «плюс» означает, что перед положительными числами следует выводить знак (по умолчанию выводится только знак «минус» перед отрицательными). Символ «t» — использовать разделитель тысяч (тонкий пробел для русского, запятая для английского). Число «3» до точки означает, что если целая часть оказалась короче трёх символов, то её следует дополнить слева нулями до трёх разрядов. Число «5» после точки — вывести не более 5 знаков после запятой. Чтобы вывести в точности 5 знаков (дополнив их нулями справа при необходимости), перед их количеством нужно поставить 0: {$num..03} выведет число ровно с тремя знаками после запятой. Обратите внимание, что здесь нет дополнительных опций, касающихся целой части, поэтому в описании формата стоят две точки подряд.

TODO: форматирование размеров файлов и градусов/минут/секунд.

Типографика (TODO)

http://mdash.ru/rules.html

http://www.artlebedev.ru/kovodstvo/sections/62/ (и Ководство в целом)

https://github.com/typograf/typograf/blob/dev/docs/RULES.ru.md

Интерфейс для перевода

Библиотека не берёт на себя ответственность за непосредственно создание и изменение переводов, но предоставляет два интерфейса, упрощающих эти процессы.

Вызов T.showAllKeys(path) откроет попап-окно со списком всех строк в текущем переводе. Опциональным параметром path можно отфильтровать список, показав только ключи, находящиеся по указанному пути. Вызов T.showKey(key) откроет попап-окно для редактирования указанной строки.

Чтобы обработать нажатие кнопки «Сохранить», предварительно необходимо повесить обработчик: T.addUpdateListener(func). В функцию func будет передан объект вида

{
  ru: {
    updatedKey: 'new_value',
    addedKey: 'value',
    removedKey: null,
  }
}

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

Чтобы процесс перевода был более комфортным, предлагается также «инлайн-интерфейс» для переводчиков. Чтобы перевести библиотеку в такой режим, сделайте вызов T.toggleInlineTranslation(true) перед отрисовкой приложения (или перерисуйте его заново). В этом режиме все строки будут обернуты в следующий HTML-код:

<span class="inline-translation" style="border-bottom: 1px dotted #333; cursor: pointer" onclick="T.showKey('key'); event.stopPropagation(); return false;">...</span>

Чтобы запретить оборачивание отдельных строк в этот код, при вызове T('key', ...) допишите восклицательный знак после имени ключа: T('key!', ...).

После этого при клике по любому переведенному тексту будет открываться попап для перевода соответствующего ключа. При сохранении будет сделан тот же вызов обработчика, что указано выше.

При использовании инлайн-интерфейса в фреймворках, следящих за безопасностью выводимых строк нужно применять средства для подстановки «небезопасного HTML» (например, в Angular вызывать $sce.trustAsHtml и использовать директиву ng-bind-html). В свою очередь, T.js по умолчанию делает безопасными все входные данные (но не сами строки — в них можно использовать HTML-тэги), заменяя < и > на &lt; и &gt; соответственно.