tina4ruby 3.13.47 → 3.13.48

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '008f9200b3c67bd44a13fba5bbfeb6895792ff8844ca01815fcb116adf1cf526'
4
- data.tar.gz: ff713e6d0bcefb5795d8a8f10eb5de37893bd8f24fb026bcff2b13128236467d
3
+ metadata.gz: 9a325d3de2855893935edf93852d2ebc796def59bbb68dd668420da07de78f28
4
+ data.tar.gz: be1f417f6fe10f8e68c038a16839eb0a036bb8b1a15b58f67764ef1e1d31988d
5
5
  SHA512:
6
- metadata.gz: b8f63842da92b8084f4adec4920cf4d969d7c344f0d7db43bdea734f513f3eade99be7ffc63af89176f071c7fc411b9d3e083f96b79859415986a855967deb75
7
- data.tar.gz: 1aa428df186321c9640cf4b790a9657619ba5c96c8de14016b4f805ba64fe19440ba7ffe8ec60104a7505f81b6d8c1a5cb7677489c844edbf54e9e82ced042f5
6
+ metadata.gz: 9c5da9a717c386c9e36a5fd5cb1d347d7bf5baa660c8efaee3f564dc37acadb3398f98a81c73d90885b71e1571e9edc93896cf1921584953b80372ccd3dca02d
7
+ data.tar.gz: 5ccc156283bcdb8fc2e176b7342358cc43adde6c68191bf605e2e8e7774fd8d7c574798a589d14647ea75fb82d7a9d81ab01e1ed0ed9d8bf07e7bb60340e8317
@@ -24,38 +24,19 @@ module Tina4
24
24
  @current_locale = locale.to_s
25
25
  end
26
26
 
27
- def load(root_dir = Dir.pwd)
28
- locale_dir_override = ENV["TINA4_LOCALE_DIR"]
29
- search_dirs = locale_dir_override && !locale_dir_override.empty? ? [locale_dir_override] : LOCALE_DIRS
30
-
31
- search_dirs.each do |dir|
32
- locale_dir = File.expand_path(dir, root_dir)
33
- next unless Dir.exist?(locale_dir)
27
+ # The fallback locale used when a key is missing in the current locale.
28
+ # Mirrors the Python master (TINA4_LOCALE, else "en").
29
+ def default_locale
30
+ ENV["TINA4_LOCALE"] || "en"
31
+ end
34
32
 
33
+ def load(root_dir = Dir.pwd)
34
+ locale_search_dirs(root_dir).each do |locale_dir|
35
35
  Dir.glob(File.join(locale_dir, "*.json")).each do |file|
36
- locale = File.basename(file, ".json")
37
- data = JSON.parse(File.read(file))
38
- translations[locale] ||= {}
39
- translations[locale].merge!(data)
40
- # Build leaf-key aliases from the loaded data
41
- build_leaf_aliases(locale, data)
42
- Tina4::Log.debug("Loaded locale: #{locale} from #{file}")
36
+ ingest_json_file(File.basename(file, ".json"), file)
43
37
  end
44
-
45
- # Also support YAML
46
38
  Dir.glob(File.join(locale_dir, "*.{yml,yaml}")).each do |file|
47
- begin
48
- require "yaml"
49
- locale = File.basename(file, File.extname(file))
50
- data = YAML.safe_load(File.read(file))
51
- if data.is_a?(Hash)
52
- translations[locale] ||= {}
53
- translations[locale].merge!(data)
54
- build_leaf_aliases(locale, data)
55
- end
56
- rescue LoadError
57
- Tina4::Log.warning("YAML support requires the 'yaml' gem")
58
- end
39
+ ingest_yaml_file(File.basename(file, File.extname(file)), file)
59
40
  end
60
41
  end
61
42
  end
@@ -64,22 +45,53 @@ module Tina4
64
45
  lang = locale || current_locale
65
46
  value = lookup(lang, key)
66
47
 
67
- if value.nil? && lang != "en"
68
- value = lookup("en", key)
48
+ # Fallback: current locale -> default/fallback locale -> the key itself.
49
+ if value.nil? && lang != default_locale
50
+ value = lookup(default_locale, key)
69
51
  end
70
52
 
71
53
  value = default || key if value.nil?
72
54
 
73
- # Interpolation: "Hello %{name}" => "Hello World"
74
- interpolations.each do |k, v|
75
- value = value.gsub("%{#{k}}", v.to_s)
76
- end
77
-
78
- value
55
+ # Interpolation: "Hello {name}" => "Hello World". PARTIAL — each {name}
56
+ # token present in interpolations is replaced; a missing param's
57
+ # placeholder is left literal, and a malformed placeholder ({x.y} with a
58
+ # dot, {n:d} with a spec, a lone unmatched brace) is left literal too.
59
+ # Never raises — a bad template must not crash t().
60
+ interpolate(value, interpolations)
79
61
  end
