tater 1.1.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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