mova 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.yardopts +1 -0
- data/CONTRIBUTING.md +47 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +22 -0
- data/README.md +102 -0
- data/Rakefile +7 -0
- data/benchmarks/compare/locale_fallbacks.rb +42 -0
- data/benchmarks/compare/long_scope.rb +48 -0
- data/benchmarks/each_vs_or.rb +42 -0
- data/benchmarks/fallbacks_with_local_storage.rb +21 -0
- data/benchmarks/fallbacks_with_remote_storage.rb +24 -0
- data/lib/mova.rb +36 -0
- data/lib/mova/interpolation/sprintf.rb +73 -0
- data/lib/mova/read_strategy/eager.rb +25 -0
- data/lib/mova/read_strategy/lazy.rb +21 -0
- data/lib/mova/scope.rb +94 -0
- data/lib/mova/storage/chain.rb +124 -0
- data/lib/mova/storage/memory.rb +67 -0
- data/lib/mova/storage/readonly.rb +51 -0
- data/lib/mova/translator.rb +183 -0
- data/mova.gemspec +18 -0
- data/test/acceptance_test.rb +22 -0
- data/test/interpolation/sprintf_test.rb +60 -0
- data/test/mova_test.rb +21 -0
- data/test/read_strategy/eager_test.rb +47 -0
- data/test/read_strategy/lazy_test.rb +46 -0
- data/test/scope_test.rb +41 -0
- data/test/storage/chain_test.rb +100 -0
- data/test/storage/memory_test.rb +52 -0
- data/test/storage/readonly_test.rb +36 -0
- data/test/test_helper.rb +47 -0
- data/test/translator/get_test.rb +55 -0
- data/test/translator/initialize_test.rb +23 -0
- data/test/translator/put_test.rb +27 -0
- metadata +121 -0
@@ -0,0 +1,183 @@
|
|
1
|
+
module Mova
|
2
|
+
# Wrapper around a storage that provides key management and fallbacks.
|
3
|
+
#
|
4
|
+
# Translator knows that keys by definition are dot-separated and each key should
|
5
|
+
# include a locale. It also flattens any given hash, because ordinary key-value
|
6
|
+
# storage can handle only flat data.
|
7
|
+
#
|
8
|
+
# Translator is in charge of returning non-empty data for given set of locales and keys,
|
9
|
+
# because it is likely that some translations are missing.
|
10
|
+
#
|
11
|
+
# @since 0.1.0
|
12
|
+
class Translator
|
13
|
+
include ReadStrategy::Lazy
|
14
|
+
|
15
|
+
# @!attribute [r] storage
|
16
|
+
# Key-value storage for translations.
|
17
|
+
# @return [#read, #read_multi, #write, #exist?, #clear]
|
18
|
+
attr_reader :storage
|
19
|
+
|
20
|
+
module Overridable
|
21
|
+
# @param opts [Hash]
|
22
|
+
# @option opts [see #storage] :storage default: {Storage::Memory} instance
|
23
|
+
def initialize(opts = {})
|
24
|
+
@storage = opts.fetch(:storage) do
|
25
|
+
require "mova/storage/memory"
|
26
|
+
Storage::Memory.new
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# @return [Array<String, Symbol>] locales that should be tried until non-empty
|
31
|
+
# translation would be found.
|
32
|
+
# @param current_locale [String, Symbol]
|
33
|
+
#
|
34
|
+
# @example Override locale fallbacks
|
35
|
+
# translator = Mova::Translator.new.tap do |t|
|
36
|
+
# def t.locales_to_try(locale)
|
37
|
+
# [locale, :en]
|
38
|
+
# end
|
39
|
+
# end
|
40
|
+
# translator.put(en: {hello: "world"})
|
41
|
+
# translator.get(:hello, :de) #=> "world"; tried "de.hello", then "en.hello"
|
42
|
+
def locales_to_try(current_locale)
|
43
|
+
[current_locale]
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [Array<String, Symbol>] keys that should be tried until non-empty
|
47
|
+
# translation would be found.
|
48
|
+
# @param key [String, Symbol]
|
49
|
+
#
|
50
|
+
# @example Override key fallbacks
|
51
|
+
# translator = Mova::Translator.new.tap do |t|
|
52
|
+
# def t.keys_to_try(key)
|
53
|
+
# [key, "errors.#{key}"]
|
54
|
+
# end
|
55
|
+
# end
|
56
|
+
# translator.put(en: {errors: {fail: "Fail"}})
|
57
|
+
# translator.get(:fail, :en) #=> "Fail"; tried "en.fail", then "en.errors.fail"
|
58
|
+
def keys_to_try(key)
|
59
|
+
[key]
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return [String] default value if no translation was found.
|
63
|
+
# @param locales [Array<String>] that were used to find a translation.
|
64
|
+
# @param keys [Array<String>] that were used to find a translation.
|
65
|
+
# @param get_options [Hash{Symbol => Object}] that were passed to {#get}
|
66
|
+
#
|
67
|
+
# @example Override default value handling
|
68
|
+
# translator = Mova::Translator.new.tap do |t|
|
69
|
+
# def t.default(locales, keys, get_options)
|
70
|
+
# "translation is missing"
|
71
|
+
# end
|
72
|
+
# end
|
73
|
+
# translator.get("hello", :de) #=> "translation is missing"
|
74
|
+
def default(locales, keys, get_options)
|
75
|
+
EMPTY_TRANSLATION
|
76
|
+
end
|
77
|
+
end
|
78
|
+
include Overridable
|
79
|
+
|
80
|
+
# Retrieves translation from the storage or return default value.
|
81
|
+
#
|
82
|
+
# @return [String] translation or default value if nothing found
|
83
|
+
#
|
84
|
+
# @example
|
85
|
+
# translator.put(en: {hello: "world"})
|
86
|
+
# translator.get("hello", :en) #=> "world"
|
87
|
+
# translator.get("bye", :en) #=> ""
|
88
|
+
#
|
89
|
+
# @example Providing the default if nothing found
|
90
|
+
# translator.get("hello", :de, default: "nothing") #=> "nothing"
|
91
|
+
#
|
92
|
+
# @overload get(key, locale, opts = {})
|
93
|
+
# @param key [String, Symbol]
|
94
|
+
# @param locale [String, Symbol]
|
95
|
+
#
|
96
|
+
# @overload get(keys, locale, opts = {})
|
97
|
+
# @param keys [Array<String, Symbol>] use this to redefine an array returned
|
98
|
+
# by {#keys_to_try}.
|
99
|
+
# @param locale [String, Symbol]
|
100
|
+
#
|
101
|
+
# @example
|
102
|
+
# translator.put(en: {fail: "Fail"})
|
103
|
+
# translator.get(["big.fail", "mine.fail"], :en) #=> ""; tried "en.big.fail", then "en.mine.fail"
|
104
|
+
#
|
105
|
+
# @overload get(key, locales, opts = {})
|
106
|
+
# @param key [String, Symbol]
|
107
|
+
# @param locales [Array<String, Symbol>] use this to redefine an array returned
|
108
|
+
# by {#locales_to_try}.
|
109
|
+
#
|
110
|
+
# @example
|
111
|
+
# translator.put(en: {hello: "world"})
|
112
|
+
# translator.get(:hello, :de) #=> ""; tried only "de.hello"
|
113
|
+
# translator.get(:hello, [:de, :en]) #=> "world"; tried "de.hello", then "en.hello"
|
114
|
+
#
|
115
|
+
# @example Disable locale fallbacks locally
|
116
|
+
# translator.put(en: {hello: "world"}) # suppose this instance has fallback to :en locale
|
117
|
+
# translator.get(:hello, :de) #=> "world"; tried "de.hello", then "en.hello"
|
118
|
+
# translator.get(:hello, [:de]) #=> ""; tried only "de.hello"
|
119
|
+
#
|
120
|
+
# @overload get(keys, locales, opts = {})
|
121
|
+
# @param keys [Array<String, Symbol>]
|
122
|
+
# @param locales [Array<String, Symbol>]
|
123
|
+
#
|
124
|
+
# @note Keys fallback has a higher priority than locales one, that is, Mova
|
125
|
+
# tries to find a translation for any given key and only then it fallbacks
|
126
|
+
# to another locale.
|
127
|
+
#
|
128
|
+
# @param opts [Hash]
|
129
|
+
# @option opts [String] :default use this to redefine default value returned
|
130
|
+
# by {#default}.
|
131
|
+
#
|
132
|
+
# @see #locales_to_try
|
133
|
+
# @see #keys_to_try
|
134
|
+
# @see #default
|
135
|
+
#
|
136
|
+
def get(key, locale, opts = {})
|
137
|
+
keys = resolve_scopes(key)
|
138
|
+
locales = resolve_locales(locale)
|
139
|
+
read_first(locales, keys) || opts[:default] || default(locales, keys, opts)
|
140
|
+
end
|
141
|
+
|
142
|
+
# Writes translations to the storage.
|
143
|
+
#
|
144
|
+
# @return [void]
|
145
|
+
# @param translations [Hash{String, Symbol => String, Hash}] where
|
146
|
+
# root key/keys must be a locale
|
147
|
+
#
|
148
|
+
# @example
|
149
|
+
# translator.put(en: {world: "world"}, uk: {world: "світ"})
|
150
|
+
# translator.get("world", :uk) #=> "світ"
|
151
|
+
def put(translations)
|
152
|
+
Scope.flatten(translations).each do |key, value|
|
153
|
+
storage.write(key, value) unless storage.exist?(key)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# @see #put
|
158
|
+
#
|
159
|
+
# @return [void]
|
160
|
+
#
|
161
|
+
# @note This method overwrites existing translations.
|
162
|
+
def put!(translations)
|
163
|
+
Scope.flatten(translations).each do |key, value|
|
164
|
+
storage.write(key, value)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# @private
|
169
|
+
def inspect
|
170
|
+
"<##{self.class.name} storage=#{storage.inspect}>"
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
|
175
|
+
def resolve_locales(locale)
|
176
|
+
(locale if locale.is_a? Array) || locales_to_try(locale)
|
177
|
+
end
|
178
|
+
|
179
|
+
def resolve_scopes(key)
|
180
|
+
(key if key.is_a? Array) || keys_to_try(key)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
data/mova.gemspec
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
Gem::Specification.new do |spec|
|
2
|
+
spec.name = "mova"
|
3
|
+
spec.version = "0.1.0"
|
4
|
+
spec.authors = ["Andrii Malyshko"]
|
5
|
+
spec.email = ["mail@nashbridges.me"]
|
6
|
+
spec.summary = "Translation and localization library"
|
7
|
+
spec.description = spec.summary
|
8
|
+
spec.homepage = "https://github.com/mova-rb/mova"
|
9
|
+
spec.license = "MIT"
|
10
|
+
|
11
|
+
spec.files = `git ls-files -z`.split("\x0")
|
12
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
13
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
14
|
+
spec.require_paths = ["lib"]
|
15
|
+
|
16
|
+
spec.add_development_dependency "bundler", "~> 1.6"
|
17
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
18
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
module Mova
|
4
|
+
class AcceptanceTest < Minitest::Test
|
5
|
+
def test_get
|
6
|
+
translator = Translator.new.tap do |t|
|
7
|
+
def t.locales_to_try(current_locale)
|
8
|
+
[current_locale, :en]
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
translator.put(en: {global: {hello: "world"}}, de: {hi: "Hallo"})
|
13
|
+
|
14
|
+
assert_equal "Hallo", translator.get(:hi, :de)
|
15
|
+
assert_equal "world", translator.get("global.hello", :de)
|
16
|
+
assert_equal "", translator.get("global.hello", [:de])
|
17
|
+
assert_equal "world", translator.get(["hello", "global.hello"], :en)
|
18
|
+
assert_equal "", translator.get(:nothing, :en)
|
19
|
+
assert_equal "nothing", translator.get(:nothing, :en, default: "nothing")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "mova/interpolation/sprintf"
|
3
|
+
|
4
|
+
module Mova
|
5
|
+
class SprintfTest < Minitest::Test
|
6
|
+
def interpolator
|
7
|
+
@interpolator ||= Interpolation::Sprintf.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_no_placeholders_and_no_values
|
11
|
+
assert_equal "hi there!", interpolator.call("hi there!", {})
|
12
|
+
end
|
13
|
+
|
14
|
+
def test_no_placeholders_and_values
|
15
|
+
assert_equal "hi there!", interpolator.call("hi there!", subject: "people")
|
16
|
+
end
|
17
|
+
|
18
|
+
def test_replaced_placeholders
|
19
|
+
values = {subject: "people", type: "smart"}
|
20
|
+
assert_equal "hi there, smart people!", interpolator.call("hi there, %{type} %{subject}!", values)
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_placeholder_and_extra_value
|
24
|
+
assert_equal "hi there, people!", interpolator.call("hi there, %{subject}!", subject: "people", type: "smart")
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_placeholder_and_missing_value
|
28
|
+
values = {type: "smart"}
|
29
|
+
expect(interpolator).to receive(:missing_placeholder).with(:subject, values, "hi there, %{subject}!").and_return("<missing value>")
|
30
|
+
assert_equal "hi there, <missing value>!", interpolator.call("hi there, %{subject}!", values)
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_escaped_placeholder
|
34
|
+
assert_equal "hi there, %{subject}!", interpolator.call("hi there, %%{subject}!", subject: "people")
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_activesupport_safebuffer_like_strings
|
38
|
+
substring_class = Class.new(String) do
|
39
|
+
def gsub(*args, &blk)
|
40
|
+
to_str.gsub(*args, &blk)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
string = substring_class.new("hi there, %{subject}!")
|
45
|
+
assert_equal "hi there, people!", interpolator.call(string, subject: "people")
|
46
|
+
end
|
47
|
+
|
48
|
+
def test_ruby_sprintf
|
49
|
+
assert_equal "1", interpolator.call("%<num>d", num: 1)
|
50
|
+
assert_equal "0b1", interpolator.call("%<num>#b", num: 1)
|
51
|
+
assert_equal "foo", interpolator.call("%<msg>s", msg: "foo")
|
52
|
+
assert_equal "1.000000", interpolator.call("%<num>f", num: 1.0)
|
53
|
+
assert_equal " 1", interpolator.call("%<num>3.0f", num: 1.0)
|
54
|
+
assert_equal "100.00", interpolator.call("%<num>2.2f", num: 100.0)
|
55
|
+
assert_equal "0x64", interpolator.call("%<num>#x", num: 100.0)
|
56
|
+
assert_raises(ArgumentError) { interpolator.call("%<num>,d", num: 100) }
|
57
|
+
assert_raises(ArgumentError) { interpolator.call("%<num>/d", num: 100) }
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
data/test/mova_test.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
module Mova
|
4
|
+
class MovaTest < Minitest::Test
|
5
|
+
def test_presence_nil
|
6
|
+
assert_nil Mova.presence(nil)
|
7
|
+
end
|
8
|
+
|
9
|
+
def test_presence_empty
|
10
|
+
assert_nil Mova.presence("")
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_presence_non_empty
|
14
|
+
assert_equal "hello", Mova.presence("hello")
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_presence_spaces_only
|
18
|
+
assert_equal " ", Mova.presence(" ")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
require "mova/read_strategy/eager"
|
3
|
+
|
4
|
+
module Mova
|
5
|
+
class EagerReadStrategyTest < Minitest::Test
|
6
|
+
include Test::Doubles
|
7
|
+
|
8
|
+
def translator_class
|
9
|
+
Struct.new(:storage) do
|
10
|
+
include ReadStrategy::Eager
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def translator
|
15
|
+
@translator ||= translator_class.new(storage)
|
16
|
+
end
|
17
|
+
|
18
|
+
def test_read_first
|
19
|
+
expect(storage).to receive(:read_multi).with("de.hello").and_return("de.hello" => "Hallo")
|
20
|
+
assert_equal "Hallo", translator.read_first([:de], ["hello"])
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_read_first_returns_nil_when_nothing_found
|
24
|
+
expect(storage).to receive(:read_multi).with("de.hello").and_return({})
|
25
|
+
assert_nil translator.read_first([:de], ["hello"])
|
26
|
+
end
|
27
|
+
|
28
|
+
def test_read_first_fallbacks_to_next_scope
|
29
|
+
expect(storage).to receive(:read_multi).with("de.hello", "de.hi").and_return("de.hi" => "Hallo")
|
30
|
+
assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_read_first_fallbacks_when_empty_result_in_storage
|
34
|
+
expect(storage).to receive(:read_multi).with("de.hello", "de.hi").and_return(
|
35
|
+
"de.hello" => "", "de.hi" => "Hallo"
|
36
|
+
)
|
37
|
+
assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
|
38
|
+
end
|
39
|
+
|
40
|
+
def test_read_first_fallbacks_to_next_locale
|
41
|
+
expect(storage).to receive(:read_multi).with("de.hello", "de.hi", "en.hello", "en.hi").and_return(
|
42
|
+
"en.hello" => "Hello", "en.hi" => "hi"
|
43
|
+
)
|
44
|
+
assert_equal "Hello", translator.read_first([:de, :en], ["hello", "hi"])
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
module Mova
|
4
|
+
class LazyReadStrategyTest < Minitest::Test
|
5
|
+
include Test::Doubles
|
6
|
+
|
7
|
+
def translator_class
|
8
|
+
Struct.new(:storage) do
|
9
|
+
include ReadStrategy::Lazy
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def translator
|
14
|
+
@translator ||= translator_class.new(storage)
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_read_first
|
18
|
+
expect(storage).to receive(:read).with("de.hello") { "Hallo" }
|
19
|
+
assert_equal "Hallo", translator.read_first([:de], ["hello"])
|
20
|
+
end
|
21
|
+
|
22
|
+
def test_read_first_returns_nil_when_nothing_found
|
23
|
+
expect(storage).to receive(:read).with("de.hello") { nil }
|
24
|
+
assert_nil translator.read_first([:de], ["hello"])
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_read_first_fallbacks_to_next_scope
|
28
|
+
expect(storage).to receive(:read).ordered.with("de.hello") { nil }
|
29
|
+
expect(storage).to receive(:read).ordered.with("de.hi") { "Hallo" }
|
30
|
+
assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_read_first_fallbacks_when_empty_result_in_storage
|
34
|
+
expect(storage).to receive(:read).ordered.with("de.hello") { "" }
|
35
|
+
expect(storage).to receive(:read).ordered.with("de.hi") { "Hallo" }
|
36
|
+
assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
|
37
|
+
end
|
38
|
+
|
39
|
+
def test_read_first_fallbacks_to_next_locale
|
40
|
+
expect(storage).to receive(:read).ordered.with("de.hello") { nil }
|
41
|
+
expect(storage).to receive(:read).ordered.with("de.hi") { nil }
|
42
|
+
expect(storage).to receive(:read).ordered.with("en.hello") { "Hello" }
|
43
|
+
assert_equal "Hello", translator.read_first([:de, :en], ["hello", "hi"])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/test/scope_test.rb
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
module Mova
|
4
|
+
class ScopeTest < Minitest::Test
|
5
|
+
def test_join
|
6
|
+
assert_equal "hello.world", Scope.join("hello", "world")
|
7
|
+
end
|
8
|
+
|
9
|
+
def test_join_symbols
|
10
|
+
assert_equal "hello.world", Scope.join(:hello, :world)
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_join_array
|
14
|
+
assert_equal "hello.world", Scope.join(["hello", "world"])
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_split
|
18
|
+
assert_equal ["hello", "world"], Scope.split("hello.world")
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_flatten_simple
|
22
|
+
expected = {"hello" => "world"}
|
23
|
+
assert_equal expected, Scope.flatten(hello: "world")
|
24
|
+
end
|
25
|
+
|
26
|
+
def test_flatten_with_one_root
|
27
|
+
expected = {"en.foo" => "bar", "en.inner.foo" => "bar"}
|
28
|
+
assert_equal expected, Scope.flatten(en: {foo: "bar", inner: {foo: "bar"}})
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_flatten_with_multiple_roots
|
32
|
+
expected = {"en.foo" => "bar", "ru.foo" => "bar"}
|
33
|
+
assert_equal expected, Scope.flatten(en: {foo: "bar"}, ru: {foo: "bar"})
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_cross_join
|
37
|
+
expected = ["de.hello", "de.hi", "en.hello", "en.hi"]
|
38
|
+
assert_equal expected, Scope.cross_join([:de, :en], ["hello", "hi"])
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|