80
62
 
81
63
  def set_locale(locale)
82
64
  self.current_locale = locale.to_s
65
+ # Lazily load the new locale's file if it hasn't been loaded yet, so a
66
+ # switch to an unloaded locale resolves its keys (parity with Python/PHP/
67
+ # Node, where setting the locale triggers a per-locale load).
68
+ load_locale(locale.to_s) unless translations.key?(locale.to_s)
69
+ current_locale
70
+ end
71
+
72
+ # Load a SINGLE locale's file (JSON, then YAML) from the configured search
73
+ # dirs if it is not already loaded. Boot-safe (a malformed file is skipped,
74
+ # never raised) — mirrors Python's _load_locale.
75
+ def load_locale(locale, root_dir = Dir.pwd)
76
+ locale = locale.to_s
77
+ return if translations.key?(locale)
78
+
79
+ locale_search_dirs(root_dir).each do |locale_dir|
80
+ json = File.join(locale_dir, "#{locale}.json")
81
+ if File.file?(json)
82
+ ingest_json_file(locale, json)
83
+ return
84
+ end
85
+ %w[yml yaml].each do |ext|
86
+ yaml = File.join(locale_dir, "#{locale}.#{ext}")
87
+ if File.file?(yaml)
88
+ ingest_yaml_file(locale, yaml)
89
+ return
90
+ end
91
+ end
92
+ end
93
+ # No file found — cache an empty table so lookups fall back to the key.
94
+ translations[locale] ||= {}
83
95
  end
84
96
 
85
97
  def get_locale
@@ -117,12 +129,102 @@ module Tina4
117
129
  end
118
130
  end
119
131
 
120
- def available_locales
121
- translations.keys
132
+ # List available locale codes by scanning the configured locale dir(s) for
133
+ # *.json / *.yml / *.yaml file stems, sorted. When no dir exists/has files,
134
+ # returns a [default_locale] floor (parity with Python/PHP/Node — never an
135
+ # empty list when a default locale is known).
136
+ def available_locales(root_dir = Dir.pwd)
137
+ stems = []
138
+ locale_search_dirs(root_dir).each do |locale_dir|
139
+ %w[*.json *.yml *.yaml].each do |pattern|
140
+ Dir.glob(File.join(locale_dir, pattern)).each do |file|
141
+ stems << File.basename(file, File.extname(file))
142
+ end
143
+ end
144
+ end
145
+ stems.uniq!
146
+ stems.empty? ? [default_locale] : stems.sort
122
147
  end
123
148
 
124
149
  private
125
150
 
