Рецепт i18n. Основа — Babel, json с кофе и грант с hbs на свой вкус

в 21:54, , рубрики: babel, i18n, javascript, json, python, tutorial, Веб-разработка, локализация, метки: , , , , ,

В своем предыдущем посте я писал о том зачем и почему нужно было сделать pybabel-hbs, экстрактор строк gettext из шаблонов handlebars.

Чуть позже появилась необходимость извлекать так же из json.
Так появился pybabel-json.
pip install pybabel-json либо на github

Там использовался лексер джаваскрипта встроенный в babel, но нюансы так же имелись, но пост не об этом, написанное там менее интересное, чем было в hbs плагине и вряд ли нуждается в заострении внимания.

Этот пост о том, как же в целом выглядит полный набор для локализации, от и до, что делать с данными из БД, либо из другого не совсем статичного места.
От и до включает в себя:
(должен заметить — что ни один пункт не является обязательным, все это достаточно легко подключается к любому приложению только частично и по необходимости)

— Babel. Набор утилит для локализации приложений.
— Grunt. Менеджер задач(task-ов),
— coffeescript. В представлении не нуждается, весь клиенстский код написан на coffee, и из него тоже нужно извлекать строки.
— handlebars — темплейты
— json — хранилища строк
— Jed. gettext клиент для js
— po2json. Утилита для перевода .po файлов в .json формат поддерживаемый Jed-ом

Немного о gettext и мифах

gettext — изначально набор утилит для локализации приложений, сегодня же я бы назвал gettext еще и общепринятым форматом. (не путать с единственным)
Минимальную суть можно описать так, есть строки на английском, которые проходят через некую функцию gettext и на выходе превращаются в строку на нужном языке, сохраняя правила языка касающиеся разного склонения для множественных чисел + возможность указать контекст и домэин.
Важно заметить, что именно строки, они же ключи, а не константа USER_WELCOME_MESSAGE где-то превращающаяся в текст.

Контекст нужен далеко не всем и в своих плагинах babel-а я его пока что не реализовывал, так как без надобности, пулл реквесты приветствуются
О домэине будет пара слов позже.
А вот ngettext — штука безусловно необходимая многим, если не всем.
И тут же о мифах.

Ноль яблок.              Zero apples
Одно яблоко.             One apple
Два яблока.              Two apples
Пять яблок.              Five apples

Этот простой пример должен показать всем любителям языковых констант а-ля «USER_WELCOME_MESSAGE», которые потом отдаются на перевод, что все не так просто как кажется на первый взгляд.

За то, какая строка будет выбрана решают правила предопределенные и описанные в babel:
Например это для английского:

"Plural-Forms: nplurals=2; plural=(n != 1)n"

А это для русского:

"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)n"

Велик и Могуч :)
Не нужно бояться, в ручную этого писать для, например, японского не прийдется.

Так вот, о мифах.
Несколько раз слышал мнение, что можно делать основной сайт на русском и оборачивать русские же строки в вызовы gettext, а потом добавить английский.
Если у вас свои костыли с использованием тех самых языковых констант, у вас нигде нет склоняемых предложений с числами, а используется некрасивый формат в стиле «У вас яблок: 1», то конечно, можно делать основным русский.
Ежели вы хотите отобразить пользователю чуть более красивые сообщения, как например «У вас 1 яблоко», «У вас 7 яблок» то основным языком должен быть английский.

Почему? Все дело в яблоках.
Множественное число не всегда в единственном числе, а единственное число не всегда для единицы.
Английский в этом плане прост, русский же нет.

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

Таким образом, если вы все таки хотите использовать русский по умолчанию вам как минимум прийдется поддерживать файл перевода русский-русский, в котором строка «У вас есть %s яблок» будет превращаться в правильное склонение. Да, можно — но это криво.
При изменении нужно будет помнить, что изменен только ключ, а не строка на русском языке и нужно пойти и параллельно править файл русского языка. В общем, не нужно так делать. ngettext максимально совместим именно с английским языком в качестве оригинала.

