...

понедельник, 28 октября 2013 г.

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

В своем предыдущем посте я писал о том зачем и почему нужно было сделать 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 и на выходе превращаются в строку на нужном языке, сохраняя правила языка касающиеся разного склонения для множественных чисел + возможность указать контекст и домэин.

Важно заметить, что именно строки, они же ключи, а не константа 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] ""


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




Все те, у кого до сих пор 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, либо между ними.

Но для начала поискать в конфигурации обоих.


Если что-то упустил, не описал и т.д. — пишите в комментах, дополню.

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

Остальному найдется место в комментариях


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers. Five Filters recommends:



Комментариев нет:

Отправить комментарий