Using Rails I18n translations to solve dynamic content issues

Last week during my main work contract I had been tasked with doing some would-be trivial content updates to a section of a client application. Content updates would normally involve hopping into a view changing some images or text and re-deploying. This particular section though is a little more complex then that and way more complex then it should be.

The Setup

This particular section of the application has rotating content. Each rotation we’ll refer to as a Phase. Each phase is displayed for a length of time determined by the client and is switched on demand via an admin interface. Some structural changes occur on pages but very few. For the most part it’s copy that changes. On some pages it could be entire blocks of copy on others maybe simple date changes.
The phases represent stages of a competition. ex:

  • Phase 1: Contest closed - References last years contest.
  • Phase 2: Announcing the call for submissions opening date and rules.
  • Phase 3: Call for submissions opened.
  • Phase 4: Submissions closed. Public judging occurs.
  • Phase 5: Public Judging closes. Internal judging occurs.
  • Phase 6: Winners Announced.

The Problem

How to offer phase specific copy with out gratuitous amounts of switch or else/if logic in views. As well as keeping the copy and app as DRY as possible.

Failed Attempts

The original implemntation setup was to use partials. Have one partial with the standard set of copy and then have phase specific partials that would be included if a phase specific file exists. This was simple. Everyone knows how partials work and the swtiching system was easy enough to see and figure out.

The drawbacks weren’t apparent at first. Over time as the client required more and more minute details the partials became more difficult to manage. Some became less DRY as larger blocks of copy would have to be repeated with small differenes such as dates or names. Over time the similar content would get out of sync as someone would make a change in one phase specific file but not realize the same change was required in an earlier or later phase specific file. We ended up having numbered phase folders phase1 - phase6 all with similar files inside. All of which needed to be changed when layout or copy updates occured.

This became an un-mangable system. Something will always be missed. Copy will always be wrong somewhere in any given phase at some point in time.

Introduction to I18n

Within the same section of application a few months back the client requested certain pages have internationalized content. Rails makes this very easy with the inclusion of the I18n gem. Following a simple structure to manage each pages copy we created a single folder for each page and placed each language file within the folder:

the directory
structure for the internationaliztion files

This worked simple enough and meant a handful of pages in this section of the app are now using I18n helpers and had copy stored in the YAML files.

Copy stored in YAML translates into a basic key value store.

en:
  awards:
    program:
      title: The title to the Awards Program page

With that in our translation files the copy now becomes accessbile using the I18n interface.

I18n.t('awards.program.title')
=> "The title to the Awards Program page"

A new Idea

After fighting with phase specific files and more copy issues I decided we needed to revisit the problem. I began thinking about the process of internationalizing the previous pages. The usage for I18n solves almost the exact problem we’re having: Same page, different copy.

I decided to try something simple. Add the content in the yaml, follow it with the phase specific content and offer it a naming convention to match the yaml key + phase. Something along the lines of:

awards:
  marquee:
    pane1:
      title: Audience Voting
      title_p5: Voting now closed
      text: An AUDIENCE AWARD...

I then added a helper method for views: note: The @phase variable is being set in a before filter within this section of the app.

def pt(string)
  phase_string = "#{string}_p#{@phase}"
  I18n.t(phase_string, :default => string.to_sym)
end

What this does: Appends the provided key with the phase number (Lets assume 5.) Then checks to see if that key is set. If the key is not set use the original key to find the default return value. Based on our YAML file above we can now call the method with pt('awards.marquee.pane1.title'). If the phase happens to be set to 5 our return value will be "Voting now closed" otherwise we will get "Audience Voting". YAY! It worked.

It just doesn’t sit right though. I left the code as is for the rest of the day and thought about how it would be used and where it could be problematic.

Let it stew

The next day I came back to the problem having identified a few situations where the format of the YAML file just wouldn’t hold up.

1) What if every field in pane1 needs to be changed per phase. That will quickly create a chaotic yaml file. title_p1, title_p2, title_p3, etc.

2) What if phase 5 and phase 6 are identical. Now I have to make duplicate entries for title_p5 and title_p6. That’s not very DRY.

The solution needs to be more flexible I thought. I’m on the right track but it should be easier and obvious to manage the content.

Lets look at the YAML for the second problem above:

awards:
  index:
    module4:
      tab1:
        title: Awards
        header: Awards Contest
        text: The Ad...
        title_p5: Voting Now Closed
        header_p5: Awards Finalists
        text_p5: Winners to be...
        title_p6: Voting Now Closed
        header_p6: Awards Finalists
        text_p6: Winners to be...

# At best we could DRY it up a little by using YAML anchors (&) and references (*)
    ...
        title_p5: &voting_closed Voting Now Closed
        header_p5: &voting_closed_header Awards Finalists
        text_p5: &voting_closed_text Winners to be...
        title_p6: *voting_closed
        header_p6: *voting_closed_header
        text_p6: *voting_closed_text

We managed to DRY it up a little but it’s still hideous. I’d have to define every single phase specific key even if it’s the same content.
I don’t want to do that. That is the existing problem but shuffled to a different spot in the app.

I thought about it some more.

What if I could define a phase specific “tab1” in the yaml. I’d still have to express each phase but the nesting would allow me to DRY it up pretty quick.

At some point I also decided prepending the phase was better then appending.

I redefined the YAML file in a manner I’d like to be able to manage my content:

awards:
  index:
    module4:
      tab1:
        title: Awards
        p2_title: Phase 2 title
        header: Awards Contest
        text: The Ad...
      p5_tab1: &voting_closed
        title: Voting Now Closed
        header: Awards Finalists
        text: Winners to be...
      p6_tab1: *voting_closed

