tater 1.3.2 → 2.0.4

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: d41ee7aa6cc55a97ad00ee6b96ccbb6127344f9b3e85c4cf3b162f72f3d115d8
4
- data.tar.gz: 2fe1b6c5aff5fdfcc48dbd8212ddbbf0903f9af071c42ff1178c1f20752f536a
3
+ metadata.gz: 51644f41d80e87d20ed43b9e8c6b4797fb7aeb4258c94d5d9f9d7a50eb5ee5c6
4
+ data.tar.gz: 1fe43b99fbc2f24cdc342ec4278ff7140ee6b9579dd51d236fcc713907f1455d
5
5
  SHA512:
6
- metadata.gz: d686b81192264e1f78f17c0decf0ac3811582e0fe464d2f5f44281481351abca8bd3906fe218a2ae9309a3d84177382f5de7348f63ae3967b7d2af2dc452bcb6
7
- data.tar.gz: 942fd8b6a433b65778f3d7a78f07a8d99c902c5483eec105e0c75689d40db7d0664170963e514d5e78928450b6c0499dce8c3afd63f0eaded6d9843cf3e703a8
6
+ metadata.gz: d4c059fdc696d39c489e61d3e53954fe921feaba78055bd5e62c944d45daf10dfa178b76e4e836d326ba61be0dbf7bbec13b63f52b95e13f23da9ca5be4d3b8c
7
+ data.tar.gz: 6b12ffa8f2a1186f2de92c9c16bb5c2e1027f93ab5ee2c877dbad297777274b9f4f38882255490db3c555a0dafb129ed876bd784d3a872e07a4e7e9f6e0e24ec
@@ -1,112 +1,111 @@
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://secure.travis-ci.org/evanleck/tater.svg)](https://travis-ci.org/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
- Or install it yourself as:
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 = {
43
- 'some' => {
44
- 'key' => 'This here string!'
45
- },
46
- 'interpolated' => 'Hello %{you}!'
40
+ 'en' => {
41
+ 'some' => {
42
+ 'key' => 'This here string!'
43
+ },
44
+ 'interpolated' => 'Hello %{you}!'
45
+ }
47
46
  }
48
47
 
49
- i18n = Tater.new
48
+ i18n = Tater.new(locale: 'en')
50
49
  i18n.load(messages: messages)
51
50
 
51
+ # OR
52
+ i18n = Tater.new(locale: 'en', messages: messages)
53
+
52
54
  # Basic lookup:
53
55
  i18n.translate('some.key') # => 'This here string!'
54
56
 
55
57
  # Interpolation:
56
58
  i18n.translate('interpolated', you: 'world') # => 'Hello world!'
57
- ```
58
-
59
+ #+end_src
59
60
 
60
- ## Array Localization
61
+ ** Array localization
61
62
 
62
63
  Given an array, Tater will do it's best to join the elements of the array into a
63
64
  sentence based on how many elements there are.
64
65
 
65
- ```yaml
66
+ #+begin_example
66
67
  en:
67
68
  array:
68
69
  last_word_connector: ", and "
69
70
  two_words_connector: " and "
70
71
  words_connector: ", "
71
- ```
72
+ #+end_example
72
73
 
73
- ```ruby
74
+ #+begin_src ruby
74
75
  i18n.localize(%w[tacos enchiladas burritos]) # => "tacos, enchiladas, and burritos"
75
- ```
76
-
76
+ #+end_src
77
77
 
78
- ## Numeric Localization
78
+ ** Numeric localization
79
79
 
80
- Numeric localization (`Numeric`, `Integer`, `Float`, and `BigDecimal`) require
80
+ Numeric localization (=Numeric=, =Integer=, =Float=, and =BigDecimal=) require
81
81
  filling in a separator and delimiter. For example:
82
82
 
83
- ```yaml
83
+ #+begin_example
84
84
  en:
85
85
  numeric:
86
86
  delimiter: ','
87
87
  separator: '.'
88
- ```
88
+ #+end_example
89
89
 
90
90
  With that, you can do things like this:
91
91
 
92
- ```ruby
92
+ #+begin_src ruby
93
93
  i18n.localize(1000.2) # => "1,000.20"
94
- ```
94
+ #+end_src
95
95
 
96
96
  The separator and delimiter can also be passed in per-call:
97
97
 
98
- ```ruby
98
+ #+begin_src ruby
99
99
  i18n.localize(1000.2, delimiter: '_', separator: '+') # => "1_000+20"
100
- ```
100
+ #+end_src
101
101
 
102
+ ** Date and time localization
102
103
 
103
- ## Date and Time Localization
104
-
105
- Date and time localization (`Date`, `Time`, and `DateTime`) require filling in
104
+ Date and time localization (=Date=, =Time=, and =DateTime=) require filling in
106
105
  all of the needed names and abbreviations for days and months. Here's the
107
106
  example for French, which is used in the tests.
108
107
 
109
- ```yaml
108
+ #+begin_example
110
109
  fr:
111
110
  time:
112
111
  am: 'am'
@@ -169,96 +168,95 @@ fr:
169
168
  - oct.
170
169
  - nov.
171
170
  - déc.
