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 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "traductor"
5
+ require "traductor/cli"
6
+
7
+ Traductor::CLI.start(ARGV)
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Traductor
4
+ class Error < StandardError; end
5
+ class UnsupportedFormatError < Error; end
6
+ class TranslationParseError < Error; end
7
+ class ConfigurationError < Error; end
8
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Traductor
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module Traductor
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []