Rails и Angular - любовь к переводам

Как то так сложилось, что разработчики любят пихать локализацию даже туда где это нафиг не сдалось. Наверное, ко мне эта мысль пришла не так уж и давно... Казалось бы зачем столько вызовов переводов в проекте, который никогда не будет переводиться на другой язык? НО... это же не красиво писать в шаблонах по-русски :)

Проблематика

При создании каталога, о котором я писал ранее, я обратил внимание на огромное кол-во карт на иностранных языках. Основная часть конечно была на английском, но встречались варианты на французком, итальянском, чешском и т.д. В этот момент ко мне и пришла мысль сделать локализацию интерфейса на различные языки.

К чему была лирика? Обычно мы лепим локализацию везде где только можно, но так как она не используется - мы не замечаем, что она не работает. Функциональных требований нет, а значит проблема не всплывет никогда....

Так как проект я изначально делал на Angular, используя для шаблонизации связку angular-rails-templates + haml, я как всегда во вьюхах налепил класических = t :my_text_for_translation. Это было главной ошибкой :)

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

  • Вы залили код на сервер;
  • Запустили сборку ассетов или это сделал за вас capistrano;
  • Начало подгружаться окружение, в том числе следующая строка config.i18n.default_locale = :ru;
  • Метод сборки ассетов нашел шаблоны в папке с js и... отрендерил их в локали по умолчанию ;
  • На выходе вы получили файл application.js содержащим уже полностью переведенные шаблоны.

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

Решения

Решений с ходу мне в голову пришло 2, но я думаю их больше:

  • компилировать разные application.js для разных языков;
  • обойтись методами перевода для js приложений.

Я остановился на втором вариант6, который показался мне немного замороченней, но по-интереснее.

Ранее для получения локализаций в js частях rails приложения мы использовали gem i18n-js. В данном случае не будем особо отходить от традиции. Основная задача гема будет - выгрузить rails переводы в js.

Перед тем как писать файл преводов ознакомьтесь с возможностью конфигурации гема:

translations:
  - file: "app/assets/javascripts/i18n/translations.js"
    # с помощью этого параметра можно отсекать не нужные переводы, 
    # что уменьшит объем передаваемых данных на клиент
    only: "*" 

Далее для работы нам понадобятся angular библиотеки для перевода. Так как человек по природе ленив, я решил не лезть в поиск (вспомнив, что кое-кто такую задачу недавно решал и полез в внутренний gitlab). Спустя 3 минуты поисков уже ознакомился с тем как применять либу и закинул файлы модуля в vendor папку.

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

#= require ts/translate
#= require ts/directive/translate
#= require ts/filter/translate
#= require ts/service/storage-key
#= require ts/service/default-interpolation
#= require ts/service/translate

Как оказалось для базовых переводов это минимально необходимый набор.

Напомню, что для получения трансляций у вас должна быть еще строчка:

#= require i18n/translations

А так же в основном лайауте приложения:

<script type="text/javascript">
  I18n = {}
  I18n.defaultLocale = "<%= I18n.default_locale %>";
  I18n.locale = "<%= I18n.locale %>";
</script>

Далее загружаем наши трансляции в модуль angular:

angular
  .module('app', [
    'translate'
  ])
  .config ($translateProvider /* Делаем инъекцию */) ->
    /* Получаем локаль отрендеренную в лайаут */
    locale = I18n.currentLocale()
    /* Доавляем наши трансляции в провайдер и устанавливаем язык */
    $translateProvider.translations(locale, I18n.translations[locale]).preferredLanguage(locale);

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

-# в атрибутах елемента
%span{translation: 'my_text_for_translation'}

-# как фильтр
%span
  {{'my_text_for_translation' | translation}}

Кстати, поймал очень интересный баг - у меня отказался отрабатывать ng-click если в атрибутах соседствал translation. Проблема решилась переносом трансляции в элемент и перевод через фильтр. Дальше разбираться с проблемой откровенно не стал.

Домены

Каждый раз когда речь заходить о доменах и рельсе - кто-то внутри меня встает и уходит прочь :)

Заставить Rails кушать домены не так сложно:

Rails.application.routes.draw do
  constraints(LangSubdomain) do
    # Тут ваши роуты
  end
end

Сам класс LangSubdomain выглядит так:

class LangSubdomain
  def self.matches?(request)
    I18n.available_locales.map(&:to_s).include?(request.subdomain) || request.subdomain.empty?
  end
end

Не забудьте проверить, что I18n.available_locale не содержит лишнего. Если же хочется установить ручками в config/application.rb пишем нечто вроде config.i18n.available_locales = [:ru, :en].

Дальше, как подсказывает нам официальная доккументация от Rails нам нужно сделать так в ApplicationController:

def set_locale
  I18n.locale = extract_locale_from_tld || I18n.default_locale
end

def extract_locale_from_tld
  parsed_locale = request.subdomains.first
  I18n.available_locales.map(&:to_s).include?(parsed_locale) ? parsed_locale.to_sym : nil
end

Вроде все понятно и просто, но НЕ работает.

Не буду томить и сразу скажу как лечиться большинство проблем с определением поддоменов и правильным построением ссылок через хелперы:

# config/environments/development.rb
# для адресов типа http://localhost:3000
config.action_dispatch.tld_length = 0

# config/environments/production.rb
# для адресов типа http://mysite.com
config.action_dispatch.tld_length = 1

TLD - это "top-level domain", ну а length я думаю понимаем о чем :)

Следующая проблема с которой вы столкнетесь - построение ссылок с доменом. На тему как запихать домен в хелпер куча вопросов на стековерфлоу, но не все ответы адекватны. Для примера - некоторые люди изобретают целые бибилиотеки, не зная что если правильно выставлен tld_length начинает адекватно отрабатывать параметр subdomain. Например - root_url(:subdomain => 'en')

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

Скажу честно, куда только не приходится пихать этот бедный default_host, что бы он подхватился.. и кстати не просто подхватился, а еще и в ассетах работал. У меня он прописан в:

# congig/environments/development.rb
# если честно не знаю зачем. люди рекомендуют, но как показывает практика - 
# rails от этого работать не начинает
config.action_controller.default_url_options = { host: 'localhost', port: 3000 }


# config/routes.rb
# а вот это rails прожевал и ему понравилось
# в это время перфекционист плакал...
Rails.application.routes.default_url_options[:host] = case Rails.env
  when 'development' then 'localhost'
  when 'production'  then 'hmm-maps.info'
end

Если у вас не работают хелперы при рендере angular-templates -

# config/initializers/assets.rb
Rails.application.assets.context_class.class_eval do
  include ActionView::Helpers
  include Rails.application.routes.url_helpers
  include ApplicationHelper
end

Урок внимательности

У меня вследствии ночного кодинга был редкий затуп на тему "Почему не переводится" для продакшен режима... А потом было озарение - у Rails и I18n-js есть такая штука как I18n.fallbacks которая отражает что будет происходить, если перевод для текущей локали отсутвует. Так вот при установленной опции - будут подхватываться переводы из локали по умолчанию.