better_translate 0.5.0 → 1.0.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 +4 -4
- data/.env.example +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/.yardopts +10 -0
- data/CHANGELOG.md +125 -114
- data/CLAUDE.md +385 -0
- data/README.md +629 -244
- data/Rakefile +7 -1
- data/Steepfile +29 -0
- data/docs/implementation/00-overview.md +220 -0
- data/docs/implementation/01-setup_dependencies.md +668 -0
- data/docs/implementation/02-error_handling.md +65 -0
- data/docs/implementation/03-core_components.md +457 -0
- data/docs/implementation/03.5-variable_preservation.md +509 -0
- data/docs/implementation/04-provider_architecture.md +571 -0
- data/docs/implementation/05-translation_logic.md +1065 -0
- data/docs/implementation/06-main_module_api.md +122 -0
- data/docs/implementation/07-direct_translation_helpers.md +582 -0
- data/docs/implementation/08-rails_integration.md +323 -0
- data/docs/implementation/09-testing_suite.md +228 -0
- data/docs/implementation/10-documentation_examples.md +150 -0
- data/docs/implementation/11-quality_security.md +65 -0
- data/docs/implementation/12-cli_standalone.md +698 -0
- data/exe/better_translate +9 -0
- data/lib/better_translate/cache.rb +125 -0
- data/lib/better_translate/cli.rb +304 -0
- data/lib/better_translate/configuration.rb +201 -0
- data/lib/better_translate/direct_translator.rb +131 -0
- data/lib/better_translate/errors.rb +101 -0
- data/lib/better_translate/progress_tracker.rb +157 -0
- data/lib/better_translate/provider_factory.rb +45 -0
- data/lib/better_translate/providers/anthropic_provider.rb +154 -0
- data/lib/better_translate/providers/base_http_provider.rb +239 -0
- data/lib/better_translate/providers/chatgpt_provider.rb +138 -44
- data/lib/better_translate/providers/gemini_provider.rb +123 -61
- data/lib/better_translate/railtie.rb +18 -0
- data/lib/better_translate/rate_limiter.rb +90 -0
- data/lib/better_translate/strategies/base_strategy.rb +58 -0
- data/lib/better_translate/strategies/batch_strategy.rb +56 -0
- data/lib/better_translate/strategies/deep_strategy.rb +45 -0
- data/lib/better_translate/strategies/strategy_selector.rb +43 -0
- data/lib/better_translate/translator.rb +115 -284
- data/lib/better_translate/utils/hash_flattener.rb +104 -0
- data/lib/better_translate/validator.rb +105 -0
- data/lib/better_translate/variable_extractor.rb +259 -0
- data/lib/better_translate/version.rb +2 -9
- data/lib/better_translate/yaml_handler.rb +168 -0
- data/lib/better_translate.rb +97 -73
- data/lib/generators/better_translate/analyze/USAGE +12 -0
- data/lib/generators/better_translate/analyze/analyze_generator.rb +94 -0
- data/lib/generators/better_translate/install/USAGE +13 -0
- data/lib/generators/better_translate/install/install_generator.rb +71 -0
- data/lib/generators/better_translate/install/templates/README +20 -0
- data/lib/generators/better_translate/install/templates/initializer.rb.tt +47 -0
- data/lib/generators/better_translate/translate/USAGE +13 -0
- data/lib/generators/better_translate/translate/translate_generator.rb +114 -0
- data/lib/tasks/better_translate.rake +136 -0
- data/sig/better_translate/cache.rbs +28 -0
- data/sig/better_translate/cli.rbs +24 -0
- data/sig/better_translate/configuration.rbs +78 -0
- data/sig/better_translate/direct_translator.rbs +18 -0
- data/sig/better_translate/errors.rbs +46 -0
- data/sig/better_translate/progress_tracker.rbs +29 -0
- data/sig/better_translate/provider_factory.rbs +8 -0
- data/sig/better_translate/providers/anthropic_provider.rbs +27 -0
- data/sig/better_translate/providers/base_http_provider.rbs +44 -0
- data/sig/better_translate/providers/chatgpt_provider.rbs +25 -0
- data/sig/better_translate/providers/gemini_provider.rbs +22 -0
- data/sig/better_translate/railtie.rbs +7 -0
- data/sig/better_translate/rate_limiter.rbs +20 -0
- data/sig/better_translate/strategies/base_strategy.rbs +19 -0
- data/sig/better_translate/strategies/batch_strategy.rbs +13 -0
- data/sig/better_translate/strategies/deep_strategy.rbs +11 -0
- data/sig/better_translate/strategies/strategy_selector.rbs +10 -0
- data/sig/better_translate/translator.rbs +24 -0
- data/sig/better_translate/utils/hash_flattener.rbs +14 -0
- data/sig/better_translate/validator.rbs +14 -0
- data/sig/better_translate/variable_extractor.rbs +40 -0
- data/sig/better_translate/version.rbs +4 -0
- data/sig/better_translate/yaml_handler.rbs +29 -0
- data/sig/better_translate.rbs +32 -2
- data/sig/faraday.rbs +22 -0
- data/sig/generators/better_translate/analyze/analyze_generator.rbs +18 -0
- data/sig/generators/better_translate/install/install_generator.rbs +14 -0
- data/sig/generators/better_translate/translate/translate_generator.rbs +10 -0
- data/sig/optparse.rbs +9 -0
- data/sig/psych.rbs +5 -0
- data/sig/rails.rbs +34 -0
- metadata +89 -203
- data/lib/better_translate/helper.rb +0 -83
- data/lib/better_translate/providers/base_provider.rb +0 -102
- data/lib/better_translate/service.rb +0 -144
- data/lib/better_translate/similarity_analyzer.rb +0 -218
- data/lib/better_translate/utils.rb +0 -55
- data/lib/better_translate/writer.rb +0 -75
- data/lib/generators/better_translate/analyze_generator.rb +0 -57
- data/lib/generators/better_translate/install_generator.rb +0 -14
- data/lib/generators/better_translate/templates/better_translate.rb +0 -56
- data/lib/generators/better_translate/translate_generator.rb +0 -84
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# 03.5 - Variable Preservation (Critical Feature)
|
|
2
|
+
|
|
3
|
+
[← Previous: 03-Core Components](./03-core_components.md) | [Back to Index](../../IMPLEMENTATION_PLAN.md) | [Next: 04-Provider Architecture →](./04-provider_architecture.md)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Variable Preservation System
|
|
8
|
+
|
|
9
|
+
**PRIORITY: CRITICAL** - This feature prevents bugs by ensuring interpolation variables are not translated or modified.
|
|
10
|
+
|
|
11
|
+
### Overview
|
|
12
|
+
|
|
13
|
+
When translating text that contains variables (e.g., `%{name}`, `{{user}}`), we must ensure these variables remain unchanged. This system extracts variables before translation, replaces them with safe placeholders, and restores them after translation.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 3.5.1 `lib/better_translate/variable_extractor.rb`
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# frozen_string_literal: true
|
|
21
|
+
|
|
22
|
+
module BetterTranslate
|
|
23
|
+
# Extracts and preserves interpolation variables during translation
|
|
24
|
+
#
|
|
25
|
+
# Supports multiple variable formats:
|
|
26
|
+
# - Rails I18n: %{name}, %{count}
|
|
27
|
+
# - I18n.js: {{user}}, {{email}}
|
|
28
|
+
# - ES6 templates: ${var}
|
|
29
|
+
# - Simple braces: {name}
|
|
30
|
+
#
|
|
31
|
+
# @example
|
|
32
|
+
# extractor = VariableExtractor.new("Hello %{name}, you have {{count}} messages")
|
|
33
|
+
# safe_text = extractor.extract
|
|
34
|
+
# #=> "Hello __VAR_0__, you have __VAR_1__ messages"
|
|
35
|
+
#
|
|
36
|
+
# translated = translate(safe_text) # "Ciao __VAR_0__, hai __VAR_1__ messaggi"
|
|
37
|
+
# final = extractor.restore(translated)
|
|
38
|
+
# #=> "Ciao %{name}, hai {{count}} messaggi"
|
|
39
|
+
#
|
|
40
|
+
class VariableExtractor
|
|
41
|
+
# Variable patterns to detect and preserve
|
|
42
|
+
VARIABLE_PATTERNS = {
|
|
43
|
+
rails: /%\{[^}]+\}/, # %{name}, %{count}
|
|
44
|
+
i18n_js: /\{\{[^}]+\}\}/, # {{user}}, {{email}}
|
|
45
|
+
es6: /\$\{[^}]+\}/, # ${var}
|
|
46
|
+
simple: /\{[a-zA-Z_][a-zA-Z0-9_]*\}/ # {name} but not {1,2,3}
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Combined pattern to match any variable format
|
|
50
|
+
COMBINED_PATTERN = Regexp.union(*VARIABLE_PATTERNS.values)
|
|
51
|
+
|
|
52
|
+
# Placeholder prefix
|
|
53
|
+
PLACEHOLDER_PREFIX = "__VAR_"
|
|
54
|
+
PLACEHOLDER_SUFFIX = "__"
|
|
55
|
+
|
|
56
|
+
# @return [String] Original text with variables
|
|
57
|
+
attr_reader :original_text
|
|
58
|
+
|
|
59
|
+
# @return [Array<String>] Extracted variables in order
|
|
60
|
+
attr_reader :variables
|
|
61
|
+
|
|
62
|
+
# @return [Hash<String, String>] Mapping of placeholders to original variables
|
|
63
|
+
attr_reader :placeholder_map
|
|
64
|
+
|
|
65
|
+
# Initialize extractor with text
|
|
66
|
+
#
|
|
67
|
+
# @param text [String] Text containing variables
|
|
68
|
+
def initialize(text)
|
|
69
|
+
@original_text = text
|
|
70
|
+
@variables = []
|
|
71
|
+
@placeholder_map = {}
|
|
72
|
+
@reverse_map = {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Extract variables and replace with placeholders
|
|
76
|
+
#
|
|
77
|
+
# @return [String] Text with variables replaced by placeholders
|
|
78
|
+
def extract
|
|
79
|
+
return original_text if original_text.nil? || original_text.empty?
|
|
80
|
+
|
|
81
|
+
result = original_text.dup
|
|
82
|
+
index = 0
|
|
83
|
+
|
|
84
|
+
# Find and replace all variables
|
|
85
|
+
result.gsub!(COMBINED_PATTERN) do |match|
|
|
86
|
+
placeholder = "#{PLACEHOLDER_PREFIX}#{index}#{PLACEHOLDER_SUFFIX}"
|
|
87
|
+
@variables << match
|
|
88
|
+
@placeholder_map[placeholder] = match
|
|
89
|
+
@reverse_map[match] = placeholder
|
|
90
|
+
index += 1
|
|
91
|
+
placeholder
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Restore variables from placeholders in translated text
|
|
98
|
+
#
|
|
99
|
+
# @param translated_text [String] Translated text with placeholders
|
|
100
|
+
# @param strict [Boolean] If true, raises error if variables are missing
|
|
101
|
+
# @return [String] Translated text with original variables restored
|
|
102
|
+
# @raise [ValidationError] if strict mode and variables are missing
|
|
103
|
+
def restore(translated_text, strict: true)
|
|
104
|
+
return translated_text if translated_text.nil? || translated_text.empty?
|
|
105
|
+
return translated_text if @placeholder_map.empty?
|
|
106
|
+
|
|
107
|
+
result = translated_text.dup
|
|
108
|
+
|
|
109
|
+
# Restore all placeholders
|
|
110
|
+
@placeholder_map.each do |placeholder, original_var|
|
|
111
|
+
result.gsub!(placeholder, original_var)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Validate all variables are present
|
|
115
|
+
if strict
|
|
116
|
+
validate_variables!(result)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
result
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if text contains variables
|
|
123
|
+
#
|
|
124
|
+
# @return [Boolean] true if variables are present
|
|
125
|
+
def variables?
|
|
126
|
+
!@variables.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get count of variables
|
|
130
|
+
#
|
|
131
|
+
# @return [Integer] Number of variables
|
|
132
|
+
def variable_count
|
|
133
|
+
@variables.size
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Validate that all original variables are present in text
|
|
137
|
+
#
|
|
138
|
+
# @param text [String] Text to validate
|
|
139
|
+
# @raise [ValidationError] if variables are missing or modified
|
|
140
|
+
# @return [true] if all variables are present
|
|
141
|
+
def validate_variables!(text)
|
|
142
|
+
missing = []
|
|
143
|
+
extra = []
|
|
144
|
+
|
|
145
|
+
# Check for missing variables
|
|
146
|
+
@variables.each do |var|
|
|
147
|
+
unless text.include?(var)
|
|
148
|
+
missing << var
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Check for extra/unknown variables (potential corruption)
|
|
153
|
+
text.scan(COMBINED_PATTERN).each do |var|
|
|
154
|
+
unless @variables.include?(var)
|
|
155
|
+
extra << var
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if missing.any? || extra.any?
|
|
160
|
+
error_msg = []
|
|
161
|
+
error_msg << "Missing variables: #{missing.join(', ')}" if missing.any?
|
|
162
|
+
error_msg << "Unexpected variables: #{extra.join(', ')}" if extra.any?
|
|
163
|
+
|
|
164
|
+
raise ValidationError.new(
|
|
165
|
+
"Variable validation failed: #{error_msg.join('; ')}",
|
|
166
|
+
context: {
|
|
167
|
+
original_variables: @variables,
|
|
168
|
+
missing: missing,
|
|
169
|
+
extra: extra,
|
|
170
|
+
text: text
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Extract variables from text without creating instance
|
|
179
|
+
#
|
|
180
|
+
# @param text [String] Text to analyze
|
|
181
|
+
# @return [Array<String>] List of variables found
|
|
182
|
+
def self.find_variables(text)
|
|
183
|
+
return [] if text.nil? || text.empty?
|
|
184
|
+
text.scan(COMBINED_PATTERN)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Check if text contains variables
|
|
188
|
+
#
|
|
189
|
+
# @param text [String] Text to check
|
|
190
|
+
# @return [Boolean] true if variables are present
|
|
191
|
+
def self.contains_variables?(text)
|
|
192
|
+
return false if text.nil? || text.empty?
|
|
193
|
+
text.match?(COMBINED_PATTERN)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 3.5.2 Integration with BaseHttpProvider
|
|
202
|
+
|
|
203
|
+
Update `lib/better_translate/providers/base_http_provider.rb` to use VariableExtractor:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# Add to BaseHttpProvider class
|
|
207
|
+
|
|
208
|
+
# Translate text with variable preservation
|
|
209
|
+
#
|
|
210
|
+
# @param text [String] Text to translate
|
|
211
|
+
# @param target_lang_code [String] Target language code
|
|
212
|
+
# @param target_lang_name [String] Target language name
|
|
213
|
+
# @param preserve_variables [Boolean] Whether to preserve variables (default: true)
|
|
214
|
+
# @return [String] Translated text
|
|
215
|
+
# @api private
|
|
216
|
+
def translate_with_variable_preservation(text, target_lang_code, target_lang_name, preserve_variables: true)
|
|
217
|
+
# If no variables or preservation disabled, translate directly
|
|
218
|
+
unless preserve_variables && VariableExtractor.contains_variables?(text)
|
|
219
|
+
return translate_text_raw(text, target_lang_code, target_lang_name)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Extract variables
|
|
223
|
+
extractor = VariableExtractor.new(text)
|
|
224
|
+
safe_text = extractor.extract
|
|
225
|
+
|
|
226
|
+
if config.verbose
|
|
227
|
+
puts "[VariablePreservation] Found #{extractor.variable_count} variables: #{extractor.variables.join(', ')}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Translate text with placeholders
|
|
231
|
+
translated = translate_text_raw(safe_text, target_lang_code, target_lang_name)
|
|
232
|
+
|
|
233
|
+
# Restore variables
|
|
234
|
+
begin
|
|
235
|
+
final_text = extractor.restore(translated, strict: true)
|
|
236
|
+
|
|
237
|
+
if config.verbose
|
|
238
|
+
puts "[VariablePreservation] ✓ All variables preserved"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
final_text
|
|
242
|
+
rescue ValidationError => e
|
|
243
|
+
# Log warning but return translation anyway (non-strict mode fallback)
|
|
244
|
+
warn "[VariablePreservation] WARNING: #{e.message}"
|
|
245
|
+
|
|
246
|
+
# Attempt non-strict restore
|
|
247
|
+
extractor.restore(translated, strict: false)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Raw translation without variable preservation (used internally)
|
|
252
|
+
#
|
|
253
|
+
# @param text [String] Text to translate
|
|
254
|
+
# @param target_lang_code [String] Target language code
|
|
255
|
+
# @param target_lang_name [String] Target language name
|
|
256
|
+
# @return [String] Translated text
|
|
257
|
+
# @api private
|
|
258
|
+
def translate_text_raw(text, target_lang_code, target_lang_name)
|
|
259
|
+
# Original translate_text implementation
|
|
260
|
+
# Subclasses implement this method
|
|
261
|
+
raise NotImplementedError, "#{self.class} must implement #translate_text_raw"
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Note:** Each provider (ChatGPT, Gemini, Anthropic) should rename their `translate_text` to `translate_text_raw` and use `translate_with_variable_preservation` as the public interface.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 3.5.3 Test: `spec/better_translate/variable_extractor_spec.rb`
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
# frozen_string_literal: true
|
|
273
|
+
|
|
274
|
+
RSpec.describe BetterTranslate::VariableExtractor do
|
|
275
|
+
describe "#extract" do
|
|
276
|
+
it "extracts Rails I18n variables" do
|
|
277
|
+
extractor = described_class.new("Hello %{name}, you have %{count} messages")
|
|
278
|
+
safe_text = extractor.extract
|
|
279
|
+
|
|
280
|
+
expect(safe_text).to eq("Hello __VAR_0__, you have __VAR_1__ messages")
|
|
281
|
+
expect(extractor.variables).to eq(["%{name}", "%{count}"])
|
|
282
|
+
expect(extractor.variable_count).to eq(2)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it "extracts I18n.js variables" do
|
|
286
|
+
extractor = described_class.new("Welcome {{user}}!")
|
|
287
|
+
safe_text = extractor.extract
|
|
288
|
+
|
|
289
|
+
expect(safe_text).to eq("Welcome __VAR_0__!")
|
|
290
|
+
expect(extractor.variables).to eq(["{{user}}"])
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "extracts ES6 template variables" do
|
|
294
|
+
extractor = described_class.new("Total: ${amount}")
|
|
295
|
+
safe_text = extractor.extract
|
|
296
|
+
|
|
297
|
+
expect(safe_text).to eq("Total: __VAR_0__")
|
|
298
|
+
expect(extractor.variables).to eq(["${amount}"])
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it "extracts simple brace variables" do
|
|
302
|
+
extractor = described_class.new("Hello {name}")
|
|
303
|
+
safe_text = extractor.extract
|
|
304
|
+
|
|
305
|
+
expect(safe_text).to eq("Hello __VAR_0__")
|
|
306
|
+
expect(extractor.variables).to eq(["{name}"])
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "extracts mixed variable formats" do
|
|
310
|
+
text = "Hi %{name}, you have {{count}} items (${total})"
|
|
311
|
+
extractor = described_class.new(text)
|
|
312
|
+
safe_text = extractor.extract
|
|
313
|
+
|
|
314
|
+
expect(extractor.variables).to eq(["%{name}", "{{count}}", "${total}"])
|
|
315
|
+
expect(safe_text).to eq("Hi __VAR_0__, you have __VAR_1__ items (__VAR_2__)")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it "handles text without variables" do
|
|
319
|
+
extractor = described_class.new("Hello world")
|
|
320
|
+
safe_text = extractor.extract
|
|
321
|
+
|
|
322
|
+
expect(safe_text).to eq("Hello world")
|
|
323
|
+
expect(extractor.variables).to be_empty
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
it "handles empty text" do
|
|
327
|
+
extractor = described_class.new("")
|
|
328
|
+
expect(extractor.extract).to eq("")
|
|
329
|
+
expect(extractor.variables).to be_empty
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
describe "#restore" do
|
|
334
|
+
it "restores variables to original format" do
|
|
335
|
+
extractor = described_class.new("Hello %{name}")
|
|
336
|
+
safe_text = extractor.extract
|
|
337
|
+
translated = "Ciao __VAR_0__"
|
|
338
|
+
|
|
339
|
+
restored = extractor.restore(translated)
|
|
340
|
+
expect(restored).to eq("Ciao %{name}")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it "restores multiple variables in correct positions" do
|
|
344
|
+
extractor = described_class.new("You have %{count} messages from {{user}}")
|
|
345
|
+
safe_text = extractor.extract
|
|
346
|
+
# Simulated translation that reorders placeholders
|
|
347
|
+
translated = "{{user}} ti ha inviato %{count} messaggi"
|
|
348
|
+
# Replace placeholders
|
|
349
|
+
translated = "__VAR_1__ ti ha inviato __VAR_0__ messaggi"
|
|
350
|
+
|
|
351
|
+
restored = extractor.restore(translated)
|
|
352
|
+
expect(restored).to eq("{{user}} ti ha inviato %{count} messaggi")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it "raises error in strict mode if variable is missing" do
|
|
356
|
+
extractor = described_class.new("Hello %{name}")
|
|
357
|
+
extractor.extract
|
|
358
|
+
translated = "Ciao" # Missing variable
|
|
359
|
+
|
|
360
|
+
expect {
|
|
361
|
+
extractor.restore(translated, strict: true)
|
|
362
|
+
}.to raise_error(BetterTranslate::ValidationError, /Missing variables: %\{name\}/)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it "does not raise in non-strict mode if variable is missing" do
|
|
366
|
+
extractor = described_class.new("Hello %{name}")
|
|
367
|
+
extractor.extract
|
|
368
|
+
translated = "Ciao"
|
|
369
|
+
|
|
370
|
+
expect {
|
|
371
|
+
extractor.restore(translated, strict: false)
|
|
372
|
+
}.not_to raise_error
|
|
373
|
+
|
|
374
|
+
expect(extractor.restore(translated, strict: false)).to eq("Ciao")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
it "detects unexpected variables" do
|
|
378
|
+
extractor = described_class.new("Hello world")
|
|
379
|
+
extractor.extract
|
|
380
|
+
translated = "Ciao %{name}" # Unexpected variable added
|
|
381
|
+
|
|
382
|
+
expect {
|
|
383
|
+
extractor.restore(translated, strict: true)
|
|
384
|
+
}.to raise_error(BetterTranslate::ValidationError, /Unexpected variables: %\{name\}/)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
describe ".find_variables" do
|
|
389
|
+
it "finds all variables in text" do
|
|
390
|
+
text = "Hi %{name}, {{count}} items"
|
|
391
|
+
variables = described_class.find_variables(text)
|
|
392
|
+
|
|
393
|
+
expect(variables).to contain_exactly("%{name}", "{{count}}")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
it "returns empty array for text without variables" do
|
|
397
|
+
expect(described_class.find_variables("Hello world")).to eq([])
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
describe ".contains_variables?" do
|
|
402
|
+
it "returns true if variables are present" do
|
|
403
|
+
expect(described_class.contains_variables?("Hello %{name}")).to be true
|
|
404
|
+
expect(described_class.contains_variables?("Hi {{user}}")).to be true
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
it "returns false if no variables" do
|
|
408
|
+
expect(described_class.contains_variables?("Hello world")).to be false
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
describe "#validate_variables!" do
|
|
413
|
+
it "passes validation if all variables are present" do
|
|
414
|
+
extractor = described_class.new("Hello %{name}")
|
|
415
|
+
extractor.extract
|
|
416
|
+
|
|
417
|
+
expect {
|
|
418
|
+
extractor.validate_variables!("Ciao %{name}")
|
|
419
|
+
}.not_to raise_error
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
it "fails validation if variables are missing" do
|
|
423
|
+
extractor = described_class.new("Hello %{name} and %{title}")
|
|
424
|
+
extractor.extract
|
|
425
|
+
|
|
426
|
+
expect {
|
|
427
|
+
extractor.validate_variables!("Ciao %{name}")
|
|
428
|
+
}.to raise_error(BetterTranslate::ValidationError)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## 3.5.4 Configuration Option
|
|
437
|
+
|
|
438
|
+
Add to `lib/better_translate/configuration.rb`:
|
|
439
|
+
|
|
440
|
+
```ruby
|
|
441
|
+
# @return [Boolean] Preserve interpolation variables during translation (default: true)
|
|
442
|
+
attr_accessor :preserve_variables
|
|
443
|
+
|
|
444
|
+
# In initialize method:
|
|
445
|
+
@preserve_variables = true
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## Usage Examples
|
|
451
|
+
|
|
452
|
+
### Example 1: Automatic Variable Preservation
|
|
453
|
+
|
|
454
|
+
```ruby
|
|
455
|
+
# Input YAML
|
|
456
|
+
en:
|
|
457
|
+
welcome: "Welcome %{name}!"
|
|
458
|
+
messages: "You have {{count}} new messages"
|
|
459
|
+
|
|
460
|
+
# After translation (Italian)
|
|
461
|
+
it:
|
|
462
|
+
welcome: "Benvenuto %{name}!"
|
|
463
|
+
messages: "Hai {{count}} nuovi messaggi"
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Example 2: Direct Translation with Variables
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
text = "Hello %{user}, your balance is ${amount}"
|
|
470
|
+
result = BetterTranslate.translate_text(text, from: "en", to: "it")
|
|
471
|
+
#=> "Ciao %{user}, il tuo saldo è ${amount}"
|
|
472
|
+
|
|
473
|
+
# Variables are automatically preserved!
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Example 3: Error Detection
|
|
477
|
+
|
|
478
|
+
```ruby
|
|
479
|
+
# If translation removes a variable
|
|
480
|
+
text = "Total: %{amount}"
|
|
481
|
+
# Translation accidentally returns: "Totale:" (missing %{amount})
|
|
482
|
+
#=> ValidationError raised with details about missing variable
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Benefits
|
|
488
|
+
|
|
489
|
+
1. **Bug Prevention**: Eliminates broken interpolations in translations
|
|
490
|
+
2. **Automatic**: Works transparently in all translation flows
|
|
491
|
+
3. **Multiple Formats**: Supports Rails, I18n.js, ES6, and simple braces
|
|
492
|
+
4. **Validation**: Detects missing or corrupted variables
|
|
493
|
+
5. **Configurable**: Can be disabled if needed
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## Implementation Checklist
|
|
498
|
+
|
|
499
|
+
- [ ] Create `lib/better_translate/variable_extractor.rb`
|
|
500
|
+
- [ ] Update `BaseHttpProvider` to use variable preservation
|
|
501
|
+
- [ ] Update all providers (ChatGPT, Gemini, Anthropic) to use `translate_text_raw`
|
|
502
|
+
- [ ] Add `preserve_variables` configuration option
|
|
503
|
+
- [ ] Create comprehensive test suite
|
|
504
|
+
- [ ] Update YARD documentation
|
|
505
|
+
- [ ] Add usage examples to README
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
[← Previous: 03-Core Components](./03-core_components.md) | [Back to Index](../../IMPLEMENTATION_PLAN.md) | [Next: 04-Provider Architecture →](./04-provider_architecture.md)
|