172
- ```
171
+ #+end_example
173
172
 
174
- The statically defined keys for dates are `days`, `abbreviated_days`, `months`,
175
- and `abbreviated_months`. Only `am` and `pm` are needed for times and only if
176
- 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.
177
176
 
178
177
  With all of that, you can do something like:
179
178
 
180
- ```ruby
179
+ #+begin_src ruby
181
180
  i18n.localize(Date.new(1970, 1, 1), format: '%A') # => 'jeudi'
182
181
 
183
182
  # Or, using a key defined in "formats":
184
183
  i18n.localize(Date.new(1970, 1, 1), format: 'day') # => 'jeudi'
185
- ```
186
-
184
+ #+end_src
187
185
 
188
- ## Cascading Lookups
186
+ ** Cascading lookups
189
187
 
190
- 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
191
189
  incrementally.
192
190
 
193
- ```ruby
191
+ #+begin_src ruby
194
192
  messages = {
195
- 'login' => {
196
- 'title' => 'Login',
197
- 'description' => 'Normal description.'
193
+ 'en' => {
194
+ 'login' => {
195
+ 'title' => 'Login',
196
+ 'description' => 'Normal description.'
198
197
 
199
- 'special' => {
200
- 'title' => 'Special Login'
198
+ 'special' => {
199
+ 'title' => 'Special Login'
200
+ }
201
201
  }
202
202
  }
203
203
  }
204
204
 
205
- i18n = Tater.new(messages: messages)
205
+ i18n = Tater.new(locale: 'en', messages: messages)
206
206
  i18n.translate('login.special.title') # => 'Special Login'
207
207
  i18n.translate('login.special.description') # => 'Tater lookup failed'
208
208
 
209
209
  i18n.translate('login.special.description', cascade: true) # => 'Normal description.'
210
- ```
210
+ #+end_src
211
211
 
212
212
  With cascade, the final key stays the same, but pieces of the scope get lopped
213
213
  off. In this case, lookups will be tried in this order:
214
214
 
215
- 1. `'login.special.description'`
216
- 2. `'login.description'`
215
+ 1. ='login.special.description'=
216
+ 2. ='login.description'=
217
217
 
218
218
  This can be useful when you want to override some messages but don't want to
219
219
  have to copy all of the other, non-overwritten messages.
220
220
 
221
221
  Cascading can also be enabled by default when initializing an instance of Tater.
222
222
 
223
- ```ruby
223
+ #+begin_src ruby
224
224
  Tater.new(cascade: true)
225
- ```
225
+ #+end_src
226
226
 
227
227
  Cascading is off by default.
228
228
 
229
-
230
- ## Defaults
229
+ ** Defaults
231
230
 
232
231
  If you'd like to default to another value in case of a missed lookup, you can
233
- provide the `:default` option to `#translate`.
232
+ provide the =:default= option to =#translate=.
234
233
 
235
- ```ruby
234
+ #+begin_src ruby
236
235
  Tater.new.translate('nope', default: 'Yep!') # => 'Yep!'
237
- ```
238
-
236
+ #+end_src
239
237
 
240
- ## Procs and Messages in Ruby
238
+ ** Procs and messages in Ruby
241
239
 
242
240
  Ruby files can be used to store messages in addition to YAML, so long as the
243
- Ruby file returns a `Hash` when evalled.
241
+ Ruby file returns a =Hash= when evalled.
244
242
 
245
- ```ruby
243
+ #+begin_src ruby
246
244
  {
247
- en: {
245
+ 'en' => {
248
246
  ruby: proc do |key, options = {}|
249
247
  "Hey #{ key }!"
250
248
  end
251
249
  }
252
250
  }
253
- ```
254
-
251
+ #+end_src
255
252
 
256
- ## Multiple Locales
253
+ ** Multiple locales
257
254
 
258
- If you like to check multiple locales and pull the first matching one out, you
259
- can pass the `:locales` option an array of top-level locale keys.
255
+ If you would like to check multiple locales and pull the first matching one out,
256
+ you can pass the =:locales= option to initialization or the =translate= method
257
+ with an array of top-level locale keys.
260
258
 
261
- ```ruby
259
+ #+begin_src ruby
262
260
  messages = {
263
261
  'en' => {
264
262
  'title' => 'Login',
@@ -272,31 +270,28 @@ messages = {
272
270
  i18n = Tater.new(messages: messages)
273
271
  i18n.translate('title', locales: %w[fr en]) # => 'la connexion'
274
272
  i18n.translate('description', locales: %w[fr en]) # => 'English description.'
275
- ```
276
273
 
277
- Locales will tried in order and which one matches first will be returned.
274
+ # OR
275
+ i18n = Tater.new(messages: messages, locales: %w[fr en])
276
+ i18n.translate('title') # => 'la connexion'
277
+ i18n.translate('description') # => 'English description.'
278
+ #+end_src
278
279
 
280
+ Locales will be tried in order and whichever one matches first will be returned.
279
281
 
280
- ## Limitations
282
+ ** Limitations
281
283
 
282
- - 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.
283
285
  - It doesn't handle pluralization yet, though it may in the future.
284
- - It doesn't cache anything, that's up to you.
285
-
286
-
287
- ## Why?
288
-
289
- Because [Ruby I18n][rubyi18n] is amazing and I wanted to try to create a minimum
290
- viable implementation of the bits of I18n that I use 90% of the time. Tater is a
291
- single file that handles the basics of lookup and interpolation.
292
286
 
287
+ ** Why?
293
288
 
294
- ## 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.
295
292
 
296
- I was orininally going to call this library "Translator" but with a
297
- [numeronym][numeronym] like I18n: "t8r". I looked at it for a while but I read
298
- it as "tater" instead of "tee-eight-arr" so I figured I'd just name it Tater.
299
- Tater the translator.
293
+ ** Trivia
300
294
 
301
- [numeronym]: https://en.wikipedia.org/wiki/Numeronym
302
- [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,25 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
  require 'bigdecimal'
3
+ require 'date'
4
+ require 'time'
3
5
  require 'yaml'
4
6
 
5
7
  # Tater is a internationalization (i18n) and localization (l10n) library
6
8
  # designed for speed and simplicity.
7
9
  class Tater
8
10
  class MissingLocalizationFormat < ArgumentError; end
11
+
9
12
  class UnLocalizableObject < ArgumentError; end
10
13
 
11
14
  module Utils # :nodoc:
12
15
  # Merge all the way down.
13
16
  #
14
17
  # @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.
18
+ # The target Hash to merge into.
17
19
  # @param from [Hash]
18
20
  # The Hash to copy values from.
19
- def self.deep_merge!(to, from)
20
- to.merge!(from) do |_key, left, right|
21
+ # @return [Hash]
22
+ def self.deep_merge(to, from)
23
+ to.merge(from) do |_key, left, right|
21
24
  if left.is_a?(Hash) && right.is_a?(Hash)
22
- Utils.deep_merge!(left, right)
25
+ Utils.deep_merge(left, right)
23
26
  else
24
27
  right
25
28
  end
@@ -29,18 +32,32 @@ class Tater
29
32
  # Transform keys all the way down.
30
33
  #
31
34
  # @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|
35
+ # The Hash to stringify keys for.
36
+ # @return [Hash]
37
+ def self.deep_stringify_keys(hash)
38
+ hash.transform_keys(&:to_s).transform_values do |value|
36
39
  if value.is_a?(Hash)
37
- Utils.deep_stringify_keys!(value)
40
+ Utils.deep_stringify_keys(value)
38
41
  else
39
42
  value
40
43
  end
41
44
  end
42
45
  end
43
46
 
47
+ # Freeze all the way down.
48
+ #
49
+ # @param hash [Hash]
50
+ # @return [Hash]
51
+ def self.deep_freeze(hash)
52
+ hash.transform_keys(&:freeze).transform_values do |value|
53
+ if value.is_a?(Hash)
54
+ Utils.deep_freeze(value)
55
+ else
56
+ value.freeze
57
+ end
58
+ end.freeze
59
+ end
60
+
44
61
  # Try to interpolate these things, if one of them is a string.
45
62
  #
46
63
  # @param string [String]
@@ -49,7 +66,7 @@ class Tater
49
66
  # The values to interpolate into the target string.
50
67
  #
51
68
  # @return [String]
52
- def self.interpolate(string, options = {})
69
+ def self.interpolate(string, options = HASH)
53
70
  return string unless string.is_a?(String)
54
71
  return string if options.empty?
55
72
 
@@ -72,8 +89,8 @@ class Tater
72
89
  end
73
90
 
74
91
  DEFAULT = 'default'
75
- DEFAULT_LOCALE = 'en'
76
92
  DELIMITING_REGEX = /(\d)(?=(\d\d\d)+(?!\d))/.freeze
93
+ HASH = {}.freeze
77
94
  SEPARATOR = '.'
78
95
  SUBSTITUTION_REGEX = /%(|\^)[aAbBpP]/.freeze
79
96
 
@@ -83,7 +100,15 @@ class Tater
83
100
  # @return [Hash]
84
101
  attr_reader :messages
85
102
 
86
- def initialize(path: nil, messages: nil, locale: DEFAULT_LOCALE, cascade: false)
103
+ # @param cascade [Boolean]
104
+ # A boolean indicating if lookups should cascade by default.
105
+ # @param locale [String]
106
+ # The default locale.
107
+ # @param messages [Hash]
108
+ # A hash of messages ready to be loaded in.
109
+ # @param path [String]
110
+ # A path to search for YAML or Ruby files to load messages from.
111
+ def initialize(cascade: false, locale: nil, messages: nil, path: nil)
87
112
  @cascade = cascade
88
113
  @locale = locale
89
114
  @messages = {}
@@ -99,11 +124,11 @@ class Tater
99
124
  @cascade
100
125
  end
101
126
 
102
- # An array of the available locale codes.
127
+ # An array of the available locale codes found in loaded messages.
103
128
  #
104
129
  # @return [Array]
105
130
  def available
106
- messages.keys.map(&:to_s)
131
+ @available ||= messages.keys
107
132
  end
108
133
 
109
134
  # Is this locale available in our current set of messages?
@@ -114,24 +139,38 @@ class Tater
114
139
  end
115
140
 
116
141
  # Load messages into our internal cache, either from a path containing YAML
117
- # files or a collection of messages.
142
+ # files or a Hash of messages.
118
143
  #
119
144
  # @param path [String]
120
145
  # A path to search for YAML or Ruby files to load messages from.
121
146
  # @param messages [Hash]
122
147
  # A hash of messages ready to be loaded in.
123
148
  def load(path: nil, messages: nil)
149
+ return if path.nil? && messages.nil?
150
+
124
151
  if path
125
152
  Dir.glob(File.join(path, '**', '*.{yml,yaml}')).each do |file|
126
- Utils.deep_merge!(@messages, YAML.load_file(file))
153
+ @messages = Utils.deep_merge(@messages, YAML.load_file(file))
127
154
  end
128
155
 
129
156
  Dir.glob(File.join(path, '**', '*.rb')).each do |file|
130
- Utils.deep_merge!(@messages, Utils.deep_stringify_keys!(eval(IO.read(file), binding, file))) # rubocop:disable Security/Eval
157
+ @messages = Utils.deep_merge(@messages, Utils.deep_stringify_keys(eval(IO.read(file), binding, file))) # rubocop:disable Security/Eval
131
158
  end
132
159
  end
133
160
 
134
- Utils.deep_merge!(@messages, Utils.deep_stringify_keys!(messages)) if messages
161
+ @messages = Utils.deep_merge(@messages, Utils.deep_stringify_keys(messages)) if messages
162
+ @messages = Utils.deep_freeze(@messages)
163
+
164
+ # Gotta recalculate available locales after updating.
165
+ remove_instance_variable(:@available) if instance_variable_defined?(:@available)
166
+
167
+ # Not only does this clear our cache but it establishes the basic structure
168
+ # that we rely on in other methods.
169
+ @cache = {}
170
+
171
+ @messages.each_key do |key|
172
+ @cache[key] = { false => {}, true => {} }
173
+ end
135
174
  end
136
175
 
137
176
  # Set the current locale, if it's available.
@@ -144,7 +183,7 @@ class Tater
144
183
 
145
184
  # Localize an Array, Date, Time, DateTime, or Numeric object.
146
185
  #
147
- # @param object [Date, Time, DateTime, Numeric]
186
+ # @param object [Array<String>, Date, Time, DateTime, Numeric]
148
187
  # The object to localize.
149
188
  # @param options [Hash]
150
189
  # Options to configure localization.
@@ -154,9 +193,9 @@ class Tater
154
193
  # @option options [String] :locale
155
194
  # The locale to use in lieu of the current default.
156
195
  # @option options [String] :delimiter
157
- # The delimiter to use when localizing numberic values.
196
+ # The delimiter to use when localizing numeric values.
158
197
  # @option options [String] :separator
159
- # The separator to use when localizing numberic values.
198
+ # The separator to use when localizing numeric values.
160
199
  # @option options [String] :two_words_connector
161
200
  # The string used to join two array elements together e.g. " and ".
162
201
  # @option options [String] :words_connector
@@ -167,75 +206,16 @@ class Tater
167
206
  #
168
207
  # @return [String]
169
208
  # A localized version of the object passed in.
170
- def localize(object, options = {})
171
- format_key = options.delete(:format) || DEFAULT
172
- locale_override = options.delete(:locale)
173
-
209
+ def localize(object, options = HASH)
174
210
  case object
175
211
  when String
176
212
  object
177
213
  when Numeric
178
- delimiter = options.delete(:delimiter) || lookup('numeric.delimiter', locale_override)
179
- separator = options.delete(:separator) || lookup('numeric.separator', locale_override)
180
- precision = options.delete(:precision) || 2
181
-
182
- raise(MissingLocalizationFormat, "Numeric localization delimiter ('numeric.delimiter') missing or not passed as option :delimiter") unless delimiter
183
- raise(MissingLocalizationFormat, "Numeric localization separator ('numeric.separator') missing or not passed as option :separator") unless separator
184
-
185
- # Heavily cribbed from Rails.
186
- integer, fraction = Utils.string_from_numeric(object).split('.')
187
- integer.gsub!(DELIMITING_REGEX) do |number|
188
- "#{ number }#{ delimiter }"
189
- end
190
-
191
- if precision.zero?
192
- integer
193
- else
194
- [integer, fraction&.ljust(precision, '0')].compact.join(separator)
195
- end
214
+ localize_numeric(object, options)
196
215
  when Date, Time, DateTime
197
- key = object.class.to_s.downcase
198
- format = lookup("#{ key }.formats.#{ format_key }", locale_override) || format_key
199
-
200
- # Heavily cribbed from I18n, many thanks to the people who sorted this out
201
- # before I worked on this library.
202
- format = format.gsub(SUBSTITUTION_REGEX) do |match|
203
- case match
204
- when '%a' then lookup('date.abbreviated_days', locale_override)[object.wday]
205
- when '%^a' then lookup('date.abbreviated_days', locale_override)[object.wday].upcase
206
- when '%A' then lookup('date.days', locale_override)[object.wday]
207
- when '%^A' then lookup('date.days', locale_override)[object.wday].upcase
208
- when '%b' then lookup('date.abbreviated_months', locale_override)[object.mon - 1]
209
- when '%^b' then lookup('date.abbreviated_months', locale_override)[object.mon - 1].upcase
210
- when '%B' then lookup('date.months', locale_override)[object.mon - 1]
211
- when '%^B' then lookup('date.months', locale_override)[object.mon - 1].upcase
212
- when '%p' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale_override).upcase if object.respond_to?(:hour) # rubocop:disable Metrics/BlockNesting
213
- when '%P' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale_override).downcase if object.respond_to?(:hour) # rubocop:disable Metrics/BlockNesting
214
- end
215
- end
216
-
217
- object.strftime(format)
216
+ localize_datetime(object, options)
218
217
  when Array
219
- case object.length
220
- when 0
221
- ''
222
- when 1
223
- object[0]
224
- when 2
225
- two_words_connector = options.delete(:two_words_connector) || lookup('array.two_words_connector', locale_override)
226
-
227
- raise(MissingLocalizationFormat, "Sentence localization connector ('array.two_words_connector') missing or not passed as option :two_words_connector") unless two_words_connector
228
-
229
- "#{ object[0] }#{ two_words_connector }#{ object[1] }"
230
- else
231
- last_word_connector = options.delete(:last_word_connector) || lookup('array.last_word_connector', locale_override)
232
- words_connector = options.delete(:words_connector) || lookup('array.words_connector', locale_override)
233
-
234
- raise(MissingLocalizationFormat, "Sentence localization connector ('array.last_word_connector') missing or not passed as option :last_word_connector") unless last_word_connector
235
- raise(MissingLocalizationFormat, "Sentence localization connector ('array.words_connector') missing or not passed as option :words_connector") unless words_connector
236
-
237
- "#{ object[0...-1].join(words_connector) }#{ last_word_connector }#{ object[-1] }"
238
- end
218
+ localize_array(object, options)
239
219
  else
240
220
  raise(UnLocalizableObject, "The object class #{ object.class } cannot be localized by Tater.")
241
221
  end
@@ -244,27 +224,45 @@ class Tater
244
224
 
245
225
  # Lookup a key in the messages hash, using the current locale or an override.
246
226
  #
227
+ # @example Using the default locale, look up a key's value.
228
+ # i18n = Tater.new(locale: 'en', messages: { 'en' => { 'greeting' => { 'world' => 'Hello, world!' } } })
229
+ # i18n.lookup('greeting.world') # => "Hello, world!"
230
+ #
247
231
  # @param key [String]
248
- # @param locale_override [String]
249
- # A locale to use instead of our current one.
250
- # @param cascade_override [Boolean]
232
+ # The period-separated key path to look for within our messages.
233
+ # @param locale [String]
234
+ # A locale to use instead of our current one, if any.
235
+ # @param cascade [Boolean]
251
236
  # A boolean to forcibly set the cascade option for this lookup.
252
237
  #
253
238
  # @return
254
- # Basically anything that can be stored in YAML, including nil.
255
- def lookup(key, locale_override = nil, cascade_override = nil)
256
- path = key.split(SEPARATOR).prepend(locale_override || locale).map(&:to_s)
239
+ # Basically anything that can be stored in your messages Hash.
240
+ def lookup(key, locale: nil, cascade: nil)
241
+ locale =
242
+ if locale.nil?
243
+ @locale
244
+ else
245
+ locale.to_s
246
+ end
247
+
248
+ cascade = @cascade if cascade.nil?
249
+
250
+ @cache[locale][cascade][key] ||= begin
251
+ path = key.split(SEPARATOR)
257
252
 
258
- if cascade_override.nil? ? @cascade : cascade_override
259
- while path.length >= 2
260
- attempt = @messages.dig(*path)
253
+ message = @messages[locale].dig(*path)
261
254
 
262
- break attempt if attempt
255
+ if message.nil? && cascade
256
+ message =
257
+ while path.length > 1
258
+ path.delete_at(path.length - 2)
259
+ attempt = @messages[locale].dig(*path)
263
260
 
264
- path.delete_at(path.length - 2)
261
+ break attempt unless attempt.nil?
262
+ end
265
263
  end
266
- else
267
- @messages.dig(*path)
264
+
265
+ message
268
266
  end
269
267
  end
270
268
 
@@ -284,20 +282,18 @@ class Tater
284
282
  # An array of locales to look within.
285
283
  #
286
284
  # @return [Boolean]
287
- def includes?(key, options = {})
288
- cascade_override = options.delete(:cascade)
289
- locale_override = options.delete(:locale)
290
- locales = options.delete(:locales)
291
-
285
+ def includes?(key, options = HASH)
292
286
  message =
293
- if locale_override || !locales
294
- lookup(key, locale_override, cascade_override)
295
- else
296
- locales.find do |accept|
297
- found = lookup(key, accept, cascade_override)
287
+ if options.key?(:locales)
288
+ options[:locales].append(@locale) if @locale && !options[:locales].include?(@locale)
289
+
290
+ options[:locales].find do |accept|
291
+ found = lookup(key, locale: accept, cascade: options[:cascade])
298
292
 
299
- break found if found
293
+ break found unless found.nil?
300
294
  end
295
+ else
296
+ lookup(key, locale: options[:locale], cascade: options[:cascade])
301
297
  end
302
298
 
303
299
  !message.nil?
@@ -307,7 +303,7 @@ class Tater
307
303
  # It's effectively a combination of #lookup and #interpolate.
308
304
  #
309
305
  # @example
310
- # Tater.new(messages: { 'en' => { 'hi' => 'Hello' }}).translate('hi') # => 'Hello'
306
+ # Tater.new(locale: 'en', messages: { 'en' => { 'hi' => 'Hello' }}).translate('hi') # => 'Hello'
311
307
  #
312
308
  # @param key [String]
313
309
  # The period-separated key path to look within our messages for.
@@ -319,36 +315,136 @@ class Tater
319
315
  # @option options [String] :default
320
316
  # A default string to return, should lookup fail.
321
317
  # @option options [String] :locale
322
- # A specific locale to lookup within. This will take precedence over the
323
- # :locales option.
318
+ # A specific locale to lookup within.
324
319
  # @option options [Array<String>] :locales
325
- # An array of locales to look within.
320
+ # An array of locales to look within. This will take precedence over the
321
+ # :locale option and will append the default :locale option passed during
322
+ # initialization if present.
326
323
  #
327
324
  # @return [String]
328
325
  # The translated and interpreted string, if found, or any data at the
329
326
  # defined key.
330
- def translate(key, options = {})
331
- cascade_override = options.delete(:cascade)
332
- locale_override = options.delete(:locale)
333
- locales = options.delete(:locales)
334
-
327
+ def translate(key, options = HASH)
335
328
  message =
336
- if locale_override || !locales
337
- lookup(key, locale_override, cascade_override)
338
- else
339
- locales.find do |accept|
340
- found = lookup(key, accept, cascade_override)
329
+ if options.key?(:locales)
330
+ options[:locales].append(@locale) if @locale && !options[:locales].include?(@locale)
331
+
332
+ options[:locales].find do |accept|
333
+ found = lookup(key, locale: accept, cascade: options[:cascade])
341
334
 
342
- break found if found
335
+ break found unless found.nil?
343
336
  end
337
+ else
338
+ lookup(key, locale: options[:locale], cascade: options[:cascade])
344
339
  end
345
340
 
346
341
  # Call procs that should return a string.
347
- if message.is_a?(Proc)
348
- message = message.call(key, options)
349
- end
342
+ message = message.call(key, options) if message.is_a?(Proc)
350
343
 
351
- Utils.interpolate(message, options) || options.delete(:default) { "Tater lookup failed: #{ locale_override || locales || locale }.#{ key }" }
344
+ Utils.interpolate(message, options) || options[:default] || "Tater lookup failed: #{ options[:locale] || options[:locales] || locale }.#{ key }"
352
345
  end
353
346
  alias t translate
347
+
348
+ private
349
+
350
+ # Localize an Array object.
351
+ #
352
+ # @param object [Array<String>]
353
+ # The array to localize.
354
+ # @param options [Hash]
355
+ # Options to configure localization.
356
+ # @return [String]
357
+ # The localize array string.
358
+ def localize_array(object, options)
359
+ case object.length
360
+ when 0
361
+ ''
362
+ when 1
363
+ object[0]
364
+ when 2
365
+ two_words_connector = options[:two_words_connector] || lookup('array.two_words_connector', locale: options[:locale])
366
+
367
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.two_words_connector') missing or not passed as option :two_words_connector") unless two_words_connector
368
+
369
+ "#{ object[0] }#{ two_words_connector }#{ object[1] }"
370
+ else
371
+ last_word_connector = options[:last_word_connector] || lookup('array.last_word_connector', locale: options[:locale])
372
+ words_connector = options[:words_connector] || lookup('array.words_connector', locale: options[:locale])
373
+
374
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.last_word_connector') missing or not passed as option :last_word_connector") unless last_word_connector
375
+ raise(MissingLocalizationFormat, "Sentence localization connector ('array.words_connector') missing or not passed as option :words_connector") unless words_connector
376
+
377
+ "#{ object[0...-1].join(words_connector) }#{ last_word_connector }#{ object[-1] }"
378
+ end
379
+ end
380
+
381
+ # Localize a Date, DateTime, or Time object.
382
+ #
383
+ # @param object [Date, DateTime, Time]
384
+ # The date-ish object to localize.
385
+ # @param options [Hash]
386
+ # Options to configure localization.
387
+ # @return [String]
388
+ # The localized date string.
389
+ def localize_datetime(object, options)
390
+ frmt = options[:format] || DEFAULT
391
+ loc = options[:locale]
392
+ format = lookup("#{ object.class.to_s.downcase }.formats.#{ frmt }", locale: loc) || frmt
393
+
394
+ # Heavily cribbed from I18n, many thanks to the people who sorted this out
395
+ # before I worked on this library.
396
+ format = format.gsub(SUBSTITUTION_REGEX) do |match|
397
+ case match
398
+ when '%a' then lookup('date.abbreviated_days', locale: loc)[object.wday]
399
+ when '%^a' then lookup('date.abbreviated_days', locale: loc)[object.wday].upcase
400
+ when '%A' then lookup('date.days', locale: loc)[object.wday]
401
+ when '%^A' then lookup('date.days', locale: loc)[object.wday].upcase
402
+ when '%b' then lookup('date.abbreviated_months', locale: loc)[object.mon - 1]
403
+ when '%^b' then lookup('date.abbreviated_months', locale: loc)[object.mon - 1].upcase
404
+ when '%B' then lookup('date.months', locale: loc)[object.mon - 1]
405
+ when '%^B' then lookup('date.months', locale: loc)[object.mon - 1].upcase
406
+ when '%p' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale: loc).upcase if object.respond_to?(:hour)
407
+ when '%P' then lookup("time.#{ object.hour < 12 ? 'am' : 'pm' }", locale: loc).downcase if object.respond_to?(:hour)
408
+ end
409
+ end
410
+
411
+ if format.include?('%')
412
+ object.strftime(format)
413
+ else
414
+ format
415
+ end
416
+ end
417
+
418
+ # Localize a Numeric object.
419
+ #
420
+ # @param object [Array<String>, Date, Time, DateTime, Numeric]
421
+ # The object to localize.
422
+ # @param options [Hash]
423
+ # Options to configure localization.
424
+ # @return [String]
425
+ # The localized numeric string.
426
+ def localize_numeric(object, options)
427
+ delimiter = options[:delimiter] || lookup('numeric.delimiter', locale: options[:locale])
428
+ separator = options[:separator] || lookup('numeric.separator', locale: options[:locale])
429
+ precision = options[:precision] || 2
430
+
431
+ raise(MissingLocalizationFormat, "Numeric localization delimiter ('numeric.delimiter') missing or not passed as option :delimiter") unless delimiter
432
+ raise(MissingLocalizationFormat, "Numeric localization separator ('numeric.separator') missing or not passed as option :separator") unless separator
433
+
434
+ # Break the number up into integer and fraction parts.
435
+ integer = Utils.string_from_numeric(object)
436
+ integer, fraction = integer.split('.') unless object.is_a?(Integer)
437
+
438
+ if object >= 1_000
439
+ integer.gsub!(DELIMITING_REGEX) do |number|
440
+ "#{ number }#{ delimiter }"
441
+ end
442
+ end
443
+
444
+ if precision.zero? || fraction.nil?
445
+ integer
446
+ else
447
+ "#{ integer }#{ separator }#{ fraction.ljust(precision, '0').slice(0, precision) }"
448
+ end
449
+ end
354
450
  end
