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