|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +module I18n |
| 4 | + module Backend |
| 5 | + # Backend that lazy loads translations based on the current locale. This |
| 6 | + # implementation avoids loading all translations up front. Instead, it only |
| 7 | + # loads the translations that belong to the current locale. This offers a |
| 8 | + # performance incentive in local development and test environments for |
| 9 | + # applications with many translations for many different locales. It's |
| 10 | + # particularly useful when the application only refers to a single locales' |
| 11 | + # translations at a time (ex. A Rails workload). The implementation |
| 12 | + # identifies which translation files from the load path belong to the |
| 13 | + # current locale by pattern matching against their path name. |
| 14 | + # |
| 15 | + # Specifically, a translation file is considered to belong to a locale if: |
| 16 | + # a) the filename is in the I18n load path |
| 17 | + # b) the filename ends in a supported extension (ie. .yml, .json, .po, .rb) |
| 18 | + # c) the filename starts with the locale identifier |
| 19 | + # d) the locale identifier and optional proceeding text is separated by an underscore, ie. "_". |
| 20 | + # |
| 21 | + # Examples: |
| 22 | + # Valid files that will be selected by this backend: |
| 23 | + # |
| 24 | + # "files/locales/en_translation.yml" (Selected for locale "en") |
| 25 | + # "files/locales/fr.po" (Selected for locale "fr") |
| 26 | + # |
| 27 | + # Invalid files that won't be selected by this backend: |
| 28 | + # |
| 29 | + # "files/locales/translation-file" |
| 30 | + # "files/locales/en-translation.unsupported" |
| 31 | + # "files/locales/french/translation.yml" |
| 32 | + # "files/locales/fr/translation.yml" |
| 33 | + # |
| 34 | + # The implementation uses this assumption to defer the loading of |
| 35 | + # translation files until the current locale actually requires them. |
| 36 | + # |
| 37 | + # The backend has two working modes: lazy_load and eager_load. |
| 38 | + # |
| 39 | + # Note: This backend should only be enabled in test environments! |
| 40 | + # When the mode is set to false, the backend behaves exactly like the |
| 41 | + # Simple backend, with an additional check that the paths being loaded |
| 42 | + # abide by the format. If paths can't be matched to the format, an error is raised. |
| 43 | + # |
| 44 | + # You can configure lazy loaded backends through the initializer or backends |
| 45 | + # accessor: |
| 46 | + # |
| 47 | + # # In test environments |
| 48 | + # |
| 49 | + # I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: true) |
| 50 | + # |
| 51 | + # # In other environments, such as production and CI |
| 52 | + # |
| 53 | + # I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: false) # default |
| 54 | + # |
| 55 | + class LocaleExtractor |
| 56 | + class << self |
| 57 | + def locale_from_path(path) |
| 58 | + name = File.basename(path, ".*") |
| 59 | + locale = name.split("_").first |
| 60 | + locale.to_sym unless locale.nil? |
| 61 | + end |
| 62 | + end |
| 63 | + end |
| 64 | + |
| 65 | + class LazyLoadable < Simple |
| 66 | + def initialize(lazy_load: false) |
| 67 | + @lazy_load = lazy_load |
| 68 | + end |
| 69 | + |
| 70 | + # Returns whether the current locale is initialized. |
| 71 | + def initialized? |
| 72 | + if lazy_load? |
| 73 | + initialized_locales[I18n.locale] |
| 74 | + else |
| 75 | + super |
| 76 | + end |
| 77 | + end |
| 78 | + |
| 79 | + # Clean up translations and uninitialize all locales. |
| 80 | + def reload! |
| 81 | + if lazy_load? |
| 82 | + @initialized_locales = nil |
| 83 | + @translations = nil |
| 84 | + else |
| 85 | + super |
| 86 | + end |
| 87 | + end |
| 88 | + |
| 89 | + # Eager loading is not supported in the lazy context. |
| 90 | + def eager_load! |
| 91 | + if lazy_load? |
| 92 | + raise UnsupportedMethod.new(__method__, self.class, "Cannot eager load translations because backend was configured with lazy_load: true.") |
| 93 | + else |
| 94 | + super |
| 95 | + end |
| 96 | + end |
| 97 | + |
| 98 | + # Parse the load path and extract all locales. |
| 99 | + def available_locales |
| 100 | + if lazy_load? |
| 101 | + I18n.load_path.map { |path| LocaleExtractor.locale_from_path(path) } |
| 102 | + else |
| 103 | + super |
| 104 | + end |
| 105 | + end |
| 106 | + |
| 107 | + def lookup(locale, key, scope = [], options = EMPTY_HASH) |
| 108 | + if lazy_load? |
| 109 | + I18n.with_locale(locale) do |
| 110 | + super |
| 111 | + end |
| 112 | + else |
| 113 | + super |
| 114 | + end |
| 115 | + end |
| 116 | + |
| 117 | + protected |
| 118 | + |
| 119 | + |
| 120 | + # Load translations from files that belong to the current locale. |
| 121 | + def init_translations |
| 122 | + file_errors = if lazy_load? |
| 123 | + initialized_locales[I18n.locale] = true |
| 124 | + load_translations_and_collect_file_errors(filenames_for_current_locale) |
| 125 | + else |
| 126 | + @initialized = true |
| 127 | + load_translations_and_collect_file_errors(I18n.load_path) |
| 128 | + end |
| 129 | + |
| 130 | + raise InvalidFilenames.new(file_errors) unless file_errors.empty? |
| 131 | + end |
| 132 | + |
| 133 | + def initialized_locales |
| 134 | + @initialized_locales ||= Hash.new(false) |
| 135 | + end |
| 136 | + |
| 137 | + private |
| 138 | + |
| 139 | + def lazy_load? |
| 140 | + @lazy_load |
| 141 | + end |
| 142 | + |
| 143 | + class FilenameIncorrect < StandardError |
| 144 | + def initialize(file, expected_locale, unexpected_locales) |
| 145 | + super "#{file} can only load translations for \"#{expected_locale}\". Found translations for: #{unexpected_locales}." |
| 146 | + end |
| 147 | + end |
| 148 | + |
| 149 | + # Loads each file supplied and asserts that the file only loads |
| 150 | + # translations as expected by the name. The method returns a list of |
| 151 | + # errors corresponding to offending files. |
| 152 | + def load_translations_and_collect_file_errors(files) |
| 153 | + errors = [] |
| 154 | + |
| 155 | + load_translations(files) do |file, loaded_translations| |
| 156 | + assert_file_named_correctly!(file, loaded_translations) |
| 157 | + rescue FilenameIncorrect => e |
| 158 | + errors << e |
| 159 | + end |
| 160 | + |
| 161 | + errors |
| 162 | + end |
| 163 | + |
| 164 | + # Select all files from I18n load path that belong to current locale. |
| 165 | + # These files must start with the locale identifier (ie. "en", "pt-BR"), |
| 166 | + # followed by an "_" demarcation to separate proceeding text. |
| 167 | + def filenames_for_current_locale |
| 168 | + I18n.load_path.flatten.select do |path| |
| 169 | + LocaleExtractor.locale_from_path(path) == I18n.locale |
| 170 | + end |
| 171 | + end |
| 172 | + |
| 173 | + # Checks if a filename is named in correspondence to the translations it loaded. |
| 174 | + # The locale extracted from the path must be the single locale loaded in the translations. |
| 175 | + def assert_file_named_correctly!(file, translations) |
| 176 | + loaded_locales = translations.keys.map(&:to_sym) |
| 177 | + expected_locale = LocaleExtractor.locale_from_path(file) |
| 178 | + unexpected_locales = loaded_locales.reject { |locale| locale == expected_locale } |
| 179 | + |
| 180 | + raise FilenameIncorrect.new(file, expected_locale, unexpected_locales) unless unexpected_locales.empty? |
| 181 | + end |
| 182 | + end |
| 183 | + end |
| 184 | +end |
0 commit comments