data/test/tater_test.rb CHANGED
@@ -1,28 +1,40 @@
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
8
- describe '#deep_merge!' do
9
- it 'deeply merges two hashes, modifying the first' do
10
+ describe '#deep_merge' do
11
+ it 'deeply merges two hashes, returning a new one' do
10
12
  first = { 'one' => 'one', 'two' => { 'three' => 'three' } }
11
13
  second = { 'two' => { 'four' => 'four' } }
12
14
 
13
- Tater::Utils.deep_merge!(first, second)
15
+ third = Tater::Utils.deep_merge(first, second)
14
16
 
15
- assert_equal({ 'one' => 'one', 'two' => { 'three' => 'three', 'four' => 'four' } }, first)
17
+ assert_equal({ 'one' => 'one', 'two' => { 'three' => 'three', 'four' => 'four' } }, third)
16
18
  end
17
19
  end
18
20
 
19
- describe '#deep_stringify_keys!' do
20
- it 'converts all keys into strings, recursively modifying the hash passed in' do
21
+ describe '#deep_stringify_keys' do
22
+ it 'converts all keys into strings, recursively' do
21
23
  start = { en: { login: { title: 'Hello!' } } }
24
+ finish = Tater::Utils.deep_stringify_keys(start)
25
+
26
+ assert_equal({ 'en' => { 'login' => { 'title' => 'Hello!' } } }, finish)
27
+ end
28
+ end
22
29
 
