Integrating Javascript/Vue I18n into Rails missing/auto translate pipeline

2nd November 2019 – 1579 words

Adding I18n features to a Rails app is a common and well documented use case. Awesome tools, like i18n-tasks1, helping manage to grow the translations with tooling like, Auto translate (via Google Translate), check for unused/missing translations, auto sorting translation and so forth is a blast.

Most of our apps nowadays are hybrid apps, though, that are using some amount of Javascript (Vue.js/Angular/React) on the frontside. A total I18n will also have to take that into account. When starting an app, one tends to keep the translations together with the components (like Vue i18n which adds a new block into the single file components). But using this approach, one loses the consistent tooling and translations are starting to sprinkle around the application, which makes adding another locale harder and harder.

For this purpose, I’ve build the following pipeline, which fulfills the following requirements:

  • fast development cycle (reloading a page reloads the translations also for I18n)
  • separate shipping of locale texts for each locale, e.g. a German user only has to download German locales and not download all.
  • fingerprinting of said separate locales, so that those files can be cached efficiently and at the same time don’t get stale
  • unified storage for all translations and support for the Rails tooling (i18n-tasks)

The high-level overview is the following:

  1. Using i18n-js gem, we can generate a Javascript file for each locale easily. That gem also helps us during development by providing a middleware that reloads those files
  2. We use a namespace in our I18n locale tree, e.g. js. to ship only that tree to the client and not all of that keys
  3. In every layout that requires Javascript translations, we put a little partial, that loads those files and put them into a global Javascript field onto window
  4. The Javascript initializer, in our case Vue-i18n can take that data from the window and load it as locale data
  5. During production asset building, we need to add the generation of those files to our build pipeline
  6. We can add a custom i18n-tasks scanner that helps collecting ALL usages of our Javascript keys to sync which the actual translations (e.g. integrating that into “i18n-tasks unused”, “i18n-tasks missing”)

The whole process is described in the following flow chart:

i. Adding dependencies and configure I18n-js Gem

Add Gem, bundle, add configuration

# Gemfile
gem "i18n-js"
# config/i18n-js.yml
translations:
  - file: 'vendor/assets/javascripts/locales/%{locale}.js'
    namespace: "LocaleData"
    only: '*.js'

export_i18n_js: "vendor/assets/javascripts/locales/"
js_extend: false  # this will disable Javascript I18n.extend globally

Yes, that’s right! We are using the old asset pipeline to handle the auto generated translations files, here under vendor/assets/javascript/locales, because that provides a extremely simple fingerprinting and no-fuzz integration into our existing precompile step. To make that work, we need to add all those files to the asset-precompile list, e.g. when using sprockets 4+ in app/assets/config/manifest.js:

//= link locales/en.js
//= link locales/de.js

Also, manually add the I18n-JS Middleware, to enable a seamless development reloading experience.

Rails.application.configure do
  ...
  # config/environment/development.rb
  config.middleware.use I18n::JS::Middleware

Also, add the export path into gitignore, because that files change often and are auto generated anyways:

echo "vendor/assets/javascripts/locales" >> ~/.gitignore

Don’t forget, to run the i18n-js Rake export task before deployment, otherwise those files might be missing in your deployment server/ci whatever:

echo "task 'assets:precompile' => 'i18n:js:export'" >> Rakefile

ii. Loading the correct locale data into your Javascript

First, add some example locale. As stated in the introduction, we are using a namespace js.* where all Javascript keys are kept inside. This has the advantage that we can very simply export only the necessary locale data to the client. There is an option in i18n-js, which can hide that “js.” namespace in the client, e.g. a Rails i18n key of js.component.button would be accessible as component.button in the frontend. But I’ve decided against, because that namespace interferes when using I18n-tasks to check for missing/unused in several cases2.

# config/locales/js.en.yml'
en:
  js:
    button_text: "Save"

Now, make sure, every relevant layout, that has translated Javascript on it, loads the locale before everything else, e.g. using a layout partial which you require before loading any other Javascript.

<!-- app/views/layouts/_i18n_loader.html.erb -->
<script>
window.Locale = <%= raw I18n.locale.to_json %>
window.LocaleData = {}
</script>

<%= javascript_include_tag "locales/#{I18n.locale}" %>

Then, our pack / framework initializer can pick that up. In our case, we are using Vue-i18n:

// app/javascripts/utils/i18n.js
import Vue from 'vue'
import VueI18n from 'vue-i18n'

Vue.use(VueI18n)

const i18n = new VueI18n({
  locale: window.Locale,
  fallbackLocale: 'de',
})

const rawData = window.LocaleData.translations[window.Locale]
i18n.setLocaleMessage(window.Locale, rawData)

export default i18n

Now we can include that i18n key when initializing every Vue root “app” on our site:

// app/javascripts/packs/app.js
import i18n from 'utils/i18n'

new Vue({
  el,
  i18n,
})

That’s it! Every component can now access locale data, like that:

<template>
  <!-- passing as efficient v-t handler -->
  <button v-t="'js.button_text'"></button>

  <!-- using i18n arguments -->
  <button>{{ $t('js.text_with_arguments', { name: name }) }}</button>
</template

Make sure to reference the documentation3 of Vue-i18n, as there are some differences to Rails built-in message style:

  • Passing arguments: Instead of "somekey %{argument}" use somekey {argument}
  • Pluralization: instead of:
somekey:
  one: "1 car"
  other: "%{count} cars"

use: somekey: 1 car | {n} cars or with “nothing”: somekey: no car | {n} car | {n} cars

iii. Adding I18n-Tasks Scanner Adapter

Translations should work find now. To make I18n-Tasks aware of our locales, use this scanner:

# lib/vue_i18n_scanner.rb
require 'i18n/tasks/scanners/file_scanner'
require 'pry'

# finds v-t="" and $t(c) usages
class VueI18nScanner < I18n::Tasks::Scanners::FileScanner
  include I18n::Tasks::Scanners::OccurrenceFromPosition

  KEY_IN_QUOTES = /(?<in>["'])(?<key>[\w\.]+)(?<in>["'])/.freeze
  WRAPPED_KEY_IN_QUOTES = /(?<out>["'])#{KEY_IN_QUOTES}(?<out>["'])/.freeze

  # @return [Array<[absolute key, Results::Occurrence]>]
  def scan_file(path)
    text = read_file(path)
    # single file component translation used
    return [] if text.include?('<i18n')

    out = []
    # v-t="'key'"  v-t='"key"'
    text.to_enum(:scan, /v-t=#{WRAPPED_KEY_IN_QUOTES}/).each do |_|
      key = Regexp.last_match[:key]
      occurrence = occurrence_from_position(
        path, text, Regexp.last_match.offset(:key).first
      )
      out << [key, occurrence]
    end
    text.to_enum(:scan, /\$tc?\(#{KEY_IN_QUOTES}/).each do |_|
      key = Regexp.last_match[:key]
      occurrence = occurrence_from_position(
        path, text, Regexp.last_match.offset(:key).first
      )
      out << [key, occurrence]
    end
    out
  end
end
I18n::Tasks.add_scanner 'VueI18nScanner', only: %w(*.vue)

And load that scanner in your config/i18n-tasks.js

echo "<% require './lib/vue_i18n_scanner' %>" >> config/i18n-tasks.js

Now, i18n-tasks health/unused/missing will scan our javascripts too.

  1. github.com/glebm/i18n-tasks 

  2. Adding the prefix in our custom scanner while scanning the files was a way that worked, BUT the default I18n-scanner already picked up some of the keys by it’s own regex scanner ($t(..)-usages) which then would then be marked as missed. There seemed to be no way to ignore the app/javascript/ for only the default scanner but only for all. 

  3. https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting