Internationali(z|s)ation - I18n tools

Part 28 of building a Rails 7 application

This application may never have a user that does not read English but I believe it is good practice to allow for that possibility as soon as possible in the development of an application. Retrofitting this to an existing large application would be a time consuming and possibly error prone task.

I am choosing to use "British" English for the default language as I am Australian (and live in the U.K.!) so therefore all spelling will be British.

Rails has excellent built in support for I18n but there is one additional tool I am going to add to aid in the process and that is i18n-tasks (github.com/glebm/i18n-tasks). This can be used to find missing translations and can be connected to Google Translate and/or DeepL to do automated translations.

Configuration

First of all there are some configuration settings that need to be added:

config/application.rb

    # I18n
    config.i18n.default_locale = 'en-GB'
    config.i18n.available_locales = %w[en-GB en-US th zh]
    config.i18n.raise_on_missing_translations = Rails.env.development?
    config.i18n.load_path += Dir[Rails.root.join('config/locales/**/*.{rb,yml}')]

In order:

  • Set the default locale to be en-GB if none is specified, so English becomes the de-facto translation for strings.
  • For the moment I'll just support four different locales, British English (en-GB), US English (en-US), Thai (th) and Chinese (zh). These are a bit arbitrary and could change.
  • The application should raise an exception if I attempt to use a translation that does not exist if I'm running in the development environment. For all other environments the i18n module simply shows whatever text was entered as the "key" if there is no appropriate translation.
  • Tell the i18n module where to find the locale configuration files, more on that next.

To make it a bit less overwhelming looking at the text that has been translated I'm going to split the locale files into multiple sub folders. Something like this:

- config
  - locales
    - models
      - audit
         en.yml
    - views
      - default
         en.yml
      - audits
         en.yml

The next key part of using locales is setting the current locale that Rails will use. There are a number of different ways to go about this, all of which are covered in the i18n rails guide. For me I'll make it a setting on the User itself. At this point though I don't have a way to set any attributes on the user so rectifying that will be the first step.

There is almost nothing in this that is new to this series so I won't repeat all the code for that except to say I don't have any controls for doing a dropdown selection. That will make a good topic for a Learn More that I'll add in future.

Here is the final result of my user edit page:

image.png

Now that a user can choose a locale I need to apply it. This needs to happen in the controller so I'll add a controller concern to contain the code:

app/controllers/concerns/localisation.rb

module Localisation
  extend ActiveSupport::Concern

  included do
    around_action :use_locale
  end  

  private

  def use_locale(&action)
    locale = current_user&.locale || I18n.default_locale
    I18n.with_locale(locale, &action)
  end
end

The use_locale method will first look to see if our currently authenticated user has specified a locale. If there is no user or the user has no setting I'll fall back to the default locale which as mentioned previously is 'en-GB'. The with_locale method provided by i18n takes care of applying the appropriate translations while executing the given action.

This concern is included into the ApplicationController for use by all other controllers in the application:

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Localisation

  add_flash_types :success, :information, :error, :warning

  protected 

  def current_user
    nil
  end
end

Applying the translations to all my currently hard coded English text is the next (somewhat tedious) step. Doing this now rather than later though will save time in the future. There are various ways to use a translation. Here are the examples that were useful to my application:

ActiveRecord "human" class methods

Given the following yml file:

config/locales/models/item_sell_pack/en-GB.yml

---
en-GB:
  activerecord:
    attributes:
      item_sell_pack:
        canonical: Canonical
        created_at: Created At
        name: Name
        updated_at: Updated At
    models:
      item_sell_pack:
        one: Item Sell Pack
        other: Item Sell Packs

I can now use these methods to retrieve translations for classes and attributes.

<class>.human_attribute_name - will look up the key for <locale>.activerecord.<class>.attributes.<attribute name>.

Example:

<% component.with_field do |c| %>
      <% c.with_attribute_text(
             attribute: :name,
             label: resource_class.human_attribute_name(:name)
       ) %>
<% end %>

<class>.model_name.human(count: x) - will look up they key for <locale>.activerecord.models.<class>.[one | other] where it will choose one or other depending on whether the count is provided (if it is not given then one is assumed).

Example:

<%= render PageHeading::Component.new(title: resource_class.model_name.human(count: 2)) do |c| %>

The t method and lazy lookups

Given the following yml file:

config/locales/views/item_sell_packs/en-GB.yml

---
en-GB:
  item_sell_packs:
    index:
      description: These are the names for how a supplier sells a complete package
      filters:
        canonical:
          label: Canonical
        name:
          label: Name contains
    navigation:
      tabs:
        details: Details
    resource:
      description: The name for how a supplier sells a complete package
      help:
        canonical: Name is acceptable to all users?
      invalid_message:
        name: A name must be entered.

The t method can be used to retrieve a translation for a given key.

Example:

<%= render PageHeading::Component.new(description: t('item_sell_packs.index.description')) do |c| %>

The phrase "These are the names for how a supplier sells a complete package" will be picked and displayed to the user for this key. However, there is a shortcut that can be applied for views that are structured correctly in the locale yml:

Example:

<%= render PageHeading::Component.new(description: t('.description')) do |c| %>

This has the exact same effect as the first example and requires much less typing!

Managing translations

Keeping on top of the translation yml files can be a challenging process, but fortunately the excellent gem i18n-tasks can help out immensely. This tool can find unused translation keys, or keys that are missing a translation, including across languages. It can also be used to keep the translation files in a neat and consistent format.

It is only needed for development so I'll add it to those groups in my Gemfile:

Gemfile

group :development, :test do
  gem 'i18n-tasks', '~> 1.0.11'
end