23
- Tater::Utils.deep_stringify_keys!(start)
30
+ describe '#deep_freeze' do
31
+ it 'freezes the keys and values, recursively' do
32
+ start = Tater::Utils.deep_stringify_keys({ en: { login: { title: 'Hello!' } } })
33
+ finish = Tater::Utils.deep_freeze(start)
24
34
 
25
- assert_equal({ 'en' => { 'login' => { 'title' => 'Hello!' } } }, start)
35
+ assert finish.frozen?
36
+ assert finish.keys.all?(&:frozen?)
37
+ assert finish.values.all?(&:frozen?)
26
38
  end
27
39
  end
28
40
 
@@ -46,14 +58,14 @@ describe Tater do
46
58
  it 'converts numerics to decimal-ish strings' do
47
59
  assert_equal '1', Tater::Utils.string_from_numeric(1)
48
60
  assert_equal '1.0', Tater::Utils.string_from_numeric(1.0)
49
- assert_equal '1.0', Tater::Utils.string_from_numeric(BigDecimal(1))
61
+ assert_equal '1.0', Tater::Utils.string_from_numeric(BigDecimal('1'))
50
62
  end
51
63
  end
52
64
  end
53
65
 
54
66
  describe '#available?' do
55
67
  let :i18n do
56
- Tater.new(path: File.expand_path('test/fixtures'))
68
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
57
69
  end
