tina4ruby 3.13.46 → 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: 46946847036ae1f2e6a3fede0084ffe84ab4144b7ec7a16570048b70994a7bee
4
- data.tar.gz: db9caef85f32313bf13190f4ed5c3501c7783e82a155b5e59b7f21a6f87bd2f9
3
+ metadata.gz: 9a325d3de2855893935edf93852d2ebc796def59bbb68dd668420da07de78f28
4
+ data.tar.gz: be1f417f6fe10f8e68c038a16839eb0a036bb8b1a15b58f67764ef1e1d31988d
5
5
  SHA512:
6
- metadata.gz: decedd4ef3df3a7ee7243e64975ecc76471c763ec18fe2f8d024e683e02e3329c016baeff41c680cc75747c90cda61b4581fd7370ce34629a141f3fd6faf8489
7
- data.tar.gz: fd1c3e6b602d540388a56b2a0288d903adafc4e137b232645e7ee3e871c20885edadc531528ce443bd800045c175734666db312efed827cb8cef4c550dcd9cb1
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)
@@ -366,59 +366,134 @@ module Tina4
366
366
  sql.gsub(SMART_QUOTE_RE, SMART_QUOTES)
367
367
  end
368
368
 
369
- # Split SQL into individual statements, handling:
370
- # - $$ delimited stored procedure blocks
371
- # - // delimited blocks
372
- # - Block comments /* ... */
373
- # - Line comments -- ...
374
- # Matches the Python/Node.js approach: extract blocks first, split on ;, restore blocks.
369
+ # Split SQL into individual statements with a single-pass, quote- and
370
+ # comment-aware scanner. The split decision is made character by character so
371
+ # the delimiter only ever fires in real statement position.
372
+ #
373
+ # This is the fix for issue #54: the old implementation split on +delimiter+
374
+ # BEFORE stripping +-- …+ line comments, so a +;+ inside a line comment
375
+ # fragmented one statement into several broken pieces. A scanner that knows
376
+ # where it is (code / comment / string) cannot make that mistake.
377
+ #
378
+ # Handled, in priority order, only when NOT already inside a stored-proc block:
379
+ # - $$ … $$ and // … // stored-proc blocks are kept intact (inner ; never
380
+ # splits). A // preceded by ':' is a URL scheme (https://…), not a delimiter.
381
+ # - /* … */ block comments are stripped.
382
+ # - -- … line comments are stripped to end of line (the newline is kept).
383
+ # - '…' single-quoted strings and "…" double-quoted identifiers are copied
384
+ # verbatim, honouring the SQL doubled-quote escape ('' / ""); a ;, -- or /*
385
+ # inside a literal is data, not a delimiter or comment.
386
+ # Mirrors the tina4-python _split_statements / tina4-php scanner (parity).
375
387
  def split_sql_statements(sql, delimiter = ";")
376
388
  # Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
377
389
  # an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs.
378
390
  sql = normalize_quotes(sql)
379
391
 
380
- blocks = []
392
+ statements = []
393
+ current = +""
394
+ n = sql.length
395
+ dlen = delimiter.length
396
+ i = 0
397
+ in_dollar_block = false
398
+ in_slash_block = false
399
+
400
+ while i < n
401
+ ch = sql[i]
402
+
403
+ # $$ … $$ stored-proc block (toggle).
404
+ if !in_slash_block && ch == "$" && i + 1 < n && sql[i + 1] == "$"
405
+ current << "$$"
406
+ i += 2
407
+ in_dollar_block = !in_dollar_block
408
+ next
409
+ end
381
410
 
382
- # Extract $$ ... $$ blocks (stored procedures, triggers, etc.)
383
- processed = sql.gsub(/\$\$(.*?)\$\$/m) do
384
- blocks << $~.to_s
385
- "__BLOCK_#{blocks.length - 1}__"
386
- end
411
+ # // // stored-proc block (toggle) but NOT a `://` URL scheme.
412
+ if !in_dollar_block && ch == "/" && i + 1 < n && sql[i + 1] == "/" &&
413
+ !(i.positive? && sql[i - 1] == ":")
414
+ current << "//"
415
+ i += 2
416
+ in_slash_block = !in_slash_block
417
+ next
418
+ end
387
419
 
388
- # Extract // ... // blocks (stored procedures, triggers, etc.). The `//`
389
- # delimiters must NOT be preceded by a colon, so a URL scheme
390
- # (`https://…`) or other `://` literal inside a migration is never
391
- # captured as an opaque stored-proc block (it would otherwise swallow
392
- # everything between two `//` occurrences and skip statement splitting).
393
- processed = processed.gsub(/(?<!:)\/\/(.*?)(?<!:)\/\//m) do
394
- blocks << $~.to_s
395
- "__BLOCK_#{blocks.length - 1}__"
396
- end
420
+ # Inside a stored-proc block: consume verbatim (inner ; never splits).
421
+ if in_dollar_block || in_slash_block
422
+ current << ch
423
+ i += 1
424
+ next
425
+ end
426
+
427
+ # Block comment /* … */ — stripped.
428
+ if ch == "/" && i + 1 < n && sql[i + 1] == "*"
429
+ endpos = sql.index("*/", i + 2)
430
+ i = endpos ? endpos + 2 : n
431
+ next
432
+ end
397
433
 
398
- # Remove block comments (/* ... */) but not inside stored proc blocks (already extracted)
399
- clean = processed.gsub(/\/\*.*?\*\//m, "")
434
+ # Line comment -- stripped to end of line; the newline is left for the
435
+ # next iteration so line structure (and NEXT-line boundaries) survive.
436
+ if ch == "-" && i + 1 < n && sql[i + 1] == "-"
437
+ endpos = sql.index("\n", i + 2)
438
+ i = endpos || n
439
+ next
440
+ end
400
441
 
401
- statements = []
402
- clean.split(delimiter).each do |stmt|
403
- lines = []
404
- stmt.split("\n").each do |line|
405
- stripped = line.strip
406
- next if stripped.empty? || stripped.start_with?("--")
407
- # Remove inline comments (-- after SQL)
408
- comment_pos = line.index("--")
409
- line = line[0...comment_pos] if comment_pos && comment_pos >= 0
410
- lines << line
442
+ # Single-quoted string literal — '' escapes a quote. Copied verbatim.
443
+ if ch == "'"
444
+ current << "'"
445
+ i += 1
446
+ while i < n
447
+ if sql[i] == "'" && i + 1 < n && sql[i + 1] == "'"
448
+ current << "''"
449
+ i += 2
450
+ elsif sql[i] == "'"
451
+ current << "'"
452
+ i += 1
453
+ break
454
+ else
455
+ current << sql[i]
456
+ i += 1
457
+ end
458
+ end
459
+ next
411
460
  end
412
- cleaned = lines.join("\n").strip
413
461
 
414
- # Restore block placeholders
415
- blocks.each_with_index do |block, i|
416
- cleaned = cleaned.gsub("__BLOCK_#{i}__", block)
462
+ # Double-quoted identifier — "" escapes a quote. Same verbatim handling.
463
+ if ch == '"'
464
+ current << '"'
465
+ i += 1
466
+ while i < n
467
+ if sql[i] == '"' && i + 1 < n && sql[i + 1] == '"'
468
+ current << '""'
469
+ i += 2
470
+ elsif sql[i] == '"'
471
+ current << '"'
472
+ i += 1
473
+ break
474
+ else
475
+ current << sql[i]
476
+ i += 1
477
+ end
478
+ end
479
+ next
480
+ end
481
+
482
+ # Statement delimiter — only reached outside blocks/comments/strings.
483
+ if !delimiter.empty? && sql[i, dlen] == delimiter
484
+ stmt = current.strip
485
+ statements << stmt unless stmt.empty?
486
+ current = +""
487
+ i += dlen
488
+ next
417
489
  end
418
490
 
419
- statements << cleaned unless cleaned.empty?
491
+ current << ch
492
+ i += 1
420
493
  end
421
494
 
495
+ stmt = current.strip
496
+ statements << stmt unless stmt.empty?
422
497
  statements
423
498
  end
424
499
 
@@ -103,6 +103,9 @@ module Tina4
103
103
  ""
104
104
  end
105
105
 
106
+ # Resolve #{ ... } interpolation (before $var substitution + nesting).
107
+ content = resolve_interpolation(content, variables)
108
+
106
109
  # Replace variable references
107
110
  variables.each do |name, value|
108
111
  content = content.gsub("$#{name}", value)
@@ -117,6 +120,22 @@ module Tina4
117
120
  content
118
121
  end
119
122
 
123
+ # Resolve SCSS #{ ... } interpolation. Each #{ expr } is replaced by its
124
+ # resolved inner text: a $variable inside the braces resolves to its value,
125
+ # anything else is inlined verbatim (trimmed). Lets a value carry a
126
+ # variable inside a string context plain $var substitution can't reach --
127
+ # e.g. calc(100% - #{$gap}) -> calc(100% - 20px) -- and lets a variable
128
+ # appear in a selector (.icon-#{$name} -> .icon-home). Run BEFORE nesting
129
+ # flatten so the literal braces never confuse the block matcher.
130
+ def resolve_interpolation(content, variables)
131
+ names = variables.keys.sort_by { |k| -k.length }
132
+ content.gsub(/#\{([^{}]*)\}/) do
133
+ inner = Regexp.last_match(1).strip
134
+ names.each { |name| inner = inner.gsub("$#{name}", variables[name]) }
135
+ inner
136
+ end
137
+ end
138
+
120
139
  def process_imports(content, base_dir)
121
140
  content.gsub(/@import\s+["'](.+?)["']\s*;/) do
122
141
  import_path = Regexp.last_match(1)
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.46"
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.46
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-24 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