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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Locallingo
|
|
4
|
+
module Validators
|
|
5
|
+
# Reports keys present in the source locale but absent from a target locale.
|
|
6
|
+
class Missing
|
|
7
|
+
def initialize(cli_name: "lingo")
|
|
8
|
+
@cli_name = cli_name
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# +source+ and +target+ are flat key=>value hashes for one target +locale+.
|
|
12
|
+
def call(source:, target:, locale:)
|
|
13
|
+
(source.keys - target.keys).map do |key|
|
|
14
|
+
{
|
|
15
|
+
type: :missing,
|
|
16
|
+
locale:,
|
|
17
|
+
key:,
|
|
18
|
+
suggestion: "Run: #{@cli_name} translate --locale #{locale}"
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../state_store"
|
|
4
|
+
|
|
5
|
+
module Locallingo
|
|
6
|
+
module Validators
|
|
7
|
+
# Reports target keys whose recorded source hash no longer matches the
|
|
8
|
+
# current source value — i.e. the English text changed after translation.
|
|
9
|
+
class Outdated
|
|
10
|
+
def initialize(cli_name: "lingo")
|
|
11
|
+
@cli_name = cli_name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# +source+ is the flat source hash; +locale_state+ is the target locale's
|
|
15
|
+
# loaded state (key => { "source_hash" => ... }).
|
|
16
|
+
def call(source:, locale_state:, locale:)
|
|
17
|
+
outdated_keys(source, locale_state).map do |key|
|
|
18
|
+
{
|
|
19
|
+
type: :outdated,
|
|
20
|
+
locale:,
|
|
21
|
+
key:,
|
|
22
|
+
suggestion: "Source changed. Run: #{@cli_name} translate --locale #{locale} --force-key #{key}"
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Keys whose stored source_hash differs from the current source hash.
|
|
28
|
+
def outdated_keys(source, locale_state)
|
|
29
|
+
source.filter_map do |key, value|
|
|
30
|
+
stored = locale_state.dig(key, "source_hash")
|
|
31
|
+
key if stored && stored != StateStore.hash(value)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/locallingo.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "locallingo/version"
|
|
4
|
+
require_relative "locallingo/configuration"
|
|
5
|
+
require_relative "locallingo/json_extraction"
|
|
6
|
+
require_relative "locallingo/key_flattener"
|
|
7
|
+
require_relative "locallingo/state_store"
|
|
8
|
+
require_relative "locallingo/providers/ruby_llm"
|
|
9
|
+
require_relative "locallingo/validators/missing"
|
|
10
|
+
require_relative "locallingo/validators/outdated"
|
|
11
|
+
require_relative "locallingo/validators/duplicate_values"
|
|
12
|
+
require_relative "locallingo/validators/manual_edits"
|
|
13
|
+
require_relative "locallingo/quality/static_rules"
|
|
14
|
+
require_relative "locallingo/quality/british_spellings"
|
|
15
|
+
require_relative "locallingo/quality/terminology"
|
|
16
|
+
require_relative "locallingo/manager"
|
|
17
|
+
require_relative "locallingo/quality_checker"
|
|
18
|
+
require_relative "locallingo/reporter"
|
|
19
|
+
require_relative "locallingo/cli"
|
|
20
|
+
|
|
21
|
+
# Locallingo — AI-assisted i18n translation, drift detection and quality linting.
|
|
22
|
+
#
|
|
23
|
+
# The public entry points are the `lingo` CLI (exe/lingo → Locallingo::CLI) and,
|
|
24
|
+
# programmatically, Locallingo::Manager / Locallingo::QualityChecker, both driven
|
|
25
|
+
# by a Locallingo::Configuration loaded from `.locallingo.yml`.
|
|
26
|
+
module Locallingo
|
|
27
|
+
class Error < StandardError; end
|
|
28
|
+
|
|
29
|
+
# Raised when an LLM call is attempted but the configured provider has no
|
|
30
|
+
# credentials available.
|
|
31
|
+
class MissingCredentialsError < Error; end
|
|
32
|
+
|
|
33
|
+
# Load the configuration for +root_path+ (defaults to the current directory),
|
|
34
|
+
# optionally scoped to a package path from the `packages:` list.
|
|
35
|
+
def self.configuration(root_path: Dir.pwd, package: nil)
|
|
36
|
+
Configuration.load(root_path:, package:)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Locallingo
|
|
6
|
+
# Enforces fully-qualified i18n keys instead of relative keys.
|
|
7
|
+
#
|
|
8
|
+
# Relative keys (e.g. `t(".notice")`) depend on Rails' lazy lookup, which is
|
|
9
|
+
# implicit and fragile: moving a translation between files, renaming an
|
|
10
|
+
# action, or reusing a string in a different context all silently break the
|
|
11
|
+
# lookup. Fully qualified keys are explicit and grep-able.
|
|
12
|
+
#
|
|
13
|
+
# The autocorrector mirrors Rails' lazy-lookup scope, deriving the prefix
|
|
14
|
+
# from the file path (mapped through `ScopedDirectories`) plus the enclosing
|
|
15
|
+
# method name. When the path does not map to a known convention it flags the
|
|
16
|
+
# offense but leaves qualification to the developer rather than guessing.
|
|
17
|
+
#
|
|
18
|
+
# The set of scoped directories is configurable so the cop fits apps with
|
|
19
|
+
# different layouts:
|
|
20
|
+
#
|
|
21
|
+
# Locallingo/RelativeI18nKey:
|
|
22
|
+
# ScopedDirectories: [controllers, mailers, views, components]
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad
|
|
26
|
+
# t(".title")
|
|
27
|
+
# t(".nested.key")
|
|
28
|
+
#
|
|
29
|
+
# # good
|
|
30
|
+
# t("users.index.title")
|
|
31
|
+
# t("users.show.nested.key")
|
|
32
|
+
class RelativeI18nKey < Base
|
|
33
|
+
extend AutoCorrector
|
|
34
|
+
|
|
35
|
+
MSG = "Use fully-qualified i18n key instead of relative key `%<key>s`."
|
|
36
|
+
|
|
37
|
+
DEFAULT_SCOPED_DIRECTORIES = %w[
|
|
38
|
+
controllers mailers views components models services jobs notifiers
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# @!method t_with_string_arg?(node)
|
|
42
|
+
def_node_matcher :t_with_string_arg?, <<~PATTERN
|
|
43
|
+
(send nil? :t (str $_) ...)
|
|
44
|
+
PATTERN
|
|
45
|
+
|
|
46
|
+
def on_send(node)
|
|
47
|
+
t_with_string_arg?(node) do |key|
|
|
48
|
+
next unless key.start_with?(".")
|
|
49
|
+
|
|
50
|
+
add_offense(node, message: format(MSG, key:)) do |corrector|
|
|
51
|
+
qualified = qualify(node, key)
|
|
52
|
+
next unless qualified
|
|
53
|
+
|
|
54
|
+
corrector.replace(node.first_argument, "\"#{qualified}\"")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def scoped_directories
|
|
62
|
+
Array(cop_config["ScopedDirectories"]).then do |configured|
|
|
63
|
+
configured.empty? ? DEFAULT_SCOPED_DIRECTORIES : configured
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def qualify(node, relative_key)
|
|
68
|
+
scope = lazy_lookup_scope(node)
|
|
69
|
+
return nil unless scope
|
|
70
|
+
|
|
71
|
+
"#{scope}#{relative_key}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Mirrors Rails' lazy-lookup scope. Returns nil if the path does not map
|
|
75
|
+
# to a known convention (we'd rather flag and require manual
|
|
76
|
+
# qualification than guess wrong).
|
|
77
|
+
def lazy_lookup_scope(node)
|
|
78
|
+
path = processed_source.file_path
|
|
79
|
+
return nil if path.nil?
|
|
80
|
+
|
|
81
|
+
rel = path.sub(%r{\A.*?app/}, "")
|
|
82
|
+
dir = rel.split("/").first
|
|
83
|
+
return nil unless scoped_directories.include?(dir)
|
|
84
|
+
|
|
85
|
+
file_scope = rel.sub(%r{\A[^/]+/}, "").delete_suffix(".rb")
|
|
86
|
+
file_scope = file_scope.delete_suffix("_controller").delete_suffix("_mailer")
|
|
87
|
+
base = file_scope.tr("/", ".")
|
|
88
|
+
|
|
89
|
+
method_name = enclosing_method(node)
|
|
90
|
+
method_name ? "#{base}.#{method_name}" : base
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enclosing_method(node)
|
|
94
|
+
node.each_ancestor(:def).first&.method_name&.to_s
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Locallingo
|
|
6
|
+
# Enforces `I18n.l(value, format: :name)` instead of `.strftime(...)` in
|
|
7
|
+
# view files.
|
|
8
|
+
#
|
|
9
|
+
# Hardcoded strftime patterns bypass locale-aware formatting. Use I18n.l
|
|
10
|
+
# with named formats defined in config/locales/time.*.yml and date.*.yml.
|
|
11
|
+
#
|
|
12
|
+
# `.strftime` inside a `value:` pair is exempt: HTML datetime-local input
|
|
13
|
+
# values must follow the HTML spec, not locale display formatting.
|
|
14
|
+
#
|
|
15
|
+
# Scope with the standard RuboCop `Include`/`Exclude` in your config:
|
|
16
|
+
#
|
|
17
|
+
# Locallingo/StrftimeInView:
|
|
18
|
+
# Include: [app/views/**/*.rb]
|
|
19
|
+
# Exclude: [app/views/**/*_mailer/**/*.rb]
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# # bad
|
|
23
|
+
# @user.created_at.strftime("%B %d, %Y")
|
|
24
|
+
#
|
|
25
|
+
# # good
|
|
26
|
+
# I18n.l(@user.created_at, format: :long)
|
|
27
|
+
class StrftimeInView < Base
|
|
28
|
+
MSG = "Use `I18n.l(value, format: :name)` instead of `.strftime(...)`. " \
|
|
29
|
+
"Define formats in config/locales/{time,date}.*.yml."
|
|
30
|
+
|
|
31
|
+
RESTRICT_ON_SEND = %i[strftime].freeze
|
|
32
|
+
|
|
33
|
+
def on_send(node)
|
|
34
|
+
return if in_html_input_context?(node)
|
|
35
|
+
|
|
36
|
+
add_offense(node.loc.selector)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Allow strftime for HTML datetime-local input values, where the format
|
|
42
|
+
# is dictated by the HTML spec, not user display.
|
|
43
|
+
def in_html_input_context?(node)
|
|
44
|
+
node.each_ancestor(:pair).any? do |pair|
|
|
45
|
+
pair.key.value == :value if pair.key.respond_to?(:value)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: locallingo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mikael Henriksson
|
|
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
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '2'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rubocop
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.75'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '2'
|
|
42
|
+
type: :development
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '1.75'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '2'
|
|
52
|
+
description: Locallingo extends i18n-tasks with AI translation (via RubyLLM — OpenAI,
|
|
53
|
+
Anthropic, and more), source-hash drift detection so only missing/changed keys are
|
|
54
|
+
re-translated, and a static + AI translation-quality linter. Everything app-specific
|
|
55
|
+
— target locales, provider/model, prompt context and glossary, per-language style
|
|
56
|
+
guides, which validators and quality rules run, and post-translate hooks — is config-driven
|
|
57
|
+
via `.locallingo.yml`, with a default block plus optional per-package overrides.
|
|
58
|
+
Ships the `lingo` CLI.
|
|
59
|
+
email: mikael@zoolutions.llc
|
|
60
|
+
executables:
|
|
61
|
+
- lingo
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- CHANGELOG.md
|
|
66
|
+
- LICENSE.txt
|
|
67
|
+
- README.md
|
|
68
|
+
- config/default.yml
|
|
69
|
+
- config/locallingo.default.yml
|
|
70
|
+
- exe/lingo
|
|
71
|
+
- lib/locallingo.rb
|
|
72
|
+
- lib/locallingo/cli.rb
|
|
73
|
+
- lib/locallingo/configuration.rb
|
|
74
|
+
- lib/locallingo/json_extraction.rb
|
|
75
|
+
- lib/locallingo/key_flattener.rb
|
|
76
|
+
- lib/locallingo/manager.rb
|
|
77
|
+
- lib/locallingo/providers/ruby_llm.rb
|
|
78
|
+
- lib/locallingo/quality/british_spellings.rb
|
|
79
|
+
- lib/locallingo/quality/static_rules.rb
|
|
80
|
+
- lib/locallingo/quality/terminology.rb
|
|
81
|
+
- lib/locallingo/quality_checker.rb
|
|
82
|
+
- lib/locallingo/reporter.rb
|
|
83
|
+
- lib/locallingo/rubocop.rb
|
|
84
|
+
- lib/locallingo/state_store.rb
|
|
85
|
+
- lib/locallingo/validators/duplicate_values.rb
|
|
86
|
+
- lib/locallingo/validators/manual_edits.rb
|
|
87
|
+
- lib/locallingo/validators/missing.rb
|
|
88
|
+
- lib/locallingo/validators/outdated.rb
|
|
89
|
+
- lib/locallingo/version.rb
|
|
90
|
+
- lib/rubocop/cop/locallingo/relative_i18n_key.rb
|
|
91
|
+
- lib/rubocop/cop/locallingo/strftime_in_view.rb
|
|
92
|
+
homepage: https://github.com/mhenrixon/locallingo
|
|
93
|
+
licenses:
|
|
94
|
+
- MIT
|
|
95
|
+
metadata:
|
|
96
|
+
source_code_uri: https://github.com/mhenrixon/locallingo
|
|
97
|
+
changelog_uri: https://github.com/mhenrixon/locallingo/blob/main/CHANGELOG.md
|
|
98
|
+
bug_tracker_uri: https://github.com/mhenrixon/locallingo/issues
|
|
99
|
+
rubygems_mfa_required: 'true'
|
|
100
|
+
rdoc_options: []
|
|
101
|
+
require_paths:
|
|
102
|
+
- lib
|
|
103
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '3.2'
|
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '0'
|
|
113
|
+
requirements: []
|
|
114
|
+
rubygems_version: 4.0.9
|
|
115
|
+
specification_version: 4
|
|
116
|
+
summary: AI-assisted i18n translation, drift detection, and quality linting on top
|
|
117
|
+
of i18n-tasks
|
|
118
|
+
test_files: []
|