58
70
 
59
71
  it 'tells you if the locale is available' do
@@ -88,6 +100,14 @@ describe Tater do
88
100
 
89
101
  assert_instance_of(Hash, i18n.messages)
90
102
  end
103
+
104
+ it 'freezes messages after loading' do
105
+ i18n = Tater.new(messages: { 'hey' => 'Oh hi' })
106
+
107
+ assert i18n.messages.frozen?
108
+ assert i18n.messages.keys.all?(&:frozen?)
109
+ assert i18n.messages.values.all?(&:frozen?)
110
+ end
91
111
  end
92
112
 
93
113
  describe '#available' do
@@ -98,11 +118,17 @@ describe Tater do
98
118
  it 'returns an array with the available locales (i.e. the top-level keys in our messages hash)' do
99
119
  assert_equal %w[en delimiter_only separator_only fr].sort, i18n.available.sort
100
120
  end
121
+
122
+ it 'updates the available list when new messages are loaded' do
123
+ i18n.load(messages: { 'added' => { 'hey' => 'yeah' } })
124
+
125
+ assert_equal %w[en delimiter_only separator_only fr added].sort, i18n.available.sort
126
+ end
101
127
  end
102
128
 
103
129
  describe '#lookup' do
104
130
  let :i18n do
105
- Tater.new(path: File.expand_path('test/fixtures'))
131
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
106
132
  end
