traductor 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/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +252 -0
- data/Rakefile +12 -0
- data/assets/traductor-logo.svg +37 -0
- data/exe/traductor +7 -0
- data/lib/traductor/batch_builder.rb +31 -0
- data/lib/traductor/cli.rb +185 -0
- data/lib/traductor/configuration.rb +41 -0
- data/lib/traductor/diff.rb +44 -0
- data/lib/traductor/errors.rb +8 -0
- data/lib/traductor/glossary.rb +35 -0
- data/lib/traductor/interpolation_guard.rb +46 -0
- data/lib/traductor/key_flattener.rb +38 -0
- data/lib/traductor/locale_file.rb +65 -0
- data/lib/traductor/prompt_builder.rb +75 -0
- data/lib/traductor/result.rb +24 -0
- data/lib/traductor/translator.rb +146 -0
- data/lib/traductor/version.rb +5 -0
- data/lib/traductor.rb +43 -0
- data/sig/traductor.rbs +4 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6cf478916abe688a2b19d099ab687a4a2a212183c45075640c75929722c9d528
|
|
4
|
+
data.tar.gz: 45508818d262d06d5bc04db0c54a45b8137a88eb9a539a1426a5c7dd85c83f53
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 532e43c9a7d48e843b9489287c93965c57ffb37778aae126ddbc29a16c0b2919de9452749a73ac91fa3b78f9eb11589a62030897902b1e8ed28e8706a38e2166
|
|
7
|
+
data.tar.gz: 448cdb7685fe96afff23c2d5bebd62500fedb54718b6ba8778b58abec61a1e9abb41267c557d536744adab1f3321823f12efafba3812ecc0474ebe110e0538cf
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alvaro Delgado
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/traductor-logo.svg" alt="Traductor" width="400">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://rubygems.org/gems/traductor"><img src="https://img.shields.io/gem/v/traductor.svg?color=e74c3c" alt="Gem Version"></a>
|
|
7
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
|
|
8
|
+
<a href="https://ruby-doc.org/core-3.1.0/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-red.svg" alt="Ruby >= 3.1"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
**Traductor** is an AI-powered locale file translator for Ruby applications. It uses [RubyLLM](https://github.com/crmne/ruby_llm) under the hood, so you can translate with **any LLM provider** — OpenAI, Anthropic, AWS Bedrock, Google Gemini, and more.
|
|
14
|
+
|
|
15
|
+
Works with **any framework**: Rails (YAML), React/Next.js (JSON), or anything that uses standard locale files.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Model-agnostic** — Use GPT-4, Claude, Gemini, Llama, or any model supported by RubyLLM
|
|
20
|
+
- **Incremental translation** — Only translates new and missing keys, saving time and cost
|
|
21
|
+
- **Interpolation protection** — Safely preserves `%{name}`, `{{variable}}`, and `${value}` placeholders
|
|
22
|
+
- **Glossary support** — Define project-specific terminology for consistent translations
|
|
23
|
+
- **YAML + JSON** — Supports Rails i18n YAML and JSON locale files out of the box
|
|
24
|
+
- **CLI + Ruby API** — Use from the command line or programmatically in your code
|
|
25
|
+
- **Smart batching** — Groups related keys together for contextually consistent translations
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add to your Gemfile:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "traductor"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then run:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
bundle install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install directly:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
gem install traductor
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
### 1. Initialize configuration
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
traductor init
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This creates a `.traductor.yml` in your project root:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
source_locale: en
|
|
59
|
+
target_locales:
|
|
60
|
+
- es
|
|
61
|
+
- fr
|
|
62
|
+
|
|
63
|
+
source_paths:
|
|
64
|
+
- config/locales/en.yml
|
|
65
|
+
|
|
66
|
+
# model: gpt-4.1-mini
|
|
67
|
+
# glossary_path: .traductor-glossary.yml
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 2. Configure your LLM provider
|
|
71
|
+
|
|
72
|
+
Traductor uses RubyLLM, so configure your provider's API key:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# Pick one (or more):
|
|
76
|
+
export OPENAI_API_KEY="sk-..."
|
|
77
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
78
|
+
export GEMINI_API_KEY="..."
|
|
79
|
+
export AWS_ACCESS_KEY_ID="..." && export AWS_SECRET_ACCESS_KEY="..."
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3. Translate
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
# Preview what will be translated
|
|
86
|
+
traductor translate --dry-run
|
|
87
|
+
|
|
88
|
+
# Translate to all configured locales
|
|
89
|
+
traductor translate
|
|
90
|
+
|
|
91
|
+
# Translate to specific locales
|
|
92
|
+
traductor translate --targets es fr de ja
|
|
93
|
+
|
|
94
|
+
# Translate a specific file
|
|
95
|
+
traductor translate --source config/locales/en.yml --targets es
|
|
96
|
+
|
|
97
|
+
# Use a specific model
|
|
98
|
+
traductor translate --model claude-sonnet-4-5
|
|
99
|
+
|
|
100
|
+
# Force full re-translation (ignore existing translations)
|
|
101
|
+
traductor translate --full
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 4. Check differences
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
# See what keys need translation
|
|
108
|
+
traductor diff --source config/locales/en.yml --target es
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## CLI Reference
|
|
112
|
+
|
|
113
|
+
| Command | Description |
|
|
114
|
+
|---------|-------------|
|
|
115
|
+
| `traductor init` | Generate `.traductor.yml` configuration |
|
|
116
|
+
| `traductor translate` | Translate locale files |
|
|
117
|
+
| `traductor diff` | Show translation differences |
|
|
118
|
+
| `traductor version` | Show version |
|
|
119
|
+
|
|
120
|
+
### `traductor translate` options
|
|
121
|
+
|
|
122
|
+
| Option | Description |
|
|
123
|
+
|--------|-------------|
|
|
124
|
+
| `--source PATH` | Source file path (overrides config) |
|
|
125
|
+
| `--targets es fr de` | Target locale codes (overrides config) |
|
|
126
|
+
| `--model MODEL` | LLM model to use (overrides config) |
|
|
127
|
+
| `--output DIR` | Output directory (overrides config) |
|
|
128
|
+
| `--full` | Force full re-translation |
|
|
129
|
+
| `--dry-run` | Preview without calling the LLM |
|
|
130
|
+
| `--config PATH` | Config file path (default: `.traductor.yml`) |
|
|
131
|
+
|
|
132
|
+
## Ruby API
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
require "traductor"
|
|
136
|
+
|
|
137
|
+
# Configure
|
|
138
|
+
Traductor.configure do |config|
|
|
139
|
+
config.source_locale = "en"
|
|
140
|
+
config.model = "gpt-4.1-mini"
|
|
141
|
+
config.temperature = 0.3
|
|
142
|
+
config.batch_size = 30
|
|
143
|
+
config.glossary_path = ".traductor-glossary.yml"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Translate
|
|
147
|
+
result = Traductor.translate(
|
|
148
|
+
"config/locales/en.yml",
|
|
149
|
+
target_locales: ["es", "fr", "de"]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
result.success? # => true
|
|
153
|
+
result.locales # => ["es", "fr", "de"]
|
|
154
|
+
result.path_for("es") # => "config/locales/es.yml"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Glossary
|
|
158
|
+
|
|
159
|
+
Create a `.traductor-glossary.yml` to enforce consistent terminology:
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
"Sign up":
|
|
163
|
+
es: "Registrarse"
|
|
164
|
+
fr: "S'inscrire"
|
|
165
|
+
de: "Registrieren"
|
|
166
|
+
|
|
167
|
+
"Dashboard":
|
|
168
|
+
es: "Panel de control"
|
|
169
|
+
fr: "Tableau de bord"
|
|
170
|
+
de: "Dashboard"
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Glossary terms are injected into the LLM prompt so translations always use your preferred terminology.
|
|
174
|
+
|
|
175
|
+
## How It Works
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
Source locale file (en.yml / en.json)
|
|
179
|
+
│
|
|
180
|
+
├─ Parse & flatten nested keys to dot-notation
|
|
181
|
+
├─ Diff against existing target (incremental mode)
|
|
182
|
+
├─ Protect interpolation variables (%{name} → __TVAR0__)
|
|
183
|
+
├─ Batch related keys by namespace
|
|
184
|
+
├─ Build prompt with glossary + translation rules
|
|
185
|
+
├─ Send to LLM via RubyLLM
|
|
186
|
+
├─ Parse JSON response
|
|
187
|
+
├─ Restore interpolation variables (__TVAR0__ → %{name})
|
|
188
|
+
├─ Merge with existing translations
|
|
189
|
+
└─ Write target locale file (es.yml / es.json)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Configuration
|
|
193
|
+
|
|
194
|
+
Full `.traductor.yml` reference:
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
# Source locale code
|
|
198
|
+
source_locale: en
|
|
199
|
+
|
|
200
|
+
# Target locales to translate into
|
|
201
|
+
target_locales:
|
|
202
|
+
- es
|
|
203
|
+
- fr
|
|
204
|
+
- de
|
|
205
|
+
- ja
|
|
206
|
+
- pt-BR
|
|
207
|
+
|
|
208
|
+
# Source file paths (supports glob patterns)
|
|
209
|
+
source_paths:
|
|
210
|
+
- config/locales/en.yml
|
|
211
|
+
- config/locales/models/en.yml
|
|
212
|
+
|
|
213
|
+
# Output directory (defaults to same directory as source)
|
|
214
|
+
# output_dir: config/locales
|
|
215
|
+
|
|
216
|
+
# LLM model (any model supported by RubyLLM)
|
|
217
|
+
# model: gpt-4.1-mini
|
|
218
|
+
|
|
219
|
+
# Temperature (lower = more consistent, higher = more creative)
|
|
220
|
+
# temperature: 0.3
|
|
221
|
+
|
|
222
|
+
# Max keys per LLM request
|
|
223
|
+
# batch_size: 30
|
|
224
|
+
|
|
225
|
+
# Path to glossary file
|
|
226
|
+
# glossary_path: .traductor-glossary.yml
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Supported Interpolation Formats
|
|
230
|
+
|
|
231
|
+
| Format | Example | Framework |
|
|
232
|
+
|--------|---------|-----------|
|
|
233
|
+
| Ruby/Rails | `%{name}` | Rails i18n |
|
|
234
|
+
| Handlebars | `{{variable}}` | React, Ember |
|
|
235
|
+
| Template literals | `${value}` | JavaScript/ES6 |
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
```sh
|
|
240
|
+
git clone https://github.com/AAlvAAro/traductor.git
|
|
241
|
+
cd traductor
|
|
242
|
+
bin/setup
|
|
243
|
+
bundle exec rspec
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Contributing
|
|
247
|
+
|
|
248
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/AAlvAAro/traductor).
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
Released under the [MIT License](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 120" fill="none">
|
|
2
|
+
<!-- Background -->
|
|
3
|
+
<rect width="400" height="120" rx="12" fill="#1a1a2e"/>
|
|
4
|
+
|
|
5
|
+
<!-- Globe icon -->
|
|
6
|
+
<circle cx="60" cy="60" r="32" stroke="#e74c3c" stroke-width="2.5" fill="none" opacity="0.9"/>
|
|
7
|
+
<ellipse cx="60" cy="60" rx="14" ry="32" stroke="#e74c3c" stroke-width="2" fill="none" opacity="0.7"/>
|
|
8
|
+
<line x1="28" y1="60" x2="92" y2="60" stroke="#e74c3c" stroke-width="1.5" opacity="0.5"/>
|
|
9
|
+
<line x1="28" y1="44" x2="92" y2="44" stroke="#e74c3c" stroke-width="1" opacity="0.3"/>
|
|
10
|
+
<line x1="28" y1="76" x2="92" y2="76" stroke="#e74c3c" stroke-width="1" opacity="0.3"/>
|
|
11
|
+
|
|
12
|
+
<!-- Translation arrows -->
|
|
13
|
+
<path d="M 80 38 L 95 38" stroke="#3498db" stroke-width="2" stroke-linecap="round"/>
|
|
14
|
+
<path d="M 91 34 L 95 38 L 91 42" stroke="#3498db" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
15
|
+
|
|
16
|
+
<path d="M 80 82 L 95 82" stroke="#2ecc71" stroke-width="2" stroke-linecap="round"/>
|
|
17
|
+
<path d="M 91 78 L 95 82 L 91 86" stroke="#2ecc71" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
18
|
+
|
|
19
|
+
<!-- Small speech bubbles -->
|
|
20
|
+
<rect x="97" y="30" width="16" height="12" rx="3" fill="#3498db" opacity="0.8"/>
|
|
21
|
+
<text x="101" y="40" font-family="monospace" font-size="7" fill="white" font-weight="bold">EN</text>
|
|
22
|
+
|
|
23
|
+
<rect x="97" y="74" width="16" height="12" rx="3" fill="#2ecc71" opacity="0.8"/>
|
|
24
|
+
<text x="101" y="84" font-family="monospace" font-size="7" fill="white" font-weight="bold">ES</text>
|
|
25
|
+
|
|
26
|
+
<!-- Gem name -->
|
|
27
|
+
<text x="130" y="55" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="32" fill="white" font-weight="700" letter-spacing="-0.5">traductor</text>
|
|
28
|
+
|
|
29
|
+
<!-- Tagline -->
|
|
30
|
+
<text x="130" y="78" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="13" fill="#888" font-weight="400">AI-powered locale translator</text>
|
|
31
|
+
|
|
32
|
+
<!-- Ruby gem badge -->
|
|
33
|
+
<rect x="130" y="86" width="46" height="16" rx="4" fill="#e74c3c" opacity="0.15"/>
|
|
34
|
+
<text x="136" y="97" font-family="monospace" font-size="9" fill="#e74c3c" opacity="0.9">ruby</text>
|
|
35
|
+
<rect x="182" y="86" width="86" height="16" rx="4" fill="#3498db" opacity="0.15"/>
|
|
36
|
+
<text x="188" y="97" font-family="monospace" font-size="9" fill="#3498db" opacity="0.9">model-agnostic</text>
|
|
37
|
+
</svg>
|
data/exe/traductor
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Traductor
|
|
4
|
+
class BatchBuilder
|
|
5
|
+
def initialize(flat_keys, batch_size:)
|
|
6
|
+
@flat_keys = flat_keys
|
|
7
|
+
@batch_size = batch_size
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Groups keys by their top-level namespace, then splits into
|
|
11
|
+
# batches of at most batch_size entries.
|
|
12
|
+
def build
|
|
13
|
+
grouped = group_by_namespace
|
|
14
|
+
batches = []
|
|
15
|
+
|
|
16
|
+
grouped.each_value do |keys_hash|
|
|
17
|
+
keys_hash.each_slice(@batch_size) do |slice|
|
|
18
|
+
batches << slice.to_h
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
batches.reject(&:empty?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def group_by_namespace
|
|
28
|
+
@flat_keys.group_by { |key, _value| key.split(".").first }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Traductor
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
desc "init", "Create a .traductor.yml configuration file"
|
|
8
|
+
option :source, type: :string, default: "en", desc: "Source locale code"
|
|
9
|
+
def init
|
|
10
|
+
if File.exist?(".traductor.yml")
|
|
11
|
+
say ".traductor.yml already exists", :yellow
|
|
12
|
+
return
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
content = <<~YAML
|
|
16
|
+
# Traductor configuration
|
|
17
|
+
source_locale: #{options[:source]}
|
|
18
|
+
target_locales:
|
|
19
|
+
- es
|
|
20
|
+
- fr
|
|
21
|
+
|
|
22
|
+
# Source file paths (glob patterns supported)
|
|
23
|
+
source_paths:
|
|
24
|
+
- config/locales/#{options[:source]}.yml
|
|
25
|
+
|
|
26
|
+
# Output directory (defaults to same directory as source file)
|
|
27
|
+
# output_dir: config/locales
|
|
28
|
+
|
|
29
|
+
# LLM settings (any model supported by RubyLLM)
|
|
30
|
+
# model: gpt-4.1-mini
|
|
31
|
+
# temperature: 0.3
|
|
32
|
+
|
|
33
|
+
# Translation settings
|
|
34
|
+
# batch_size: 30
|
|
35
|
+
# glossary_path: .traductor-glossary.yml
|
|
36
|
+
YAML
|
|
37
|
+
|
|
38
|
+
File.write(".traductor.yml", content)
|
|
39
|
+
say "Created .traductor.yml", :green
|
|
40
|
+
say "Edit the file to configure your locales and source paths."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc "translate", "Translate source locale file(s) to target languages"
|
|
44
|
+
option :source, type: :string, desc: "Source file path (overrides config)"
|
|
45
|
+
option :targets, type: :array, desc: "Target locale codes (overrides config)"
|
|
46
|
+
option :model, type: :string, desc: "LLM model to use (overrides config)"
|
|
47
|
+
option :full, type: :boolean, default: false, desc: "Force full re-translation"
|
|
48
|
+
option :config, type: :string, default: ".traductor.yml", desc: "Config file path"
|
|
49
|
+
option :output, type: :string, desc: "Output directory (overrides config)"
|
|
50
|
+
option :dry_run, type: :boolean, default: false, desc: "Show what would be translated"
|
|
51
|
+
def translate
|
|
52
|
+
load_config(options[:config])
|
|
53
|
+
apply_overrides
|
|
54
|
+
|
|
55
|
+
source_paths = resolve_source_paths
|
|
56
|
+
target_locales = resolve_target_locales
|
|
57
|
+
|
|
58
|
+
if source_paths.empty?
|
|
59
|
+
say "No source files found. Run 'traductor init' or specify --source.", :red
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if target_locales.empty?
|
|
64
|
+
say "No target locales specified. Edit .traductor.yml or use --targets.", :red
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
source_paths.each do |source_path|
|
|
69
|
+
say "Source: #{source_path}", :blue
|
|
70
|
+
|
|
71
|
+
translator = Translator.new(
|
|
72
|
+
source_path: source_path,
|
|
73
|
+
target_locales: target_locales,
|
|
74
|
+
output_dir: options[:output] || Traductor.configuration.output_dir,
|
|
75
|
+
incremental: !options[:full]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if options[:dry_run]
|
|
79
|
+
summary = translator.dry_run
|
|
80
|
+
summary.each do |locale, info|
|
|
81
|
+
say " #{locale}: #{info[:keys_to_translate]} keys to translate", :cyan
|
|
82
|
+
info[:keys].first(10).each { |key| say " - #{key}" }
|
|
83
|
+
say " ... and #{info[:keys].size - 10} more" if info[:keys].size > 10
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
result = translator.call
|
|
87
|
+
|
|
88
|
+
result.results.each do |locale, path|
|
|
89
|
+
say " #{locale}: #{path}", :green
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result.errors.each do |error|
|
|
93
|
+
say " #{error[:locale]}: ERROR - #{error[:error]}", :red
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
desc "diff", "Show translation differences between source and target"
|
|
100
|
+
option :source, type: :string, desc: "Source file path"
|
|
101
|
+
option :target, type: :string, desc: "Target file path or locale code"
|
|
102
|
+
option :config, type: :string, default: ".traductor.yml", desc: "Config file path"
|
|
103
|
+
def diff
|
|
104
|
+
load_config(options[:config])
|
|
105
|
+
|
|
106
|
+
source_path = options[:source] || Traductor.configuration.source_paths&.first
|
|
107
|
+
unless source_path && File.exist?(source_path)
|
|
108
|
+
say "Source file not found: #{source_path}", :red
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
source_file = LocaleFile.new(source_path)
|
|
113
|
+
source_flat = KeyFlattener.flatten(source_file.content)
|
|
114
|
+
|
|
115
|
+
target_path = resolve_target_path(options[:target], source_path)
|
|
116
|
+
unless target_path && File.exist?(target_path)
|
|
117
|
+
say "Target file not found: #{target_path}", :red
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
target_file = LocaleFile.new(target_path)
|
|
122
|
+
target_flat = KeyFlattener.flatten(target_file.content)
|
|
123
|
+
|
|
124
|
+
diff = Diff.new(source_flat: source_flat, target_flat: target_flat)
|
|
125
|
+
|
|
126
|
+
say "Diff: #{source_path} -> #{target_path}", :blue
|
|
127
|
+
say ""
|
|
128
|
+
|
|
129
|
+
if diff.added.any?
|
|
130
|
+
say "Added (#{diff.added.size} keys need translation):", :green
|
|
131
|
+
diff.added.each { |key, value| say " + #{key}: #{value}" }
|
|
132
|
+
say ""
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if diff.removed.any?
|
|
136
|
+
say "Removed (#{diff.removed.size} keys in target but not in source):", :red
|
|
137
|
+
diff.removed.each { |key, _| say " - #{key}" }
|
|
138
|
+
say ""
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
say "Unchanged: #{diff.unchanged.size} keys", :cyan
|
|
142
|
+
say diff.summary.inspect
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
desc "version", "Show Traductor version"
|
|
146
|
+
def version
|
|
147
|
+
say "traductor #{Traductor::VERSION}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def load_config(path)
|
|
153
|
+
Traductor.configuration.load_from_file(path)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def apply_overrides
|
|
157
|
+
config = Traductor.configuration
|
|
158
|
+
config.model = options[:model] if options[:model]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolve_source_paths
|
|
162
|
+
if options[:source]
|
|
163
|
+
[options[:source]]
|
|
164
|
+
else
|
|
165
|
+
paths = Array(Traductor.configuration.source_paths)
|
|
166
|
+
paths.flat_map { |pattern| Dir.glob(pattern) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def resolve_target_locales
|
|
171
|
+
options[:targets] || Array(Traductor.configuration.target_locales).map(&:to_s)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def resolve_target_path(target_option, source_path)
|
|
175
|
+
return target_option if target_option&.include?(".")
|
|
176
|
+
|
|
177
|
+
locale = target_option || Traductor.configuration.target_locales&.first
|
|
178
|
+
return nil unless locale
|
|
179
|
+
|
|
180
|
+
ext = File.extname(source_path)
|
|
181
|
+
dir = File.dirname(source_path)
|
|
182
|
+
File.join(dir, "#{locale}#{ext}")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Traductor
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :source_locale, :target_locales, :source_paths,
|
|
8
|
+
:output_dir, :model, :provider, :temperature,
|
|
9
|
+
:glossary_path, :batch_size
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@source_locale = "en"
|
|
13
|
+
@target_locales = []
|
|
14
|
+
@source_paths = []
|
|
15
|
+
@output_dir = nil
|
|
16
|
+
@model = nil
|
|
17
|
+
@provider = nil
|
|
18
|
+
@temperature = 0.3
|
|
19
|
+
@glossary_path = nil
|
|
20
|
+
@batch_size = 30
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def load_from_file(path = ".traductor.yml")
|
|
24
|
+
return unless File.exist?(path)
|
|
25
|
+
|
|
26
|
+
yaml = YAML.safe_load_file(path, permitted_classes: [Symbol])
|
|
27
|
+
return unless yaml.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
apply_hash(yaml)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def apply_hash(hash)
|
|
35
|
+
hash.each do |key, value|
|
|
36
|
+
setter = :"#{key}="
|
|
37
|
+
public_send(setter, value) if respond_to?(setter)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Traductor
|
|
4
|
+
class Diff
|
|
5
|
+
attr_reader :added, :removed, :unchanged
|
|
6
|
+
|
|
7
|
+
def initialize(source_flat:, target_flat:)
|
|
8
|
+
@source_flat = source_flat
|
|
9
|
+
@target_flat = target_flat
|
|
10
|
+
compute
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns only the keys that need translation (new keys not yet in target)
|
|
14
|
+
def keys_to_translate
|
|
15
|
+
@added
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def summary
|
|
19
|
+
{
|
|
20
|
+
added: @added.size,
|
|
21
|
+
removed: @removed.size,
|
|
22
|
+
unchanged: @unchanged.size
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def compute
|
|
29
|
+
@added = {}
|
|
30
|
+
@removed = {}
|
|
31
|
+
@unchanged = {}
|
|
32
|
+
|
|
33
|
+
@source_flat.each do |key, value|
|
|
34
|
+
if @target_flat.key?(key)
|
|
35
|
+
@unchanged[key] = @target_flat[key]
|
|
36
|
+
else
|
|
37
|
+
@added[key] = value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@removed = @target_flat.reject { |key, _| @source_flat.key?(key) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Traductor
|
|
6
|
+
class Glossary
|
|
7
|
+
attr_reader :terms
|
|
8
|
+
|
|
9
|
+
def initialize(path = nil)
|
|
10
|
+
@terms = path && File.exist?(path) ? load_terms(path) : {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns terms relevant to a specific target locale
|
|
14
|
+
def for_locale(locale)
|
|
15
|
+
locale = locale.to_s
|
|
16
|
+
@terms.select { |_term, translations| translations.key?(locale) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Formats glossary entries for inclusion in prompts
|
|
20
|
+
def to_prompt_text(locale)
|
|
21
|
+
entries = for_locale(locale)
|
|
22
|
+
return nil if entries.empty?
|
|
23
|
+
|
|
24
|
+
entries.map { |term, translations|
|
|
25
|
+
"- \"#{term}\" -> \"#{translations[locale.to_s]}\""
|
|
26
|
+
}.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def load_terms(path)
|
|
32
|
+
YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Traductor
|
|
4
|
+
class InterpolationGuard
|
|
5
|
+
PATTERNS = [
|
|
6
|
+
/(%\{[^}]+\})/, # Ruby/Rails: %{name}
|
|
7
|
+
/(\{\{[^}]+\}\})/, # Handlebars/React: {{variable}}
|
|
8
|
+
/(\$\{[^}]+\})/, # ES6 template literals: ${variable}
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
COMBINED_PATTERN = Regexp.union(PATTERNS)
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@placeholders = {}
|
|
15
|
+
@counter = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Replace interpolation variables with numbered placeholders
|
|
19
|
+
def protect(text)
|
|
20
|
+
return text unless text.is_a?(String)
|
|
21
|
+
|
|
22
|
+
text.gsub(COMBINED_PATTERN) do |match|
|
|
23
|
+
placeholder = "__TVAR#{@counter}__"
|
|
24
|
+
@placeholders[placeholder] = match
|
|
25
|
+
@counter += 1
|
|
26
|
+
placeholder
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Restore original interpolation variables from placeholders
|
|
31
|
+
def restore(text)
|
|
32
|
+
return text unless text.is_a?(String)
|
|
33
|
+
|
|
34
|
+
result = text.dup
|
|
35
|
+
@placeholders.each do |placeholder, original|
|
|
36
|
+
result.gsub!(placeholder, original)
|
|
37
|
+
end
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset!
|
|
42
|
+
@placeholders.clear
|
|
43
|
+
@counter = 0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Traductor
|
|
4
|
+
class KeyFlattener
|
|
5
|
+
# Converts a nested hash to flat dot-notation key-value pairs.
|
|
6
|
+
#
|
|
7
|
+
# {"users" => {"greeting" => "Hello %{name}"}}
|
|
8
|
+
# => {"users.greeting" => "Hello %{name}"}
|
|
9
|
+
#
|
|
10
|
+
def self.flatten(hash, prefix = nil)
|
|
11
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
12
|
+
full_key = [prefix, key].compact.join(".")
|
|
13
|
+
if value.is_a?(Hash)
|
|
14
|
+
result.merge!(flatten(value, full_key))
|
|
15
|
+
else
|
|
16
|
+
result[full_key] = value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Converts flat dot-notation key-value pairs back to a nested hash.
|
|
22
|
+
#
|
|
23
|
+
# {"users.greeting" => "Hola %{name}"}
|
|
24
|
+
# => {"users" => {"greeting" => "Hola %{name}"}}
|
|
25
|
+
#
|
|
26
|
+
def self.unflatten(hash)
|
|
27
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
28
|
+
parts = key.split(".")
|
|
29
|
+
current = result
|
|
30
|
+
parts[0..-2].each do |part|
|
|
31
|
+
current[part] ||= {}
|
|
32
|
+
current = current[part]
|
|
33
|
+
end
|
|
34
|
+
current[parts.last] = value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Traductor
|
|
7
|
+
class LocaleFile
|
|
8
|
+
attr_reader :path, :format, :data, :locale
|
|
9
|
+
|
|
10
|
+
def initialize(path)
|
|
11
|
+
@path = path
|
|
12
|
+
@format = detect_format(path)
|
|
13
|
+
@data = parse
|
|
14
|
+
@locale = extract_locale
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the content hash without the top-level locale key.
|
|
18
|
+
# For YAML: {"en" => {"hello" => "Hello"}} -> {"hello" => "Hello"}
|
|
19
|
+
# For JSON: returns as-is if no single top-level locale key
|
|
20
|
+
def content
|
|
21
|
+
if data.is_a?(Hash) && data.keys.length == 1
|
|
22
|
+
data.values.first
|
|
23
|
+
else
|
|
24
|
+
data
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(data, output_path)
|
|
29
|
+
dir = File.dirname(output_path)
|
|
30
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
31
|
+
|
|
32
|
+
case detect_format(output_path)
|
|
33
|
+
when :yaml
|
|
34
|
+
File.write(output_path, data.to_yaml)
|
|
35
|
+
when :json
|
|
36
|
+
File.write(output_path, JSON.pretty_generate(data) + "\n")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def parse
|
|
43
|
+
case format
|
|
44
|
+
when :yaml then YAML.safe_load_file(path, permitted_classes: [Symbol])
|
|
45
|
+
when :json then JSON.parse(File.read(path))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detect_format(file_path)
|
|
50
|
+
case File.extname(file_path).downcase
|
|
51
|
+
when ".yml", ".yaml" then :yaml
|
|
52
|
+
when ".json" then :json
|
|
53
|
+
else raise UnsupportedFormatError, "Unsupported file format: #{File.extname(file_path)}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_locale
|
|
58
|
+
if data.is_a?(Hash) && data.keys.length == 1
|
|
59
|
+
data.keys.first
|
|
60
|
+
else
|
|
61
|
+
File.basename(path, File.extname(path))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Traductor
|
|
4
|
+
class PromptBuilder
|
|
5
|
+
def initialize(source_locale:, target_locale:, glossary: nil)
|
|
6
|
+
@source_locale = source_locale
|
|
7
|
+
@target_locale = target_locale
|
|
8
|
+
@glossary = glossary
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def system_prompt
|
|
12
|
+
parts = []
|
|
13
|
+
parts << <<~PROMPT.strip
|
|
14
|
+
You are a professional translator specializing in software localization.
|
|
15
|
+
You translate from #{locale_name(@source_locale)} to #{locale_name(@target_locale)}.
|
|
16
|
+
|
|
17
|
+
Rules:
|
|
18
|
+
1. Translate ONLY the values, never the keys.
|
|
19
|
+
2. Preserve all placeholders exactly as they appear (e.g., __TVAR0__, __TVAR1__). Do not translate, modify, or reorder placeholders.
|
|
20
|
+
3. Maintain the same tone and formality level as the source text.
|
|
21
|
+
4. For technical terms (e.g., API, URL, HTML), keep them untranslated unless there is a well-established localized term.
|
|
22
|
+
5. Handle pluralization naturally for the target language.
|
|
23
|
+
6. Keep translations concise — UI strings should remain similar in length.
|
|
24
|
+
7. Respond ONLY with valid JSON. No explanations, no markdown fences, no extra text.
|
|
25
|
+
PROMPT
|
|
26
|
+
|
|
27
|
+
if @glossary
|
|
28
|
+
glossary_text = @glossary.to_prompt_text(@target_locale)
|
|
29
|
+
if glossary_text
|
|
30
|
+
parts << <<~GLOSSARY.strip
|
|
31
|
+
|
|
32
|
+
Glossary — always use these exact translations:
|
|
33
|
+
#{glossary_text}
|
|
34
|
+
GLOSSARY
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
parts.join("\n\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def user_prompt(batch)
|
|
42
|
+
<<~PROMPT.strip
|
|
43
|
+
Translate the following JSON object's values from #{locale_name(@source_locale)} to #{locale_name(@target_locale)}.
|
|
44
|
+
Return a JSON object with the same keys and translated values.
|
|
45
|
+
|
|
46
|
+
#{JSON.generate(batch)}
|
|
47
|
+
PROMPT
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def locale_name(code)
|
|
53
|
+
LOCALE_NAMES.fetch(code.to_s, code.to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
LOCALE_NAMES = {
|
|
57
|
+
"en" => "English", "es" => "Spanish", "fr" => "French",
|
|
58
|
+
"de" => "German", "pt" => "Portuguese", "it" => "Italian",
|
|
59
|
+
"ja" => "Japanese", "ko" => "Korean", "zh" => "Chinese",
|
|
60
|
+
"ar" => "Arabic", "ru" => "Russian", "nl" => "Dutch",
|
|
61
|
+
"sv" => "Swedish", "pl" => "Polish", "tr" => "Turkish",
|
|
62
|
+
"th" => "Thai", "vi" => "Vietnamese", "hi" => "Hindi",
|
|
63
|
+
"uk" => "Ukrainian", "cs" => "Czech", "da" => "Danish",
|
|
64
|
+
"fi" => "Finnish", "el" => "Greek", "he" => "Hebrew",
|
|
65
|
+
"hu" => "Hungarian", "id" => "Indonesian", "ms" => "Malay",
|
|
66
|
+
"no" => "Norwegian", "ro" => "Romanian", "sk" => "Slovak",
|
|
67
|
+
"bg" => "Bulgarian", "ca" => "Catalan", "hr" => "Croatian",
|
|
68
|
+
"et" => "Estonian", "lv" => "Latvian", "lt" => "Lithuanian",
|
|
69
|
+
"sl" => "Slovenian", "sr" => "Serbian",
|
|
70
|
+
"pt-BR" => "Brazilian Portuguese",
|
|
71
|
+
"zh-TW" => "Traditional Chinese",
|
|
72
|
+
"zh-CN" => "Simplified Chinese"
|
|
73
|
+
}.freeze
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Traductor
|
|
4
|
+
class Result
|
|
5
|
+
attr_reader :results, :errors
|
|
6
|
+
|
|
7
|
+
def initialize(results: {}, errors: [])
|
|
8
|
+
@results = results
|
|
9
|
+
@errors = errors
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def success?
|
|
13
|
+
@errors.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def locales
|
|
17
|
+
@results.keys
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def path_for(locale)
|
|
21
|
+
@results[locale.to_s]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Traductor
|
|
7
|
+
class Translator
|
|
8
|
+
def initialize(source_path:, target_locales:, output_dir: nil, incremental: true)
|
|
9
|
+
@source_file = LocaleFile.new(source_path)
|
|
10
|
+
@target_locales = Array(target_locales).map(&:to_s)
|
|
11
|
+
@output_dir = output_dir || File.dirname(source_path)
|
|
12
|
+
@incremental = incremental
|
|
13
|
+
@config = Traductor.configuration
|
|
14
|
+
@glossary = Glossary.new(@config.glossary_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
source_flat = KeyFlattener.flatten(@source_file.content)
|
|
19
|
+
results = {}
|
|
20
|
+
errors = []
|
|
21
|
+
|
|
22
|
+
@target_locales.each do |target_locale|
|
|
23
|
+
path = translate_locale(source_flat, target_locale)
|
|
24
|
+
results[target_locale] = path
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
errors << { locale: target_locale, error: e.message }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Result.new(results: results, errors: errors)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns keys that would be translated without calling the LLM
|
|
33
|
+
def dry_run
|
|
34
|
+
source_flat = KeyFlattener.flatten(@source_file.content)
|
|
35
|
+
summary = {}
|
|
36
|
+
|
|
37
|
+
@target_locales.each do |target_locale|
|
|
38
|
+
keys = if @incremental
|
|
39
|
+
determine_keys_to_translate(source_flat, target_locale)
|
|
40
|
+
else
|
|
41
|
+
source_flat
|
|
42
|
+
end
|
|
43
|
+
summary[target_locale] = {
|
|
44
|
+
keys_to_translate: keys.size,
|
|
45
|
+
keys: keys.keys
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
summary
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def translate_locale(source_flat, target_locale)
|
|
55
|
+
keys_to_translate = if @incremental
|
|
56
|
+
determine_keys_to_translate(source_flat, target_locale)
|
|
57
|
+
else
|
|
58
|
+
source_flat
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if keys_to_translate.empty?
|
|
62
|
+
return output_path_for(target_locale)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
guard = InterpolationGuard.new
|
|
66
|
+
protected_keys = keys_to_translate.each_with_object({}) do |(key, value), hash|
|
|
67
|
+
hash[key] = value.is_a?(String) ? guard.protect(value) : value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
batches = BatchBuilder.new(protected_keys, batch_size: @config.batch_size).build
|
|
71
|
+
prompt_builder = PromptBuilder.new(
|
|
72
|
+
source_locale: @source_file.locale,
|
|
73
|
+
target_locale: target_locale,
|
|
74
|
+
glossary: @glossary
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
chat = build_chat
|
|
78
|
+
chat.with_instructions(prompt_builder.system_prompt)
|
|
79
|
+
|
|
80
|
+
translated_flat = {}
|
|
81
|
+
|
|
82
|
+
batches.each do |batch|
|
|
83
|
+
response = chat.ask(prompt_builder.user_prompt(batch))
|
|
84
|
+
parsed = parse_response(response.content)
|
|
85
|
+
restored = parsed.each_with_object({}) do |(key, value), hash|
|
|
86
|
+
hash[key] = value.is_a?(String) ? guard.restore(value) : value
|
|
87
|
+
end
|
|
88
|
+
translated_flat.merge!(restored)
|
|
89
|
+
guard.reset!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
write_output(target_locale, translated_flat)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def determine_keys_to_translate(source_flat, target_locale)
|
|
96
|
+
target_path = output_path_for(target_locale)
|
|
97
|
+
return source_flat unless File.exist?(target_path)
|
|
98
|
+
|
|
99
|
+
target_file = LocaleFile.new(target_path)
|
|
100
|
+
target_flat = KeyFlattener.flatten(target_file.content)
|
|
101
|
+
|
|
102
|
+
diff = Diff.new(source_flat: source_flat, target_flat: target_flat)
|
|
103
|
+
diff.keys_to_translate
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_output(target_locale, translated_flat)
|
|
107
|
+
existing_flat = load_existing_translations(target_locale)
|
|
108
|
+
merged = existing_flat.merge(translated_flat)
|
|
109
|
+
|
|
110
|
+
nested = KeyFlattener.unflatten(merged)
|
|
111
|
+
output_data = { target_locale => nested }
|
|
112
|
+
|
|
113
|
+
path = output_path_for(target_locale)
|
|
114
|
+
@source_file.write(output_data, path)
|
|
115
|
+
path
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def load_existing_translations(target_locale)
|
|
119
|
+
path = output_path_for(target_locale)
|
|
120
|
+
return {} unless File.exist?(path)
|
|
121
|
+
|
|
122
|
+
file = LocaleFile.new(path)
|
|
123
|
+
KeyFlattener.flatten(file.content)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def output_path_for(target_locale)
|
|
127
|
+
ext = File.extname(@source_file.path)
|
|
128
|
+
File.join(@output_dir, "#{target_locale}#{ext}")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_chat
|
|
132
|
+
args = {}
|
|
133
|
+
args[:model] = @config.model if @config.model
|
|
134
|
+
chat = RubyLLM.chat(**args)
|
|
135
|
+
chat.with_temperature(@config.temperature)
|
|
136
|
+
chat
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse_response(content)
|
|
140
|
+
cleaned = content.gsub(/\A```(?:json)?\n?/, "").gsub(/\n?```\z/, "").strip
|
|
141
|
+
JSON.parse(cleaned)
|
|
142
|
+
rescue JSON::ParserError => e
|
|
143
|
+
raise TranslationParseError, "Failed to parse LLM response as JSON: #{e.message}\nResponse: #{content}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/traductor.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "traductor/version"
|
|
4
|
+
require_relative "traductor/errors"
|
|
5
|
+
require_relative "traductor/configuration"
|
|
6
|
+
require_relative "traductor/key_flattener"
|
|
7
|
+
require_relative "traductor/locale_file"
|
|
8
|
+
require_relative "traductor/interpolation_guard"
|
|
9
|
+
require_relative "traductor/diff"
|
|
10
|
+
require_relative "traductor/glossary"
|
|
11
|
+
require_relative "traductor/batch_builder"
|
|
12
|
+
require_relative "traductor/prompt_builder"
|
|
13
|
+
require_relative "traductor/translator"
|
|
14
|
+
require_relative "traductor/result"
|
|
15
|
+
|
|
16
|
+
module Traductor
|
|
17
|
+
class << self
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configure
|
|
23
|
+
yield(configuration)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset_configuration!
|
|
27
|
+
@configuration = Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Primary API: translate a source file to target locales
|
|
31
|
+
#
|
|
32
|
+
# Traductor.translate("config/locales/en.yml", target_locales: ["es", "fr"])
|
|
33
|
+
#
|
|
34
|
+
def translate(source_path, target_locales:, output_dir: nil, incremental: true)
|
|
35
|
+
Translator.new(
|
|
36
|
+
source_path: source_path,
|
|
37
|
+
target_locales: target_locales,
|
|
38
|
+
output_dir: output_dir,
|
|
39
|
+
incremental: incremental
|
|
40
|
+
).call
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/sig/traductor.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: traductor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alvaro Delgado
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ruby_llm
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: thor
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.3'
|
|
40
|
+
description: Traductor uses RubyLLM to translate YAML and JSON locale files across
|
|
41
|
+
languages. Supports incremental translation, glossaries, interpolation variable
|
|
42
|
+
protection, and works with any framework (Rails, React, Next.js).
|
|
43
|
+
email:
|
|
44
|
+
- hola@alvarodelgado.dev
|
|
45
|
+
executables:
|
|
46
|
+
- traductor
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- ".rspec"
|
|
51
|
+
- ".rubocop.yml"
|
|
52
|
+
- LICENSE.txt
|
|
53
|
+
- README.md
|
|
54
|
+
- Rakefile
|
|
55
|
+
- assets/traductor-logo.svg
|
|
56
|
+
- exe/traductor
|
|
57
|
+
- lib/traductor.rb
|
|
58
|
+
- lib/traductor/batch_builder.rb
|
|
59
|
+
- lib/traductor/cli.rb
|
|
60
|
+
- lib/traductor/configuration.rb
|
|
61
|
+
- lib/traductor/diff.rb
|
|
62
|
+
- lib/traductor/errors.rb
|
|
63
|
+
- lib/traductor/glossary.rb
|
|
64
|
+
- lib/traductor/interpolation_guard.rb
|
|
65
|
+
- lib/traductor/key_flattener.rb
|
|
66
|
+
- lib/traductor/locale_file.rb
|
|
67
|
+
- lib/traductor/prompt_builder.rb
|
|
68
|
+
- lib/traductor/result.rb
|
|
69
|
+
- lib/traductor/translator.rb
|
|
70
|
+
- lib/traductor/version.rb
|
|
71
|
+
- sig/traductor.rbs
|
|
72
|
+
homepage: https://github.com/AAlvAAro/traductor
|
|
73
|
+
licenses:
|
|
74
|
+
- MIT
|
|
75
|
+
metadata:
|
|
76
|
+
homepage_uri: https://github.com/AAlvAAro/traductor
|
|
77
|
+
source_code_uri: https://github.com/AAlvAAro/traductor
|
|
78
|
+
changelog_uri: https://github.com/AAlvAAro/traductor/blob/main/CHANGELOG.md
|
|
79
|
+
rubygems_mfa_required: 'true'
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 3.1.0
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.6.9
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: AI-powered locale file translator for Ruby applications
|
|
97
|
+
test_files: []
|