Integrating Javascript (Vue) I18n into Rails pipeline with missing/auto translate features
Tags: ruby, rails, vue, i18n
Published: 2020-01-14 08:22:26

---
title: Integrating Javascript (Vue) I18n into Rails pipeline with missing/auto translate features
published: true
date: 2020-01-14 09:20:00 UTC
tags: [ruby, rails, vue, i18n]
canonical_url: https://www.stefanwienert.de/blog/2019/11/02/integrating-javascript-vue-i18n-into-rails-missing-auto-translate-pipeline/
---

Adding I18n features to a backend / "vanilla" Rails app is a common and well documented use case. Awesome tools, like `i18n-tasks` helping manage to grow the translations by providing tasks like: 

- Auto translate (via Google Translate), 
- check for unused/missing translations, 
- auto sorting translations,
- keeping all locales in sync and same structure/order.

Most of our apps nowadays are **hybrid apps**, though, that are using some amount of Javascript (Vue.js/Angular/React) on the frontside. A full I18n will also have to take that into account. When starting an app, I tend 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:

- i. fast development cycle - reloading a page reloads the translations also for I18n
- ii. separate shipping of locale texts for each locale - e.g. a German user only has to download German locales and not download all
- iii. fingerprinting of said separate locales - so that those files can be cached efficiently and at the same time don't get stale
- iv. 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:

![](https://www.stefanwienert.de/images/blog/2019/i18n-pipeline.png)

## i. Adding dependencies and configure I18n-js Gem

Add Gem, bundle, add configuration

```ruby
# 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`:

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

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

```ruby
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:

```bash
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:

```bash
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 cases<sup><sup id="fnref2"><a href="#fn2" rel="footnote">2</a></sup>.</sup>

```yaml
# 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.

```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:

```javascript
// 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:

```javascript
// 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:

```html
<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 }) }}
</template
```

Make sure to reference the documentation<sup><sup id="fnref3"><a href="#fn3" rel="footnote">3</a></sup></sup> 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:

```yaml
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:

```ruby
# 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 = /(?["'])(?[\w\.]+)(?["'])/.freeze
  WRAPPED_KEY_IN_QUOTES = /(?["'])#{KEY_IN_QUOTES}(?["'])/.freeze

  # @return [Array]
  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`

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

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




* * *

Used Open-Source:

{% github glebm/i18n-tasks %} 

{% github fnando/i18n-js %} 

{% github kazupon/vue-i18n %} 

<a name="fn2">2.</a> 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. 

<a name="fn3">3.</a> [https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting)