← Home

Organizing translations with I18n::Cascade and I18n::MissingTranslations

When it comes to I18n one of the questions I get asked most often is how to organize translation keys. So I thought I’d write down how we’re doing it at work, and thus adva-cms2.

DRY does not apply to L10n

Before I get into this let me (once again) explain why DRY doesn’t apply to L10n though because that’s the reason why we allow for those deeply nested keys and namespaces in I18n.

Here’s a slide from my Anatomy of Ruby I18n talk at Euruko 2010:

class Internationalization < Abstraction
  def perform

class Localization < Concretion
  def perform

Internationalization refers to the work we do as developers. E.g. we extract strings from our code and make them available in translation files. Obviously the principle of DRY applies to this work in the sense that we don’t want to reimplement those portions of our code that actually looks up translations and stuff like that.

But DRY does not apply to Localization and therefor also doesn’t apply to our translation keys which can be seen as our interface to or contract with our translators. Instead, as developers our job is to pass control and enable translators to define different translations for the (seemingly) same key in different contexts.

For example, even if the string “edit” works as a translation in almost any context in English, that might not be true for other languages that have richer semantics and might have different translations for “editing” different things. As a developer we won’t ever be able to know these things in advance and so we just can’t predict which keys can be joined (“DRYed up”) and which can’t. :'post.edit' may or may not have the same translation in every target language as :'user.edit'.

Using I18n::Cascade

That’s one reason why the I18n API supports defaults. Using defaults we might express “either use an existing flash message for this particular model or use the common default message” like this:

I18n.t(:'flash.post.update.success', :default => [:'flash.update.success'])

This way translators can decide whether to use the default translation or specify a different one for this particular model. But obviously we don’t want to type that all the time.

Also, Rails’ translate view helper supports a great convention of automatically scoping translation keys to the current controller name and the current view/template name. E.g. the following lines will both look up the same translation key :'posts.show.edit':

# in posts/show.html.erb

Now, why not extend this convention so that the translate view helper always adds defaults so it effectively behaves like this call:

t('posts.show.edit', :defaults => [:'posts.edit', :edit])

Enter I18n::Cascade.

This helper module one of the lesser known modules which are shipped with the I18n gem. It can be included into compliant I18n backends and can be configured by passing a :cascade option per request.

Starting from version 0.5.0 the behaviour above can be achieved like this:

I18n::Backend::Simple.send(:include, I18n::Backend::Cascade)

# should really be a module, but i have no idea how/where to include it :/
ActionView::Base.class_eval do
  def translate(key, options = {})
    super(key, options.merge(:cascade => { :offset => 2, :skip_root => false }))
  alias t translate

Now either of these keys could be translated and will be found … obviously starting with the most specific key :'posts.show.edit' and cascading down to the least specific one :edit:

# en.yml
  edit: Edit
    edit: Edit
      edit: Edit

I generally recommend using Rails’ controller/view scoping convention. Together with I18n::Cascade it is quite easy to provide some of the more common translations (like “new”, “edit”, “delete”) on common scopes but still allow translators to provide translations for particular view specific keys.


The i18n-missing_translations gem hooks into the I18n::ExceptionHandler class and logs I18n::MissingTranslationData exceptions. It includes:

  • an in-memory logger that simply holds missing translations during a request or test run and
  • a middleware that can be used to dump the contents of the logger to a file after each request

Pretty handy.

Using the in-memory logger makes sense, e.g., in the test environment. E.g. we could add this to the test_helper or Cucumber env:

at_exit { I18n.missing_translations.dump }

This simply outputs a YAML snippet for all translations that were missing during the test run which can be copied over to the translations file.

In development mode one rather will want to log missing translations to an actual translations file. The provided middleware does that by logging to a file missing_translations.yml in your locales dir (which is config/locales if present or the current directory otherwise). You can also pass the filename as an optional argument:

config.app_middleware.use(I18n::MissingTranslations) if Rails.env.development?

The middleware reads and writes per request. That means that on subsequent requests missing translations are added to the missing_translations.yml file. So if you go ahead and copy translations from the missing_translations.yml to your actual locale files you will also want to clear or delete missing_translations.yml.

Also note that Rails does not pick up new locale files between requests (I’d consider that a bug, in development mode it should pick them up). That effectively means that manual changes to a new missing_translations.yml file might be overwritten unless you restart the server. Thus your workflow for finding and moving missing translation keys might look something like this:

  • start the server
  • click around/work on stuff
  • check config/locales/missing_translations.yml
  • copy any missing translation keys to your actual locale files and correct the translations
  • delete or clear config/locales/missing_translations.yml
  • restart the server

By the way, starting from Rails 3.1 the `translate` view helper will use the :rescue_format facility from I18n 0.5.0 exception handling. This means that missing translations will be returned as essentially: keys.last.to_s.gsub('_', ' ').titleize wrapped into a span tag with a translation_missing class set. I.e. a missing translation for :'post.show.edit' will return “Edit”.

Hopefully these hints help working with translations a little bit.

Let me know what you think!