require 'active_support'
require 'action_view/helpers/translation_helper'
# Extentions to make internationalization (i18n) of a Rails application simpler.
# Support the method +translate+ (or shorter +t+) in models/view/controllers/mailers.
module SplendeoTranslator
# Error for use within SplendeoTranslator
class SplendeoTranslatorError < StandardError #:nodoc:
end
# SplendeoTranslator version
VERSION = '1.0.0'
# Whether strict mode is enabled
@@strict_mode = false
# Whether to fallback from the set locale to the default locale
@@fallback_mode = false
# Whether to show the raw key if the locale doesn't provide a translation
@@key_fallback_mode = false
# Whether to pseudo-translate all fetched strings
@@pseudo_translate = false
# Pseudo-translation text to prend to fetched strings.
# Used as a visible marker. Default is "["
@@pseudo_prepend = "["
# Pseudo-translation text to append to fetched strings.
# Used as a visible marker. Default is "]"
@@pseudo_append = "]"
# An optional callback to be notified when there are missing translations in views
@@missing_translation_callback = nil
# Invokes the missing translation callback, if it is defined
def self.missing_translation_callback(exception, key, options = {}) #:nodoc:
@@missing_translation_callback.call(exception, key, options) if !@@missing_translation_callback.nil?
end
# Set an optional block that gets called when there's a missing translation within a view.
# This can be used to log missing translations in production.
#
# Block takes two required parameters:
# - exception (original I18n::MissingTranslationData that was raised for the failed translation)
# - key (key that was missing)
# - options (hash of options sent to SplendeoTranslator)
# Example:
# set_missing_translation_callback do |ex, key, options|
# logger.info("Failed to find #{key}")
# end
def self.set_missing_translation_callback(&block)
@@missing_translation_callback = block
end
# Performs lookup with a given scope. The scope should be an array of strings or symbols
# ordered from highest to lowest scoping. For example, for a given PicturesController
# with an action "show" the scope should be ['pictures', 'show'] which happens automatically.
#
# The key and options parameters follow the same rules as the I18n library (they are passed through).
#
# The search order is from most specific scope to most general (and then using a default value, if provided).
# So continuing the previous example, if the key was "title" and options included :default => 'Some Picture'
# then it would continue searching until it found a value for:
# * pictures.show.title
# * pictures.title
# * title
# * use the default value (if provided)
#
# The key itself can contain a scope. For example, if there were a set of shared error messages within the
# Pictures controller, that could be found using a key like "errors.deleted_picture". The inital search with
# narrowest scope ('pictures.show.errors.deleted_picture') will not find a value, but the subsequent search with
# broader scope ('pictures.errors.deleted_picture') will find the string.
#
def self.translate_with_scope(scope, key, options={})
scope ||= [] # guard against nil scope
# Let Rails 2.3 handle keys starting with "."
raise SplendeoTranslatorError, "Skip keys with leading dot" if key.to_s.first == "."
# Keep the original options clean
original_scope = scope.dup
scoped_options = {}.merge(options)
# Raise to know if the key was found
scoped_options[:raise] = true
# Remove any default value when searching with scope
scoped_options.delete(:default)
str = nil # the string being looked for
# Loop through each scope until a string is found.
# Example: starts with scope of [:blog_posts :show] then tries scope [:blog_posts] then
# without any automatically added scope ("[]").
while str.nil?
# Set scope to use for search
scoped_options[:scope] = scope
begin
# try to find key within scope (dup the options because I18n modifies the hash)
str = I18n.translate(key, scoped_options.dup)
rescue I18n::MissingTranslationData => exc
# did not find the string, remove a layer of scoping.
# break when there are no more layers to remove (pop returns nil)
break if scope.pop.nil?
end
end
# set the default value to key if key_fallback is active
options[:default] ||= key if SplendeoTranslator.key_fallback?
# If a string is not yet found, potentially check the default locale if in fallback mode.
if str.nil? && SplendeoTranslator.fallback? && (I18n.locale != I18n.default_locale) && options[:locale].nil?
# Recurse original request, but in the context of the default locale
str ||= SplendeoTranslator.translate_with_scope(original_scope, key, options.merge({:locale => I18n.default_locale}))
end
# If a string was still not found, fall back to trying original request (gets default behavior)
str ||= I18n.translate(key, options)
# If pseudo-translating, prepend / append marker text
if SplendeoTranslator.pseudo_translate? && !str.nil?
str = SplendeoTranslator.pseudo_prepend + str + SplendeoTranslator.pseudo_append
end
str
end
class << SplendeoTranslator
# Generic translate method that mimics I18n.translate (e.g. no automatic scoping) but includes locale fallback
# and strict mode behavior.
def translate(key, options={})
SplendeoTranslator.translate_with_scope([], key, options)
end
alias :t :translate
end
# When fallback mode is enabled if a key cannot be found in the set locale,
# it uses the default locale. So, for example, if an app is mostly localized
# to Spanish (:es), but a new page is added then Spanish users will continue
# to see mostly Spanish content but the English version (assuming the default_locale is :en)
# for the new page that has not yet been translated to Spanish.
def self.fallback(enable = true)
@@fallback_mode = enable
end
# If fallback mode is enabled
def self.fallback?
@@fallback_mode
end
# When key_fallback mode is enabled, if a key cannot be found in the set locale,
# it will return the key
def self.key_fallback(enable = true)
@@key_fallback_mode = enable
end
# If key_fallback mode is enabled
def self.key_fallback?
@@key_fallback_mode
end
# Toggle whether to true an exception on *all* +MissingTranslationData+ exceptions
# Useful during testing to ensure all keys are found.
# Passing +true+ enables strict mode, +false+ installs the default exception handler which
# does not raise on +MissingTranslationData+
def self.strict_mode(enable_strict = true)
@@strict_mode = enable_strict
if enable_strict
# Switch to using contributed exception handler
I18n.exception_handler = :strict_i18n_exception_handler
else
I18n.exception_handler = :default_exception_handler
end
end
# Get if it is in strict mode
def self.strict_mode?
@@strict_mode
end
# Toggle a pseudo-translation mode that will prepend / append special text
# to all fetched strings. This is useful during testing to view pages and visually
# confirm that strings have been fully extracted into locale bundles.
def self.pseudo_translate(enable = true)
@@pseudo_translate = enable
end
# If pseudo-translated is enabled
def self.pseudo_translate?
@@pseudo_translate
end
# Pseudo-translation text to prepend to fetched strings.
# Used as a visible marker. Default is "[["
def self.pseudo_prepend
@@pseudo_prepend
end
# Set the pseudo-translation text to prepend to fetched strings.
# Used as a visible marker.
def self.pseudo_prepend=(v)
@@pseudo_prepend = v
end
# Pseudo-translation text to append to fetched strings.
# Used as a visible marker. Default is "]]"
def self.pseudo_append
@@pseudo_append
end
# Set the pseudo-translation text to append to fetched strings.
# Used as a visible marker.
def self.pseudo_append=(v)
@@pseudo_append = v
end
# Additions to TestUnit to make testing i18n easier
module Assertions
# Assert that within the block there are no missing translation keys.
# This can be used in a more tailored way that the global +strict_mode+
#
# Example:
# assert_translated do
# str = "Test will fail for #{I18n.t('a_missing_key')}"
# end
#
def assert_translated(msg = nil, &block)
# Enable strict mode to force raising of MissingTranslationData
SplendeoTranslator.strict_mode(true)
msg ||= "Expected no missing translation keys"
begin
yield
# Credtit for running the assertion
assert(true, msg)
rescue I18n::MissingTranslationData => e
# Fail!
assert_block(build_message(msg, "Exception raised:\n?", e)) {false}
ensure
# uninstall strict exception handler
SplendeoTranslator.strict_mode(false)
end
end
end
module I18nExtensions
# Add an strict exception handler for testing that will raise all exceptions
def strict_i18n_exception_handler(exception, locale, key, options)
# Raise *all* exceptions
raise exception
end
end
end
module ActionView #:nodoc:
class Base
# Redefine the +translate+ method in ActionView (contributed by TranslationHelper) that is
# context-aware of what view (or partial) is being rendered.
# Initial scoping will be scoped to [:controller_name :view_name]
def translate_with_context(key, options={})
# default to an empty scope
scope = []
# Use the template for scoping if there is a templ
unless self.template.nil?
# The outer scope will typically be the controller name ("blog_posts")
# but can also be a dir of shared partials ("shared").
outer_scope = self.template.base_path
# The template will be the view being rendered ("show.erb" or "_ad.erb")
inner_scope = self.template.name
# Partials template names start with underscore, which should be removed
inner_scope.sub!(/^_/, '')
scope = [outer_scope, inner_scope]
end
# In the case of a missing translation, fall back to letting TranslationHelper
# put in span tag for a translation_missing.
begin
SplendeoTranslator.translate_with_scope(scope, key, options.merge({:raise => true}))
rescue SplendeoTranslator::SplendeoTranslatorError, I18n::MissingTranslationData => exc
# Call the original translate method
str = translate_without_context(key, options)
# View helper adds the translation missing span like:
# In strict mode, do not allow TranslationHelper to add "translation missing" span like:
# en, missing_string
if str =~ /span class\=\"translation_missing\"/
# In strict mode, do not allow TranslationHelper to add "translation missing"
raise if SplendeoTranslator.strict_mode?
# Invoke callback if it is defined
SplendeoTranslator.missing_translation_callback(exc, key, options)
end
str
end
end
alias_method_chain :translate, :context
alias :t :translate
end
end
module ActionController #:nodoc:
class Base
# Add a +translate+ (or +t+) method to ActionController that is context-aware of what controller and action
# is being invoked. Initial scoping will be [:controller_name :action_name] when looking up keys. Example would be
# +['posts' 'show']+ for the +PostsController+ and +show+ action.
def translate_with_context(key, options={})
SplendeoTranslator.translate_with_scope([self.controller_name, self.action_name], key, options)
end
alias_method_chain :translate, :context
alias :t :translate
end
end
module ActiveRecord #:nodoc:
class Base
# Add a +translate+ (or +t+) method to ActiveRecord that is context-aware of what model is being invoked.
# Initial scoping of [:model_name] where model name is like 'blog_post' (singular - *not* the table name)
def translate(key, options={})
SplendeoTranslator.translate_with_scope([self.class.name.underscore], key, options)
end
alias :t :translate
# Add translate as a class method as well so that it can be used in validate statements, etc.
class << Base
def translate(key, options={}) #:nodoc:
SplendeoTranslator.translate_with_scope([self.name.underscore], key, options)
end
alias :t :translate
end
end
end
module ActionMailer #:nodoc:
class Base
# Add a +translate+ (or +t+) method to ActionMailer that is context-aware of what mailer and action
# is being invoked. Initial scoping of [:mailer_name :action_name] where mailer_name is like 'comment_mailer'
# and action_name is 'comment_notification' (note: no "deliver_" or "create_")
def translate(key, options={})
SplendeoTranslator.translate_with_scope([self.mailer_name, self.action_name], key, options)
end
alias :t :translate
end
end
module I18n
# Install the strict exception handler for testing
extend SplendeoTranslator::I18nExtensions
end
module Test # :nodoc: all
module Unit
class TestCase
include SplendeoTranslator::Assertions
end
end
end
# In test environment, enable strict exception handling for missing translations
if (defined? RAILS_ENV) && (RAILS_ENV == "test")
SplendeoTranslator.strict_mode(true)
end