This idea was going to let me create phase specific content at any nesting level. If I only needed a title change in phase 2. Then simply create a phase specific title. If I needed a phase specific area. Define the phase the key a level higher and anything nested will be considered copy for that area. As shown this also makes replicating entire sections for multiple phases a breeze using YAML anchors.

It sounded like a solid interface for managing the content but now I need to make it work. This requires checking for phase specific content at any given level of the YAML nesting interface. At the beginning at the end and anywhere in between.

I knew right away I wasn’t going to be writing this in a helper method so I created a new class and utilized the class from within the helper:

def pt(string)
  p = Awards::PhaseTranslations.new(string, @phase)
  p.translate
end

I started the class off pretty simple. Just replicating the usage I already had. This worked with the original YAML files but clearly wouldn’t work with the new format.

class Awards::PhaseTranslations

  attr_accessor :string, :phase

  def initialize(string, phase)
    @string = string
    @phase = phase
  end

  def find_translation(phrase = nil)
    phase_string = "#{string}_p#{@phase}"
    I18n.t(phase_string, :default => string.to_sym)
  end

  def translate
    find_translation
  end
end

In order to find phase specific keys anywhere within the nesting, the next thing I had to do was find all possible key combinations.

string = 'awards.index.module4.tab1.title'
a = string.split('.')
possibilities = a.each_with_index.map do |v,i|
  b = a.clone
  b[i] = "p#{phase}_#{v}"
  b.join('.')
end
=> ["p6_awards.index.module4.tab1.title",
    "awards.p6_index.module4.tab1.title",
    "awards.index.p6_module4.tab1.title",
    "awards.index.module4.p6_tab1.title",
    "awards.index.module4.tab1.p6_title"]

Great. Next we need to check to see if any of those keys return a value.

def find_translation(phrase = '')
  possibilities.each do |key|
    phrase = I18n.t(key, :default => '')
    break unless phrase.blank?
  end

  if phrase.blank?
    phrase = I18n.t(string, :default => '')
  end

  phrase
end

This checks to see if the keys return anything. If not returning a default blank string. If phrase is still blank after we have checked all the keys go back to using the unmodified provided key and return the phrase. We can’t just use the original string as the default anymore as the first key miss would then set the phrase to the original key value and we would short-circuit the loop.

This also worked pretty well but I quickly realized it to was problematic. If we actually wanted to pass an empty string as phase specific content (blanking out a header?) then we would actually end up with the default header. With the following YAML as an example.

awards:
  title: Awards Header
  p2_title: ''
pt('awards.title') # With phase 2
=> 'Awards Header'

This happens becuase we’re comparing against blank values and considering a blank value a miss. When in this case a blank value is exactly what we want returned.

To fix the problem we stop setting blank values on the initial lookup. Lets allow I18n to raise a missing translation exception as that is it’s normal behaviour without defaults.

def find_translation(phrase = nil)
  extract_possibilities.each do |key|
    begin
      phrase = I18n.t(key, :raise => true)
      break unless phrase.blank?
    rescue
      # Let I18n raise a MissingTranslation exception.
      # We'll blank out the value or find the default after.
    end
  end

  if phrase.nil?
    phrase = I18n.t(string, :default => '')
  end

  phrase
end

This solves our blank title issue. As shown below:

pt('awards.title') # With phase 2
=> ''

Wrapping it all up

That pretty much gives us all the functionality we need at the moment. Check out the full class:

class Awards::PhaseTranslations

  attr_accessor :string, :phase, :possibilities

  def initialize(string, phase)
    @string = string
    @phase = phase
  end

  def extract_possibilities
    a = string.split('.')
    self.possibilities = a.each_with_index.map do |v,i|
      b = a.clone
      b[i] = "p#{phase}_#{v}"
      b.join('.')
    end
  end

  def find_translation(phrase = nil)
    extract_possibilities.each do |key|
      begin
        phrase = I18n.t(key, :raise => true)
        break unless phrase.blank?
      rescue
        # Let I18n raise a MissingTranslation exception.
        # We'll blank out the value or find the default after.
      end
    end

    if phrase.nil?
      phrase = I18n.t(string, :default => '')
    end

    phrase
  end

  def translate
    find_translation
  end
end

After using translations in this way for a few days the new method proved its versatility. Allowing the formatting of the YAML files in different but very declarative manners. Just to start:

awards:
  faq:
    entry_header: What is the entry deadline?
    entry_paragraphs:
      - Call for Sub...
      - All med...
      - All med...
      - Award w...
    p5_entry_paragraphs: &entries_closed
      - Submissions are...
    p6_entry_paragraphs: *entries_closed

  index:
    module4:
      tab1:
        title: Awards
        p2_title: Phase 2 title
        header: Awards Contest
        text: The Ad...
      p5_tab1: &voting_closed
        title: Voting Now Closed
        header: Awards Finalists
        text: Winners to be...
      p6_tab1: *voting_closed

  marquee:
    pane1:
      title: Audience Award Voting
      text: An AUDIENCE AWA...
    p5_pane1: &voting_closed_marquee
      title: Voting now closed
      text: Winners of th...
    p6_pane1: *voting_closed_marquee
    pane2:
      title: Judges
      text: Meet Ad...
    pane3:
      title: Essentials
      text: The Ad...

All in all I found the new soltion to be quickly solving all the copy update issues we were previously encountering. As we move the content into the translation files slowly we can be removing all the repetitive phase specific view files that have cluttered up the view directories.

Thanks for reading through the post. The code is far from perfect but I felt like I wanted to share the process anyway. If you have any suggestions or corrections to make please send me an email or find me on twitter.