tater 2.0.1 → 3.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: 6106cd4e2b4936a681033a8d27c663cde05b412c014d155d79363b36370a07b7
4
- data.tar.gz: cc2ad7ed82fce6f3718a3f56d5475d750b006708c16882b82197c2f54c9dec32
3
+ metadata.gz: c34d7058c3566313243c4ccb7b99d967c60bf2e00e091d1fbb66dfbd257e853f
4
+ data.tar.gz: 0a5c1d4df6a34b9953086af0459af4c72cb8d9faf674d3216e1eb9e00aa0e82f
5
5
  SHA512:
6
- metadata.gz: 4cbf5271ab90d0b5d941dc3a338363c61f0a82916df5da691f2be08cf31ca8b77afa3099f9eb83e8b28c08b13d36e46eb3bb2d63129d44f5e4d3e267dea9da43
7
- data.tar.gz: f5c6d9b1d95f94bb9780d351d5d6ecfb30e9d66706559854cfa77dfa303cee9ab9b67fb325ea78a7b9a0092a6a439d0163221e8e06ae650da163391fb88c361e
6
+ metadata.gz: ac657b28f3e3421def12e3d0b50bcd895c5d1a1b23de7c97da6bf0e20bc5e0a51e20abf7394ec9e99b2137e9b2261392213f0dc85d2d3b0c598c4e73184d8857
7
+ data.tar.gz: 4b5b32cf7b23a66afcde0a73f129a76d791f62a8efa45a7223204d9e017328796cf528ad8884a2c25a568ea47d3047fff57c4973741aaae20e1e7574350a9068
@@ -1,42 +1,39 @@
1
- # Tater
1
+ * Tater
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/tater.svg)](https://badge.fury.io/rb/tater)
4
- [![Build Status](https://travis-ci.com/evanleck/tater.svg?branch=main)](https://travis-ci.com/evanleck/tater)
3
+ [[https://badge.fury.io/rb/tater][https://badge.fury.io/rb/tater.svg]]
5
4
 
6
5
  Tater is an internationalization (i18n) and localization (l10n) library designed
7
6
  for simplicity. It doesn't do everything that other libraries do, but that's by
8
7
  design.
9
8
 
10
- Under the hood, Tater uses a Hash to store the messages, the `dig` method for
11
- lookups, `strftime` for date and time localizations, and `format` for
9
+ Under the hood, Tater uses a Hash to store the messages, the =dig= method for
10
+ lookups, =strftime= for date and time localizations, and =format= for
12
11
  interpolation. That's probably 90% of what Tater does.
13
12
 
14
-
15
- ## Installation
13
+ ** Installation
16
14
 
17
15
  Tater requires Ruby 2.5 or higher. To install Tater, add this line to your
18
16
  application's Gemfile (or gems.rb):
19
17
 
20
- ```ruby
18
+ #+begin_src ruby
21
19
  gem 'tater'
22
- ```
20
+ #+end_src
23
21
 
24
22
  And then execute:
25
23
 
26
- ```sh
24
+ #+begin_src sh
27
25
  bundle
28
- ```
26
+ #+end_src
29
27
 
30
28
  Or install it yourself by running:
31
29
 
32
- ```sh
30
+ #+begin_src sh
33
31
  gem install tater
34
- ```
35
-
32
+ #+end_src
36
33
 
37
- ## Usage
34
+ ** Usage
38
35
 
39
- ```ruby
36
+ #+begin_src ruby
40
37
  require 'tater'
41
38
 
42
39
  messages = {
@@ -59,59 +56,56 @@ i18n.translate('some.key') # => 'This here string!'
59
56
 
60
57
  # Interpolation:
61
58
  i18n.translate('interpolated', you: 'world') # => 'Hello world!'
62
- ```
59
+ #+end_src
63
60
 
64
-
65
- ## Array localization
61
+ ** Array localization
66
62
 
67
63
  Given an array, Tater will do it's best to join the elements of the array into a
68
64
  sentence based on how many elements there are.
69
65
 
70
- ```yaml
66
+ #+begin_example
71
67
  en:
72
68
  array:
73
69
  last_word_connector: ", and "
74
70
  two_words_connector: " and "
75
71
  words_connector: ", "
76
- ```
72
+ #+end_example
77
73
 
78
- ```ruby
74
+ #+begin_src ruby
79
75
  i18n.localize(%w[tacos enchiladas burritos]) # => "tacos, enchiladas, and burritos"
80
- ```
81
-
76
+ #+end_src
82
77
 
83
- ## Numeric localization
78
+ ** Numeric localization
84
79
 
85
- Numeric localization (`Numeric`, `Integer`, `Float`, and `BigDecimal`) require
80
+ Numeric localization (=Numeric=, =Integer=, =Float=, and =BigDecimal=) require
86
81
  filling in a separator and delimiter. For example:
87
82
 
88
- ```yaml
83
+ #+begin_example
89
84
  en:
90
85
  numeric:
91
86
  delimiter: ','
92
87
  separator: '.'
93
- ```
88
+ #+end_example
94
89
 
95
90
  With that, you can do things like this:
96
91
 
97
- ```ruby
92
+ #+begin_src ruby
98
93
  i18n.localize(1000.2) # => "1,000.20"
99
- ```
94
+ #+end_src
100
95
 
101
96
  The separator and delimiter can also be passed in per-call:
102
97
 
103
- ```ruby
98
+ #+begin_src ruby
104
99
  i18n.localize(1000.2, delimiter: '_', separator: '+') # => "1_000+20"
105
- ```
100
+ #+end_src
106
101
 
102
+ ** Date and time localization
107
103
 
108
- ## Date and time localization
109
-
110
- Date and time localization (`Date`, `Time`, and `DateTime`) require filling in
104
+ Date and time localization (=Date=, =Time=, and =DateTime=) require filling in
111
105
  all of the needed names and abbreviations for days and months. Here's the
112
106
  example for French, which is used in the tests.
113
107
 
114
- ```yaml
108
+ #+begin_example
115
109
  fr:
116
110
  time:
117
111
  am: 'am'
@@ -174,28 +168,27 @@ fr:
174
168
  - oct.
175
169
  - nov.
176
170
  - déc.
177
- ```
171
+ #+end_example
178
172
 
179
- The statically defined keys for dates are `days`, `abbreviated_days`, `months`,
180
- and `abbreviated_months`. Only `am` and `pm` are needed for times and only if
181
- you plan on using the `%p` or `%P` format strings.
173
+ The statically defined keys for dates are =days=, =abbreviated_days=, =months=,
174
+ and =abbreviated_months=. Only =am= and =pm= are needed for times and only if
175
+ you plan on using the =%p= or =%P= format strings.
182
176
 
183
177
  With all of that, you can do something like:
184
178
 
185
- ```ruby
179
+ #+begin_src ruby
186
180
  i18n.localize(Date.new(1970, 1, 1), format: '%A') # => 'jeudi'
187
181
 
188
182
  # Or, using a key defined in "formats":
189
183
  i18n.localize(Date.new(1970, 1, 1), format: 'day') # => 'jeudi'
190
- ```
191
-
184
+ #+end_src
192
185
 
193
- ## Cascading lookups
186
+ ** Cascading lookups
194
187
 
195
- Lookups can be cascaded, i.e. pieces of the scope of the can be lopped off
188
+ Lookups can be cascaded, i.e. pieces of the scope of the can be lopped off
196
189
  incrementally.
197
190
 
198
- ```ruby
191
+ #+begin_src ruby
199
192
  messages = {
200
193
  'en' => {
201
194
  'login' => {
@@ -214,42 +207,40 @@ i18n.translate('login.special.title') # => 'Special Login'
214
207
  i18n.translate('login.special.description') # => 'Tater lookup failed'
215
208
 
216
209
  i18n.translate('login.special.description', cascade: true) # => 'Normal description.'
217
- ```
210
+ #+end_src
218
211
 
219
212
  With cascade, the final key stays the same, but pieces of the scope get lopped
220
213
  off. In this case, lookups will be tried in this order:
221
214
 
222
- 1. `'login.special.description'`
223
- 2. `'login.description'`
215
+ 1. ='login.special.description'=
216
+ 2. ='login.description'=
224
217
 
225
218
  This can be useful when you want to override some messages but don't want to
226
219
  have to copy all of the other, non-overwritten messages.
227
220
 
228
221
  Cascading can also be enabled by default when initializing an instance of Tater.
229
222
 
230
- ```ruby
223
+ #+begin_src ruby
231
224
  Tater.new(cascade: true)
232
- ```
225
+ #+end_src
233
226
 
234
227
  Cascading is off by default.
235
228
 
236
-
237
- ## Defaults
229
+ ** Defaults
238
230
 
239
231
  If you'd like to default to another value in case of a missed lookup, you can
240
- provide the `:default` option to `#translate`.
232
+ provide the =:default= option to =#translate=.
241
233
 
242
- ```ruby
234
+ #+begin_src ruby
243
235
  Tater.new.translate('nope', default: 'Yep!') # => 'Yep!'
244
- ```
236
+ #+end_src
245
237
 
246
-
247
- ## Procs and messages in Ruby
238
+ ** Procs and messages in Ruby
248
239
 
249
240
  Ruby files can be used to store messages in addition to YAML, so long as the
250
- Ruby file returns a `Hash` when evalled.
241
+ Ruby file returns a =Hash= when evalled.
251
242
 
252
- ```ruby
243
+ #+begin_src ruby
253
244
  {
254
245
  'en' => {
255
246
  ruby: proc do |key, options = {}|
@@ -257,16 +248,15 @@ Ruby file returns a `Hash` when evalled.
257
248
  end
258
249
  }
259
250
  }
260
- ```
261
-
251
+ #+end_src
262
252
 
263
- ## Multiple locales
253
+ ** Multiple locales
264
254
 
265
255
  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
256
+ you can pass the =:locales= option to initialization or the =translate= method
267
257
  with an array of top-level locale keys.
268
258
 
269
- ```ruby
259
+ #+begin_src ruby
270
260
  messages = {
271
261
  'en' => {
272
262
  'title' => 'Login',
@@ -285,31 +275,23 @@ i18n.translate('description', locales: %w[fr en]) # => 'English description.'
285
275
  i18n = Tater.new(messages: messages, locales: %w[fr en])
286
276
  i18n.translate('title') # => 'la connexion'
287
277
  i18n.translate('description') # => 'English description.'
288
- ```
278
+ #+end_src
289
279
 
290
280
  Locales will be tried in order and whichever one matches first will be returned.
291
281
 
282
+ ** Limitations
292
283
 
293
- ## Limitations
294
-
295
- - It is not "pluggable", it does what it does and that's it.
284
+ - It is not pluggable, it does what it does and that's it.
296
285
  - It doesn't handle pluralization yet, though it may in the future.
297
- - It doesn't cache anything, that's up to you.
298
-
299
-
300
- ## Why?
301
-
302
- Because [Ruby I18n][rubyi18n] is amazing and I wanted to try to create a minimum
303
- viable implementation of the bits of I18n that I use 90% of the time. Tater is a
304
- single file that handles the basics of lookup and interpolation.
305
286
 
287
+ ** Why?
306
288
 
307
- ## Trivia
289
+ Because [[https://github.com/ruby-i18n/i18n][Ruby I18n]] is amazing and I wanted to try to create a minimum viable
290
+ implementation of the bits of I18n that I use 90% of the time. Tater is a single
291
+ file that handles the basics of lookup and interpolation.
308
292
 
309
- I was orininally going to call this library "Translator" but with a
310
- [numeronym][numeronym] like I18n: "t8r". I looked at it for a while but I read
311
- it as "tater" instead of "tee-eight-arr" so I figured I'd just name it Tater.
312
- Tater the translator.
293
+ ** Trivia
313
294
 
314
- [numeronym]: https://en.wikipedia.org/wiki/Numeronym
315
- [rubyi18n]: https://github.com/ruby-i18n/i18n
295
+ I was orininally going to call this library "Translator" but with a [[https://en.wikipedia.org/wiki/Numeronym][numeronym]]
296
+ like I18n: "t8r". I looked at it for a while but I read it as "tater" instead
297
+ of "tee-eight-arr" so I figured I'd just name it Tater. Tater the translator.
data/lib/tater.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
  require 'bigdecimal'
3
+ require 'date'
4
+ require 'time'
3
5
  require 'yaml'
6
+ require 'tater/utils'
4
7
 
5
8
  # Tater is a internationalization (i18n) and localization (l10n) library
6
9
  # designed for speed and simplicity.
@@ -8,83 +11,6 @@ class Tater
8
11
  class MissingLocalizationFormat < ArgumentError; end
9
12
  class UnLocalizableObject < ArgumentError; end
10
13
 
11
- module Utils # :nodoc:
12
- # Merge all the way down.
13
- #
14
- # @param to [Hash]
15
- # The target Hash to merge into.
16
- # @param from [Hash]
17
- # The Hash to copy values from.
18
- # @return [Hash]
19
- def self.deep_merge(to, from)
20
- to.merge(from) do |_key, left, right|
21
- if left.is_a?(Hash) && right.is_a?(Hash)
22
- Utils.deep_merge(left, right)
23
- else
24
- right
25
- end
26
- end
27
- end
28
-
29
- # Transform keys all the way down.
30
- #
31
- # @param hash [Hash]
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
- if value.is_a?(Hash)
37
- Utils.deep_stringify_keys(value)
38
- else
39
- value
40
- end
41
- end
42
- end
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
-
58
- # Try to interpolate these things, if one of them is a string.
59
- #
60
- # @param string [String]
61
- # The target string to interpolate into.
62
- # @param options [Hash]
63
- # The values to interpolate into the target string.
64
- #
65
- # @return [String]
66
- def self.interpolate(string, options = HASH)
67
- return string unless string.is_a?(String)
68
- return string if options.empty?
69
-
70
- format(string, options)
71
- end
72
-
73
- # Convert a Numeric to a string, particularly formatting BigDecimals to a
74
- # Float-like string representation.
75
- #
76
- # @param numeric [Numeric]
77
- #
78
- # @return [String]
79
- def self.string_from_numeric(numeric)
80
- if numeric.is_a?(BigDecimal)
81
- numeric.to_s('F')
82
- else
83
- numeric.to_s
84
- end
85
- end
86
- end
87
-
88
14
  DEFAULT = 'default'
89
15
  DELIMITING_REGEX = /(\d)(?=(\d\d\d)+(?!\d))/.freeze
90
16
  HASH = {}.freeze
@@ -106,7 +32,6 @@ class Tater
106
32
  # @param path [String]
107
33
  # A path to search for YAML or Ruby files to load messages from.
108
34
  def initialize(cascade: false, locale: nil, messages: nil, path: nil)
109
- @cache = {}
110
35
  @cascade = cascade
111
36
  @locale = locale
112
37
  @messages = {}
@@ -152,7 +77,7 @@ class Tater
152
77
  end
153
78
 
154
79
  Dir.glob(File.join(path, '**', '*.rb')).each do |file|
155
- @messages = Utils.deep_merge(@messages, Utils.deep_stringify_keys(eval(IO.read(file), binding, file))) # rubocop:disable Security/Eval
80
+ @messages = Utils.deep_merge(@messages, Utils.deep_stringify_keys(eval(File.read(file), binding, file))) # rubocop:disable Security/Eval
156
81
  end
157
82
  end
158
83
 
@@ -164,8 +89,10 @@ class Tater
164
89
 
165
90
  # Not only does this clear our cache but it establishes the basic structure
166
91
  # that we rely on in other methods.
92
+ @cache = {}
93
+
167
94
  @messages.each_key do |key|
168
- @cache[key] = { true => {}, false => {} }
95
+ @cache[key] = { false => {}, true => {} }
169
96
  end
170
97
  end
171
98
 
@@ -189,9 +116,9 @@ class Tater
189
116
  # @option options [String] :locale
190
117
  # The locale to use in lieu of the current default.
191
118
  # @option options [String] :delimiter
192
- # The delimiter to use when localizing numberic values.
119
+ # The delimiter to use when localizing numeric values.
193
120
  # @option options [String] :separator
194
- # The separator to use when localizing numberic values.
121
+ # The separator to use when localizing numeric values.
195
122
  # @option options [String] :two_words_connector
196
123
  # The string used to join two array elements together e.g. " and ".
197
124
  # @option options [String] :words_connector
@@ -207,105 +134,57 @@ class Tater
207
134
  when String
208
135
  object
209
136
  when Numeric
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
213
-
214
- raise(MissingLocalizationFormat, "Numeric localization delimiter ('numeric.delimiter') missing or not passed as option :delimiter") unless delimiter
215
- raise(MissingLocalizationFormat, "Numeric localization separator ('numeric.separator') missing or not passed as option :separator") unless separator
216
-
217
- # Heavily cribbed from Rails.
218
- integer, fraction = Utils.string_from_numeric(object).split('.')
219
- integer.gsub!(DELIMITING_REGEX) do |number|
220
- "#{ number }#{ delimiter }"
221
- end
222
-
223
- if precision.zero?
224
- integer
225
- else
226
- [integer, fraction&.ljust(precision, '0')&.slice(0, precision)].compact.join(separator)
227
- end
137
+ localize_numeric(object, options)
228
138
  when Date, Time, DateTime
229
- format = lookup("#{ object.class.to_s.downcase }.formats.#{ options[:format] || DEFAULT }", locale: options[:locale]) || options[:format] || DEFAULT
230
-
231
- # Heavily cribbed from I18n, many thanks to the people who sorted this out
232
- # before I worked on this library.
233
- format = format.gsub(SUBSTITUTION_REGEX) do |match|
234
- case match
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
245
- end
246
- end
247
-
248
- object.strftime(format)
139
+ localize_datetime(object, options)
249
140
  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
141
+ localize_array(object, options)
270
142
  else
271
143
  raise(UnLocalizableObject, "The object class #{ object.class } cannot be localized by Tater.")
272
144
  end
273
145
  end
274
- alias l localize
275
146
 
276
147
  # Lookup a key in the messages hash, using the current locale or an override.
277
148
  #
149
+ # @example Using the default locale, look up a key's value.
150
+ # i18n = Tater.new(locale: 'en', messages: { 'en' => { 'greeting' => { 'world' => 'Hello, world!' } } })
151
+ # i18n.lookup('greeting.world') # => "Hello, world!"
152
+ #
278
153
  # @param key [String]
154
+ # The period-separated key path to look for within our messages.
279
155
  # @param locale [String]
280
- # A locale to use instead of our current one.
156
+ # A locale to use instead of our current one, if any.
281
157
  # @param cascade [Boolean]
282
158
  # A boolean to forcibly set the cascade option for this lookup.
283
159
  #
284
160
  # @return
285
161
  # Basically anything that can be stored in your messages Hash.
286
162
  def lookup(key, locale: nil, cascade: nil)
287
- locale = locale.nil? ? @locale : locale
288
- cascade = cascade.nil? ? @cascade : cascade
163
+ locale =
164
+ if locale.nil?
165
+ @locale
166
+ else
167
+ locale.to_s
168
+ end
289
169
 
290
- cached(key, locale, cascade) || begin
291
- return nil unless @messages.key?(locale.to_s)
170
+ cascade = @cascade if cascade.nil?
292
171
 
293
- path = key.split(SEPARATOR).prepend(locale).map(&:to_s)
172
+ @cache[locale][cascade][key] ||= begin
173
+ path = key.split(SEPARATOR)
294
174
 
295
- message =
296
- if cascade
297
- while path.length >= 2
298
- attempt = @messages.dig(*path)
299
-
300
- break attempt unless attempt.nil?
175
+ message = @messages[locale].dig(*path)
301
176
 
177
+ if message.nil? && cascade
178
+ message =
179
+ while path.length > 1
302
180
  path.delete_at(path.length - 2)
181
+ attempt = @messages[locale].dig(*path)
182
+
183
+ break attempt unless attempt.nil?
303
184
  end
304
- else
305
- @messages.dig(*path)
306
- end
185
+ end
307
186
 
308
- cache(key, locale, cascade, message)
187
+ message
309
188
  end
310
189
  end
311
190
 
@@ -368,51 +247,140 @@ class Tater
368
247
  # The translated and interpreted string, if found, or any data at the
369
248
  # defined key.
370
249
  def translate(key, options = HASH)
371
- message =
372
- if options.key?(:locales)
373
- options[:locales].append(@locale) if @locale && !options[:locales].include?(@locale)
250
+ if options.empty?
251
+ message = lookup(key)
374
252
 
375
- options[:locales].find do |accept|
376
- found = lookup(key, locale: accept, cascade: options[:cascade])
377
-
378
- break found unless found.nil?
379
- end
253
+ if message.is_a?(Proc) # rubocop:disable Style/CaseLikeIf
254
+ message.call(key)
255
+ elsif message.is_a?(String)
256
+ message
380
257
  else
381
- lookup(key, locale: options[:locale], cascade: options[:cascade])
258
+ "Tater lookup failed: #{ locale }.#{ key }"
382
259
  end
260
+ else
261
+ message =
262
+ if options.key?(:locales)
263
+ options[:locales].append(@locale) if @locale && !options[:locales].include?(@locale)
383
264
 
384
- # Call procs that should return a string.
385
- message = message.call(key, options) if message.is_a?(Proc)
265
+ options[:locales].find do |accept|
266
+ found = lookup(key, locale: accept, cascade: options[:cascade])
386
267
 
387
- Utils.interpolate(message, options) || options[:default] || "Tater lookup failed: #{ options[:locale] || options[:locales] || locale }.#{ key }"
268
+ break found unless found.nil?
269
+ end
270
+ else
271
+ lookup(key, locale: options[:locale], cascade: options[:cascade])
272
+ end
273
+
274
+ if message.is_a?(Proc) # rubocop:disable Style/CaseLikeIf
275
+ message.call(key, options.except(:cascade, :default, :locale, :locales))
276
+ elsif message.is_a?(String)
277
+ Utils.interpolate(message, options.except(:cascade, :default, :locale, :locales))
278
+ else
279
+ options[:default] || "Tater lookup failed: #{ options[:locale] || options[:locales] || locale }.#{ key }"
280
+ end
281
+ end
388
282
  end
389
- alias t translate
390
283
 
391
284
  private
392
285
 
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.
286
+ # Localize an Array object.
287
+ #
288
+ # @param object [Array<String>]
289
+ # The array to localize.
290
+ # @param options [Hash]
291
+ # Options to configure localization.
401
292
  # @return [String]
402
- # Whatever value is being cached, often a String.
403
- def cache(key, locale, cascade, message)
404
- @cache[locale][cascade][key] = message
293
+ # The localize array string.
294
+ def localize_array(object, options)
295
+ case object.length
296
+ when 0
297
+ ''
298
+ when 1
299
+ object[0]
300
+ when 2
301
+ two_words_connector = options[:two_words_connector] || lookup('array.two_words_connector', locale: options[:locale])
302
+
303
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.two_words_connector') missing or not passed as option :two_words_connector") unless two_words_connector
304
+
305
+ "#{ object[0] }#{ two_words_connector }#{ object[1] }"
306
+ else
307
+ last_word_connector = options[:last_word_connector] || lookup('array.last_word_connector', locale: options[:locale])
308
+ words_connector = options[:words_connector] || lookup('array.words_connector', locale: options[:locale])
309
+
310
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.last_word_connector') missing or not passed as option :last_word_connector") unless last_word_connector
311
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.words_connector') missing or not passed as option :words_connector") unless words_connector
312
+
313
+ "#{ object[0...-1].join(words_connector) }#{ last_word_connector }#{ object[-1] }"
314
+ end
405
315
  end
406
316
 
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)
317
+ # Localize a Date, DateTime, or Time object.
318
+ #
319
+ # @param object [Date, DateTime, Time]
320
+ # The date-ish object to localize.
321
+ # @param options [Hash]
322
+ # Options to configure localization.
323
+ # @return [String]
324
+ # The localized date string.
325
+ def localize_datetime(object, options)
326
+ frmt = options[:format] || DEFAULT
327
+ loc = options[:locale]
328
+ format = lookup("#{ object.class.to_s.downcase }.formats.#{ frmt }", locale: loc) || frmt
329
+
330
+ # Heavily cribbed from I18n, many thanks to the people who sorted this out
331
+ # before I worked on this library.
332
+ format = format.gsub(SUBSTITUTION_REGEX) do |match|
333
+ case match
334
+ when '%a' then lookup('date.abbreviated_days', locale: loc)[object.wday]
335
+ when '%^a' then lookup('date.abbreviated_days', locale: loc)[object.wday].upcase
336
+ when '%A' then lookup('date.days', locale: loc)[object.wday]
337
+ when '%^A' then lookup('date.days', locale: loc)[object.wday].upcase
338
+ when '%b' then lookup('date.abbreviated_months', locale: loc)[object.mon - 1]
339
+ when '%^b' then lookup('date.abbreviated_months', locale: loc)[object.mon - 1].upcase
340
+ when '%B' then lookup('date.months', locale: loc)[object.mon - 1]
341
+ when '%^B' then lookup('date.months', locale: loc)[object.mon - 1].upcase
342
+ when '%p' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale: loc).upcase if object.respond_to?(:hour)
343
+ when '%P' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale: loc).downcase if object.respond_to?(:hour)
344
+ end
345
+ end
346
+
347
+ if format.include?('%')
348
+ object.strftime(format)
349
+ else
350
+ format
351
+ end
352
+ end
353
+
354
+ # Localize a Numeric object.
355
+ #
356
+ # @param object [Array<String>, Date, Time, DateTime, Numeric]
357
+ # The object to localize.
358
+ # @param options [Hash]
359
+ # Options to configure localization.
360
+ # @return [String]
361
+ # The localized numeric string.
362
+ def localize_numeric(object, options)
363
+ delimiter = options[:delimiter] || lookup('numeric.delimiter', locale: options[:locale])
364
+ separator = options[:separator] || lookup('numeric.separator', locale: options[:locale])
365
+ precision = options[:precision] || 2
366
+
367
+ raise(MissingLocalizationFormat, "Numeric localization delimiter ('numeric.delimiter') missing or not passed as option :delimiter") unless delimiter
368
+ raise(MissingLocalizationFormat, "Numeric localization separator ('numeric.separator') missing or not passed as option :separator") unless separator
369
+
370
+ # Break the number up into integer and fraction parts.
371
+ integer = Utils.string_from_numeric(object)
372
+ integer, fraction = integer.split('.') unless object.is_a?(Integer)
373
+
374
+ if object >= 1_000
375
+ integer.gsub!(DELIMITING_REGEX) do |number|
376
+ "#{ number }#{ delimiter }"
377
+ end
378
+ end
379
+
380
+ if precision.zero? || fraction.nil?
381
+ integer
382
+ else
383
+ "#{ integer }#{ separator }#{ fraction.ljust(precision, '0').slice(0, precision) }"
384
+ end
417
385
  end
418
386
  end
data/test/tater_test.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
- require_relative '../lib/tater'
3
- require 'minitest/autorun'
2
+ $LOAD_PATH.unshift File.expand_path('lib', __dir__)
3
+
4
4
  require 'date'
5
+ require 'minitest/autorun'
6
+ require 'tater'
5
7
 
6
8
  describe Tater do
7
9
  describe Tater::Utils do
@@ -59,6 +61,20 @@ describe Tater do
59
61
  assert_equal '1.0', Tater::Utils.string_from_numeric(BigDecimal('1'))
60
62
  end
61
63
  end
64
+
65
+ describe '#interpolation_string?' do
66
+ def is?(arg)
67
+ Tater::Utils.interpolation_string?(arg)
68
+ end
69
+
70
+ it 'checks whether a string contains interpolation placeholders' do
71
+ assert is?('Hey %{there}!')
72
+ assert is?('Hey %<there>s!')
73
+ refute is?('Nah, this is fine')
74
+ refute is?("<b>HTML shouldn't count")
75
+ refute is?("A single % shouldn't count")
76
+ end
77
+ end
62
78
  end
63
79
 
64
80
  describe '#available?' do
@@ -118,7 +134,7 @@ describe Tater do
118
134
  end
119
135
 
120
136
  it 'updates the available list when new messages are loaded' do
121
- i18n.load(messages: { 'added' => { 'hey' => 'yeah' }})
137
+ i18n.load(messages: { 'added' => { 'hey' => 'yeah' } })
122
138
 
123
139
  assert_equal %w[en delimiter_only separator_only fr added].sort, i18n.available.sort
124
140
  end
@@ -141,6 +157,10 @@ describe Tater do
141
157
  assert_nil i18n.lookup('nope')
142
158
  end
143
159
 
160
+ it 'returns arbitrary data at keys' do
161
+ assert_equal({ 'key' => 'This key is deeper' }, i18n.lookup('deep'))
162
+ end
163
+
144
164
  it 'cascades' do
145
165
  assert_equal 'Delicious', i18n.lookup('cascade.nope.tacos', cascade: true)
146
166
  assert_equal 'Whoaa', i18n.lookup('cascade.another.nope.crazy', cascade: true)
@@ -162,8 +182,8 @@ describe Tater do
162
182
  assert_equal 'This key is deeper', i18n.translate('deep.key')
163
183
  end
164
184
 
165
- it 'returns a hash for nested keys' do
166
- assert_equal({ 'key' => 'This key is deeper' }, i18n.translate('deep'))
185
+ it 'does not return a hash for nested keys' do
186
+ assert_equal 'Tater lookup failed: en.deep', i18n.translate('deep')
167
187
  end
168
188
 
169
189
  it 'interpolates additional variables' do
@@ -180,10 +200,6 @@ describe Tater do
180
200
  assert_equal 'Tater lookup failed: en.nope', i18n.translate('nope')
181
201
  end
182
202
 
183
- it 'is aliased as t' do
184
- assert_equal 'This is a title', i18n.t('title')
185
- end
186
-
187
203
  it 'cascades lookups' do
188
204
  assert_equal 'Tater lookup failed: en.cascade.another.nope.crazy', i18n.translate('cascade.another.nope.crazy', cascade: false)
189
205
  assert_equal 'Tater lookup failed: en.cascade.nope.tacos', i18n.translate('cascade.nope.tacos')
@@ -201,9 +217,12 @@ describe Tater do
201
217
  assert_equal 'Tater lookup failed: ["fr", "en"].neither', i18n.translate('neither', locales: %w[fr en])
202
218
  end
203
219
 
204
- it 'finds Ruby files as well' do
220
+ it 'finds Ruby files' do
205
221
  assert_equal 'Hey ruby!', i18n.translate('ruby')
206
- assert_equal 'Hey options!', i18n.translate('options', options: 'options')
222
+ end
223
+
224
+ it 'does not interpolate messages returned by procs' do
225
+ assert_equal 'Hey %{options}!', i18n.translate('options', options: 'options')
207
226
  end
208
227
  end
209
228
 
@@ -265,10 +284,28 @@ describe Tater do
265
284
  assert_equal '1NAH12', i18n.localize(BigDecimal('1.12'))
266
285
  end
267
286
 
268
- it 'accomodates precision' do
269
- assert_equal '10NAH00', i18n.localize(BigDecimal('10'))
270
- assert_equal '10', i18n.localize(BigDecimal('10'), precision: 0)
271
- assert_equal '10NAH00', i18n.localize(BigDecimal('10.00234'))
287
+ describe 'precision option' do
288
+ it 'defaults to 2' do
289
+ assert_equal '10NAH00', i18n.localize(BigDecimal('10'))
290
+ assert_equal '10NAH00', i18n.localize(10.0)
291
+ end
292
+
293
+ it 'defaults to zero for integers' do
294
+ assert_equal '10', i18n.localize(10)
295
+ end
296
+
297
+ it 'removes fractional pieces when the precision is 0' do
298
+ assert_equal '10', i18n.localize(BigDecimal('10.123456'), precision: 0)
299
+ assert_equal '10', i18n.localize(10.123456, precision: 0)
300
+
301
+ assert_equal '10', i18n.localize(BigDecimal('10.12'), precision: 0)
302
+ assert_equal '10', i18n.localize(10.12, precision: 0)
303
+ end
304
+
305
+ it 'truncates long values to the desired precision' do
306
+ assert_equal '10NAH00', i18n.localize(BigDecimal('10.00234'))
307
+ assert_equal '10NAH00', i18n.localize(10.00234)
308
+ end
272
309
  end
273
310
 
274
311
  it 'allows overriding the delimiter and separator' do
@@ -296,10 +333,6 @@ describe Tater do
296
333
  end
297
334
  end
298
335
 
299
- it 'is aliased l' do
300
- assert_equal '1970/1/1', i18n.l(Date.new(1970, 1, 1))
301
- end
302
-
303
336
  describe 'month, day, and AM/PM names' do
304
337
  let :i18n do
305
338
  Tater.new(path: File.expand_path('test/fixtures'), locale: 'fr')
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tater
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evan Lecklider
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-04-15 00:00:00.000000000 Z
11
+ date: 2021-12-18 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: minitest
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +66,34 @@ dependencies:
52
66
  - - ">="
53
67
  - !ruby/object:Gem::Version
54
68
  version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-packaging
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
55
97
  - !ruby/object:Gem::Dependency
56
98
  name: rubocop-performance
57
99
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +108,20 @@ dependencies:
66
108
  - - ">="
67
109
  - !ruby/object:Gem::Version
68
110
  version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-rake
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
69
125
  description: Minimal internationalization and localization library.
70
126
  email:
71
127
  - evan@lecklider.com
@@ -74,7 +130,7 @@ extensions: []
74
130
  extra_rdoc_files: []
75
131
  files:
76
132
  - LICENSE.txt
77
- - README.md
133
+ - README.org
78
134
  - lib/tater.rb
79
135
  - test/fixtures/another.yml
80
136
  - test/fixtures/fixtures.yml
@@ -86,8 +142,9 @@ licenses:
86
142
  - MIT
87
143
  metadata:
88
144
  bug_tracker_uri: https://github.com/evanleck/tater/issues
145
+ rubygems_mfa_required: 'true'
89
146
  source_code_uri: https://github.com/evanleck/tater
90
- post_install_message:
147
+ post_install_message:
91
148
  rdoc_options: []
92
149
  require_paths:
93
150
  - lib
@@ -102,8 +159,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
159
  - !ruby/object:Gem::Version
103
160
  version: '2.0'
104
161
  requirements: []
105
- rubygems_version: 3.2.15
106
- signing_key:
162
+ rubygems_version: 3.2.32
163
+ signing_key:
107
164
  specification_version: 4
108
165
  summary: Minimal internationalization and localization library.
109
166
  test_files: