tater 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '00580e7b14eb0c1f1bc26b9a8cc2470638aa06f2f9800b4a4c542de913fffcb4'
4
- data.tar.gz: f807ce661a2bed8ca9526f309c1548ddf292f034cbcaeae4223a385f89296c86
3
+ metadata.gz: 16fddfa1cedd9af51e3d2e04422a083a16ee055e1102ad4482bfb28beee3be78
4
+ data.tar.gz: 73273faf3a6bb5842d6adc2854ca98e81363efd0c45835de07fc2e4d807effa2
5
5
  SHA512:
6
- metadata.gz: 8ff891fe31f924f41c5bd20aaa0b58c39659a2e0e72cabd4cfd55df79d2e237daaabe2bb717415eca782f814dcd1322737a861f23fd37449e4f8516a71ea197e
7
- data.tar.gz: f231dcbca0ca5826a4c99f92afa6c98514985c262a66f473ddb8696d754e849e6c50d371cf82c056e395565b199d6179d665b28b580cade63aa24932c4a378e6
6
+ metadata.gz: a9da7fc7081b2043891c83f6ef46c1facdff44f85175ce5052af04a0e6f22bb82ffb0505a29d24a7d2a91ba00e81c5b8fccdb1c6b7118de48680639e474a7f13
7
+ data.tar.gz: 0ccb2a820ea03144b96319c61eb0b645ca86002cf88c657b906669b39702dbdefb83e009a3b34fb5adda4a9fd47deaa30fbd0c0580ff34b80c9e9c5d4051a204
data/README.md CHANGED
@@ -27,7 +27,7 @@ And then execute:
27
27
  bundle
28
28
  ```
29
29
 
30
- Or install it yourself as:
30
+ Or install it yourself by running:
31
31
 
32
32
  ```sh
33
33
  gem install tater
@@ -40,15 +40,20 @@ gem install tater
40
40
  require 'tater'
41
41
 
42
42
  messages = {
43
- 'some' => {
44
- 'key' => 'This here string!'
45
- },
46
- 'interpolated' => 'Hello %{you}!'
43
+ 'en' => {
44
+ 'some' => {
45
+ 'key' => 'This here string!'
46
+ },
47
+ 'interpolated' => 'Hello %{you}!'
48
+ }
47
49
  }
48
50
 
49
- i18n = Tater.new
51
+ i18n = Tater.new(locale: 'en')
50
52
  i18n.load(messages: messages)
51
53
 
54
+ # OR
55
+ i18n = Tater.new(locale: 'en', messages: messages)
56
+
52
57
  # Basic lookup:
53
58
  i18n.translate('some.key') # => 'This here string!'
54
59
 
@@ -56,7 +61,26 @@ i18n.translate('some.key') # => 'This here string!'
56
61
  i18n.translate('interpolated', you: 'world') # => 'Hello world!'
57
62
  ```
58
63
 
59
- ## Numeric Localization
64
+
65
+ ## Array localization
66
+
67
+ Given an array, Tater will do it's best to join the elements of the array into a
68
+ sentence based on how many elements there are.
69
+
70
+ ```yaml
71
+ en:
72
+ array:
73
+ last_word_connector: ", and "
74
+ two_words_connector: " and "
75
+ words_connector: ", "
76
+ ```
77
+
78
+ ```ruby
79
+ i18n.localize(%w[tacos enchiladas burritos]) # => "tacos, enchiladas, and burritos"
80
+ ```
81
+
82
+
83
+ ## Numeric localization
60
84
 
61
85
  Numeric localization (`Numeric`, `Integer`, `Float`, and `BigDecimal`) require
62
86
  filling in a separator and delimiter. For example:
@@ -81,7 +105,7 @@ i18n.localize(1000.2, delimiter: '_', separator: '+') # => "1_000+20"
81
105
  ```
82
106
 
83
107
 
84
- ## Date and Time Localization
108
+ ## Date and time localization
85
109
 
86
110
  Date and time localization (`Date`, `Time`, and `DateTime`) require filling in
87
111
  all of the needed names and abbreviations for days and months. Here's the
@@ -166,24 +190,26 @@ i18n.localize(Date.new(1970, 1, 1), format: 'day') # => 'jeudi'
166
190
  ```
167
191
 
168
192
 
169
- ## Cascading Lookups
193
+ ## Cascading lookups
170
194
 
171
195
  Lookups can be cascaded, i.e. pieces of the scope of the can be lopped off
172
196
  incrementally.
173
197
 
174
198
  ```ruby
