How To Create A Custom I18n Backend For Ruby And Rails

Posted by Weston Ganger

There is surprisingly little documentation on how to create an custom I18n backend for Ruby. Heres an example implementation with comments to help guide you through the process

Custom Exception Handler:


module I18n
  class CustomExceptionHandler < ExceptionHandler
    def call(exception, locale, key, options)
      if exception.is_a?(MissingTranslation) && key.to_s != 'i18n.plural.rule'
        raise exception.to_exception
      else
        super
      end
    end
  end
end

### Exception handler to actually raise translation missing exceptions
### instead of showing 'translation missing' text
I18n.exception_handler = I18n::CustomExceptionHandler.new 

Custom Backend:


module I18n
  module Backend
    class Custom
      
      include I18n::Backend::Base ### required for all custom I18n backends

      ### if you want access to the flatten_translations method
      # include I18n::Backend::Flatten
      ### Usage instructions for flatten_translations
      # flattened_h = flatten_translations(:en, data, options.fetch(:escape, true), false)
      # puts flattened_h # => en: {'common.foo.bar': "foobar"}

      def translations
        ### I18n REQUIRED METHOD

        @translations || set_translations
      end
      
      def available_locales
        ### I18n REQUIRED METHOD

        @available_locales || set_available_locales
      end

      def initialized?
        ### I18n REQUIRED METHOD

        !@translations.nil?
      end

      def reload!
        ### I18n REQUIRED METHOD

        set_translations

        return true
      end

      def translate(locale, key, options = EMPTY_HASH)
        ### I18n REQUIRED METHOD

        split_keys = I18n.normalize_keys(locale, key, options[:scope], options[:separator])

        val = translations.dig(*split_keys)

        if val.blank? && options[:fallback]
          if val.blank? && I18n.locale != I18n.default_locale
            alternate_split_keys = split_keys.dup
            alternate_split_keys[2] = I18n.default_locale.to_s

            val = translations.dig(*alternate_split_keys)
          end

          # if val.blank?
          #   generic_key = "common.#{key.to_s.split(".").last}"

          #   if I18n.exists?(generic_key)
          #     return I18n.t(generic_key, fallback: false)
          #   end
          # end
        end

        if val.blank?
          #throw(:exception, I18n::MissingTranslation.new(locale, key, options))
          return nil
        else
          return val
        end
      end

      def store_translations(locale, data, options = EMPTY_HASH)
        ### I18n OPTIONAL METHOD

        ### Only required if you want to use the I18n.store_translations method
        ### Description: Used for storing the translations. 

        ### Depending on your needs you can choose to: 
        ### A. Just store this added data within memory
        ### OR
        ### B. Actually save the data to your file/db storage method 

        ### IN-MEMORY STORE
        
        if @translations.nil?
          set_translations
        end

        locale = locale.to_sym

        @temporary_translations ||= {}

        #flattened_h = flatten_translations(locale, data, options.fetch(:escape, true), false)
        #raise "#{flattened_h}" ### DEBUG
        
        @temporary_translations[locale] ||= Concurrent::Hash.new ### Must ensure is a Concurrent::Hash as per i18n-ruby standards
        @temporary_translations[locale] = @temporary_translations[locale].deep_merge(data.deep_symbolize_keys)

        @translations = @translations.deep_merge(@temporary_translations)
        
        set_available_locales

        return true
      end

      protected

      def set_translations
        ### CUSTOM NON-I18n METHOD

        @translations = Concurrent::Hash.new ### Must ensure is a Concurrent::Hash as per i18n-ruby standards

        ### Load from your custom db here
        db_translation_data = REDIS.get("translations").with_indifferent_access

        @translations = @translations.deep_merge(db_translation_data)

        if @temporary_translations
          @translations = @translations.deep_merge(@temporary_translations)
        end

        set_available_locales

        return @translations
      end

      def set_available_locales
        ### CUSTOM NON-I18n METHOD

        @available_locales = translations.keys.map{|x| x.to_sym}
      end

    end
  end
end

Backend Chain:


backend_chain = [I18n::Backend::Custom.new]

if Rails.env.development? || Rails.env.test?
  ### Keeps the original YAML backend as a fallback so that local development still works without a populated translation DB
  backend_chain << I18n::Backend::Simple.new

  ### Alternative could be to always have the default locale stored within yml files 
  ### and allow for translations on-top of the default locale file
  ### this would be useful for all environments. For example if a developer adds a new key 
  ### and it is not yet translated in the db. Good for PR workflows
end



I18n.backend = I18n::Backend::Chain.new(*backend_chain)

### Manually add any additional translations here
# I18n.backend.store_translations :en, foo: {bar: {foobar: 'asd'} }


Related External Links:

Article Topic:Software Development - Rails

Date:October 23, 2021