locallingo 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: 45dfcfd181bb7d72fe62baef770bcf9b29f326ea60d851648d509e75e0bdf173
4
+ data.tar.gz: 228bdd7709806269301923738579ee9c21d564e27eb9d267dc1fa6f1cd560fa2
5
+ SHA512:
6
+ metadata.gz: c396ffa2b39f45696f201082209ff1c2f3909598935b40adbd89141fd959839b9b1ce73806f1731ced0fa032caff64d66e4bcfa3ad36e190d95fcfb5dfe6c2c6
7
+ data.tar.gz: 2271440aa37c981b11d88415cf020c7d69627e9279bf59b2628f2cd0b415042ad2b514c1ba31bc1d7c146106cdb852e939034018b4352797db7fc9615d76dccf
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+ - Initial extraction from the `bin/translate` toolchain into a standalone gem.
11
+ - `lingo` CLI with subcommands: `status`, `translate`, `validate`, `quality`,
12
+ `fix-quality`, `accept-edits`, `hash`, `sync`. Legacy `--flag` forms still work
13
+ and print a deprecation notice.
14
+ - `Locallingo::Configuration` — `.locallingo.yml` with a `defaults:` block plus
15
+ optional per-`packages:` overrides (deep-merged, ERB-evaluated).
16
+ - Provider-agnostic translation via RubyLLM (`provider:`/`model:` config).
17
+ - Validators (config-gated): `missing`, `outdated`, `duplicate_values`,
18
+ `manual_edits`.
19
+ - Quality checks: static rules, universal fixes, configurable terminology lists
20
+ (`business`/`banking`/custom), optional British-spelling drift, plus an
21
+ optional AI review pass.
22
+ - Source-hash drift detection with per-namespace state files under `.i18n-state/`.
23
+ - Configurable `after_translate` hook commands (e.g. `i18n-tasks normalize -p`,
24
+ `rails i18n:export`).
25
+ - RuboCop cops `Locallingo/RelativeI18nKey` (with autocorrect) and
26
+ `Locallingo/StrftimeInView`, loaded via `require: locallingo/rubocop`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Mikael Henriksson
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # Locallingo
2
+
3
+ AI-assisted i18n translation, drift detection, and quality linting on top of
4
+ [i18n-tasks](https://github.com/glebm/i18n-tasks) — packaged as a gem so the same
5
+ toolchain works across every app instead of being copy-pasted (and forking) per
6
+ repo.
7
+
8
+ Locallingo:
9
+
10
+ - **Translates** missing/changed keys through [RubyLLM](https://github.com/crmne/ruby_llm)
11
+ (OpenAI, Anthropic, Google, … — you pick the provider/model).
12
+ - **Detects drift** with per-key source hashes, so only genuinely
13
+ missing/outdated keys are re-translated.
14
+ - **Validates** translation completeness for CI (missing, outdated, and optional
15
+ duplicate-value / manual-edit checks).
16
+ - **Lints quality** — static rules, terminology lists, optional British-spelling
17
+ drift, plus an optional AI review.
18
+ - Ships **RuboCop cops** that enforce fully-qualified i18n keys and locale-aware
19
+ date formatting.
20
+
21
+ Everything app-specific lives in `.locallingo.yml` — target locales, provider and
22
+ model, prompt context/glossary, per-language style guides, which validators and
23
+ quality rules run, and post-translate hooks — with a default block plus optional
24
+ per-package overrides.
25
+
26
+ ## Installation
27
+
28
+ ```ruby
29
+ # Gemfile
30
+ group :development do
31
+ gem "locallingo"
32
+ end
33
+ ```
34
+
35
+ RubyLLM reads provider credentials from ENV (e.g. `OPENAI_API_KEY`,
36
+ `ANTHROPIC_API_KEY`). Locallingo never stores keys.
37
+
38
+ ## Configuration
39
+
40
+ Create `.locallingo.yml` at your app root:
41
+
42
+ ```yaml
43
+ defaults:
44
+ source_locale: en
45
+ target_locales: [de, sv]
46
+
47
+ provider: openai # any RubyLLM provider
48
+ translate:
49
+ model: gpt-5-mini
50
+ quality:
51
+ model: gpt-4o-mini
52
+ terminology: business # "business" | "banking" | path/to/terms.yml
53
+ british_spellings: false
54
+
55
+ context: "Cosmos, a business application"
56
+ glossary:
57
+ entity: "business/company account holder"
58
+ member: "user belonging to an entity"
59
+ language_guides:
60
+ de: config/locales/.guides/de.md # inline text or `file:` path
61
+
62
+ validators:
63
+ missing: true
64
+ outdated: true
65
+ duplicate_values: true # flags non-AR keys duplicating an AR attribute value
66
+ manual_edits: false # protects hand-edited target values
67
+
68
+ strict:
69
+ strict: [missing, outdated]
70
+ strict_all: [missing, outdated, duplicate_value]
71
+
72
+ after_translate:
73
+ - "bundle exec i18n-tasks normalize -p"
74
+
75
+ # Optional per-location overrides (each inherits `defaults`):
76
+ packages: []
77
+ # - path: engines/billing
78
+ # context: "the billing engine"
79
+ # target_locales: [de]
80
+ ```
81
+
82
+ With no `packages:`, the whole app uses `defaults` — the common case.
83
+
84
+ ## CLI
85
+
86
+ ```bash
87
+ lingo status # translation status per locale
88
+ lingo translate --locale de # translate missing/changed keys
89
+ lingo translate --force # re-translate everything
90
+ lingo validate --strict # CI gate (exit 1 on strict-tier issues)
91
+ lingo validate --strict-all # stricter CI gate
92
+ lingo quality --ai # quality linting (+ optional AI pass)
93
+ lingo fix-quality --locale en # auto-fix fixable issues
94
+ lingo accept-edits --locale de # mark current translations as intentional
95
+ lingo sync # rebuild drift state from current files
96
+ lingo hash # source-translation fingerprint
97
+ ```
98
+
99
+ Add `--package engines/billing` to any command to scope it to a package.
100
+
101
+ The legacy flag forms (`lingo --translate`, `lingo --validate`, …) still work and
102
+ print a one-line deprecation notice, so you can migrate CI and docs incrementally.
103
+
104
+ ## RuboCop cops
105
+
106
+ ```yaml
107
+ # .rubocop.yml
108
+ require:
109
+ - locallingo/rubocop
110
+ inherit_gem:
111
+ locallingo: config/default.yml
112
+ ```
113
+
114
+ - **`Locallingo/RelativeI18nKey`** — flags relative `t(".key")` lookups and
115
+ autocorrects them to fully-qualified keys derived from the file path and
116
+ enclosing method. Configure `ScopedDirectories` for your layout.
117
+ - **`Locallingo/StrftimeInView`** — flags `.strftime(...)` in views; use
118
+ `I18n.l(value, format: :name)` instead. `value:`-pair inputs (HTML datetime)
119
+ are exempt.
120
+
121
+ Locallingo recommends disabling Rails' `Rails/I18nLazyLookup` and
122
+ `Rails/I18nLocaleTexts`, which fight the fully-qualified-key convention.
123
+
124
+ ## Programmatic use
125
+
126
+ ```ruby
127
+ config = Locallingo.configuration(root_path: Dir.pwd)
128
+ Locallingo::Manager.new(config:).validate # => [violations]
129
+ Locallingo::QualityChecker.new(config:).check(locale: "en")
130
+ ```
131
+
132
+ ## License
133
+
134
+ MIT © Mikael Henriksson
@@ -0,0 +1,47 @@
1
+ # Locallingo's RuboCop cop defaults.
2
+ #
3
+ # In a host app's .rubocop.yml:
4
+ #
5
+ # require:
6
+ # - locallingo/rubocop
7
+ # inherit_gem:
8
+ # locallingo: config/default.yml
9
+ #
10
+ # Then override Include/Exclude/ScopedDirectories as needed. Locallingo also
11
+ # recommends disabling Rails' relative-key cops, which fight the fully-qualified
12
+ # convention this cop enforces (uncomment in your app):
13
+ #
14
+ # Rails/I18nLazyLookup: { Enabled: false }
15
+ # Rails/I18nLocaleTexts: { Enabled: false }
16
+
17
+ Locallingo/RelativeI18nKey:
18
+ Description: "Use fully-qualified i18n keys instead of relative `t('.key')` keys."
19
+ Enabled: true
20
+ # Directories under app/ whose path maps to a lazy-lookup scope for autocorrect.
21
+ ScopedDirectories:
22
+ - controllers
23
+ - mailers
24
+ - views
25
+ - components
26
+ - models
27
+ - services
28
+ - jobs
29
+ - notifiers
30
+ Include:
31
+ - "app/components/**/*.rb"
32
+ - "app/views/**/*.rb"
33
+ - "app/controllers/**/*.rb"
34
+ - "app/mailers/**/*.rb"
35
+ - "app/models/**/*.rb"
36
+ - "app/services/**/*.rb"
37
+ - "app/jobs/**/*.rb"
38
+ - "app/notifiers/**/*.rb"
39
+
40
+ Locallingo/StrftimeInView:
41
+ Description: "Use `I18n.l(value, format: :name)` instead of `.strftime` in views."
42
+ Enabled: true
43
+ Include:
44
+ - "app/views/**/*.rb"
45
+ Exclude:
46
+ # Mailer views format dates for email, not locale-specific display.
47
+ - "app/views/**/*_mailer/**/*.rb"
@@ -0,0 +1,70 @@
1
+ # Locallingo configuration.
2
+ #
3
+ # Copy this to `.locallingo.yml` at your app root and edit. Everything under
4
+ # `defaults:` applies to the whole app. Add `packages:` entries to override the
5
+ # defaults for a sub-directory (engine, gem, package) — each package inherits
6
+ # `defaults` and overrides only the keys it lists.
7
+ #
8
+ # The file is ERB-evaluated, so `<%= ENV["SOMETHING"] %>` works. NEVER put raw
9
+ # API keys here — the LLM provider reads its own credentials from ENV via RubyLLM.
10
+
11
+ defaults:
12
+ # The locale translations are authored in and translated FROM.
13
+ source_locale: en
14
+ # Locales translated TO (from the source). These are the ones Locallingo fills.
15
+ target_locales: [de, sv]
16
+
17
+ # Where the flat `<namespace>.<locale>.yml` files live, and where the
18
+ # source-hash drift state is tracked. Relative to the app root (or package path).
19
+ locales_dir: config/locales
20
+ state_dir: .i18n-state
21
+
22
+ # RubyLLM provider symbol (openai, anthropic, gemini, ...). Credentials come
23
+ # from ENV per RubyLLM's own config (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY).
24
+ provider: openai
25
+
26
+ translate:
27
+ model: gpt-5-mini
28
+ batch_size: 20
29
+
30
+ quality:
31
+ model: gpt-4o-mini
32
+ # Flag en-only British-vs-American spelling drift (organization -> organisation).
33
+ british_spellings: false
34
+ # Named built-in terminology list ("business" | "banking" | "none"), or a
35
+ # path to a YAML file mapping term -> suggestion (nil = term is acceptable).
36
+ terminology: business
37
+
38
+ # Prompt scaffolding. `context` names your product; `glossary` pins domain terms
39
+ # the model must not paraphrase; `placeholder_style` documents your interpolation
40
+ # syntax so the model preserves it.
41
+ context: "a business application"
42
+ placeholder_style: "%<name>s, %<count>s"
43
+ glossary: {}
44
+ # Extra per-locale style guidance appended to the translation prompt. Value is
45
+ # inline text or `file: path/to/guide.md`.
46
+ language_guides: {}
47
+
48
+ # Which validators `validate` runs.
49
+ validators:
50
+ missing: true
51
+ outdated: true
52
+ duplicate_values: false # flags non-activerecord keys duplicating an AR attribute value
53
+ manual_edits: false # protects hand-edited target values from being overwritten
54
+
55
+ # What each strict tier fails CI on. `validate --strict` uses `strict`;
56
+ # `validate --strict-all` uses `strict_all`.
57
+ strict:
58
+ strict: [missing, outdated]
59
+ strict_all: [missing, outdated, duplicate_value]
60
+
61
+ # Commands run (in order) after a successful `translate`, from the app root.
62
+ after_translate:
63
+ - "bundle exec i18n-tasks normalize -p"
64
+
65
+ # Optional per-location overrides. Empty means "single default config" (the
66
+ # common case). Each entry deep-merges onto `defaults`.
67
+ packages: []
68
+ # - path: engines/billing
69
+ # context: "the billing engine"
70
+ # target_locales: [de]
data/exe/lingo ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "locallingo"
5
+
6
+ Locallingo::CLI.start(ARGV)
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "configuration"
6
+ require_relative "manager"
7
+ require_relative "quality_checker"
8
+ require_relative "reporter"
9
+
10
+ module Locallingo
11
+ # The `lingo` command-line interface.
12
+ #
13
+ # Canonical form is subcommands (`lingo translate`, `lingo validate`, ...).
14
+ # The legacy flag form (`lingo --translate`) still works but prints a
15
+ # deprecation notice, so callers can migrate incrementally.
16
+ class CLI
17
+ CLI_NAME = "lingo"
18
+
19
+ # subcommand => the legacy flag it replaces
20
+ COMMANDS = {
21
+ "status" => "--status",
22
+ "translate" => "--translate",
23
+ "validate" => "--validate",
24
+ "quality" => "--check-quality",
25
+ "fix-quality" => "--fix-quality",
26
+ "accept-edits" => "--accept-edits",
27
+ "hash" => "--hash",
28
+ "sync" => "--sync-state"
29
+ }.freeze
30
+
31
+ # legacy flag => subcommand (for the deprecation path)
32
+ LEGACY_FLAGS = {
33
+ "--status" => "status",
34
+ "--translate" => "translate",
35
+ "--validate" => "validate",
36
+ "--check-quality" => "quality",
37
+ "--fix-quality" => "fix-quality",
38
+ "--accept-edits" => "accept-edits",
39
+ "--hash" => "hash",
40
+ "--sync-state" => "sync"
41
+ }.freeze
42
+
43
+ def self.start(argv = ARGV)
44
+ new(argv).run
45
+ end
46
+
47
+ def initialize(argv)
48
+ @argv = argv.dup
49
+ @options = { format: :text }
50
+ end
51
+
52
+ def run
53
+ command = resolve_command
54
+ options = parse_options!
55
+ config = Locallingo.configuration(root_path: Dir.pwd, package: options[:package])
56
+ dispatch(command, config, options)
57
+ rescue Locallingo::Error => e
58
+ warn "❌ #{e.message}"
59
+ exit 1
60
+ end
61
+
62
+ private
63
+
64
+ # Determine the subcommand, translating a leading legacy flag (with a
65
+ # deprecation notice) and defaulting to `status`.
66
+ def resolve_command
67
+ first = @argv.first
68
+ return "status" if first.nil?
69
+
70
+ if LEGACY_FLAGS.key?(first)
71
+ @argv.shift
72
+ subcommand = LEGACY_FLAGS[first]
73
+ warn "[deprecated] `#{first}` — use `#{CLI_NAME} #{subcommand}` instead."
74
+ return subcommand
75
+ end
76
+
77
+ # A leading non-flag token is the subcommand.
78
+ return @argv.shift unless first.start_with?("-")
79
+
80
+ "status"
81
+ end
82
+
83
+ def parse_options!
84
+ parser = build_parser
85
+ parser.parse!(@argv)
86
+ @options
87
+ end
88
+
89
+ def build_parser
90
+ OptionParser.new do |opts|
91
+ opts.banner = "Usage: #{CLI_NAME} <command> [options]\n\nCommands:\n " \
92
+ "#{COMMANDS.keys.join(", ")}\n\nOptions:"
93
+
94
+ opts.on("-l", "--locale LOCALE", "Process specific locale") { |v| @options[:locale] = v }
95
+ opts.on("-f", "--force", "Force re-translation of all keys") { @options[:force] = true }
96
+ opts.on("--force-key KEY", "Force re-translation of a specific key") do |v|
97
+ (@options[:force_keys] ||= []) << v
98
+ end
99
+ opts.on("-v", "--verbose", "Verbose output") { @options[:verbose] = true }
100
+ opts.on("-n", "--dry-run", "Show what would be done without changing files") { @options[:dry_run] = true }
101
+ opts.on("--strict", "Fail on strict-tier issues (for CI)") { @options[:strict] = true }
102
+ opts.on("--strict-all", "Fail on strict-all-tier issues (for full CI)") do
103
+ @options[:strict] = true
104
+ @options[:strict_all] = true
105
+ end
106
+ opts.on("--ai", "Use AI for quality suggestions") { @options[:use_ai] = true }
107
+ opts.on("--json", "Output in JSON format") { @options[:format] = :json }
108
+ opts.on("--package PATH", "Scope to a package from .locallingo.yml") { |v| @options[:package] = v }
109
+ opts.on("-h", "--help", "Show this help") do
110
+ puts opts
111
+ exit 0
112
+ end
113
+ end
114
+ end
115
+
116
+ def dispatch(command, config, options)
117
+ reporter = Reporter.new(config:, format: options[:format], cli_name: CLI_NAME)
118
+
119
+ case command
120
+ when "status" then cmd_status(config, options, reporter)
121
+ when "translate" then cmd_translate(config, options)
122
+ when "validate" then cmd_validate(config, options, reporter)
123
+ when "quality" then cmd_quality(config, options, reporter)
124
+ when "fix-quality" then cmd_fix_quality(config, options)
125
+ when "accept-edits" then cmd_accept_edits(config, options)
126
+ when "hash" then cmd_hash(config, options)
127
+ when "sync" then cmd_sync(config, options)
128
+ else
129
+ warn "Unknown command: #{command}\nRun `#{CLI_NAME} --help`."
130
+ exit 1
131
+ end
132
+ end
133
+
134
+ def cmd_status(config, options, reporter)
135
+ reporter.status(manager(config, options).status)
136
+ end
137
+
138
+ def cmd_translate(config, options)
139
+ mgr = manager(config, options)
140
+ puts "🔄 Translating..." if options[:verbose]
141
+ mgr.translate!(locale: options[:locale], force: options[:force], force_keys: options[:force_keys] || [])
142
+ unless options[:dry_run]
143
+ puts "📝 Running post-translate hooks..."
144
+ mgr.run_after_translate_hooks
145
+ end
146
+ puts "✅ Translation complete!"
147
+ puts "(dry run - no changes made)" if options[:dry_run]
148
+ end
149
+
150
+ def cmd_validate(config, options, reporter)
151
+ violations = manager(config, options).validate
152
+ exit(reporter.violations(violations, strict: options[:strict], strict_all: options[:strict_all]))
153
+ end
154
+
155
+ def cmd_quality(config, options, reporter)
156
+ locale = options[:locale] || config.source_locale
157
+ suggestions = quality_checker(config, options).check(locale:, use_ai: options[:use_ai])
158
+ reporter.quality(suggestions, locale:)
159
+ end
160
+
161
+ def cmd_fix_quality(config, options)
162
+ locale = options[:locale] || config.source_locale
163
+ puts(options[:dry_run] ? "🔍 Checking fixes for #{locale}..." : "🔧 Fixing quality issues for #{locale}...")
164
+ result = quality_checker(config, options).fix!(locale:, dry_run: options[:dry_run])
165
+ puts "\nFixed: #{result[:fixed]} files"
166
+ puts "Skipped: #{result[:skipped]} non-fixable suggestions"
167
+ puts "(dry run - no changes made)" if options[:dry_run]
168
+ end
169
+
170
+ def cmd_accept_edits(config, options)
171
+ unless config.validator_enabled?(:manual_edits)
172
+ warn "manual_edits validator is disabled in .locallingo.yml — nothing to accept."
173
+ return
174
+ end
175
+ manager(config, options).accept_edits!(locale: options[:locale])
176
+ puts "✅ Marked current translations as intentional."
177
+ puts "(dry run - no changes made)" if options[:dry_run]
178
+ end
179
+
180
+ def cmd_hash(config, options)
181
+ hash = manager(config, options).source_hash
182
+ options[:format] == :json ? puts(JSON.generate({ hash: })) : puts(hash)
183
+ end
184
+
185
+ def cmd_sync(config, options)
186
+ mgr = manager(config, options)
187
+ puts(options[:dry_run] ? "🔍 Would sync state file..." : "🔄 Syncing state file with current translations...")
188
+ state = mgr.sync_state!
189
+ total = state.values.sum { |locale_state| locale_state.keys.size }
190
+ puts "\nState directory: #{config.state_dir}"
191
+ state.each { |locale, locale_state| puts " #{locale}: #{locale_state.keys.size} keys" }
192
+ puts "Total tracked keys: #{total}"
193
+ puts "(dry run - no changes made)" if options[:dry_run]
194
+ end
195
+
196
+ def manager(config, options)
197
+ Manager.new(config:, dry_run: options[:dry_run], verbose: options[:verbose], cli_name: CLI_NAME)
198
+ end
199
+
200
+ def quality_checker(config, options)
201
+ QualityChecker.new(config:, verbose: options[:verbose])
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "erb"
5
+
6
+ module Locallingo
7
+ # Loads and resolves `.locallingo.yml`.
8
+ #
9
+ # The file has a `defaults:` block that applies to the whole app, plus an
10
+ # optional `packages:` list. Each package entry deep-merges onto `defaults`,
11
+ # so a package only lists the keys it changes. With no `packages:` (the common
12
+ # case), the resolved config is just `defaults` merged over the shipped
13
+ # `config/locallingo.default.yml`.
14
+ #
15
+ # Typed readers expose everything the Manager/QualityChecker/CLI need, so no
16
+ # other class parses raw config hashes.
17
+ class Configuration
18
+ CONFIG_FILENAMES = %w[.locallingo.yml .locallingo.yaml].freeze
19
+ DEFAULT_CONFIG_PATH = File.expand_path("../../config/locallingo.default.yml", __dir__)
20
+
21
+ attr_reader :root_path, :package, :data
22
+
23
+ # Resolve config for +root_path+, optionally scoped to a +package+ path from
24
+ # the `packages:` list.
25
+ def self.load(root_path: Dir.pwd, package: nil)
26
+ new(root_path:, package:)
27
+ end
28
+
29
+ def initialize(root_path: Dir.pwd, package: nil, data: nil)
30
+ @root_path = File.expand_path(root_path)
31
+ @package = package
32
+ @data = data || resolve
33
+ end
34
+
35
+ # --- locations -----------------------------------------------------------
36
+
37
+ # The directory config paths resolve against — the package path when scoped,
38
+ # else the app root.
39
+ def base_path
40
+ package ? File.join(root_path, package) : root_path
41
+ end
42
+
43
+ def source_locale = data.fetch("source_locale")
44
+ def target_locales = Array(data.fetch("target_locales"))
45
+ def all_locales = [source_locale, *target_locales].uniq
46
+
47
+ def locales_dir = File.join(base_path, data.fetch("locales_dir"))
48
+ def state_dir = File.join(base_path, data.fetch("state_dir"))
49
+ def exceptions_dir = File.join(state_dir, "exceptions")
50
+ def log_file = File.join(state_dir, "translation.log")
51
+
52
+ # --- provider / models ---------------------------------------------------
53
+
54
+ def provider = data.fetch("provider").to_sym
55
+ def translate_model = dig("translate", "model")
56
+ def batch_size = dig("translate", "batch_size")
57
+ def quality_model = dig("quality", "model")
58
+ def british_spellings? = !!dig("quality", "british_spellings")
59
+ def terminology_setting = dig("quality", "terminology")
60
+
61
+ # --- prompt scaffolding --------------------------------------------------
62
+
63
+ def context = data.fetch("context", "a business application")
64
+ def placeholder_style = data.fetch("placeholder_style", "%<name>s, %<count>s")
65
+ def glossary = data.fetch("glossary", {}) || {}
66
+
67
+ # Extra per-locale style text appended to the translation prompt. A value of
68
+ # `{ "file" => path }` is read from disk (relative to base_path); a String is
69
+ # used verbatim. Returns "" when none is configured.
70
+ def language_guide(locale)
71
+ guide = (data.fetch("language_guides", {}) || {})[locale.to_s]
72
+ return "" if guide.nil?
73
+ return guide.to_s unless guide.is_a?(Hash) && guide["file"]
74
+
75
+ path = File.expand_path(guide["file"], base_path)
76
+ File.exist?(path) ? File.read(path) : ""
77
+ end
78
+
79
+ # Human-readable language name for a locale, from the guide config or a small
80
+ # built-in map, falling back to the locale code itself.
81
+ def language_name(locale)
82
+ BUILTIN_LANGUAGE_NAMES[locale.to_s] || locale.to_s
83
+ end
84
+
85
+ # --- validators / strictness ---------------------------------------------
86
+
87
+ def validator_enabled?(name)
88
+ !!dig("validators", name.to_s)
89
+ end
90
+
91
+ # Violation types (symbols) a strict tier fails on. +tier+ is :strict or
92
+ # :strict_all.
93
+ def strict_types(tier)
94
+ Array(dig("strict", tier.to_s)).map(&:to_sym)
95
+ end
96
+
97
+ # --- hooks ---------------------------------------------------------------
98
+
99
+ def after_translate = Array(data.fetch("after_translate", []))
100
+
101
+ BUILTIN_LANGUAGE_NAMES = {
102
+ "de" => "German", "sv" => "Swedish", "fr" => "French",
103
+ "af" => "Afrikaans", "es" => "Spanish", "it" => "Italian",
104
+ "nl" => "Dutch", "pt" => "Portuguese", "da" => "Danish",
105
+ "nb" => "Norwegian", "no" => "Norwegian", "fi" => "Finnish"
106
+ }.freeze
107
+
108
+ private
109
+
110
+ def dig(*keys) = data.dig(*keys)
111
+
112
+ # Merge: shipped defaults <- user `defaults:` <- selected `packages:` entry.
113
+ def resolve
114
+ merged = deep_merge(shipped_defaults, user_defaults)
115
+ merged = deep_merge(merged, package_overrides) if package
116
+ merged
117
+ end
118
+
119
+ def shipped_defaults
120
+ load_yaml(DEFAULT_CONFIG_PATH).fetch("defaults", {})
121
+ end
122
+
123
+ def user_defaults
124
+ file = config_file
125
+ return {} unless file
126
+
127
+ load_yaml(file).fetch("defaults", {}) || {}
128
+ end
129
+
130
+ def package_overrides
131
+ file = config_file
132
+ return {} unless file
133
+
134
+ packages = load_yaml(file).fetch("packages", []) || []
135
+ entry = packages.find { |p| p["path"] == package }
136
+ raise Error, "No package #{package.inspect} in #{file}" unless entry
137
+
138
+ entry.except("path")
139
+ end
140
+
141
+ def config_file
142
+ CONFIG_FILENAMES
143
+ .map { |name| File.join(root_path, name) }
144
+ .find { |path| File.exist?(path) }
145
+ end
146
+
147
+ def load_yaml(path)
148
+ YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
149
+ end
150
+
151
+ def deep_merge(base, override)
152
+ base.merge(override) do |_key, base_val, override_val|
153
+ if base_val.is_a?(Hash) && override_val.is_a?(Hash)
154
+ deep_merge(base_val, override_val)
155
+ else
156
+ override_val
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end