151
+ # The locale directories to scan, in order: TINA4_LOCALE_DIR override (when
152
+ # set) else the built-in LOCALE_DIRS list, each resolved against root_dir,
153
+ # filtered to those that actually exist.
154
+ def locale_search_dirs(root_dir = Dir.pwd)
155
+ override = ENV["TINA4_LOCALE_DIR"]
156
+ dirs = override && !override.empty? ? [override] : LOCALE_DIRS
157
+ dirs.map { |dir| File.expand_path(dir, root_dir) }.select { |d| Dir.exist?(d) }
158
+ end
159
+
160
+ # Parse one JSON locale file into translations + leaf aliases. Boot-safe:
161
+ # a malformed/unreadable file is logged, skipped, and cached empty so a
162
+ # bad file never crashes boot and is never retried into a partial state.
163
+ def ingest_json_file(locale, file)
164
+ data = JSON.parse(File.read(file))
165
+ translations[locale] ||= {}
166
+ translations[locale].merge!(data)
167
+ build_leaf_aliases(locale, data)
168
+ Tina4::Log.debug("Loaded locale: #{locale} from #{file}")
169
+ rescue JSON::ParserError, IOError, SystemCallError, StandardError => e
170
+ Tina4::Log.error("Skipping malformed locale file #{file}: #{e.class}: #{e.message}")
171
+ translations[locale] ||= {}
172
+ end
173
+
174
+ # Parse one YAML locale file into translations + leaf aliases. Same
175
+ # boot-safe policy as ingest_json_file (Psych::SyntaxError is the YAML
176
+ # parse error; the broad StandardError is a final safety net).
177
+ def ingest_yaml_file(locale, file)
178
+ require "yaml"
179
+ data = YAML.safe_load(File.read(file))
180
+ if data.is_a?(Hash)
181
+ translations[locale] ||= {}
182
+ translations[locale].merge!(data)
183
+ build_leaf_aliases(locale, data)
184
+ end
185
+ rescue LoadError
186
+ Tina4::Log.warning("YAML support requires the 'yaml' gem")
187
+ rescue Psych::SyntaxError, IOError, SystemCallError, StandardError => e
188
+ Tina4::Log.error("Skipping malformed locale file #{file}: #{e.class}: #{e.message}")
189
+ translations[locale] ||= {}
190
+ end
191
+
192
+ # {name} placeholder. Matches Python's _PLACEHOLDER = \{(\w+)\}, so only a
193
+ # bare word inside braces is a candidate — {x.y} (a dot) and {n:d} (a spec)
194
+ # never match and are left literal, and a lone unmatched brace is untouched.
195
+ PLACEHOLDER = /\{(\w+)\}/.freeze
196
+
197
+ # Substitute {name} placeholders from params. PARTIAL + literal-leftover:
198
+ # a placeholder present in params is replaced; a missing or malformed
199
+ # placeholder is left untouched. Never raises (a bad template must not
200
+ # crash t()). Params may be keyed by symbol (kwargs) or string.
201
+ def interpolate(template, params)
202
+ return template unless template.is_a?(String)
203
+ return template if params.nil? || params.empty?
204
+
205
+ template.gsub(PLACEHOLDER) do
206
+ name = Regexp.last_match(1)
207
+ if params.key?(name.to_sym)
208
+ params[name.to_sym].to_s
209
+ elsif params.key?(name)
210
+ params[name].to_s
211
+ else
212
+ Regexp.last_match(0)
213
+ end
214
+ end
215
+ end
216
+
217
+ # Render a non-string locale scalar JSON-natively: boolean true/false ->
218
+ # "true"/"false", null/nil -> "null", numbers as their plain form ("42").
219
+ def coerce_scalar(value)
220
+ case value
221
+ when true then "true"
222
+ when false then "false"
223
+ when nil then "null"
224
+ else value.to_s
225
+ end
226
+ end
227
+
126
228
  # Recursively walk a nested hash and register leaf-key aliases.
127
229
  # First-wins: if a leaf key already exists, it is NOT overwritten.
128
230
  def build_leaf_aliases(locale, hash, prefix = nil)
@@ -132,30 +234,34 @@ module Tina4
132
234
  if value.is_a?(Hash)
133
235
  build_leaf_aliases(locale, value, full_key)
134
236
  else
135
- # Store the leaf key as an alias (first-wins)
136
- flat_aliases[locale.to_s][key.to_s] ||= value
237
+ # Store the leaf key as an alias (first-wins). Coerce non-string
238
+ # JSON-native scalars (true/false/null/number) to their string form.
239
+ flat_aliases[locale.to_s][key.to_s] ||= coerce_scalar(value)
137
240
  end
138
241
  end
139
242
  end
140
243
 
141
244
  def lookup(locale, key)
142
245
  keys = key.to_s.split(".")
143
- result = translations[locale]
144
- return nil unless result
246
+ node = translations[locale]
247
+ return nil unless node
145
248
 
146
- # Try dot-path traversal first
147
- dot_result = result
249
+ # Try dot-path traversal first. Track found-ness explicitly so a leaf
250
+ # whose value is false/nil is still recognised as resolved.
251
+ found = true
148
252
  keys.each do |k|
149
- if dot_result.is_a?(Hash)
150
- dot_result = dot_result[k] || dot_result[k.to_sym]
253
+ if node.is_a?(Hash) && (node.key?(k) || node.key?(k.to_sym))
254
+ node = node.key?(k) ? node[k] : node[k.to_sym]
151
255
  else
152
- dot_result = nil
256
+ found = false
153
257
  break
154
258
  end
155
259
  end
156
- return dot_result if dot_result.is_a?(String)
260
+ # A resolved leaf (string OR JSON-native scalar) wins. An intermediate
261
+ # Hash is not a value — fall through to the leaf alias instead.
262
+ return coerce_scalar(node) if found && !node.is_a?(Hash)
157
263
 
158
- # Fall back to leaf-key alias (only for simple keys without dots)
264
+ # Fall back to leaf-key alias (stored already-coerced as a string).
159
265
  if flat_aliases[locale]
160
266
  alias_val = flat_aliases[locale][key.to_s]
161
267
  return alias_val if alias_val.is_a?(String)
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.47"
4
+ VERSION = "3.13.48"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.47
4
+ version: 3.13.48
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-25 00:00:00.000000000 Z
11
+ date: 2026-06-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack