Rails, Internationalization, and Tú

Internationalization (or I18n if you’re hep) is a bear of a problem to deal with in software development. I’ve had to work with a multi-lingual site in PHP before. It wasn’t painful, but it was constantly annoying. I just got a chance to work with internationalization in Rails for the first time, and I was pretty excited to see how it’s been handled.

The good news that I found is that Rails’ I18n support is pretty great. The bad news is that it is overly integrated with the rest of Rails, making changing it in isolation difficult.

The Basics of I18n in Rails

There’s a great Rails Guide on internationalization that’s required reading for anyone trying this at home. My brief nutshell of an explanation is that you create a Ruby or YAML file under config/locales that has a set of hashes of hashes pointing to a specific key. An example would be { 'en' => { 'activerecord' => { 'errors' => {'blank' => 'is required' } } }. That sets the English translation for the key activerecord.errors.blank to “is required.”

You can set your Rails application’s default locale like so:

I18n.default_locale = :es

That will set it to Spanish. There’s not a definitive list of locales, so you could use :lolcatz or :morlock and it will work fine. You set the locale in a specific request like this:

I18n.locale = :es

When you do that, all keys called via I18n.translate (accessible as t in views because of helpers) will return the Spanish translation.

Overall, this is simple and easy. We found this to be helpful not only for Spanish translations, but setting English text as well. You can set the human-readable names of your ActiveRecord models and attributes through this facility, which is very nice for creating forms, especially with Formtastic.

Extending I18n

Reading these YAML and Ruby files and holding the translations in memory is the job of the class I18n::Backend::Simple, which is what’s written on the box: a simple backend for the I18n service in Rails. If you want translations to be editable by non-programmers, though, YAML and Ruby files won’t cut it.

It is easy to extend Simple or write a new backend. Once you’ve written one, you can tell `I18n` to use it:

I18n.backend = I18n::Backend::SnippetBackend.new

I wanted to store my translations in the database and knew I’d only have English and Spanish translations forever. If you’re supporting multiple languages, you’ll obviously want to add another model to the following code. Here’s the backend I wrote:

module I18n module Backend class SnippetBackend < Simple protected def init_translations load_translations(*I18n.load_path.flatten) load_translations_from_database @initialized = true end def load_translations_from_database data = { :en => {}, :es => {} } Snippet.all.each do |snippet| path = snippet.name.split(".") key = path.pop en = data[:en] es = data[:es] path.each do |group| en[group] ||= {} en = en[group] es[group] ||= {} es = es[group] end en[key] = snippet.english es[key] = snippet.spanish end data.each do |locale, d| merge_translations(locale, d) end end end end end 

This code pulls all the translations I have stored as Snippet models out of the database and merges them into the translations already loaded from the YAML and Ruby files. The only piece of magic here is splitting the key's name. The period syntax to indicate nesting is used in the keys normally, so I continued it in my snippets. To make this match the data from the YAML and Ruby files, I have to transform it into nested hashes.

Where My Problems Began

I also used a plugin called Typus for an administrative interface in this application. It’s a great plugin, and I can recommend it if you want a Django-style interface for editing content without a lot of fuss.

When displaying a list of your editable models, it pluralizes those models’ human names; for example, EmergencyContact becomes “Emergency contact” and is then pluralized to “Emergency contacts.” This works perfectly with English. However, when using Spanish, I ended up with a slight problem:

>> EmergencyContact.typus_human_name => "Emergency contact" >> EmergencyContact.typus_human_name.pluralize => "Emergency contacts" >> I18n.locale = :es => :es >> EmergencyContact.typus_human_name => "contacto de emergencia" >> EmergencyContact.typus_human_name.pluralize => "contacto de emergencias" 

“Contacto de emergencias” means something like, “a contact for lots of emergencies,” which wasn’t my intention. What I need is “contactos de emergencia,” which would require Spanish pluralization rules. The Rails pluralization rules are based on English. That made sense to me, so I could go add pluralization rules for Spanish, just like you can add translations for Spanish, right?

Unfortunately, that’s not true. Inflection rules in Rails don’t take multiple languages into account, so you can’t define new ones for a particular locale.

Quixotically, I decided to fix this. Here’s the code I ended up with:

module Inflector # Yields a singleton instance of Inflector::Inflections so you can specify additional # inflector rules. # # Example: # ActiveSupport::Inflector.inflections do |inflect| # inflect.uncountable "rails" # end # Original method def inflections if block_given? yield Inflections.instance else Inflections.instance end end # My crazy method def inflections(locale = nil) locale ||= I18n.locale locale_class = \ if locale.to_s == I18n.default_locale.to_s ActiveSupport::Inflector::Inflections else ActiveSupport::Inflector.const_get("Inflections_#{locale}") rescue nil end if locale_class.nil? ActiveSupport::Inflector.module_eval %{ class ActiveSupport::Inflector::Inflections_#{locale} < ActiveSupport::Inflector::Inflections end } locale_class = \ ActiveSupport::Inflector.const_get("Inflections_#{locale}") end if block_given? yield locale_class.instance else locale_class.instance end end # Returns the plural form of the word in the string. # # Examples: # "post".pluralize # => "posts" # "octopus".pluralize # => "octopi" # Original method def pluralize(word) result = word.to_s.dup if word.empty? || inflections.uncountables.include?(result.downcase) result else inflections.plurals.each do |(rule, replacement)| break if result.gsub!(rule, replacement) end result end end # My method def pluralize(word, locale = nil) locale ||= I18n.locale result = word.to_s.dup if word.empty? || inflections(locale).uncountables.include?(result.downcase) result else inflections(locale).plurals.each do |(rule, replacement)| if replacement.respond_to?(:call) break if result.gsub!(rule, &replacement) else break if result.gsub!(rule, replacement) end end result end end end 

This code is pretty complex, but there's only three major changes to the original methods. First, I added the ability to pass in a locale to use on each method, and made the current locale the default one. Second, I changed the behavior of the Inflections class. Instead of having one singleton class, I have a lot of singleton classes, each associated with a locale. These classes are created dynamically through a module_eval. Lastly, I added the ability for inflection transformations to be lambdas instead of just strings.

If you’re shaking your head and asking, “did he just create a new class for each different locale?”, I don’t blame you. It’s a little crazy. Believe it or not, this works magically, though! Here it is at work:

ActiveSupport::Inflector.inflections(:es) do |inflect| inflect.plural /$/, 's' inflect.plural /([^aeioué])$/, '\1es' inflect.plural /([aeiou]s)$/, '\1' inflect.plural /z$/, 'ces' inflect.plural /á([sn])$/, 'a\1es' inflect.plural /í([sn])$/, 'i\1es' inflect.plural /ó([sn])$/, 'o\1es' inflect.plural /ú([sn])$/, 'u\1es' inflect.plural(/^(\w+)\s(.+)$/, lambda { |match| head, tail = match.split(/\s+/, 2) "#{head.pluralize} #{tail}" }) end 

You'll notice that lambda in there: I added that ability so that I could transform the first word in a string instead of the last.

My Problems Compound

The above works a little too magically, however. The pluralize method is used everywhere, particularly in changing model class names to table names. As you can imagine, I started seeing errors like “No table found with name emergency_contactes.” I spent a good day running into similar issues and fixing it with code like:

# Create the name of a table like Rails does for models to table names. This method # uses the +pluralize+ method on the last word in the string. # # Examples # "RawScaledScorer".tableize # => "raw_scaled_scorers" def tableize(class_name) pluralize(underscore(class_name), I18n.default_locale) end 

Note that I called pluralize explicitly specifying that I want to use the default locale, which would be English. This is a simple fix, but I ended up having to repeat it multiple places in ActiveRecord, ActiveSupport, and ActionController.

Lessons Learned

First, even though this post’s talked about issues I had, the I18n support in Rails rocks, and I’ve found it to be a bit of a universal solvent for form issues, which I hope to talk about in another post. The ability to add new backends is nice, and the combination of my backend and Typus is hot.

I’ve learned that Rails’ inflections are used throughout the framework and trying to reuse them for automatic pluralization and singularization of non-English languages is hard. I think the results might be worth the effort, though. The translation system does have the ability to define singular and plural names for things, but they don’t work through pluralize, so you’d have to re-write how you generate plural names for model objects. That’d be more local than my sweeping monkey-patches, however. Looking back over my code, I’ve decided that it is full of red flags, but not altogether misguided.

How are you using Rails’ I18n? Have you had problems? Are the above fixes terrible hacks or awesome hacks? Let’s talk in the comments.

(You can see all the code from this post at http://gist.github.com/138956.)