Кстати, заодно покажу пример, того как выглядят .po файлы для английского и для русского

msgid "You have %(apples_count)d apple"
msgid_plural "You have %(apples_count)d apples"
msgstr[0] "У вас %(apples_count)d яблоко"
msgstr[1] "У вас %(apples_count)d яблока"
msgstr[2] "У вас %(apples_count)d яблок"
msgid "You have %(apples_count)d apple"
msgid_plural "You have %(apples_count)d apples"
msgstr[0] ""
msgstr[1] ""

Т.е кол-во результирующих строк зависит от конфигурации языка. Может быть и есть язык, в котором этак десяток форм множественного числа…

OK, So Where Do I Start?

Все те, у кого до сих пор 3 яблок должны быть мотивированы для того что бы начать

pip install babel

Тяжелая часть позади.

Осталось:
— Изменить в коде весь текст на вызовы gettext
— Натравить babel на код
— На основе полученного .pot файла сделать .po файл соответствующий каждому нужному языку.

А что собственно переводить?

Вопрос не так прост как кажется на первый взгляд:

Часть простая — шаблоны и код.
Django и flask — есть экстракторы из шаблонов
Python и javascript поддерживаются babel изначально
handlebars и json — пришлось сделать, ссылки в начале поста.
Для coffeescript — рецепт далее
Для всего остального — гугл в помощь

Еще раз, часть простая — код, для этого все строки нужно обернуть в вызовы gettext/ngettext в соответствии с форматом, который требует каждый из экстракторов. Как правило они так же предоставляют возможность переопределить какую функцию должны использовать
Например, у меня так:

pybabel extract -F babel.cfg -o messages.pot -k "trans" -k "ntrans:1,2" -k "__" .

trans и ntrans указан для джаваскрипта, а __ для питона, в котором эта функция используется для прозрачной передачи строки(об этом позже)

Т.е, все
print(«apple») нужно переделать в print(ngettext(«apple»))
А все
print(«I have %s apples») в print(ngettext(«I have %s apple»,«I have %s apples»,num_of_apples)%num_of_apples)

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

Python:

print(gettext("I have an apple!"))

print(ngettext(
      "I have %(apples_count)d apple",
      "I have %(apples_count)d apples",
       num_of_apples
).format(apples_count=num_of_apples))

Используется стандартный gettext, для flask и джанго есть свои обертки

Javascript:

console.log(i18n.trans("I have an apple!"))
console.log(i18n.ntrans("I have %(apples_count)d apple","I have %(apples_count)d apples",num_of_apples,{apples_count:num_of_apples}));

Тут и в кофе используются прокси для методов Jed отсюда:
github.com/tigrawap/pybabel-hbs/blob/master/client_side_usage/i18n.coffee
Параметры передаются в строку засчет встроенного в Jed sprintf

Coffeescript:

console.log i18n.trans "I have an apple!"
console.log i18n.ntrans "I have %(apples_count)d apple", "I have %(apples_count)d apples", num_of_apples, 
        apples_count:num_of_apples

Hadlebars:

