i18n-message_format 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/.envrc +10 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/Rakefile +12 -0
- data/devenv.lock +123 -0
- data/devenv.nix +57 -0
- data/devenv.yaml +15 -0
- data/docs/plans/2026-02-25-icu-message-format-design.md +141 -0
- data/docs/plans/2026-02-25-icu-message-format-plan.md +1947 -0
- data/lib/i18n/message_format/backend.rb +172 -0
- data/lib/i18n/message_format/cache.rb +75 -0
- data/lib/i18n/message_format/formatter.rb +179 -0
- data/lib/i18n/message_format/nodes.rb +96 -0
- data/lib/i18n/message_format/ordinal_rules.rb +64 -0
- data/lib/i18n/message_format/parser.rb +328 -0
- data/lib/i18n/message_format/version.rb +8 -0
- data/lib/i18n/message_format.rb +74 -0
- data/sig/i18n/message_format.rbs +6 -0
- metadata +78 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module I18n
|
|
6
|
+
module MessageFormat
|
|
7
|
+
# An I18n backend that parses and formats ICU MessageFormat patterns.
|
|
8
|
+
#
|
|
9
|
+
# {Backend} implements +I18n::Backend::Base+ and can be used standalone or
|
|
10
|
+
# chained with other backends via +I18n::Backend::Chain+. Translation
|
|
11
|
+
# values that are Strings are treated as ICU MessageFormat patterns; any
|
|
12
|
+
# extra options passed to {#translate} (beyond +scope+, +default+, and
|
|
13
|
+
# +separator+) are forwarded as format arguments.
|
|
14
|
+
#
|
|
15
|
+
# Non-String values (e.g. arrays or hashes used as scopes) are returned
|
|
16
|
+
# as-is without formatting.
|
|
17
|
+
#
|
|
18
|
+
# @example Standalone usage
|
|
19
|
+
# backend = I18n::MessageFormat::Backend.new("config/locales/**/*.yml")
|
|
20
|
+
# I18n.backend = backend
|
|
21
|
+
# backend.load_translations
|
|
22
|
+
# I18n.t("greeting", name: "Alice") # => "Hello, Alice!"
|
|
23
|
+
#
|
|
24
|
+
# @example Chained with the default Simple backend
|
|
25
|
+
# I18n.backend = I18n::Backend::Chain.new(
|
|
26
|
+
# I18n::MessageFormat::Backend.new("config/locales/**/*.yml"),
|
|
27
|
+
# I18n.backend
|
|
28
|
+
# )
|
|
29
|
+
class Backend
|
|
30
|
+
include ::I18n::Backend::Base
|
|
31
|
+
|
|
32
|
+
# Creates a new backend that will load translations from the given
|
|
33
|
+
# file glob patterns.
|
|
34
|
+
#
|
|
35
|
+
# @param glob_patterns [Array<String>] one or more glob patterns passed
|
|
36
|
+
# to +Dir.glob+ to locate YAML translation files
|
|
37
|
+
def initialize(*glob_patterns)
|
|
38
|
+
@glob_patterns = glob_patterns
|
|
39
|
+
@translations = {}
|
|
40
|
+
@cache = Cache.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Loads translations from all YAML files matching the glob patterns
|
|
44
|
+
# provided at construction time.
|
|
45
|
+
#
|
|
46
|
+
# Files are expected to be YAML documents whose top-level keys are locale
|
|
47
|
+
# codes (e.g. +en+, +fr+) mapping to a hash of translation keys and
|
|
48
|
+
# values.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
def load_translations
|
|
52
|
+
@glob_patterns.each do |pattern|
|
|
53
|
+
Dir.glob(pattern).each do |file|
|
|
54
|
+
data = YAML.safe_load_file(file, permitted_classes: [Symbol])
|
|
55
|
+
data.each do |locale, translations|
|
|
56
|
+
store_translations(locale.to_sym, translations)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Merges +data+ into the in-memory translation store for +locale+.
|
|
63
|
+
#
|
|
64
|
+
# Nested hashes are flattened into dot-separated keys (e.g.
|
|
65
|
+
# +{ greeting: { hello: "Hi" } }+ becomes +{ :"greeting.hello" => "Hi" }+)
|
|
66
|
+
# before being merged.
|
|
67
|
+
#
|
|
68
|
+
# @param locale [Symbol, String] the locale to store translations for
|
|
69
|
+
# @param data [Hash] a (possibly nested) hash of translation keys/values
|
|
70
|
+
# @param options [Hash] currently unused; reserved for compatibility with
|
|
71
|
+
# +I18n::Backend::Base+
|
|
72
|
+
# @return [void]
|
|
73
|
+
def store_translations(locale, data, options = {})
|
|
74
|
+
@translations[locale] ||= {}
|
|
75
|
+
deep_merge!(@translations[locale], flatten_hash(data))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Looks up and formats the translation identified by +key+ for +locale+.
|
|
79
|
+
#
|
|
80
|
+
# String values are interpreted as ICU MessageFormat patterns and
|
|
81
|
+
# formatted with any extra keys in +options+ as arguments. Non-string
|
|
82
|
+
# values are returned unchanged.
|
|
83
|
+
#
|
|
84
|
+
# Throws +:exception+ with an +I18n::MissingTranslation+ object when the
|
|
85
|
+
# key is not found, which is the conventional signal for +I18n::Backend::Base+
|
|
86
|
+
# to trigger the default/fallback mechanism.
|
|
87
|
+
#
|
|
88
|
+
# @param locale [Symbol, String] the locale to translate for
|
|
89
|
+
# @param key [Symbol, String] the translation key
|
|
90
|
+
# @param options [Hash] format arguments plus the standard I18n options
|
|
91
|
+
# (+:scope+, +:default+, +:separator+)
|
|
92
|
+
# @return [String, Object] the formatted translation string, or the raw
|
|
93
|
+
# value if it is not a String
|
|
94
|
+
# @raise [I18n::MissingTranslation] (via +throw+) when the key is absent
|
|
95
|
+
def translate(locale, key, options = {})
|
|
96
|
+
pattern = lookup(locale, key, options[:scope], options)
|
|
97
|
+
|
|
98
|
+
if pattern.nil?
|
|
99
|
+
throw(:exception, ::I18n::MissingTranslation.new(locale, key, options))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
return pattern unless pattern.is_a?(String)
|
|
103
|
+
|
|
104
|
+
arguments = options.reject { |k, _| [:scope, :default, :separator].include?(k) }
|
|
105
|
+
nodes = @cache.fetch(pattern) { Parser.new(pattern).parse }
|
|
106
|
+
Formatter.new(nodes, arguments, locale).format
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns the list of locales for which translations have been stored.
|
|
110
|
+
#
|
|
111
|
+
# @return [Array<Symbol>]
|
|
112
|
+
def available_locales
|
|
113
|
+
@translations.keys
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns +true+ if any translations have been loaded into the backend.
|
|
117
|
+
#
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def initialized?
|
|
120
|
+
!@translations.empty?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
protected
|
|
124
|
+
|
|
125
|
+
# Looks up a translation value by locale and key.
|
|
126
|
+
#
|
|
127
|
+
# @param locale [Symbol, String] the locale to look up
|
|
128
|
+
# @param key [Symbol, String] the translation key
|
|
129
|
+
# @param scope [Array, Symbol, nil] optional scope prepended to the key
|
|
130
|
+
# @param options [Hash] may include +:separator+ to override the default
|
|
131
|
+
# key separator
|
|
132
|
+
# @return [Object, nil] the translation value, or +nil+ if not found
|
|
133
|
+
def lookup(locale, key, scope = [], options = {})
|
|
134
|
+
keys = ::I18n.normalize_keys(locale, key, scope, options[:separator])
|
|
135
|
+
keys.shift # remove locale
|
|
136
|
+
|
|
137
|
+
result = @translations[locale]
|
|
138
|
+
return nil unless result
|
|
139
|
+
|
|
140
|
+
keys.each do |k|
|
|
141
|
+
return nil unless result.is_a?(Hash)
|
|
142
|
+
result = result[k] || result[k.to_s]
|
|
143
|
+
return nil if result.nil?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
result
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def flatten_hash(hash, prefix = nil)
|
|
152
|
+
result = {}
|
|
153
|
+
hash.each do |key, value|
|
|
154
|
+
full_key = prefix ? :"#{prefix}.#{key}" : key.to_sym
|
|
155
|
+
if value.is_a?(Hash)
|
|
156
|
+
result.merge!(flatten_hash(value, full_key))
|
|
157
|
+
else
|
|
158
|
+
result[full_key] = value
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def deep_merge!(base, override)
|
|
165
|
+
override.each do |key, value|
|
|
166
|
+
base[key] = value
|
|
167
|
+
end
|
|
168
|
+
base
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18n
|
|
4
|
+
module MessageFormat
|
|
5
|
+
# A thread-safe, bounded LRU (Least Recently Used) cache.
|
|
6
|
+
#
|
|
7
|
+
# Used internally to memoize parsed ASTs so the same pattern string is
|
|
8
|
+
# only parsed once. The cache evicts the least recently accessed entry
|
|
9
|
+
# when the maximum size is reached.
|
|
10
|
+
class Cache
|
|
11
|
+
# Creates a new cache instance.
|
|
12
|
+
#
|
|
13
|
+
# @param max_size [Integer] maximum number of entries to retain before
|
|
14
|
+
# evicting the least recently used entry (default: +1000+)
|
|
15
|
+
def initialize(max_size: 1000)
|
|
16
|
+
@max_size = max_size
|
|
17
|
+
@data = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Retrieves the value stored under +key+, updating its recency.
|
|
22
|
+
#
|
|
23
|
+
# @param key [Object] the cache key
|
|
24
|
+
# @return [Object, nil] the cached value, or +nil+ if not found
|
|
25
|
+
def get(key)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
return nil unless @data.key?(key)
|
|
28
|
+
value = @data.delete(key)
|
|
29
|
+
@data[key] = value
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Stores +value+ under +key+, evicting the LRU entry if necessary.
|
|
35
|
+
#
|
|
36
|
+
# @param key [Object] the cache key
|
|
37
|
+
# @param value [Object] the value to store
|
|
38
|
+
# @return [Object] the stored value
|
|
39
|
+
def set(key, value)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@data.delete(key) if @data.key?(key)
|
|
42
|
+
@data[key] = value
|
|
43
|
+
evict if @data.size > @max_size
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the cached value for +key+, computing and storing it on a miss.
|
|
48
|
+
#
|
|
49
|
+
# @param key [Object] the cache key
|
|
50
|
+
# @yield called on a cache miss to compute the value to store
|
|
51
|
+
# @yieldreturn [Object] the value to cache and return
|
|
52
|
+
# @return [Object] the cached or newly computed value
|
|
53
|
+
def fetch(key)
|
|
54
|
+
value = get(key)
|
|
55
|
+
return value unless value.nil? && !@mutex.synchronize { @data.key?(key) }
|
|
56
|
+
value = yield
|
|
57
|
+
set(key, value)
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Removes all entries from the cache.
|
|
62
|
+
#
|
|
63
|
+
# @return [void]
|
|
64
|
+
def clear
|
|
65
|
+
@mutex.synchronize { @data.clear }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def evict
|
|
71
|
+
@data.delete(@data.keys.first)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18n
|
|
4
|
+
module MessageFormat
|
|
5
|
+
# Raised when a placeholder in the pattern has no corresponding argument.
|
|
6
|
+
class MissingArgumentError < Error
|
|
7
|
+
# The name of the missing argument as it appeared in the pattern.
|
|
8
|
+
#
|
|
9
|
+
# @return [String]
|
|
10
|
+
attr_reader :argument_name
|
|
11
|
+
|
|
12
|
+
# @param argument_name [String] the name of the missing argument
|
|
13
|
+
def initialize(argument_name)
|
|
14
|
+
@argument_name = argument_name
|
|
15
|
+
super("Missing argument: #{argument_name}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Walks an AST produced by {Parser} and renders a formatted string.
|
|
20
|
+
#
|
|
21
|
+
# Each node type is dispatched to a dedicated format method. Number, date,
|
|
22
|
+
# and time formatting delegate to +I18n.localize+. Plural and ordinal
|
|
23
|
+
# categorisation uses rules registered under +i18n.plural.rule+ /
|
|
24
|
+
# +i18n.ordinal.rule+ in the active I18n backend, falling back to simple
|
|
25
|
+
# one/other logic when no rule is present.
|
|
26
|
+
class Formatter
|
|
27
|
+
# Creates a new formatter.
|
|
28
|
+
#
|
|
29
|
+
# @param nodes [Array] the AST returned by {Parser#parse}
|
|
30
|
+
# @param arguments [Hash] argument values keyed by Symbol
|
|
31
|
+
# @param locale [Symbol, String] locale used for pluralisation and
|
|
32
|
+
# number/date/time formatting
|
|
33
|
+
def initialize(nodes, arguments, locale)
|
|
34
|
+
@nodes = nodes
|
|
35
|
+
@arguments = arguments
|
|
36
|
+
@locale = locale
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Renders the AST to a String.
|
|
40
|
+
#
|
|
41
|
+
# @return [String] the fully formatted message
|
|
42
|
+
# @raise [MissingArgumentError] if a required argument is absent from
|
|
43
|
+
# the arguments hash
|
|
44
|
+
# @raise [Error] if a plural or select branch cannot be resolved
|
|
45
|
+
def format
|
|
46
|
+
format_nodes(@nodes)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def format_nodes(nodes)
|
|
52
|
+
nodes.map { |node| format_node(node) }.join
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format_node(node)
|
|
56
|
+
case node
|
|
57
|
+
when Nodes::TextNode
|
|
58
|
+
node.value
|
|
59
|
+
when Nodes::ArgumentNode
|
|
60
|
+
fetch_argument(node.name).to_s
|
|
61
|
+
when Nodes::NumberFormatNode
|
|
62
|
+
format_number(node)
|
|
63
|
+
when Nodes::DateFormatNode
|
|
64
|
+
format_date(node)
|
|
65
|
+
when Nodes::TimeFormatNode
|
|
66
|
+
format_time(node)
|
|
67
|
+
when Nodes::PluralNode
|
|
68
|
+
format_plural(node)
|
|
69
|
+
when Nodes::SelectNode
|
|
70
|
+
format_select(node)
|
|
71
|
+
when Nodes::SelectOrdinalNode
|
|
72
|
+
format_select_ordinal(node)
|
|
73
|
+
else
|
|
74
|
+
raise Error, "Unknown node type: #{node.class}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fetch_argument(name)
|
|
79
|
+
key = name.to_sym
|
|
80
|
+
unless @arguments.key?(key)
|
|
81
|
+
raise MissingArgumentError.new(name)
|
|
82
|
+
end
|
|
83
|
+
@arguments[key]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_number(node)
|
|
87
|
+
value = fetch_argument(node.name)
|
|
88
|
+
::I18n.localize(value, locale: @locale)
|
|
89
|
+
rescue ::I18n::MissingTranslationData
|
|
90
|
+
value.to_s
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def format_date(node)
|
|
94
|
+
value = fetch_argument(node.name)
|
|
95
|
+
opts = { locale: @locale }
|
|
96
|
+
opts[:format] = node.style.to_sym if node.style
|
|
97
|
+
::I18n.localize(value, **opts)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def format_time(node)
|
|
101
|
+
value = fetch_argument(node.name)
|
|
102
|
+
opts = { locale: @locale }
|
|
103
|
+
opts[:format] = node.style.to_sym if node.style
|
|
104
|
+
::I18n.localize(value, **opts)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def format_plural(node)
|
|
108
|
+
value = fetch_argument(node.name)
|
|
109
|
+
effective_value = value - node.offset
|
|
110
|
+
|
|
111
|
+
# Check exact matches first
|
|
112
|
+
exact_key = :"=#{value}"
|
|
113
|
+
if node.branches.key?(exact_key)
|
|
114
|
+
return format_branch(node.branches[exact_key], effective_value)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Use i18n pluralization rules
|
|
118
|
+
category = pluralize_cardinal(effective_value, @locale)
|
|
119
|
+
branch = node.branches[category] || node.branches[:other]
|
|
120
|
+
raise Error, "No matching plural branch for '#{category}'" unless branch
|
|
121
|
+
|
|
122
|
+
format_branch(branch, effective_value)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_select(node)
|
|
126
|
+
value = fetch_argument(node.name)
|
|
127
|
+
key = value.to_s.to_sym
|
|
128
|
+
branch = node.branches[key] || node.branches[:other]
|
|
129
|
+
raise Error, "No matching select branch for '#{key}'" unless branch
|
|
130
|
+
|
|
131
|
+
format_nodes(branch)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def format_select_ordinal(node)
|
|
135
|
+
value = fetch_argument(node.name)
|
|
136
|
+
effective_value = value - node.offset
|
|
137
|
+
|
|
138
|
+
exact_key = :"=#{value}"
|
|
139
|
+
if node.branches.key?(exact_key)
|
|
140
|
+
return format_branch(node.branches[exact_key], effective_value)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
category = pluralize_ordinal(effective_value, @locale)
|
|
144
|
+
branch = node.branches[category] || node.branches[:other]
|
|
145
|
+
raise Error, "No matching selectordinal branch for '#{category}'" unless branch
|
|
146
|
+
|
|
147
|
+
format_branch(branch, effective_value)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def format_branch(nodes, numeric_value)
|
|
151
|
+
nodes.map do |node|
|
|
152
|
+
if node.is_a?(Nodes::TextNode)
|
|
153
|
+
node.value.gsub("#", numeric_value.to_s)
|
|
154
|
+
else
|
|
155
|
+
format_node(node)
|
|
156
|
+
end
|
|
157
|
+
end.join
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pluralize_cardinal(count, locale)
|
|
161
|
+
rule = ::I18n.t(:"i18n.plural.rule", locale: locale, default: nil, resolve: false)
|
|
162
|
+
if rule.respond_to?(:call)
|
|
163
|
+
rule.call(count)
|
|
164
|
+
else
|
|
165
|
+
count == 1 ? :one : :other
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def pluralize_ordinal(count, locale)
|
|
170
|
+
rule = ::I18n.t(:"i18n.ordinal.rule", locale: locale, default: nil, resolve: false)
|
|
171
|
+
if rule.respond_to?(:call)
|
|
172
|
+
rule.call(count)
|
|
173
|
+
else
|
|
174
|
+
:other
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18n
|
|
4
|
+
module MessageFormat
|
|
5
|
+
# Namespace for the AST node types produced by {Parser}.
|
|
6
|
+
#
|
|
7
|
+
# Each node is a +Struct+ whose members correspond to the semantic
|
|
8
|
+
# components of the ICU MessageFormat construct it represents.
|
|
9
|
+
module Nodes
|
|
10
|
+
# A literal text segment that requires no further processing.
|
|
11
|
+
#
|
|
12
|
+
# @!attribute [rw] value
|
|
13
|
+
# @return [String] the literal text content
|
|
14
|
+
TextNode = Struct.new(:value)
|
|
15
|
+
|
|
16
|
+
# A simple argument placeholder, e.g. +{name}+.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [rw] name
|
|
19
|
+
# @return [String] the argument name as it appears in the pattern
|
|
20
|
+
ArgumentNode = Struct.new(:name)
|
|
21
|
+
|
|
22
|
+
# A +{name, number}+ or +{name, number, style}+ argument.
|
|
23
|
+
#
|
|
24
|
+
# @!attribute [rw] name
|
|
25
|
+
# @return [String] the argument name
|
|
26
|
+
# @!attribute [rw] style
|
|
27
|
+
# @return [String, nil] optional number format style (e.g. +"integer"+)
|
|
28
|
+
NumberFormatNode = Struct.new(:name, :style)
|
|
29
|
+
|
|
30
|
+
# A +{name, date}+ or +{name, date, style}+ argument.
|
|
31
|
+
#
|
|
32
|
+
# @!attribute [rw] name
|
|
33
|
+
# @return [String] the argument name
|
|
34
|
+
# @!attribute [rw] style
|
|
35
|
+
# @return [String, nil] optional date format style (e.g. +"short"+)
|
|
36
|
+
DateFormatNode = Struct.new(:name, :style)
|
|
37
|
+
|
|
38
|
+
# A +{name, time}+ or +{name, time, style}+ argument.
|
|
39
|
+
#
|
|
40
|
+
# @!attribute [rw] name
|
|
41
|
+
# @return [String] the argument name
|
|
42
|
+
# @!attribute [rw] style
|
|
43
|
+
# @return [String, nil] optional time format style (e.g. +"short"+)
|
|
44
|
+
TimeFormatNode = Struct.new(:name, :style)
|
|
45
|
+
|
|
46
|
+
# A +{name, plural, ...}+ argument.
|
|
47
|
+
#
|
|
48
|
+
# @!attribute [rw] name
|
|
49
|
+
# @return [String] the argument name
|
|
50
|
+
# @!attribute [rw] branches
|
|
51
|
+
# @return [Hash{Symbol => Array<Nodes::TextNode, ...>}] plural branches
|
|
52
|
+
# keyed by plural category (e.g. +:one+, +:other+) or exact-value
|
|
53
|
+
# keys like +:=0+
|
|
54
|
+
# @!attribute [rw] offset
|
|
55
|
+
# @return [Integer] value subtracted from the argument before
|
|
56
|
+
# pluralisation (default: +0+)
|
|
57
|
+
PluralNode = Struct.new(:name, :branches, :offset) do
|
|
58
|
+
# @param name [String]
|
|
59
|
+
# @param branches [Hash]
|
|
60
|
+
# @param offset [Integer]
|
|
61
|
+
def initialize(name, branches, offset = 0)
|
|
62
|
+
super(name, branches, offset)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A +{name, select, ...}+ argument.
|
|
67
|
+
#
|
|
68
|
+
# @!attribute [rw] name
|
|
69
|
+
# @return [String] the argument name
|
|
70
|
+
# @!attribute [rw] branches
|
|
71
|
+
# @return [Hash{Symbol => Array}] select branches keyed by selector
|
|
72
|
+
# value, with an optional +:other+ fallback
|
|
73
|
+
SelectNode = Struct.new(:name, :branches)
|
|
74
|
+
|
|
75
|
+
# A +{name, selectordinal, ...}+ argument.
|
|
76
|
+
#
|
|
77
|
+
# @!attribute [rw] name
|
|
78
|
+
# @return [String] the argument name
|
|
79
|
+
# @!attribute [rw] branches
|
|
80
|
+
# @return [Hash{Symbol => Array}] ordinal plural branches keyed by
|
|
81
|
+
# ordinal category (e.g. +:one+, +:two+, +:few+, +:other+) or
|
|
82
|
+
# exact-value keys like +:=1+
|
|
83
|
+
# @!attribute [rw] offset
|
|
84
|
+
# @return [Integer] value subtracted from the argument before
|
|
85
|
+
# categorisation (default: +0+)
|
|
86
|
+
SelectOrdinalNode = Struct.new(:name, :branches, :offset) do
|
|
87
|
+
# @param name [String]
|
|
88
|
+
# @param branches [Hash]
|
|
89
|
+
# @param offset [Integer]
|
|
90
|
+
def initialize(name, branches, offset = 0)
|
|
91
|
+
super(name, branches, offset)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18n
|
|
4
|
+
module MessageFormat
|
|
5
|
+
# Provides CLDR ordinal plural rules for use with +selectordinal+ arguments.
|
|
6
|
+
#
|
|
7
|
+
# Ordinal rules map a number to a category symbol (+:one+, +:two+, +:few+,
|
|
8
|
+
# +:other+, etc.) according to the Unicode CLDR ordinal plural rules for a
|
|
9
|
+
# given locale.
|
|
10
|
+
#
|
|
11
|
+
# Rules are installed into the active I18n backend under the
|
|
12
|
+
# +i18n.ordinal.rule+ key, where {Formatter} looks for them at runtime.
|
|
13
|
+
#
|
|
14
|
+
# @example Installing rules for English
|
|
15
|
+
# I18n::MessageFormat::OrdinalRules.install(:en)
|
|
16
|
+
#
|
|
17
|
+
# @example Installing all bundled rules
|
|
18
|
+
# I18n::MessageFormat::OrdinalRules.install_all
|
|
19
|
+
module OrdinalRules
|
|
20
|
+
# Built-in CLDR ordinal plural rules keyed by locale symbol.
|
|
21
|
+
#
|
|
22
|
+
# Each value is a +Proc+ that accepts an integer +n+ and returns the
|
|
23
|
+
# appropriate plural category symbol.
|
|
24
|
+
#
|
|
25
|
+
# @return [Hash{Symbol => Proc}]
|
|
26
|
+
RULES = {
|
|
27
|
+
en: lambda { |n|
|
|
28
|
+
mod10 = n % 10
|
|
29
|
+
mod100 = n % 100
|
|
30
|
+
if mod10 == 1 && mod100 != 11
|
|
31
|
+
:one
|
|
32
|
+
elsif mod10 == 2 && mod100 != 12
|
|
33
|
+
:two
|
|
34
|
+
elsif mod10 == 3 && mod100 != 13
|
|
35
|
+
:few
|
|
36
|
+
else
|
|
37
|
+
:other
|
|
38
|
+
end
|
|
39
|
+
}
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Installs the ordinal plural rule for +locale+ into the active I18n
|
|
43
|
+
# backend.
|
|
44
|
+
#
|
|
45
|
+
# Does nothing if no rule is defined for the given locale.
|
|
46
|
+
#
|
|
47
|
+
# @param locale [Symbol, String] the locale to install (e.g. +:en+)
|
|
48
|
+
# @return [void]
|
|
49
|
+
def self.install(locale)
|
|
50
|
+
rule = RULES[locale.to_sym]
|
|
51
|
+
return unless rule
|
|
52
|
+
|
|
53
|
+
::I18n.backend.store_translations(locale, { i18n: { ordinal: { rule: rule } } })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Installs ordinal plural rules for every locale in {RULES}.
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def self.install_all
|
|
60
|
+
RULES.each_key { |locale| install(locale) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|