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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +134 -0
- data/config/default.yml +47 -0
- data/config/locallingo.default.yml +70 -0
- data/exe/lingo +6 -0
- data/lib/locallingo/cli.rb +204 -0
- data/lib/locallingo/configuration.rb +161 -0
- data/lib/locallingo/json_extraction.rb +99 -0
- data/lib/locallingo/key_flattener.rb +114 -0
- data/lib/locallingo/manager.rb +376 -0
- data/lib/locallingo/providers/ruby_llm.rb +69 -0
- data/lib/locallingo/quality/british_spellings.rb +45 -0
- data/lib/locallingo/quality/static_rules.rb +97 -0
- data/lib/locallingo/quality/terminology.rb +78 -0
- data/lib/locallingo/quality_checker.rb +207 -0
- data/lib/locallingo/reporter.rb +147 -0
- data/lib/locallingo/rubocop.rb +21 -0
- data/lib/locallingo/state_store.rb +62 -0
- data/lib/locallingo/validators/duplicate_values.rb +42 -0
- data/lib/locallingo/validators/manual_edits.rb +39 -0
- data/lib/locallingo/validators/missing.rb +24 -0
- data/lib/locallingo/validators/outdated.rb +36 -0
- data/lib/locallingo/version.rb +5 -0
- data/lib/locallingo.rb +38 -0
- data/lib/rubocop/cop/locallingo/relative_i18n_key.rb +99 -0
- data/lib/rubocop/cop/locallingo/strftime_in_view.rb +51 -0
- metadata +118 -0
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
|
data/config/default.yml
ADDED
|
@@ -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,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
|