Some configuration needs to be done as well but I'll get to that in a moment when I come to how to handle translations for components.

Running i18n-tasks health will tell me whether I need to fix anything with my translations.

% i18n-tasks health
Forest (en-GB) has 126 keys in total. On average, values are 18 characters long, keys have 3.8 segments.
✓ Well done! No translations are missing.
✓ Good job! Every translation is in use.
✓ Well done! No inconsistent interpolations found.
The following data requires normalization:
config/locales/models/audit/en-GB.yml
config/locales/views/audits/en-GB.yml
Run `i18n-tasks normalize` to fix

In addition this gem has also provided some tests for my suite also:

Failure:
I18nTest#test_files_are_normalized [/rails/catalogue_cleanser/test/i18n_test.rb:29]:
The following files need to be normalized:
  config/locales/models/audit/en-GB.yml
  config/locales/views/audits/en-GB.yml
Please run `i18n-tasks normalize' to fix.
Expected ["config/locales/models/audit/en-GB.yml", "config/locales/views/audits/en-GB.yml"] to be empty.

ViewComponents

Applying i18n to components is mostly the same however the location of the translation files is different which needs to be configured for both for Rails and the i18n-tasks gem. The approach I'm using is to make the translation yml files a sidecar in the same way the Stimulus controllers are.

image.png

app/components/collection_filter/component.yml

---
en-GB:
  collection_filter:
    date_range_component:
      range_end:
        label: "%{attribute} is on or before"
      range_start:
        label: "%{attribute} is on or after"

To make this work Rails needs to have a new load_path added to the i18n configuration:

config/application.rb

config.i18n.load_path += Dir[Rails.root.join('app/components/**/*.yml')]

Using the lazy lookup works in the component HTML erb files but does not seem to in the component classes. For example using the above example yml this works:

t('.range_start.label') in the app/components/collection_filter/date_range_component.html.erb file but attempting that in the app/components/collection_filter/date_range_component.rb would not, and would require using the fully specified key to get the translation (ie collection_filter.date_range_component.range_start.label)

The i18n-tasks gem needs to know a few more things as well so it does not get confused:

config/i18n-tasks.yml

# i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks

# The "main" locale.
base_locale: en-GB
## All available locales are inferred from the data by default. Alternatively, specify them explicitly:
# locales: [es, fr]
## Reporting locale, default: en. Available: en, ru.
# internal_locale: en

# Read and write translations.
data:
  ## Translations are read from the file system. Supported format: YAML, JSON.
  ## Provide a custom adapter:
  # adapter: I18n::Tasks::Data::FileSystem

  # Locale files or `File.find` patterns where translations are read from:
  read:
    # Default:
    - config/locales/%{locale}.yml
    # More files:
    - config/locales/**/%{locale}.yml
    - app/components/**/*.yml

  # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom:
  # `i18n-tasks normalize -p` will force move the keys according to these rules
  write:
    ## For example, write devise and simple form keys to their respective files:
    # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml']
    ## Catch-all default:
    # - config/locales/%{locale}.yml

  # External locale data (e.g. gems).
  # This data is not considered unused and is never written to.
  external:
    ## Example (replace %#= with %=):
    # - "<%#= %x[bundle info vagrant --path].chomp %>/templates/locales/%{locale}.yml"

  ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class.
  # router: conservative_router

  yaml:
    write:
      # do not wrap lines at 80 characters
      line_width: -1

  ## Pretty-print JSON:
  # json:
  #   write:
  #     indent: '  '
  #     space: ' '
  #     object_nl: "\n"
  #     array_nl: "\n"

# Find translate calls
search:
  ## Paths or `File.find` patterns to search in:
  # paths:
  #  - app/

  ## Root directories for relative keys resolution.
  relative_roots:
    - app/controllers
    - app/helpers
    - app/mailers
    - app/presenters
    - app/views
    - app/components

  ## Directories where method names which should not be part of a relative key resolution.
  # By default, if a relative translation is used inside a method, the name of the method will be considered part of the resolved key.
  # Directories listed here will not consider the name of the method part of the resolved key
  #
  relative_exclude_method_name_paths:
    - app/components

  ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting:
  ##   *.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less
  ##   *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus *.webp *.map *.xlsx
  exclude:
    - app/assets/images
    - app/assets/fonts
    - app/assets/videos
    - app/assets/builds

## Translation Services
translation:
#   # Google Translate
#   # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate
  google_translate_api_key: "REDACTED"
#   # DeepL Pro Translate
#   # Get an API key and subscription at https://www.deepl.com/pro to use DeepL Pro
  deepl_api_key: "REDACTED"
  deepl_host: "https://api-free.deepl.com"
  deepl_version: "v2"

## Consider these keys used:
ignore_unused:
  - 'activerecord.models.*'
  - 'activerecord.*.attributes.*'
  - 'datetime.distance_in_words.*'
  - 'datetime.prompts.*'
  - 'errors.messages.*'

Automatic Translations with Google and/or DeepL

One of the neat things that i18n-tasks can do is connect to Google or DeepL and ask for translations of all your keys that are missing a value for a specific language. Note that requesting this may incur a cost, depending on the number of characters being sent to their services. There are also some limitations on the available languages. Once you have the API key for your chosen service(s) insert them into the config/i18n-tasks.yml file (see above) and you can execute either:

Google Example: i18n-tasks translate-missing --from=base th DeepL Example: i18n-tasks translate-missing --backend=deepl --from=en de

Specify the correct language for what you require.

And that wraps it up for localisation!

guides.rubyonrails.org/i18n.html

github.com/glebm/i18n-tasks

cloud.google.com/translate/docs/languages

deepl.com/docs-api/translate-text/translate..