{{#trans}}
I have an apple!
{{/trans}}

{{# ntrans num_of_apples apples_count=num_of_apples}}
  I have %(apples_count)d apple
{{else}}
   I have %(apples_count)d apples
{{/ntrans}}

JSON хранилище строк:

{
    "anykey":"I have an apple!",
    "another_any_key":{
           "type":"gettext_string",
           "funcname":"ngettext",
           "content":"I have %(apples_count)d apples",
           "alt_content":"I have %(apples_count)d apples"
    }
}

Оффтоп: Пояснение к этому формату в документации к pybabel-json

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

— Как я уже говорил — это простая часть, завернуть существующий текст.
Далее нужно

1) Изменить все кнопки на которых надписи на кнопки с текстами. Все знают что кнопки с текстом это плохо. Но часто это приходится принять, так как так быстрее, а дизайнер хочет именно так :)
— С этим пунктом все должно быть ясно — нудно, но необходимо

2)
Куда более интересный пункт, это что делать с вроде бы постоянными строками, но которые не совсем постоянные?
Как пример приведу наш случай — жанры к песням. Вроде бы и динамика, в БД хранятся, но по сути — редко меняющаяся статика, которую неплохо было бы выдрать и отправить на перевод.

Именно это и стало причиной появления pybabel-json.
Это решение так же является решением любой другой проблеме перевода, как например — ответ об ошибке стороннего сервера сообщением. Можно сказать что это статика, но это неподконтрольная нам статика, которую нужно красиво завернуть для перевод.
Все что нужно — создать .json файл
errors.json
с содержимым

{
    "from_F_service": [
       "Connection error",
        "Access denied"
],
    "from_T_service":[
        "Oops, it is too long"
]
}

Никаких ключей, чистый массив строк.
Самое ужасное что случится если сервис изменил сообщение — пользователь получит непереведенный вариант. Как правило это мелочи

С данными в БД ситуация похожая, в систему билда-пуша-деплоя, что бы то ни было (ведь что-то у вас есть)? на том же уровне, где будут комманды для сборки всего и вся babel-ом нужно перед этими самыми командами добавить скрипт который будет извлекать все нужные данные из БД и собирать подобный json, запущенный следом babel уже соберет данные.
Само собой — такие файлы следует добавить в .gitignore либо аналог чего-бы-там ни было, в общем, чтоб в source control не попадало

Все строки, которые получены подобным образом должны проходить через вызов gettext функции
Т.е если это в python, то gettext(), в js Jed либо прокси-методы приведенные ранее

Так же следует заметить, что порой хочется сделать в обратном порядке. Либо необходимо сделать в обратном порядке.
Т.е определить в коде что строка должна переводиться, но непосредственно сам перевод будет запущен в другом месте.
Приведу пример на python:

class SomeView(MainView):
      title=gettext("This view title")

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

В таких случаях хочется отметить для перевода, но перевести в нужном месте
Нужное место это создание объекта, а не класса
т.е


def __(string,*k,**kwargs):
    return string

class MainView(SomeParent):
    def __init__(self):
             #....
             self.title=gettext(self._title)
             #....

class SomeView(MainView):
      _title=__("This view title")

Т.е — сборщик строк определит __ как строку для перевода, сама функция не делает ничего, а перевод будет запущен в нужное время.
Таким образом все в одном месте и выглядит красиво.

Это касается многих языков, в том числе coffeescript и джаваскрипт, если вы пишете под node.js.
Для браузера это менее актуально, так как даже в момент создания класса уже должно быть известно для какого языка создавать.

Но в любом случае — правильнее перевести в конструкторе, а не в момент создания класса.

Вроде бы обошел все известные мне возможности направления перевода, допустим все это сделано.

Склеиваем все вместе

Теперь можно попытаться все это собрать, тут есть несколько простых шагов:
0) Создать пустой каталог оригинальных строк, чтоб не ругался в дальнейшем на отсутствие файла

touch messages.pot

1) Создать .po файлы целевых языков Это делается 1 раз и не должно включаться в билд. .po файлы это файлы содержащие как оригинальные строки, так и перевод к ним, для каждого языка.

pybabel init -i messages.pot -d path/i18n -l es
#Эта команда создаст .po  для испанского языка в директории path/i18n/es (включая саму директорию i18n если нужно)
#Повторить для каждого языка, либо за раз: (Кстати может кто подскажет, как это можно было сделать без echo?, echo мне кажется костылем) 
echo {es,en,fr,de,ja} | xargs -n1 pybabel init -i messages.pot -d path/i18n -l 

2) Создать/обновить .pot файл — основное хранилище строк Это так же не должно включаться в билд, а нужно запускать когда необходимо получить новые .po файлы, которые будут отправлены на перевод.

python/node/your_language update_translation_jsons 
#Упомянутое ранее обновлении данных из ДБ
pybabel extract -F babel.cfg -o messages.pot -k "trans" -k "ntrans:1,2" -k "__" .
# извлечение новых строк
# trans - для экстрактора из джаваскрипта, ntrans - тоже
#  __ для "прозрачного" экстрактора из питона
# babel.cfg - конфиг babel-а что и откуда брать
pybabel update -i messages.pot -d path/i18n/
#обновление .po файлов для всех языков,

Тут будет не лишним показать пример babel.cfg файла, это mapping файл, указывающий на то, чем и из каких файлов извлекать строки:


[python: path/backend/notifier.py]
[hbs: path/static/**.hbs]
[json: path/static/i18n/src/**.json]
[javascript: path/static/**.coffee_js]
encoding = utf-8

3) Прогнать все .po файлы через po2json, для получения .json, которых и примет Jed.
Вот это можно и нужно включить в build.
Чего нельзя делать — так это пускать в git, им там не место.

Как именно скормить все .po файлу и куда их положить — на совести юзера.
Я же их прогоняю в grunt, как и весь остальной билд.
grunt-po2json который есть на github и в репозитории гранта поломан, так как не поддерживает rename, а он нужен, так как по мне удобней, когда все конечные .json файлы идут в одну директорию, локально я это исправил, но нужно отправить на это дело пулл реквест…

Можно конечно и намного проще, после установки po2json (npm install po2json) включить нечто подобное в build script:

echo {es,en,fr,de,ja} | xargs -n1 -I {}  po2json /path/i18n/{}/LC_MESSAGES/messages.pot /path/to/build/i18n/{}.json
Не вошедшие в поток мысли, но имеющие смысл заострить на них внимание моменты

На протяжении поста несколько раз обещал «об этом позже», но для позже подходящего места не нашлось.

Как например:
coffeescript не имеет собственного экстрактора, т.к при билде статики coffeescript компилируется(либо транслируется) в javascript.
Поэтому достаточно запустить сборку .js строк после перевода в джаваскрипт
В моем случае все даже немного не так, рядом с каждым файлов coffee лежит файл coffee_js, который создается с помощью grunt watch в момент редактирования (и перезапускает дев статику, но это тема для отдельного поста :) ), эти файлы само собой вне гита. Вот из них строки и вытаскиваются

— Еще было упоминание о домэинах.
Домэины в конечном итоге это разные файлы, messages.pot/messages.po = домэин messages
Можно создавать несколько домэинов, все домэины привязывать к Jed инстансу, либо создавать несколько разных Jed инстанцев и перенаправлять в них
Но для этого нужно расширять хелперы handlebars либо любую другую обертку… У меня такой необходимости еще не было никогда, а как правило предпочитаю не делать ничего лишнего заранее :)

— Небольшая сноска к тексу во вступительном блоке

Ежели вы хотите отобразить пользователю чуть более красивые сообщения, как например «У вас 1 яблоко», «У вас 7 яблок» то основным языком должен быть английский.

Тут следует понимать, что в вызове ngettext необходимо писать именно «you have %(apples_count)d apples», а не «you have one apple»
Т.к в и в случае одного и в случае 21-ого конечная строка должна быть в первой форме — т.е «У вас %d яблоко»

— Так же будет важным заострить внимание на одном вопросе, который я еще не успел решить на автоматическом уровне:
babel создает «пустую строку» (конфигурация .po файла, определяющая какой это язык и какие должны быть строки для множественного числа) в формате не совместимом с Jed
Jed ожидает, что там будет «plural_forms», babel же выдает Plural-Forms
Тут нужно будет править либо вывод babel, либо вход Jed, либо между ними.
Но для начала поискать в конфигурации обоих.

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

Автор: TigraSan

Источник

Поделиться

* - обязательные к заполнению поля