Skip to content

Instantly share code, notes, and snippets.

@martijnenco
Last active May 10, 2025 22:40
Show Gist options
  • Save martijnenco/5082c7ff82514f08f8f735d0ac3e47bd to your computer and use it in GitHub Desktop.
Save martijnenco/5082c7ff82514f08f8f735d0ac3e47bd to your computer and use it in GitHub Desktop.
Ruby I18n translation variations

I18n Variations

This initializer extends the standard Ruby I18n library to support "variations" of translations. This allows you to define multiple versions of a translation string under the same key and select the appropriate one based on context, without cluttering your locale files with verbose, duplicated keys.

Problem Solved

Standard I18n is excellent for internationalization, but it doesn't natively offer a clean way to handle contextual differences within the same language and key. For example, you might need:

  • Different tones (e.g., formal vs. informal greetings).
  • Domain-specific terminology (e.g., "hottest" for an adult site vs. "most popular" for a children's site).
  • User-segment-specific language.
  • Existing translations will still be used if no variation is set.

Traditionally, this might be handled by creating more specific keys (greetings_formal, greetings_informal) or by embedding logic directly in your views/controllers, leading to less maintainable code and locale files.

How It Works

This solution introduces the concept of "variations" to I18n:

  1. Define Variations: You can define a set of available variations (e.g., :formal, :informal, :thriller, :child).
  2. Structure Translations: In your YAML locale files, you can structure translations for a key as a hash, where each key-value pair represents a variation.
    en:
      sorting:
        created_at: "Recently added first" # No variations are given, so this will be used as for all variations
        most_sold: "Most sold first"
        hotness_score:
          all: "Trending first"   # Default/fallback variation
          child: "Most popular first"
          thriller: "Hottest first"
  3. Set Current Variation: You can set a global "current variation" (e.g., in a Rails controller before_action) or specify a variation directly in the I18n.t call.
  4. Automatic Resolution:
    • When I18n.t('some.key') is called, if the resolved value is a hash and a current variation is active, the system attempts to return the string for that specific variation.
    • If the specific variation isn't found within the hash, it falls back to a configurable I18n.default_variation (e.g., : ).
    • If neither is found, an error is raised.
    • If no variation is active, or the key doesn't resolve to a hash of variations, the standard I18n behavior applies.

Key Features & Benefits

  • Cleaner Locale Files: Keeps your translation files organized by grouping related contextual translations under a single key.
  • Maintainable Contextual Translations: Centralizes the logic for choosing translation variations, rather than scattering it throughout your codebase.
  • Flexibility:
    • Set a global variation (thread-safe) for the current request or context.
    • Override the global variation on a per-translation basis using I18n.t('key', variation: :some_variation).
  • Configurable Defaults: Define a default variation to fall back to if a specific variation is requested but not found.
  • Validation: Optionally define a list of I18n.available_variations to ensure only valid variations are set, preventing typos and unexpected behavior.
  • Explicit Errors: Raises specific errors (I18n::InvalidVariationError, I18n::MissingDefaultVariationError) if configurations are incorrect or translations are missing, aiding in debugging.

When to Use

This approach is beneficial when you need to present different "flavors" of text for the same logical concept based on runtime context, such as:

  • User roles or preferences.
  • Different sections or "themes" of your application.
  • A/B testing different phrasings.
  • Adapting language for specific target audiences (e.g., age groups, professional vs. casual).
  • DRYing up your locale files.

By extending I18n with this variation logic, you can manage complex contextual translation requirements in a more structured and Rails-idiomatic way.

# config/application.rb
module YourApplication
class Application < Rails::Application
...
config.i18n.default_locale = 'nl-NL'
config.i18n.available_locales = ['nl-NL', 'en-GB']
# Add this in your config
config.i18n.default_variation = :all
config.i18n.available_variations = [:all, :fantasy, :spiritual, :thriller, :child]
...
end
end
# app/controllers/application_controller.rb
# frozen_string_literal: true
class ApplicationController < ActionController::Base
before_action :set_locale
before_action :set_theme
private
def set_locale
I18n.locale = (I18n.available_locales.map(&:to_s).include?(params[:locale]) && params[:locale]) ||
session[:locale] ||
http_accept_language.compatible_language_from(I18n.available_locales) ||
I18n.default_locale
session[:locale] = I18n.locale
rescue I18n::InvalidLocale
I18n.locale = I18n.default_locale
session[:locale] = I18n.locale
end
# Use something like this to set to variation, similiar to how you would set the locale.
def set_theme
if Rails.env.development?
if params[:theme].present? && I18n.available_variations.include?(params[:theme].to_sym)
@current_theme = params[:theme]
cookies[:theme] = { value: @current_theme, expires: 1.year.from_now, domain: :all }
elsif cookies[:theme].present? && I18n.available_variations.include?(cookies[:theme].to_sym)
@current_theme = cookies[:theme]
end
end
if @current_theme.blank? # If not set by param/cookie in dev, or if in other envs
hostname = request.host
key_from_host = hostname.split('.').first # Directly get the 'xxreadings' or 'localhost' part
@current_theme = case key_from_host
when 'allreadings'
:all
when 'childreadings'
:child
when 'fantasyreadings'
:fantasy
when 'spiritualreadings'
:spiritual
when 'thrillerreadings'
:thriller
else # rubocop:disable Lint/DuplicateBranch
:all
end
end
I18n.current_variation = @current_theme
end
end
# config/initializers/i18n.rb
# frozen_string_literal: true
# This module contains the custom translation logic.
# It will be prepended to I18n's singleton class.
module I18nVariationLogic
# This is our overridden version of I18n.translate.
def translate(key, **options)
# 1. Determine the active variation for this specific translation call.
options_variation = options.delete(:variation)&.to_sym
active_variation = options_variation || I18n.current_variation
# 2. Call the original I18n.translate method using `super`.
i18n_result = super
# 3. "Dive in" if the result from `super` is a Hash and an `active_variation`
# is actually set (globally or via options). This means we are attempting
# to resolve a specific variation.
if i18n_result.is_a?(Hash) && active_variation
resolved_hash = i18n_result
# a. Try to find the translation for the `active_variation` directly.
if resolved_hash.key?(active_variation)
return resolved_hash[active_variation]
# b. If `active_variation` key isn't found, try the configured `I18n.default_variation`
# as a fallback.
elsif resolved_hash.key?(I18n.default_variation)
return resolved_hash[I18n.default_variation]
end
end
# 4. If the `i18n_result` was not a Hash, or if no `active_variation` was set
# (meaning we weren't trying to resolve a specific variation), return the
# original result from `super`.
i18n_result
end
end
# Extend the I18n module itself to add global variation settings.
module I18n
# Define custom error classes within the I18n module for better namespacing.
class VariationError < StandardError; end
class InvalidVariationError < VariationError
def initialize(variation, available_variations)
super("Invalid variation ':#{variation}'. Must be one of :#{available_variations.join(', :')}.")
end
end
class << self
# Getter/Setter for the configured default variation key.
# This is the key that the system will fall back to within a variation hash
# if the specifically requested variation is not found.
attr_writer :default_variation
def default_variation
@default_variation || :all # Default to :all if not configured
end
# Getter/Setter for the list of available (valid) variations.
# If this list is populated, `current_variation=` will validate against it.
attr_writer :available_variations
def available_variations
@available_variations || [] # Default to empty array if not configured
end
# Getter for the global current_variation.
# Stored on Thread.current for thread-safety.
def current_variation
Thread.current[:i18n_current_variation]
end
# Setter for the global current_variation.
# Validates against `I18n.available_variations` if it's populated.
def current_variation=(variation_key)
sym_variation_key = variation_key&.to_sym
Rails.logger.debug "sym_variation_key: #{sym_variation_key}"
Rails.logger.debug "available_variations: #{I18n.available_variations}"
if I18n.available_variations.any? && sym_variation_key && I18n.available_variations.exclude?(sym_variation_key)
raise I18n::InvalidVariationError.new(sym_variation_key, I18n.available_variations)
end
Thread.current[:i18n_current_variation] = sym_variation_key
end
# Prepend our custom logic module into I18n's singleton class.
prepend I18nVariationLogic
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment