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