Blog tech

I18n extra translations

Rédigé par Nicolas Zermati | 12 mars 2013

Ruby dispose d’une large palette de bibliothèques. Parmi ce qu’il y a de plus indispensable on compte l’internationalisation, ou i18n. La référence est l’implémentation de Sven Fuchs. C’est cette gem qui est utilisée par Rails pour traiter tout ce qui concerne l’i18n.

Objectifs

Dans cet article je vais étendre les fonctionnalités d’i18n. L’objectif est de pouvoir choisir une langue de référence puis d’être en mesure de lister les clés manquantes ainsi que les clés non utilisées. La première situation où un tel système serait utile c’est lors d’une suite de tests.

Modifier le backend

Rails utilise par défaut I18n::Backend::Simple pour les traductions. Ce backend repose sur I18n::Backend::Base. C’est la méthode translate de ce dernier module qui va nous intéresser…

En effet, nous allons étendre le backend pour qu’il intercepte et enregistre tout les appels à translate et si oui ou non il ont échoués. Pour parvenir à ce résultat je vais m’inspirer de i18n-missing_translation.

L’idée est d’arriver à surcharger la methode translate originale avec :

def translate(locale, key, options = {})
  result = catch(:exception) { super }

  if result.kind_of? I18n::MissingTranslation
    # La clé est manquante
    throw :exception, result
  else
    # La clé est utilisée par l'application
    result
  end
end

Dans un premier temps on va créer une classe Store qui va permette de stocker les clés de traduction. Voici le contenu de cette classe avec, juste en dessous du code, quelques explications.

class Store < Hash
  def use(l, k, o) ; add_key(l, k , o, :used) end
  def miss(l, k, o) ; add_key(l, k, o, :missing) end

  def used?(keys)
    keys.inject(self){|h, k| h.kind_of?(Hash) ? h[k] : (break h[k])} == :used
  end

  protected

  def add_key(l, k, o, value)
    keys = I18n.normalize_keys(l, k, o[:scope]).dup
    l    = keys.pop.to_s
    h    = keys.inject(self){|h, k| h.key?(k.to_s) ? h[k.to_s] : (h[k.to_s] = {})}
    h[l] = value
  end
end

La méthode translate utilise les paramètres : locale, key et options. La methode I18n.normalize_keys permet de transformer ces options en un Array de Symbol. Par exemple I18n.normalize_keys(:en, 'foo', 'bar.bar') donnera ce resultat : [:en, :bar, :bar, :foo]. Notre classe définit deux méthodes utilisant les paramètres de translate : use et miss. Elles remplaceront les commentaires dans notre surcharge de la méthode translate par la suite…

Les deux one-liners avec inject sont plus complexes, ils réalisent tout deux une exploration en profondeur pour :

  • rechercher une clé (used?) ou
  • remplir récursivement l’instance de Store (add_key).

À présent, on peut mettre nos deux fichiers dans un scope : ici j’ai choisi I18n::ExtraTranslations. Il faut également penser à inclure notre nouvelle méthode translate dans I18n::Backend::Simple.

Voici l’arborescence des fichiers une fois cette organisation effectuée :

lib
└── i18n
    ├── extra_translations
    │   ├── simple_extension.rb
    │   └── store.rb
    └── extra_translations.rb

Les contenus des fichiers simple_extension.rb et store.rb ont été modifiés mais sont disponibles sur notre dépot Github. Et voici le contenu du fichier extra_translations.rb :

require 'i18n'
require 'i18n/exceptions'

dirname = File.dirname(__FILE__)

module I18n
  class ExtraTranslations
    autoload :Store, "#{dirname}/extra_translations/store.rb"
    autoload :SimpleExtension, "#{dirname}/extra_translations/simple_extension.rb"

    class << self
      # An ExtraTranslations::Store will be availaible everywhere
      attr_writer :extra_translations
      def extra_translations
        @extra_translations ||= ExtraTranslations::Store.new
      end

      # Allow the user to focus its search on a specific locale
      attr_writer :locale
      def locale
        @locale ||= I18n.default_locale
      end

      # Returns all the unused translation of some files and
      # groups the results by filename.
      def unused_translations(filenames=nil)
        filenames ||= ["./config/locales/#{locale}.yml"]
        filenames.inject({}) do |memo, filename|
          data = YAML.load_file(filename)
          keys = all_keys_from(data, [])
          memo[filename] = keys.select{|keys| !extra_translations.used?(keys)}
          memo[filename].map!{|keys| keys.join('.')}
          memo
        end
      end

      # Returns all the translate keys that throw an error
      def missing_translations
        keys = all_keys_from(extra_translations, [], []) do |keys, _|
          !extra_translations.used?(keys)
        end
        keys.map{ |keys| keys.join('.') }
      end

      protected

        # Build a list of translation paths
        def all_keys_from(data, memo, stack=[], &filter)
          if data.kind_of? Hash
            keys = data.inject([]) do |m, (k, v)|
              m + all_keys_from(v, [], stack.dup << k, &filter)
            end
            memo + keys
          elsif filter.nil? || filter.call(stack, data)
            memo << stack
          else
            memo
          end
        end
    end
  end

  Backend::Simple.include ExtraTranslations::SimpleExtension
end

Rapport à la fin des tests

Le plus important, pour obtenir un rapport à la fin des tests vous n’avez qu’a ajouter les lignes suivantes à votre spec_helper.rb :

require './lib/i18n/extra_translations'
at_exit do
  require 'awesome_print'
  puts "Missing translations:"
  ap I18n::ExtraTranslations.missing_translations
  puts "Unused translations:"
  ap I18n::ExtraTranslations.unused_translations
end

Conclusion

Dans cet article, tout est dans le répertoire ./lib de l’application Rails. J’ai toutefois pris le temps de créer une gem et d’y ajouter des tests.

L’i18n est un des aspects importants d’une application. Elle représente une forte valeur ajoutée pour les clients sans pour autant être attirante pour les développeurs. C’est pourquoi il est nécessaire d’utiliser des outils pour nous simplifier cet aspect des projets.

J’espère que cet article vous aura montré à quel point on peut créer ces outils rapidement.

L’équipe Synbioz.

Libres d’être ensemble.