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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
module Quality
|
|
5
|
+
# Regex-based, provider-free translation-quality rules. Ported from the
|
|
6
|
+
# original TranslationQualityChecker STATIC_RULES / UNIVERSAL_FIXES.
|
|
7
|
+
module StaticRules
|
|
8
|
+
# Locale-agnostic auto-fixable corrections.
|
|
9
|
+
UNIVERSAL_FIXES = {
|
|
10
|
+
"can not" => "cannot",
|
|
11
|
+
"Can not" => "Cannot"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
RULES = {
|
|
15
|
+
terminology: {
|
|
16
|
+
/\bcan not\b/i => "Use 'cannot' (one word)",
|
|
17
|
+
/\blogin\s+(to|regularly|now|here|again|first|using|with)\b/i =>
|
|
18
|
+
"Use 'log in' (verb) not 'login' here",
|
|
19
|
+
/\bclick\s+here\b/i => "Avoid 'click here' - use descriptive link text",
|
|
20
|
+
/\bplease\b.*\bplease\b/i => "Multiple 'please' in same text - remove redundancy"
|
|
21
|
+
},
|
|
22
|
+
placeholders: {
|
|
23
|
+
/%\{[^}]*\s[^}]*\}/ => "Placeholder contains spaces - may cause issues",
|
|
24
|
+
/%\{[A-Z]/ => "Placeholder starts with uppercase - convention is lowercase"
|
|
25
|
+
},
|
|
26
|
+
clarity: {
|
|
27
|
+
/\betc\.?\b/i => "Avoid 'etc.' - be specific or use 'and more'",
|
|
28
|
+
/\bstuff\b/i => "Vague word 'stuff' - be more specific",
|
|
29
|
+
/\bthings\b/i => "Vague word 'things' - be more specific",
|
|
30
|
+
/\basap\b/i => "Avoid abbreviation 'ASAP' - use 'as soon as possible'",
|
|
31
|
+
/\bfyi\b/i => "Avoid abbreviation 'FYI' - rephrase",
|
|
32
|
+
/\s{2,}/ => "Multiple consecutive spaces"
|
|
33
|
+
},
|
|
34
|
+
business: {
|
|
35
|
+
/\bsorry\b/i => "Consider 'We apologize' for formal business tone",
|
|
36
|
+
/\boops\b/i => "Informal - use professional error messaging",
|
|
37
|
+
/\buh\s*oh\b/i => "Informal - use professional error messaging",
|
|
38
|
+
/\bawesome\b/i => "Consider more professional alternatives",
|
|
39
|
+
/\bcool\b(?!\s*down)/i => "Informal - consider 'great' or 'excellent'"
|
|
40
|
+
},
|
|
41
|
+
accessibility: {
|
|
42
|
+
/\bsee\s+below\b/i => "Screen reader unfriendly - describe the content",
|
|
43
|
+
/\babove\b/i => "Positional reference - may not work for all users",
|
|
44
|
+
/\bred\b.*\berror\b|\berror\b.*\bred\b/i => "Don't rely on color alone for meaning"
|
|
45
|
+
}
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# Suggestions from the regex RULES for one key/text pair.
|
|
51
|
+
def check(key, text, locale)
|
|
52
|
+
suggestions = []
|
|
53
|
+
|
|
54
|
+
RULES.each do |category, rules|
|
|
55
|
+
rules.each do |pattern, message|
|
|
56
|
+
next unless text.match?(pattern)
|
|
57
|
+
|
|
58
|
+
suggestions << {
|
|
59
|
+
key:, text:, locale:, category:,
|
|
60
|
+
issue: message,
|
|
61
|
+
match: text.match(pattern).to_s,
|
|
62
|
+
severity: severity_for_category(category),
|
|
63
|
+
source: :static
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
suggestions
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Auto-fixable universal fixes for one key/text pair.
|
|
72
|
+
def universal_fixes(key, text, locale)
|
|
73
|
+
UNIVERSAL_FIXES.filter_map do |wrong, correct|
|
|
74
|
+
next unless text.include?(wrong)
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
key:, text:, locale:,
|
|
78
|
+
category: :grammar,
|
|
79
|
+
issue: "Use '#{correct}' instead of '#{wrong}'",
|
|
80
|
+
match: wrong,
|
|
81
|
+
fix: { from: wrong, to: correct },
|
|
82
|
+
severity: :warning,
|
|
83
|
+
source: :static
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def severity_for_category(category)
|
|
89
|
+
case category
|
|
90
|
+
when :placeholders then :error
|
|
91
|
+
when :terminology, :accessibility then :warning
|
|
92
|
+
else :info # :clarity, :business, :length, etc.
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Locallingo
|
|
6
|
+
module Quality
|
|
7
|
+
# Business/domain terminology checks. A term maps to a suggestion string, or
|
|
8
|
+
# to nil when the term is acceptable (present only so it is documented as
|
|
9
|
+
# reviewed). Selected by `quality.terminology`: a built-in name ("business",
|
|
10
|
+
# "banking", "none") or a path to a custom YAML file.
|
|
11
|
+
class Terminology
|
|
12
|
+
# Generic business terminology (the cosmos-2 default).
|
|
13
|
+
BUSINESS = {
|
|
14
|
+
"wire transfer" => "Consider 'bank transfer' for broader understanding",
|
|
15
|
+
"checking account" => "Use 'current account' for non-US markets",
|
|
16
|
+
"savings account" => nil,
|
|
17
|
+
"routing number" => "Use 'sort code' (UK) or 'branch code' for non-US",
|
|
18
|
+
"zip code" => "Use 'postal code' for international audiences",
|
|
19
|
+
"transaction" => nil,
|
|
20
|
+
"company" => nil,
|
|
21
|
+
"business" => nil,
|
|
22
|
+
"customer" => nil,
|
|
23
|
+
"client" => nil,
|
|
24
|
+
"beneficiary" => nil,
|
|
25
|
+
"payee" => nil,
|
|
26
|
+
"kyc" => nil,
|
|
27
|
+
"kyb" => nil
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Banking terminology (the zazu/app default) — a superset of BUSINESS with
|
|
31
|
+
# extra always-acceptable regulatory terms.
|
|
32
|
+
BANKING = BUSINESS.merge(
|
|
33
|
+
"know your customer" => nil,
|
|
34
|
+
"know your business" => nil,
|
|
35
|
+
"aml" => nil,
|
|
36
|
+
"cdd" => nil
|
|
37
|
+
).freeze
|
|
38
|
+
|
|
39
|
+
BUILTINS = { "business" => BUSINESS, "banking" => BANKING, "none" => {} }.freeze
|
|
40
|
+
|
|
41
|
+
attr_reader :terms
|
|
42
|
+
|
|
43
|
+
# +setting+ is a built-in name, a path to a YAML file, or nil (=> business).
|
|
44
|
+
def initialize(setting, base_path: Dir.pwd)
|
|
45
|
+
@terms = resolve(setting, base_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Suggestions for flagged (non-nil) terms found in +text+.
|
|
49
|
+
def check(key, text, locale)
|
|
50
|
+
terms.filter_map do |term, suggestion|
|
|
51
|
+
next unless suggestion
|
|
52
|
+
next unless text.downcase.include?(term.downcase)
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
key:, text:, locale:,
|
|
56
|
+
category: :terminology,
|
|
57
|
+
issue: suggestion,
|
|
58
|
+
match: term,
|
|
59
|
+
severity: :info,
|
|
60
|
+
source: :static
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def resolve(setting, base_path)
|
|
68
|
+
return BUSINESS if setting.nil?
|
|
69
|
+
return BUILTINS.fetch(setting) if BUILTINS.key?(setting)
|
|
70
|
+
|
|
71
|
+
path = File.expand_path(setting, base_path)
|
|
72
|
+
raise Error, "Unknown terminology #{setting.inspect} (not a builtin or a file)" unless File.exist?(path)
|
|
73
|
+
|
|
74
|
+
YAML.safe_load_file(path) || {}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
require_relative "configuration"
|
|
6
|
+
require_relative "key_flattener"
|
|
7
|
+
require_relative "providers/ruby_llm"
|
|
8
|
+
require_relative "quality/static_rules"
|
|
9
|
+
require_relative "quality/british_spellings"
|
|
10
|
+
require_relative "quality/terminology"
|
|
11
|
+
|
|
12
|
+
module Locallingo
|
|
13
|
+
# Checks translation quality and suggests improvements.
|
|
14
|
+
#
|
|
15
|
+
# Static (provider-free) checks: regex rules, universal fixes, terminology,
|
|
16
|
+
# optional British-spelling drift, and a very-long-text heuristic. An optional
|
|
17
|
+
# AI pass (RubyLLM) reviews a sample for clarity/professionalism.
|
|
18
|
+
#
|
|
19
|
+
# `fix!` rewrites the auto-fixable suggestions (universal fixes + British
|
|
20
|
+
# spellings) back into the locale files, preserving case.
|
|
21
|
+
class QualityChecker
|
|
22
|
+
LONG_TEXT_THRESHOLD = 200
|
|
23
|
+
|
|
24
|
+
attr_reader :config, :verbose, :logger
|
|
25
|
+
|
|
26
|
+
def initialize(config: nil, root_path: nil, package: nil, verbose: false, logger: nil)
|
|
27
|
+
@config = config || Configuration.load(root_path: root_path || Dir.pwd, package:)
|
|
28
|
+
@verbose = verbose
|
|
29
|
+
@logger = logger
|
|
30
|
+
@provider = Providers::RubyLLM.new(provider: @config.provider)
|
|
31
|
+
@terminology = Quality::Terminology.new(@config.terminology_setting, base_path: @config.base_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check all translations for a locale.
|
|
35
|
+
def check(locale: nil, use_ai: false)
|
|
36
|
+
locale ||= config.source_locale
|
|
37
|
+
translations = load_locale_translations(locale)
|
|
38
|
+
|
|
39
|
+
suggestions = translations.flat_map { |key, text| check_text(key, text, locale) }
|
|
40
|
+
suggestions.concat(ai_sample(translations, locale)) if use_ai
|
|
41
|
+
suggestions
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check a single key.
|
|
45
|
+
def check_key(key, locale, use_ai: false)
|
|
46
|
+
translations = load_locale_translations(locale)
|
|
47
|
+
text = translations[key]
|
|
48
|
+
return [{ key:, error: "Key not found" }] unless text
|
|
49
|
+
|
|
50
|
+
suggestions = check_text(key, text, locale)
|
|
51
|
+
suggestions.concat(suggest_improvements({ key => text }, locale)) if use_ai
|
|
52
|
+
suggestions
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Auto-fix the fixable suggestions in the locale files. Returns
|
|
56
|
+
# { fixed: <file count>, skipped: <non-fixable count> }.
|
|
57
|
+
def fix!(locale: nil, dry_run: false)
|
|
58
|
+
locale ||= config.source_locale
|
|
59
|
+
suggestions = check(locale:)
|
|
60
|
+
fixable = suggestions.select { |s| s[:fix] }
|
|
61
|
+
return { fixed: 0, skipped: suggestions.size - fixable.size } if fixable.empty?
|
|
62
|
+
|
|
63
|
+
changed = apply_fixes(locale, fixable)
|
|
64
|
+
|
|
65
|
+
changed.each { |file, content| File.write(file, content) } unless dry_run
|
|
66
|
+
changed.each_key { |file| log("#{dry_run ? "Would fix" : "Fixed"}: #{file}") }
|
|
67
|
+
|
|
68
|
+
{ fixed: changed.size, skipped: suggestions.size - fixable.size }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# AI suggestions for a batch of key=>text pairs.
|
|
72
|
+
def suggest_improvements(keys_with_text, locale)
|
|
73
|
+
unless @provider.credentials?
|
|
74
|
+
warn "⚠️ No LLM credentials — skipping AI suggestions"
|
|
75
|
+
return []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
result = @provider.chat(
|
|
79
|
+
model: config.quality_model,
|
|
80
|
+
instructions: quality_prompt(locale),
|
|
81
|
+
payload: keys_with_text
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
result.map do |key, suggestion|
|
|
85
|
+
symbolized = suggestion.transform_keys(&:to_sym)
|
|
86
|
+
symbolized[:severity] = symbolized[:severity]&.to_sym || :info
|
|
87
|
+
{ key:, text: keys_with_text[key], locale:, source: :ai, **symbolized }
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
warn "⚠️ AI suggestion failed: #{e.message}"
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def check_text(key, text, locale)
|
|
97
|
+
suggestions = []
|
|
98
|
+
suggestions.concat(Quality::StaticRules.universal_fixes(key, text, locale))
|
|
99
|
+
suggestions.concat(Quality::StaticRules.check(key, text, locale))
|
|
100
|
+
suggestions.concat(@terminology.check(key, text, locale))
|
|
101
|
+
suggestions.concat(Quality::BritishSpellings.check(key, text, locale)) if british_spellings_for?(locale)
|
|
102
|
+
suggestions << long_text_suggestion(key, text, locale) if text.length > LONG_TEXT_THRESHOLD
|
|
103
|
+
suggestions.compact
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def british_spellings_for?(locale)
|
|
107
|
+
config.british_spellings? && locale.to_s == config.source_locale.to_s
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def long_text_suggestion(key, text, locale)
|
|
111
|
+
{
|
|
112
|
+
key:, text: truncate(text, 100), locale:,
|
|
113
|
+
category: :length,
|
|
114
|
+
issue: "Text is very long (#{text.length} chars) - consider splitting",
|
|
115
|
+
severity: :info, source: :static
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ai_sample(translations, locale)
|
|
120
|
+
sample_size = [translations.size, 100].min
|
|
121
|
+
sample = translations.to_a.sample(sample_size).to_h
|
|
122
|
+
suggest_improvements(sample, locale)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def apply_fixes(locale, fixable)
|
|
126
|
+
changed = {}
|
|
127
|
+
Dir.glob(File.join(config.locales_dir, "**", "*.#{locale}.yml")).each do |file|
|
|
128
|
+
original = File.read(file)
|
|
129
|
+
content = original.dup
|
|
130
|
+
|
|
131
|
+
fixable.each do |suggestion|
|
|
132
|
+
next unless content.include?(suggestion[:text])
|
|
133
|
+
|
|
134
|
+
fixed_text = apply_case_preserving_fix(suggestion[:text], suggestion[:fix])
|
|
135
|
+
content = content.gsub(suggestion[:text], fixed_text)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
changed[file] = content if content != original
|
|
139
|
+
end
|
|
140
|
+
changed
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_case_preserving_fix(text, fix)
|
|
144
|
+
text.gsub(/\b#{Regexp.escape(fix[:from])}\b/i) do |match|
|
|
145
|
+
if match == match.upcase then fix[:to].upcase
|
|
146
|
+
elsif match[0] == match[0].upcase then fix[:to].capitalize
|
|
147
|
+
else fix[:to]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def quality_prompt(locale)
|
|
153
|
+
<<~PROMPT
|
|
154
|
+
Review these UI translations for #{config.context}.
|
|
155
|
+
Suggest improvements for clarity, professionalism, and user-friendliness.
|
|
156
|
+
#{glossary_section}
|
|
157
|
+
For each translation that needs improvement, provide:
|
|
158
|
+
1. The issue (brief)
|
|
159
|
+
2. Suggested improvement
|
|
160
|
+
3. Severity: "error" (must fix), "warning" (should fix), "info" (nice to have)
|
|
161
|
+
|
|
162
|
+
Only include translations that actually need changes.
|
|
163
|
+
Return ONLY a raw JSON object, with no surrounding prose and no markdown
|
|
164
|
+
code fences: {"key": {"issue": "...", "suggestion": "...", "severity": "..."}}
|
|
165
|
+
|
|
166
|
+
Locale: #{locale}
|
|
167
|
+
PROMPT
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def glossary_section
|
|
171
|
+
return "" if config.glossary.empty?
|
|
172
|
+
|
|
173
|
+
lines = config.glossary.map { |term, meaning| " - \"#{term}\" = #{meaning}" }
|
|
174
|
+
"\n#{config.context} terminology:\n#{lines.join("\n")}\n"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def load_locale_translations(locale)
|
|
178
|
+
translations = {}
|
|
179
|
+
patterns = [
|
|
180
|
+
File.join(config.locales_dir, "**", "*.#{locale}.yml"),
|
|
181
|
+
File.join(config.locales_dir, "#{locale}.yml")
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
patterns.each do |pattern|
|
|
185
|
+
Dir.glob(pattern).each do |file|
|
|
186
|
+
content = YAML.load_file(file)
|
|
187
|
+
next unless content.is_a?(Hash) && content[locale]
|
|
188
|
+
|
|
189
|
+
KeyFlattener.flatten(content[locale]).each do |key, value|
|
|
190
|
+
translations[key] = value if value.is_a?(String)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
translations
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def truncate(text, length)
|
|
199
|
+
text.length > length ? "#{text[0, length]}..." : text
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def log(message)
|
|
203
|
+
logger&.info(message)
|
|
204
|
+
warn(message) if verbose
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Locallingo
|
|
6
|
+
# Renders status/violation/quality output for the CLI (text + JSON) and
|
|
7
|
+
# computes strict exit codes. Extracted from the original bin/translate
|
|
8
|
+
# printers so the CLI stays thin.
|
|
9
|
+
class Reporter
|
|
10
|
+
SEVERITY_ICONS = { error: "🔴", warning: "🟡", info: "🔵" }.freeze
|
|
11
|
+
TYPE_ICONS = {
|
|
12
|
+
missing: "❌", outdated: "🔄", duplicate_value: "🔁", manual_edit: "✏️"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config:, format: :text, io: $stdout, cli_name: "lingo")
|
|
16
|
+
@config = config
|
|
17
|
+
@format = format
|
|
18
|
+
@io = io
|
|
19
|
+
@cli_name = cli_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def status(status)
|
|
23
|
+
return json(status) if json?
|
|
24
|
+
|
|
25
|
+
puts "\n📊 Translation Status"
|
|
26
|
+
puts "=" * 60
|
|
27
|
+
status.each { |locale, info| print_locale_status(locale, info) }
|
|
28
|
+
puts ""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Prints violations and returns the strict exit code.
|
|
32
|
+
def violations(violations, strict: false, strict_all: false)
|
|
33
|
+
if json?
|
|
34
|
+
json(violations)
|
|
35
|
+
return exit_code(violations, strict:, strict_all:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if violations.empty?
|
|
39
|
+
puts "\n✅ All translations valid!"
|
|
40
|
+
return 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
puts "\n❌ Translation Issues Found"
|
|
44
|
+
puts "=" * 60
|
|
45
|
+
violations.group_by { |v| v[:type] }.each { |type, items| print_violation_group(type, items) }
|
|
46
|
+
puts "\nTotal: #{violations.size} issues"
|
|
47
|
+
|
|
48
|
+
exit_code(violations, strict:, strict_all:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def quality(suggestions, locale:)
|
|
52
|
+
return json(suggestions) if json?
|
|
53
|
+
|
|
54
|
+
if suggestions.empty?
|
|
55
|
+
puts "\n✅ No quality issues found!"
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
puts "\n📝 Translation Quality Suggestions"
|
|
60
|
+
puts "=" * 60
|
|
61
|
+
print_quality_by_severity(suggestions)
|
|
62
|
+
print_quality_summary(suggestions, locale)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Exit code for a strict tier, per the configured strict_types.
|
|
66
|
+
def exit_code(violations, strict:, strict_all:)
|
|
67
|
+
return 0 unless strict || strict_all
|
|
68
|
+
|
|
69
|
+
tier = strict_all ? :strict_all : :strict
|
|
70
|
+
error_types = @config.strict_types(tier)
|
|
71
|
+
violations.any? { |v| error_types.include?(v[:type]) } ? 1 : 0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def json? = @format == :json
|
|
77
|
+
def puts(str = "") = @io.puts(str)
|
|
78
|
+
|
|
79
|
+
def json(data)
|
|
80
|
+
@io.puts(JSON.pretty_generate(data))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def print_locale_status(locale, info)
|
|
84
|
+
icon = info[:missing].zero? && info[:outdated].zero? ? "✅" : "⚠️"
|
|
85
|
+
puts "\n#{icon} #{locale}"
|
|
86
|
+
puts " Total keys: #{info[:total_keys]}"
|
|
87
|
+
puts " Translated: #{info[:translated]}"
|
|
88
|
+
puts " Missing: #{info[:missing]}"
|
|
89
|
+
puts " Outdated: #{info[:outdated]}"
|
|
90
|
+
print_key_list("Missing (first 10):", info[:missing_keys])
|
|
91
|
+
print_key_list("Outdated (first 10):", info[:outdated_keys])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def print_key_list(label, keys)
|
|
95
|
+
return unless keys&.any?
|
|
96
|
+
|
|
97
|
+
puts " #{label}"
|
|
98
|
+
keys.each { |k| puts " - #{k}" }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def print_violation_group(type, items)
|
|
102
|
+
puts "\n#{TYPE_ICONS.fetch(type, "❓")} #{type.to_s.tr("_", " ").capitalize} (#{items.size})"
|
|
103
|
+
items.first(10).each do |v|
|
|
104
|
+
puts " #{v[:locale]}: #{v[:key]}"
|
|
105
|
+
puts " → #{v[:suggestion]}"
|
|
106
|
+
end
|
|
107
|
+
puts " ... and #{items.size - 10} more" if items.size > 10
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def print_quality_by_severity(suggestions)
|
|
111
|
+
by_severity = suggestions.group_by { |s| s[:severity] }
|
|
112
|
+
%i[error warning info].each do |severity|
|
|
113
|
+
items = by_severity[severity] || []
|
|
114
|
+
next if items.empty?
|
|
115
|
+
|
|
116
|
+
fixable = items.count { |s| s[:fix] }
|
|
117
|
+
note = fixable.positive? ? " (#{fixable} auto-fixable)" : ""
|
|
118
|
+
puts "\n#{SEVERITY_ICONS[severity]} #{severity.to_s.capitalize} (#{items.size})#{note}"
|
|
119
|
+
items.first(20).each { |s| print_quality_item(s) }
|
|
120
|
+
puts " ... and #{items.size - 20} more" if items.size > 20
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def print_quality_item(suggestion)
|
|
125
|
+
puts " #{suggestion[:key]}"
|
|
126
|
+
puts " Text: #{truncate(suggestion[:text].to_s, 60)}"
|
|
127
|
+
puts " Issue: #{suggestion[:issue]}"
|
|
128
|
+
puts " Fix: Replace '#{suggestion[:fix][:from]}' → '#{suggestion[:fix][:to]}'" if suggestion[:fix]
|
|
129
|
+
puts ""
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def print_quality_summary(suggestions, locale)
|
|
133
|
+
fixable = suggestions.count { |s| s[:fix] }
|
|
134
|
+
puts "\n#{"-" * 60}"
|
|
135
|
+
puts "Summary: #{suggestions.size} issues found"
|
|
136
|
+
if fixable.positive?
|
|
137
|
+
puts "\nTo auto-fix #{fixable} issues, run:"
|
|
138
|
+
puts " #{@cli_name} fix-quality --locale #{locale}"
|
|
139
|
+
end
|
|
140
|
+
puts ""
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def truncate(text, length)
|
|
144
|
+
text.length > length ? "#{text[0, length]}..." : text
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loads Locallingo's RuboCop cops. `require`d only from a host app's
|
|
4
|
+
# `.rubocop.yml` (`require: locallingo/rubocop`), so `rubocop` is never pulled
|
|
5
|
+
# into the app's runtime — it is a development-time dependency of both the host
|
|
6
|
+
# app and this gem.
|
|
7
|
+
#
|
|
8
|
+
# The shipped defaults live in config/default.yml; a host app enables/scopes the
|
|
9
|
+
# cops there via `inherit_gem`.
|
|
10
|
+
|
|
11
|
+
require "rubocop"
|
|
12
|
+
|
|
13
|
+
require_relative "../rubocop/cop/locallingo/relative_i18n_key"
|
|
14
|
+
require_relative "../rubocop/cop/locallingo/strftime_in_view"
|
|
15
|
+
|
|
16
|
+
module Locallingo
|
|
17
|
+
# Absolute path to the cop defaults, for `inherit_gem: locallingo: <path>`.
|
|
18
|
+
module RuboCop
|
|
19
|
+
CONFIG_DEFAULT = File.expand_path("../../config/default.yml", __dir__)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Locallingo
|
|
8
|
+
# Reads and writes the source-hash drift state under `state_dir`.
|
|
9
|
+
#
|
|
10
|
+
# State is split into one JSON file per top-level namespace and locale
|
|
11
|
+
# (`accounts.de.json`, ...) so diffs stay small and reviewable. Each entry
|
|
12
|
+
# records the source hash a key was translated from, so a later source change
|
|
13
|
+
# marks the translation outdated. Optionally a `target_hash` and `manual` flag
|
|
14
|
+
# are tracked so hand-edited target values can be protected from overwrites.
|
|
15
|
+
class StateStore
|
|
16
|
+
attr_reader :state_dir
|
|
17
|
+
|
|
18
|
+
def initialize(state_dir)
|
|
19
|
+
@state_dir = state_dir
|
|
20
|
+
FileUtils.mkdir_p(@state_dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# CRC32 hash — fast and compact (8 hex chars).
|
|
24
|
+
def self.hash(text)
|
|
25
|
+
format("%08x", Zlib.crc32(text.to_s))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def hash(text) = self.class.hash(text)
|
|
29
|
+
|
|
30
|
+
# Load the combined state for a locale (merged across its namespace files).
|
|
31
|
+
def load(locale)
|
|
32
|
+
combined = {}
|
|
33
|
+
Dir.glob(File.join(state_dir, "*.#{locale}.json")).each do |file|
|
|
34
|
+
combined.merge!(JSON.parse(File.read(file)))
|
|
35
|
+
end
|
|
36
|
+
combined
|
|
37
|
+
rescue JSON::ParserError => e
|
|
38
|
+
raise Error,
|
|
39
|
+
"Corrupted state file: #{e.message}\n" \
|
|
40
|
+
"This would cause state loss. Fix the JSON manually or restore from git."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Save a locale's state, split back into per-namespace files. Namespace files
|
|
44
|
+
# that no longer have keys are removed.
|
|
45
|
+
def save(locale, locale_state)
|
|
46
|
+
by_namespace = locale_state.each_with_object({}) do |(key, value), groups|
|
|
47
|
+
namespace = key.split(".").first
|
|
48
|
+
(groups[namespace] ||= {})[key] = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
by_namespace.each do |namespace, keys|
|
|
52
|
+
state_file = File.join(state_dir, "#{namespace}.#{locale}.json")
|
|
53
|
+
File.write(state_file, JSON.pretty_generate(keys.sort.to_h))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Dir.glob(File.join(state_dir, "*.#{locale}.json")).each do |file|
|
|
57
|
+
namespace = File.basename(file).delete_suffix(".#{locale}.json")
|
|
58
|
+
File.delete(file) unless by_namespace.key?(namespace)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
module Validators
|
|
5
|
+
# Detects source-locale keys whose value matches an
|
|
6
|
+
# `activerecord.attributes.*` value. The AR key wins; the non-AR key is the
|
|
7
|
+
# "duplicate" and should reuse the AR key instead.
|
|
8
|
+
#
|
|
9
|
+
# Operates on the flat source hash only, to avoid false positives from
|
|
10
|
+
# grammatical-form differences in non-English locales.
|
|
11
|
+
class DuplicateValues
|
|
12
|
+
AR_ATTRIBUTES_PREFIX = "activerecord.attributes."
|
|
13
|
+
AR_PREFIX = "activerecord."
|
|
14
|
+
|
|
15
|
+
# +source+ is the flat source (en) hash. Returns :duplicate_value
|
|
16
|
+
# violations naming both keys.
|
|
17
|
+
def call(source:)
|
|
18
|
+
ar_keys_by_value = source
|
|
19
|
+
.select { |key, _| key.start_with?(AR_ATTRIBUTES_PREFIX) }
|
|
20
|
+
.group_by { |_key, value| value }
|
|
21
|
+
.transform_values { |pairs| pairs.map(&:first) }
|
|
22
|
+
|
|
23
|
+
source.filter_map do |key, value|
|
|
24
|
+
# Skip ALL activerecord.* keys (models, attributes, etc.). Rails
|
|
25
|
+
# reserves this namespace for AR-generated translations, and model
|
|
26
|
+
# names colliding with attribute labels is intentional, not a dup.
|
|
27
|
+
next if key.start_with?(AR_PREFIX)
|
|
28
|
+
next unless ar_keys_by_value.key?(value)
|
|
29
|
+
|
|
30
|
+
ar_dupes = ar_keys_by_value[value]
|
|
31
|
+
{
|
|
32
|
+
type: :duplicate_value,
|
|
33
|
+
locale: "en",
|
|
34
|
+
key:,
|
|
35
|
+
suggestion: "Value '#{value}' duplicates #{ar_dupes.join(", ")}. " \
|
|
36
|
+
"Use #{ar_dupes.first} instead."
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../state_store"
|
|
4
|
+
|
|
5
|
+
module Locallingo
|
|
6
|
+
module Validators
|
|
7
|
+
# Detects target values that were hand-edited after Locallingo wrote them.
|
|
8
|
+
#
|
|
9
|
+
# When enabled, the Manager records a `target_hash` alongside `source_hash`
|
|
10
|
+
# for each translated key. If the current target value's hash differs from
|
|
11
|
+
# the recorded `target_hash` (and the key is not already flagged `manual`),
|
|
12
|
+
# it was edited by a human — surface it so the operator can protect it with
|
|
13
|
+
# `accept-edits` before the next translate run overwrites it.
|
|
14
|
+
class ManualEdits
|
|
15
|
+
def initialize(cli_name: "lingo")
|
|
16
|
+
@cli_name = cli_name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# +target+ is the flat target hash; +locale_state+ its loaded state.
|
|
20
|
+
def call(target:, locale_state:, locale:)
|
|
21
|
+
target.filter_map do |key, value|
|
|
22
|
+
entry = locale_state[key]
|
|
23
|
+
next unless entry.is_a?(Hash)
|
|
24
|
+
next if entry["manual"]
|
|
25
|
+
|
|
26
|
+
stored = entry["target_hash"]
|
|
27
|
+
next unless stored && stored != StateStore.hash(value)
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
type: :manual_edit,
|
|
31
|
+
locale:,
|
|
32
|
+
key:,
|
|
33
|
+
suggestion: "Value was hand-edited. Protect it: #{@cli_name} accept-edits --locale #{locale}"
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|