175
199
  messages = {
176
- 'login' => {
177
- 'title' => 'Login',
178
- 'description' => 'Normal description.'
200
+ 'en' => {
201
+ 'login' => {
202
+ 'title' => 'Login',
203
+ 'description' => 'Normal description.'
179
204
 
180
- 'special' => {
181
- 'title' => 'Special Login'
205
+ 'special' => {
206
+ 'title' => 'Special Login'
207
+ }
182
208
  }
183
209
  }
184
210
  }
185
211
 
186
- i18n = Tater.new(messages: messages)
212
+ i18n = Tater.new(locale: 'en', messages: messages)
187
213
  i18n.translate('login.special.title') # => 'Special Login'
188
214
  i18n.translate('login.special.description') # => 'Tater lookup failed'
189
215
 
@@ -218,14 +244,14 @@ Tater.new.translate('nope', default: 'Yep!') # => 'Yep!'
218
244
  ```
219
245
 
220
246
 
221
- ## Procs and Messages in Ruby
247
+ ## Procs and messages in Ruby
222
248
 
223
249
  Ruby files can be used to store messages in addition to YAML, so long as the
224
250
  Ruby file returns a `Hash` when evalled.
225
251
 
226
252
  ```ruby
227
253
  {
228
- en: {
254
+ 'en' => {
229
255
  ruby: proc do |key, options = {}|
230
256
  "Hey #{ key }!"
231
257
  end
@@ -234,10 +260,11 @@ Ruby file returns a `Hash` when evalled.
234
260
  ```
235
261
 
236
262
 
237
- ## Multiple Locales
263
+ ## Multiple locales
238
264
 
239
- If you like to check multiple locales and pull the first matching one out, you
240
- can pass the `:locales` option an array of top-level locale keys.
265
+ If you would like to check multiple locales and pull the first matching one out,
266
+ you can pass the `:locales` option to initialization or the `translate` method
267
+ with an array of top-level locale keys.
241
268
 
242
269
  ```ruby
243
270
  messages = {
@@ -253,9 +280,14 @@ messages = {
253
280
  i18n = Tater.new(messages: messages)
254
281
  i18n.translate('title', locales: %w[fr en]) # => 'la connexion'
255
282
  i18n.translate('description', locales: %w[fr en]) # => 'English description.'
283
+
284
+ # OR
285
+ i18n = Tater.new(messages: messages, locales: %w[fr en])
286
+ i18n.translate('title') # => 'la connexion'
287
+ i18n.translate('description') # => 'English description.'
256
288
  ```
257
289
 
258
- Locales will tried in order and which one matches first will be returned.
290
+ Locales will be tried in order and whichever one matches first will be returned.
259
291
 
260
292
 
261
293
  ## Limitations
@@ -12,14 +12,14 @@ class Tater
12
12
  # Merge all the way down.
13
13
  #
14
14
  # @param to [Hash]
15
- # The target Hash to merge into. Note that modification is done in-place,
16
- # not on a copy of the object.
15
+ # The target Hash to merge into.
17
16
  # @param from [Hash]
18
17
  # The Hash to copy values from.
19
- def self.deep_merge!(to, from)
20
- to.merge!(from) do |_key, left, right|
18
+ # @return [Hash]
19
+ def self.deep_merge(to, from)
20
+ to.merge(from) do |_key, left, right|
21
21
  if left.is_a?(Hash) && right.is_a?(Hash)
22
- Utils.deep_merge!(left, right)
22
+ Utils.deep_merge(left, right)
23
23
  else
24
24
  right
25
25
  end
@@ -29,18 +29,32 @@ class Tater
29
29
  # Transform keys all the way down.
30
30
  #
31
31
  # @param hash [Hash]
32
- # The Hash to modify. Note that modification is done in-place, not on a copy
33
- # of the object.
34
- def self.deep_stringify_keys!(hash)
35
- hash.transform_keys!(&:to_s).transform_values! do |value|
32
+ # The Hash to stringify keys for.
33
+ # @return [Hash]
34
+ def self.deep_stringify_keys(hash)
35
+ hash.transform_keys(&:to_s).transform_values do |value|
36
36
  if value.is_a?(Hash)
37
- Utils.deep_stringify_keys!(value)
37
+ Utils.deep_stringify_keys(value)
38
38
  else
39
39
  value
40
40
  end
41
41
  end
42
42
  end
43
43
 
44
+ # Freeze all the way down.
45
+ #
46
+ # @param hash [Hash]
47
+ # @return [Hash]
48
+ def self.deep_freeze(hash)
49
+ hash.transform_keys(&:freeze).transform_values do |value|
50
+ if value.is_a?(Hash)
51
+ Utils.deep_freeze(value)
52
+ else
53
+ value.freeze
54
+ end
55
+ end.freeze
56
+ end
57
+
44
58
  # Try to interpolate these things, if one of them is a string.
45
59
  #
46
60
  # @param string [String]
@@ -49,8 +63,9 @@ class Tater
49
63
  # The values to interpolate into the target string.
50
64
  #
51
65
  # @return [String]
52
- def self.interpolate(string, options = {})
66
+ def self.interpolate(string, options = HASH)
53
67
  return string unless string.is_a?(String)
68
+ return string if options.empty?
54
69
 
55
70
  format(string, options)
56
71
  end
@@ -71,8 +86,8 @@ class Tater
71
86
  end
72
87
 
73
88
  DEFAULT = 'default'
74
- DEFAULT_LOCALE = 'en'
75
89
  DELIMITING_REGEX = /(\d)(?=(\d\d\d)+(?!\d))/.freeze
90
+ HASH = {}.freeze
76
91
  SEPARATOR = '.'
77
92
  SUBSTITUTION_REGEX = /%(|\^)[aAbBpP]/.freeze
78
93
 
@@ -82,7 +97,16 @@ class Tater
82
97
  # @return [Hash]
83
98
  attr_reader :messages
84
99
 
85
- def initialize(path: nil, messages: nil, locale: DEFAULT_LOCALE, cascade: false)
100
+ # @param cascade [Boolean]
101
+ # A boolean indicating if lookups should cascade by default.
102
+ # @param locale [String]
103
+ # The default locale.
104
+ # @param messages [Hash]
105
+ # A hash of messages ready to be loaded in.
106
+ # @param path [String]
107
+ # A path to search for YAML or Ruby files to load messages from.
108
+ def initialize(cascade: false, locale: nil, messages: nil, path: nil)
109
+ @cache = {}
86
110
  @cascade = cascade
87
111
  @locale = locale
88
112
  @messages = {}
@@ -98,11 +122,11 @@ class Tater
98
122
  @cascade
99
123
  end
100
124
 
101
- # An array of the available locale codes.
125
+ # An array of the available locale codes found in loaded messages.
102
126
  #
103
127
  # @return [Array]
104
128
  def available
105
- messages.keys.map(&:to_s)
129
+ @available ||= messages.keys
106
130
  end
107
131
 
108
132
  # Is this locale available in our current set of messages?
@@ -113,24 +137,36 @@ class Tater
113
137
  end
114
138
 
115
139
  # Load messages into our internal cache, either from a path containing YAML
116
- # files or a collection of messages.
140
+ # files or a Hash of messages.
117
141
  #
118
142
  # @param path [String]
119
143
  # A path to search for YAML or Ruby files to load messages from.
120
144
  # @param messages [Hash]
121
145
  # A hash of messages ready to be loaded in.
122
146
  def load(path: nil, messages: nil)
147
+ return if path.nil? && messages.nil?
148
+
123
149
  if path
124
150
  Dir.glob(File.join(path, '**', '*.{yml,yaml}')).each do |file|
125
- Utils.deep_merge!(@messages, YAML.load_file(file))
151
+ @messages = Utils.deep_merge(@messages, YAML.load_file(file))
126
152
  end
127
153
 
128
154
  Dir.glob(File.join(path, '**', '*.rb')).each do |file|
129
- Utils.deep_merge!(@messages, Utils.deep_stringify_keys!(eval(IO.read(file), binding, file)))
155
+ @messages = Utils.deep_merge(@messages, Utils.deep_stringify_keys(eval(IO.read(file), binding, file))) # rubocop:disable Security/Eval
130
156
  end
131
157
  end
132
158
 
133
- Utils.deep_merge!(@messages, Utils.deep_stringify_keys!(messages)) if messages
159
+ @messages = Utils.deep_merge(@messages, Utils.deep_stringify_keys(messages)) if messages
160
+ @messages = Utils.deep_freeze(@messages)
161
+
162
+ # Gotta recalculate available locales after updating.
163
+ remove_instance_variable(:@available) if instance_variable_defined?(:@available)
164
+
165
+ # Not only does this clear our cache but it establishes the basic structure
166
+ # that we rely on in other methods.
167
+ @messages.each_key do |key|
168
+ @cache[key] = { true => {}, false => {} }
169
+ end
134
170
  end
135
171
 
136
172
  # Set the current locale, if it's available.
@@ -141,9 +177,9 @@ class Tater
141
177
  @locale = locale.to_s if available?(locale)
142
178
  end
143
179
 
144
- # Localize a Date, Time, DateTime, or Numeric object.
180
+ # Localize an Array, Date, Time, DateTime, or Numeric object.
145
181
  #
146
- # @param object [Date, Time, DateTime, Numeric]
182
+ # @param object [Array<String>, Date, Time, DateTime, Numeric]
147
183
  # The object to localize.
148
184
  # @param options [Hash]
149
185
  # Options to configure localization.
@@ -156,20 +192,24 @@ class Tater
156
192
  # The delimiter to use when localizing numberic values.
157
193
  # @option options [String] :separator
158
194
  # The separator to use when localizing numberic values.
195
+ # @option options [String] :two_words_connector
196
+ # The string used to join two array elements together e.g. " and ".
197
+ # @option options [String] :words_connector
198
+ # The string used to connect multiple array elements e.g. ", ".
199
+ # @option options [String] :last_word_connector
200
+ # The string used to connect the final element with preceding array elements
201
+ # e.g. ", and ".
159
202
  #
160
203
  # @return [String]
161
204
  # A localized version of the object passed in.
162
- def localize(object, options = {})
163
- format_key = options.delete(:format) || DEFAULT
164
- locale_override = options.delete(:locale)
165
-
205
+ def localize(object, options = HASH)
166
206
  case object
167
207
  when String
168
208
  object
169
209
  when Numeric
170
- delimiter = options.delete(:delimiter) || lookup('numeric.delimiter', locale_override)
171
- separator = options.delete(:separator) || lookup('numeric.separator', locale_override)
172
- precision = options.delete(:precision) || 2
210
+ delimiter = options[:delimiter] || lookup('numeric.delimiter', locale: options[:locale])
211
+ separator = options[:separator] || lookup('numeric.separator', locale: options[:locale])
212
+ precision = options[:precision] || 2
173
213
 
174
214
  raise(MissingLocalizationFormat, "Numeric localization delimiter ('numeric.delimiter') missing or not passed as option :delimiter") unless delimiter
175
215
  raise(MissingLocalizationFormat, "Numeric localization separator ('numeric.separator') missing or not passed as option :separator") unless separator
@@ -186,27 +226,47 @@ class Tater
186
226
  [integer, fraction&.ljust(precision, '0')].compact.join(separator)
187
227
  end
188
228
  when Date, Time, DateTime
189
- key = object.class.to_s.downcase
190
- format = lookup("#{ key }.formats.#{ format_key }", locale_override) || format_key
229
+ format = lookup("#{ object.class.to_s.downcase }.formats.#{ options[:format] || DEFAULT }", locale: options[:locale]) || options[:format] || DEFAULT
191
230
 
192
231
  # Heavily cribbed from I18n, many thanks to the people who sorted this out
193
232
  # before I worked on this library.
194
233
  format = format.gsub(SUBSTITUTION_REGEX) do |match|
195
234
  case match
196
- when '%a' then lookup('date.abbreviated_days', locale_override)[object.wday]
197
- when '%^a' then lookup('date.abbreviated_days', locale_override)[object.wday].upcase
198
- when '%A' then lookup('date.days', locale_override)[object.wday]
199
- when '%^A' then lookup('date.days', locale_override)[object.wday].upcase
200
- when '%b' then lookup('date.abbreviated_months', locale_override)[object.mon - 1]
201
- when '%^b' then lookup('date.abbreviated_months', locale_override)[object.mon - 1].upcase
202
- when '%B' then lookup('date.months', locale_override)[object.mon - 1]
203
- when '%^B' then lookup('date.months', locale_override)[object.mon - 1].upcase
204
- when '%p' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale_override).upcase if object.respond_to?(:hour) # rubocop:disable Metrics/BlockNesting
205
- when '%P' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale_override).downcase if object.respond_to?(:hour) # rubocop:disable Metrics/BlockNesting
235
+ when '%a' then lookup('date.abbreviated_days', locale: options[:locale])[object.wday]
236
+ when '%^a' then lookup('date.abbreviated_days', locale: options[:locale])[object.wday].upcase
237
+ when '%A' then lookup('date.days', locale: options[:locale])[object.wday]
238
+ when '%^A' then lookup('date.days', locale: options[:locale])[object.wday].upcase
239
+ when '%b' then lookup('date.abbreviated_months', locale: options[:locale])[object.mon - 1]
240
+ when '%^b' then lookup('date.abbreviated_months', locale: options[:locale])[object.mon - 1].upcase
241
+ when '%B' then lookup('date.months', locale: options[:locale])[object.mon - 1]
242
+ when '%^B' then lookup('date.months', locale: options[:locale])[object.mon - 1].upcase
243
+ when '%p' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale: options[:locale]).upcase if object.respond_to?(:hour) # rubocop:disable Metrics/BlockNesting
244
+ when '%P' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale: options[:locale]).downcase if object.respond_to?(:hour) # rubocop:disable Metrics/BlockNesting
206
245
  end
207
246
  end
208
247
 
209
248
  object.strftime(format)
249
+ when Array
250
+ case object.length
251
+ when 0
252
+ ''
253
+ when 1
254
+ object[0]
255
+ when 2
256
+ two_words_connector = options[:two_words_connector] || lookup('array.two_words_connector', locale: options[:locale])
257
+
258
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.two_words_connector') missing or not passed as option :two_words_connector") unless two_words_connector
259
+
260
+ "#{ object[0] }#{ two_words_connector }#{ object[1] }"
261
+ else
262
+ last_word_connector = options[:last_word_connector] || lookup('array.last_word_connector', locale: options[:locale])
263
+ words_connector = options[:words_connector] || lookup('array.words_connector', locale: options[:locale])
264
+
265
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.last_word_connector') missing or not passed as option :last_word_connector") unless last_word_connector
266
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.words_connector') missing or not passed as option :words_connector") unless words_connector
267
+
268
+ "#{ object[0...-1].join(words_connector) }#{ last_word_connector }#{ object[-1] }"
269
+ end
210
270
  else
211
271
  raise(UnLocalizableObject, "The object class #{ object.class } cannot be localized by Tater.")
212
272
  end
@@ -216,36 +276,77 @@ class Tater
216
276
  # Lookup a key in the messages hash, using the current locale or an override.
217
277
  #
218
278
  # @param key [String]
219
- # @param locale_override [String]
279
+ # @param locale [String]
220
280
  # A locale to use instead of our current one.
221
- # @param cascade_override [Boolean]
281
+ # @param cascade [Boolean]
222
282
  # A boolean to forcibly set the cascade option for this lookup.
223
283
  #
224
284
  # @return
225
- # Basically anything that can be stored in YAML, including nil.
226
- def lookup(key, locale_override = nil, cascade_override = nil)
227
- path = key.split(SEPARATOR).prepend(locale_override || locale).map(&:to_s)
285
+ # Basically anything that can be stored in your messages Hash.
286
+ def lookup(key, locale: nil, cascade: nil)
287
+ locale = locale.nil? ? @locale : locale
288
+ cascade = cascade.nil? ? @cascade : cascade
289
+
290
+ cached(key, locale, cascade) || begin
291
+ return nil unless @messages.key?(locale.to_s)
228
292
 
229
- if cascade_override.nil? ? @cascade : cascade_override
230
- while path.length >= 2 do
231
- attempt = @messages.dig(*path)
293
+ path = key.split(SEPARATOR).prepend(locale).map(&:to_s)
232
294
 
233
- if attempt
234
- break attempt
295
+ message =
296
+ if cascade
297
+ while path.length >= 2
298
+ attempt = @messages.dig(*path)
299
+
300
+ break attempt unless attempt.nil?
301
+
302
+ path.delete_at(path.length - 2)
303
+ end
235
304
  else
236
- path.delete_at(path.length - 2)
305
+ @messages.dig(*path)
237
306
  end
238
- end
239
- else
240
- @messages.dig(*path)
307
+
308
+ cache(key, locale, cascade, message)
241
309
  end
242
310
  end
243
311
 
312
+ # Check that there's a key at the given path.
313
+ #
314
+ # @param key [String]
315
+ # The period-separated key path to look within our messages for.
316
+ # @param options [Hash]
317
+ # Options to pass to the #lookup method, including locale overrides.
318
+ #
319
+ # @option options [Boolean] :cascade
320
+ # Should this lookup cascade or not? Can override @cascade.
321
+ # @option options [String] :locale
322
+ # A specific locale to lookup within. This will take precedence over the
323
+ # :locales option.
324
+ # @option options [Array<String>] :locales
325
+ # An array of locales to look within.
326
+ #
327
+ # @return [Boolean]
328
+ def includes?(key, options = HASH)
329
+ message =
330
+ if options.key?(:locales)
331
+ options[:locales].append(@locale) if @locale && !options[:locales].include?(@locale)
332
+
333
+ options[:locales].find do |accept|
334
+ found = lookup(key, locale: accept, cascade: options[:cascade])
335
+
336
+ break found unless found.nil?
337
+ end
338
+ else
339
+ lookup(key, locale: options[:locale], cascade: options[:cascade])
340
+ end
341
+
342
+ !message.nil?
343
+ end
344
+
244
345
  # Translate a key path and optional interpolation arguments into a string.
245
346
  # It's effectively a combination of #lookup and #interpolate.
246
347
  #
247
348
  # @example
248
- # Tater.new(messages: { 'en' => { 'hi' => 'Hello' }}).translate('hi') # => 'Hello'
349
+ # Tater.new(locale: 'en', messages: { 'en' => { 'hi' => 'Hello' }}).translate('hi') # => 'Hello'
249
350
  #
250
351
  # @param key [String]
251
352
  # The period-separated key path to look within our messages for.
@@ -257,36 +358,61 @@ class Tater
257
358
  # @option options [String] :default
258
359
  # A default string to return, should lookup fail.
259
360
  # @option options [String] :locale
260
- # A specific locale to lookup within. This will take precedence over the
261
- # :locales option.
361
+ # A specific locale to lookup within.
262
362
  # @option options [Array<String>] :locales
263
- # An array of locales to look within.
363
+ # An array of locales to look within. This will take precedence over the
364
+ # :locale option and will append the default :locale option passed during
365
+ # initialization if present.
264
366
  #
265
367
  # @return [String]
266
368
  # The translated and interpreted string, if found, or any data at the
267
369
  # defined key.
268
- def translate(key, options = {})
269
- cascade_override = options.delete(:cascade)
270
- locale_override = options.delete(:locale)
271
- locales = options.delete(:locales)
272
-
370
+ def translate(key, options = HASH)
273
371
  message =
274
- if locale_override || !locales
275
- lookup(key, locale_override, cascade_override)
276
- else
277
- locales.find do |accept|
278
- found = lookup(key, accept, cascade_override)
372
+ if options.key?(:locales)
373
+ options[:locales].append(@locale) if @locale && !options[:locales].include?(@locale)
374
+
375
+ options[:locales].find do |accept|
376
+ found = lookup(key, locale: accept, cascade: options[:cascade])
279
377
 
280
- break found if found
378
+ break found unless found.nil?
281
379
  end
380
+ else
381
+ lookup(key, locale: options[:locale], cascade: options[:cascade])
282
382
  end
283
383
 
284
384
  # Call procs that should return a string.
285
- if message.is_a?(Proc)
286
- message = message.call(key, options)
287
- end
385
+ message = message.call(key, options) if message.is_a?(Proc)
288
386
 
289
- Utils.interpolate(message, options) || options.delete(:default) { "Tater lookup failed: #{ locale_override || locales || locale }.#{ key }" }
387
+ Utils.interpolate(message, options) || options[:default] || "Tater lookup failed: #{ options[:locale] || options[:locales] || locale }.#{ key }"
290
388
  end
291
389
  alias t translate
390
+
391
+ private
392
+
393
+ # @param key [String]
394
+ # The cache key, often in the form "something.nested.like.this"
395
+ # @param locale [String]
396
+ # The locale to store the value for.
397
+ # @param cascade [Boolean]
398
+ # Was this a cascading lookup?
399
+ # @param message [String]
400
+ # The message being cached, often a String.
401
+ # @return [String]
402
+ # Whatever value is being cached, often a String.
403
+ def cache(key, locale, cascade, message)
404
+ @cache[locale][cascade][key] = message
405
+ end
406
+
407
+ # @param key [String]
408
+ # The cache key, often in the form "something.nested.like.this"
409
+ # @param locale [String]
410
+ # The locale to store the value for.
411
+ # @param cascade [Boolean]
412
+ # Was this a cascading lookup?
413
+ # @return [String, nil]
414
+ # The cached message or nil.
415
+ def cached(key, locale, cascade)
416
+ @cache.dig(locale, cascade, key)
417
+ end
292
418
  end
@@ -8,6 +8,11 @@ en:
8
8
  deep:
9
9
  key: 'This key is deeper'
10
10
 
11
+ array:
12
+ last_word_connector: ", and "
13
+ two_words_connector: " and "
14
+ words_connector: ", "
15
+
11
16
  date:
12
17
  formats:
13
18
  default: '%Y/%-m/%-d'
@@ -1,10 +1,11 @@
1
+ # frozen_string_literal: true
1
2
  {
2
3
  en: {
3
- ruby: proc do |key, options = {}|
4
+ ruby: proc do |key, _options = {}|
4
5
  "Hey #{ key }!"
5
6
  end,
6
- options: proc do |_key, options = {}|
7
- "Hey %{options}!"
7
+ options: proc do |_key, _options = {}|
8
+ 'Hey %{options}!'
8
9
  end
9
10
  }
10
11
  }
@@ -5,24 +5,34 @@ require 'date'
5
5
 
6
6
  describe Tater do
7
7
  describe Tater::Utils do
8
- describe '#deep_merge!' do
9
- it 'deeply merges two hashes, modifying the first' do
8
+ describe '#deep_merge' do
9
+ it 'deeply merges two hashes, returning a new one' do
10
10
  first = { 'one' => 'one', 'two' => { 'three' => 'three' } }
11
11
  second = { 'two' => { 'four' => 'four' } }
12
12
 
13
- Tater::Utils.deep_merge!(first, second)
13
+ third = Tater::Utils.deep_merge(first, second)
14
14
 
15
- assert_equal({ 'one' => 'one', 'two' => { 'three' => 'three', 'four' => 'four' } }, first)
15
+ assert_equal({ 'one' => 'one', 'two' => { 'three' => 'three', 'four' => 'four' } }, third)
16
16
  end
17
17
  end
18
18
 
19
- describe '#deep_stringify_keys!' do
20
- it 'converts all keys into strings, recursively modifying the hash passed in' do
19
+ describe '#deep_stringify_keys' do
20
+ it 'converts all keys into strings, recursively' do
21
21
  start = { en: { login: { title: 'Hello!' } } }
22
+ finish = Tater::Utils.deep_stringify_keys(start)
22
23
 
23
- Tater::Utils.deep_stringify_keys!(start)
24
+ assert_equal({ 'en' => { 'login' => { 'title' => 'Hello!' } } }, finish)
25
+ end
26
+ end
27
+
28
+ describe '#deep_freeze' do
29
+ it 'freezes the keys and values, recursively' do
30
+ start = Tater::Utils.deep_stringify_keys({ en: { login: { title: 'Hello!' } } })
31
+ finish = Tater::Utils.deep_freeze(start)
24
32
 
25
- assert_equal({ 'en' => { 'login' => { 'title' => 'Hello!' } } }, start)
33
+ assert finish.frozen?
34
+ assert finish.keys.all?(&:frozen?)
35
+ assert finish.values.all?(&:frozen?)
26
36
  end
27
37
  end
28
38
 
@@ -31,25 +41,29 @@ describe Tater do
31
41
  assert_equal 'this thing', Tater::Utils.interpolate('this %{what}', what: 'thing')
32
42
  end
33
43
 
34
- it 'raises a KeyError when an argument is missing' do
44
+ it 'raises a KeyError when an argument is missing (but options are passed)' do
35
45
  assert_raises(KeyError) do
36
- Tater::Utils.interpolate('this %{what}')
46
+ Tater::Utils.interpolate('this %{what}', nope: 'thing')
37
47
  end
38
48
  end
49
+
50
+ it 'returns the string unchanged when options are empty (does not raise a KeyError)' do
51
+ assert_equal 'this %{what}', Tater::Utils.interpolate('this %{what}')
52
+ end
39
53
  end
40
54
 
41
55
  describe '#string_from_numeric' do
42
56
  it 'converts numerics to decimal-ish strings' do
43
57
  assert_equal '1', Tater::Utils.string_from_numeric(1)
44
58
  assert_equal '1.0', Tater::Utils.string_from_numeric(1.0)
45
- assert_equal '1.0', Tater::Utils.string_from_numeric(BigDecimal(1))
59
+ assert_equal '1.0', Tater::Utils.string_from_numeric(BigDecimal('1'))
46
60
  end
47
61
  end
48
62
  end
49
63
 
50
64
  describe '#available?' do
51
65
  let :i18n do
52
- Tater.new(path: File.expand_path('test/fixtures'))
66
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
53
67
  end
54
68
 
55
69
  it 'tells you if the locale is available' do
@@ -84,6 +98,14 @@ describe Tater do
84
98
 
85
99
  assert_instance_of(Hash, i18n.messages)
86
100
  end
101
+
102
+ it 'freezes messages after loading' do
103
+ i18n = Tater.new(messages: { 'hey' => 'Oh hi' })
104
+
105
+ assert i18n.messages.frozen?
106
+ assert i18n.messages.keys.all?(&:frozen?)
107
+ assert i18n.messages.values.all?(&:frozen?)
108
+ end
87
109
  end
88
110
 
89
111
  describe '#available' do
@@ -94,11 +116,17 @@ describe Tater do
94
116
  it 'returns an array with the available locales (i.e. the top-level keys in our messages hash)' do
95
117
  assert_equal %w[en delimiter_only separator_only fr].sort, i18n.available.sort
96
118
  end
119
+
120
+ it 'updates the available list when new messages are loaded' do
121
+ i18n.load(messages: { 'added' => { 'hey' => 'yeah' }})
122
+
123
+ assert_equal %w[en delimiter_only separator_only fr added].sort, i18n.available.sort
124
+ end
97
125
  end
98
126
 
99
127
  describe '#lookup' do
100
128
  let :i18n do
101
- Tater.new(path: File.expand_path('test/fixtures'))
129
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
102
130
  end
103
131
 
104
132
  it 'returns keys from messages' do
@@ -114,16 +142,16 @@ describe Tater do
114
142
  end
115
143
 
116
144
  it 'cascades' do
117
- assert_equal 'Delicious', i18n.lookup('cascade.nope.tacos', nil, true)
118
- assert_equal 'Whoaa', i18n.lookup('cascade.another.nope.crazy', nil, true)
119
- assert_nil i18n.lookup('cascade.another.nope.crazy', nil, false)
145
+ assert_equal 'Delicious', i18n.lookup('cascade.nope.tacos', cascade: true)
146
+ assert_equal 'Whoaa', i18n.lookup('cascade.another.nope.crazy', cascade: true)
147
+ assert_nil i18n.lookup('cascade.another.nope.crazy', cascade: false)
120
148
  assert_nil i18n.lookup('cascade.nahhhhhh')
121
149
  end
122
150
  end
123
151
 
124
152
  describe '#translate' do
125
153
  let :i18n do
126
- Tater.new(path: File.expand_path('test/fixtures'))
154
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
127
155
  end
128
156
 
129
157
  it 'translates strings' do
@@ -181,7 +209,36 @@ describe Tater do
181
209
 
182
210
  describe '#localize' do
183
211
  let :i18n do
184
- Tater.new(path: File.expand_path('test/fixtures'))
212
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
213
+ end
214
+
215
+ let :fr do
216
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'fr')
217
+ end
218
+
219
+ it 'localizes arrays' do
220
+ assert_equal 'tacos and burritos', i18n.localize(%w[tacos burritos])
221
+ assert_equal 'tacos', i18n.localize(%w[tacos])
222
+ assert_equal 'tacos, enchiladas, and burritos', i18n.localize(%w[tacos enchiladas burritos])
223
+
224
+ assert_equal 'tacos + enchiladas ++ burritos', fr.localize(%w[tacos enchiladas burritos], words_connector: ' + ', last_word_connector: ' ++ ')
225
+ assert_equal 'tacostwoburritos', fr.localize(%w[tacos burritos], two_words_connector: 'two')
226
+
227
+ assert_raises(Tater::MissingLocalizationFormat) do
228
+ fr.localize(%w[tacos burritos])
229
+ end
230
+
231
+ assert_raises(Tater::MissingLocalizationFormat) do
232
+ fr.localize(%w[tacos burritos], last_word_connector: 'last', words_connector: 'words')
233
+ end
234
+
235
+ assert_raises(Tater::MissingLocalizationFormat) do
236
+ fr.localize(%w[tacos burritos], last_word_connector: 'last')
237
+ end
238
+
239
+ assert_raises(Tater::MissingLocalizationFormat) do
240
+ fr.localize(%w[tacos burritos], words_connector: 'words')
241
+ end
185
242
  end
186
243
 
187
244
  it 'localizes Dates' do
@@ -303,7 +360,7 @@ describe Tater do
303
360
 
304
361
  describe '#locale=' do
305
362
  let :i18n do
306
- Tater.new(path: File.expand_path('test/fixtures'))
363
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
307
364
  end
308
365
 
309
366
  it 'overrides the locale when available' do
@@ -334,4 +391,29 @@ describe Tater do
334
391
  assert cascade.cascades?
335
392
  end
336
393
  end
394
+
395
+ describe '#includes?' do
396
+ let :i18n do
397
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
398
+ end
399
+
400
+ it 'tells you if you have a translation' do
401
+ assert i18n.includes?('deep')
402
+ assert i18n.includes?('deep.key')
403
+ refute i18n.includes?('deep.nope')
404
+ refute i18n.includes?('nope')
405
+ end
406
+
407
+ it 'allows overriding the locale' do
408
+ assert i18n.includes?('french', locale: 'fr')
409
+ assert i18n.includes?('french', locales: %w[en fr])
410
+ refute i18n.includes?('french', locales: %w[en])
411
+ refute i18n.includes?('french')
412
+ end
413
+
414
+ it 'allows cascading' do
415
+ assert i18n.includes?('cascade.nope.tacos', cascade: true)
416
+ refute i18n.includes?('cascade.nope.tacos', cascade: false)
417
+ end
418
+ end
337
419
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tater
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evan Lecklider
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-07-14 00:00:00.000000000 Z
11
+ date: 2020-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rubocop
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -38,6 +52,20 @@ dependencies:
38
52
  - - ">="
39
53
  - !ruby/object:Gem::Version
40
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop-performance
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
41
69
  description: Minimal internationalization and localization library.
42
70
  email:
43
71
  - evan@lecklider.com
@@ -74,13 +102,13 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
102
  - !ruby/object:Gem::Version
75
103
  version: '2.0'
76
104
  requirements: []
77
- rubygems_version: 3.0.4
105
+ rubygems_version: 3.1.4
78
106
  signing_key:
79
107
  specification_version: 4
80
108
  summary: Minimal internationalization and localization library.
81
109
  test_files:
82
- - test/fixtures/messages/more.yml
83
110
  - test/fixtures/ruby.rb
84
- - test/fixtures/fixtures.yml
85
111
  - test/fixtures/another.yml
112
+ - test/fixtures/fixtures.yml
113
+ - test/fixtures/messages/more.yml
86
114
  - test/tater_test.rb