107
133
 
108
134
  it 'returns keys from messages' do
@@ -118,16 +144,16 @@ describe Tater do
118
144
  end
119
145
 
120
146
  it 'cascades' do
121
- assert_equal 'Delicious', i18n.lookup('cascade.nope.tacos', nil, true)
122
- assert_equal 'Whoaa', i18n.lookup('cascade.another.nope.crazy', nil, true)
123
- assert_nil i18n.lookup('cascade.another.nope.crazy', nil, false)
147
+ assert_equal 'Delicious', i18n.lookup('cascade.nope.tacos', cascade: true)
148
+ assert_equal 'Whoaa', i18n.lookup('cascade.another.nope.crazy', cascade: true)
149
+ assert_nil i18n.lookup('cascade.another.nope.crazy', cascade: false)
124
150
  assert_nil i18n.lookup('cascade.nahhhhhh')
125
151
  end
126
152
  end
127
153
 
128
154
  describe '#translate' do
129
155
  let :i18n do
130
- Tater.new(path: File.expand_path('test/fixtures'))
156
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
131
157
  end
132
158
 
133
159
  it 'translates strings' do
@@ -185,7 +211,7 @@ describe Tater do
185
211
 
186
212
  describe '#localize' do
187
213
  let :i18n do
188
- Tater.new(path: File.expand_path('test/fixtures'))
214
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
189
215
  end
