lib/i18n/backend/lazy_loadable.rb
# frozen_string_literal: true module I18n module Backend # Backend that lazy loads translations based on the current locale. This # implementation avoids loading all translations up front. Instead, it only # loads the translations that belong to the current locale. This offers a # performance incentive in local development and test environments for # applications with many translations for many different locales. It's # particularly useful when the application only refers to a single locales' # translations at a time (ex. A Rails workload). The implementation # identifies which translation files from the load path belong to the # current locale by pattern matching against their path name. # # Specifically, a translation file is considered to belong to a locale if: # a) the filename is in the I18n load path # b) the filename ends in a supported extension (ie. .yml, .json, .po, .rb) # c) the filename starts with the locale identifier # d) the locale identifier and optional proceeding text is separated by an underscore, ie. "_". # # Examples: # Valid files that will be selected by this backend: # # "files/locales/en_translation.yml" (Selected for locale "en") # "files/locales/fr.po" (Selected for locale "fr") # # Invalid files that won't be selected by this backend: # # "files/locales/translation-file" # "files/locales/en-translation.unsupported" # "files/locales/french/translation.yml" # "files/locales/fr/translation.yml" # # The implementation uses this assumption to defer the loading of # translation files until the current locale actually requires them. # # The backend has two working modes: lazy_load and eager_load. # # Note: This backend should only be enabled in test environments! # When the mode is set to false, the backend behaves exactly like the # Simple backend, with an additional check that the paths being loaded # abide by the format. If paths can't be matched to the format, an error is raised. # # You can configure lazy loaded backends through the initializer or backends # accessor: # # # In test environments # # I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: true) # # # In other environments, such as production and CI # # I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: false) # default # class LocaleExtractor class << self def locale_from_path(path) name = File.basename(path, ".*") locale = name.split("_").first locale.to_sym unless locale.nil? end end end class LazyLoadable < Simple def initialize(lazy_load: false) @lazy_load = lazy_load end # Returns whether the current locale is initialized. def initialized? if lazy_load? initialized_locales[I18n.locale] else super end end # Clean up translations and uninitialize all locales. def reload! if lazy_load? @initialized_locales = nil @translations = nil else super end end # Eager loading is not supported in the lazy context. def eager_load! if lazy_load? raise UnsupportedMethod.new(__method__, self.class, "Cannot eager load translations because backend was configured with lazy_load: true.") else super end end # Parse the load path and extract all locales. def available_locales if lazy_load? I18n.load_path.map { |path| LocaleExtractor.locale_from_path(path) }.uniq else super end end def lookup(locale, key, scope = [], options = EMPTY_HASH) if lazy_load? I18n.with_locale(locale) do super end else super end end protected # Load translations from files that belong to the current locale. def init_translations file_errors = if lazy_load? initialized_locales[I18n.locale] = true load_translations_and_collect_file_errors(filenames_for_current_locale) else @initialized = true load_translations_and_collect_file_errors(I18n.load_path) end raise InvalidFilenames.new(file_errors) unless file_errors.empty? end def initialized_locales @initialized_locales ||= Hash.new(false) end private def lazy_load? @lazy_load end class FilenameIncorrect < StandardError def initialize(file, expected_locale, unexpected_locales) super "#{file} can only load translations for \"#{expected_locale}\". Found translations for: #{unexpected_locales}." end end # Loads each file supplied and asserts that the file only loads # translations as expected by the name. The method returns a list of # errors corresponding to offending files. def load_translations_and_collect_file_errors(files) errors = [] load_translations(files) do |file, loaded_translations| assert_file_named_correctly!(file, loaded_translations) rescue FilenameIncorrect => e errors << e end errors end # Select all files from I18n load path that belong to current locale. # These files must start with the locale identifier (ie. "en", "pt-BR"), # followed by an "_" demarcation to separate proceeding text. def filenames_for_current_locale I18n.load_path.flatten.select do |path| LocaleExtractor.locale_from_path(path) == I18n.locale end end # Checks if a filename is named in correspondence to the translations it loaded. # The locale extracted from the path must be the single locale loaded in the translations. def assert_file_named_correctly!(file, translations) loaded_locales = translations.keys.map(&:to_sym) expected_locale = LocaleExtractor.locale_from_path(file) unexpected_locales = loaded_locales.reject { |locale| locale == expected_locale } raise FilenameIncorrect.new(file, expected_locale, unexpected_locales) unless unexpected_locales.empty? end end end end