190
216
 
191
217
  let :fr do
@@ -241,6 +267,30 @@ describe Tater do
241
267
  assert_equal '1NAH12', i18n.localize(BigDecimal('1.12'))
242
268
  end
243
269
 
270
+ describe 'precision option' do
271
+ it 'defaults to 2' do
272
+ assert_equal '10NAH00', i18n.localize(BigDecimal('10'))
273
+ assert_equal '10NAH00', i18n.localize(10.0)
274
+ end
275
+
276
+ it 'defaults to zero for integers' do
277
+ assert_equal '10', i18n.localize(10)
278
+ end
279
+
280
+ it 'removes fractional pieces when the precision is 0' do
281
+ assert_equal '10', i18n.localize(BigDecimal('10.123456'), precision: 0)
282
+ assert_equal '10', i18n.localize(10.123456, precision: 0)
283
+
284
+ assert_equal '10', i18n.localize(BigDecimal('10.12'), precision: 0)
285
+ assert_equal '10', i18n.localize(10.12, precision: 0)
286
+ end
287
+
288
+ it 'truncates long values to the desired precision' do
289
+ assert_equal '10NAH00', i18n.localize(BigDecimal('10.00234'))
290
+ assert_equal '10NAH00', i18n.localize(10.00234)
291
+ end
292
+ end
293
+
244
294
  it 'allows overriding the delimiter and separator' do
245
295
  assert_equal '10WOO000NAH12', i18n.localize(10_000.12, delimiter: 'WOO')
246
296
  assert_equal '10TURKEYS000YA12', i18n.localize(10_000.12, separator: 'YA')
@@ -336,7 +386,7 @@ describe Tater do
336
386
 
337
387
  describe '#locale=' do
338
388
  let :i18n do
339
- Tater.new(path: File.expand_path('test/fixtures'))
389
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
340
390
  end
341
391
 
342
392
  it 'overrides the locale when available' do
@@ -370,7 +420,7 @@ describe Tater do
370
420
 
371
421
  describe '#includes?' do
372
422
  let :i18n do
373
- Tater.new(path: File.expand_path('test/fixtures'))
423
+ Tater.new(path: File.expand_path('test/fixtures'), locale: 'en')
374
424
  end
375
425
 
376
426
  it 'tells you if you have a translation' do
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: 1.3.2
4
+ version: 2.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evan Lecklider
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-08-06 00:00:00.000000000 Z
11
+ date: 2021-07-12 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
@@ -102,13 +158,13 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
158
  - !ruby/object:Gem::Version
103
159
  version: '2.0'
104
160
  requirements: []
105
- rubygems_version: 3.1.4
161
+ rubygems_version: 3.2.22
106
162
  signing_key:
107
163
  specification_version: 4
108
164
  summary: Minimal internationalization and localization library.
109
165
  test_files:
110
- - test/fixtures/ruby.rb
111
166
  - test/fixtures/another.yml
112
167
  - test/fixtures/fixtures.yml
113
168
  - test/fixtures/messages/more.yml
169
+ - test/fixtures/ruby.rb
114
170
  - test/tater_test.rb