i18n-context-generator 0.3.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.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ module Writers
5
+ # Shared utilities for writer classes (description filtering, file discovery).
6
+ module Helpers
7
+ def skip_description?(description)
8
+ description.include?('No usage found') || description.include?('Processing failed')
9
+ end
10
+
11
+ def writable_result?(result)
12
+ return false unless result&.description
13
+ return false unless result.error.nil?
14
+ return false if result.description.strip.empty?
15
+
16
+ !skip_description?(result.description)
17
+ end
18
+
19
+ def result_matches_source_path?(result, source_path)
20
+ return true unless result.respond_to?(:source_file) && result.source_file
21
+
22
+ File.expand_path(result.source_file) == File.expand_path(source_path)
23
+ end
24
+
25
+ def find_swift_files(path, ignore_patterns: [])
26
+ files = if File.file?(path) && path.end_with?('.swift')
27
+ [path]
28
+ elsif File.directory?(path)
29
+ Dir.glob(File.join(path, '**', '*.swift'))
30
+ else
31
+ []
32
+ end
33
+
34
+ filter_ignored_paths(files, ignore_patterns)
35
+ end
36
+
37
+ private
38
+
39
+ def filter_ignored_paths(paths, ignore_patterns)
40
+ compiled_patterns = ignore_patterns.map { |pattern| glob_to_regex(pattern) }
41
+ paths.reject { |path| ignored_path?(path, compiled_patterns) }.sort
42
+ end
43
+
44
+ def ignored_path?(path, compiled_patterns)
45
+ compiled_patterns.any? { |pattern| pattern.match?(path) }
46
+ end
47
+
48
+ def glob_to_regex(glob_pattern)
49
+ regex_str = Regexp.escape(glob_pattern)
50
+ .gsub('\*\*/', '(.*/)?')
51
+ .gsub('\*\*', '.*')
52
+ .gsub('\*', '[^/]*')
53
+ .gsub('\?', '.')
54
+ Regexp.new("(?:^|/)#{regex_str}(?:$|/)")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module I18nContextGenerator
6
+ module Writers
7
+ # Writes extraction results to a JSON file.
8
+ class JsonWriter
9
+ def write(results, path)
10
+ output = {
11
+ generated_at: Time.now.iso8601,
12
+ version: I18nContextGenerator::VERSION,
13
+ total: results.size,
14
+ entries: results.sort_by(&:key).map do |result|
15
+ {
16
+ key: result.key,
17
+ text: result.text,
18
+ context: {
19
+ description: result.description,
20
+ ui_element: result.ui_element,
21
+ tone: result.tone,
22
+ max_length: result.max_length
23
+ },
24
+ locations: result.locations,
25
+ error: result.error
26
+ }
27
+ end
28
+ }
29
+
30
+ File.write(path, Oj.dump(output, indent: 2, mode: :compat))
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ module Writers
5
+ # Writer that updates iOS .strings files with context comments
6
+ # Uses the dotstrings gem for proper parsing and generation
7
+ class StringsWriter
8
+ include Helpers
9
+
10
+ def initialize(context_prefix: 'Context: ', context_mode: 'replace')
11
+ @context_prefix = context_prefix
12
+ @context_mode = context_mode
13
+ end
14
+
15
+ def write(results, source_path)
16
+ return unless File.exist?(source_path)
17
+
18
+ # Parse the existing file
19
+ original_file = DotStrings.parse_file(source_path, strict: false)
20
+ results_by_key = results.each_with_object({}) do |result, lookup|
21
+ next unless result_matches_source_path?(result, source_path)
22
+
23
+ lookup[result.key] = result
24
+ end
25
+
26
+ # Build new file with updated comments (DotStrings::Item is immutable)
27
+ new_file = DotStrings::File.new
28
+
29
+ original_file.items.each do |item|
30
+ result = results_by_key[item.key]
31
+
32
+ new_comment = if writable_result?(result)
33
+ build_comment(item.comment, result.description)
34
+ else
35
+ item.comment
36
+ end
37
+
38
+ new_item = DotStrings::Item.new(
39
+ key: item.key,
40
+ value: item.value,
41
+ comment: new_comment
42
+ )
43
+ new_file << new_item
44
+ end
45
+
46
+ # Write back to file
47
+ File.write(source_path, new_file.to_s)
48
+ end
49
+
50
+ private
51
+
52
+ def build_comment(existing_comment, context_description)
53
+ context_line = "#{@context_prefix}#{context_description}"
54
+
55
+ if existing_comment.nil? || existing_comment.empty? || @context_mode == 'replace'
56
+ context_line
57
+ elsif !@context_prefix.empty? && existing_comment.include?(@context_prefix)
58
+ # Replace existing context line (idempotent update)
59
+ existing_comment.gsub(/#{Regexp.escape(@context_prefix)}[^\n]*/, context_line)
60
+ else
61
+ # Append context to existing comment
62
+ "#{existing_comment}\n#{context_line}"
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ module Writers
5
+ # Writer that updates comment: parameters in Swift localization calls
6
+ # Supports NSLocalizedString, String(localized:), Text(), and custom functions
7
+ class SwiftWriter
8
+ include Helpers
9
+
10
+ SWIFT_STRING_PATTERN = '"(?:\\\\.|[^"\\\\])*"'
11
+ COMMENT_ARGUMENT_PATTERN = /comment:\s*"((?:\\.|[^"\\])*)"/
12
+ private_constant :SWIFT_STRING_PATTERN, :COMMENT_ARGUMENT_PATTERN
13
+
14
+ # Default patterns for Swift localization functions
15
+ # Each pattern should capture: (prefix)(key)(middle)(comment_value)(suffix)
16
+ DEFAULT_FUNCTIONS = %w[
17
+ NSLocalizedString
18
+ String(localized:
19
+ Text(
20
+ ].freeze
21
+
22
+ def initialize(functions: nil, context_prefix: 'Context: ', context_mode: 'replace')
23
+ @functions = functions || DEFAULT_FUNCTIONS
24
+ @context_prefix = context_prefix
25
+ @context_mode = context_mode
26
+ end
27
+
28
+ # Write context back to all Swift files that contain the keys
29
+ # @param results [Array] extraction results with key and description
30
+ # @param source_paths [Array<String>] paths to search for Swift files
31
+ def write_to_source_files(results, source_paths, ignore_patterns: [])
32
+ results_by_key = results.to_h { |r| [r.key, r] }
33
+
34
+ source_paths.each do |source_path|
35
+ swift_files = find_swift_files(source_path, ignore_patterns: ignore_patterns)
36
+
37
+ swift_files.each do |swift_file|
38
+ update_file(swift_file, results_by_key)
39
+ end
40
+ end
41
+ end
42
+
43
+ # Update a single Swift file with context comments
44
+ # @param path [String] path to Swift file
45
+ # @param results_by_key [Hash] results keyed by translation key
46
+ def update_file(path, results_by_key)
47
+ return unless File.exist?(path)
48
+
49
+ content = File.read(path)
50
+ original_content = content.dup
51
+ updated = false
52
+
53
+ results_by_key.each do |key, result|
54
+ next unless writable_result?(result)
55
+
56
+ new_content = update_comment_for_key(content, key, result.description)
57
+ if new_content != content
58
+ content = new_content
59
+ updated = true
60
+ end
61
+ end
62
+
63
+ if updated && content != original_content
64
+ File.write(path, content)
65
+ true
66
+ else
67
+ false
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Update comment for a specific key in the content
74
+ def update_comment_for_key(content, key, description)
75
+ escaped_key = Regexp.escape(key)
76
+
77
+ @functions.each do |func|
78
+ # Build pattern based on function type
79
+ pattern = build_pattern_for_function(func, escaped_key)
80
+ next unless pattern
81
+
82
+ # Try to match and replace
83
+ content = content.gsub(pattern) do |match|
84
+ update_match(match, func, key, description)
85
+ end
86
+ end
87
+
88
+ content
89
+ end
90
+
91
+ def build_pattern_for_function(func, escaped_key)
92
+ comment_pattern = "comment:\\s*#{SWIFT_STRING_PATTERN}"
93
+
94
+ case func
95
+ when 'NSLocalizedString'
96
+ # NSLocalizedString("key", comment: "...")
97
+ # NSLocalizedString("key", value: "...", comment: "...")
98
+ # NSLocalizedString("key", tableName: "...", comment: "...")
99
+ Regexp.new("NSLocalizedString\\(\\s*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
100
+ when 'String(localized:'
101
+ # String(localized: "key", comment: "...")
102
+ Regexp.new("String\\(\\s*localized:\\s*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
103
+ when 'Text('
104
+ # Text("key", comment: "...")
105
+ # Text(LocalizedStringKey("key"), comment: "...")
106
+ Regexp.new("Text\\([^)]*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
107
+ else
108
+ # Custom function - assume pattern like: func("key", ..., comment: "...")
109
+ escaped_func = Regexp.escape(func)
110
+ Regexp.new("#{escaped_func}\\([^)]*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
111
+ end
112
+ end
113
+
114
+ def update_match(match, _func, _key, new_comment)
115
+ # Replace the comment value while preserving the rest of the call
116
+ match.gsub(COMMENT_ARGUMENT_PATTERN) do |_comment_match|
117
+ existing_comment = unescape_swift_string(Regexp.last_match(1))
118
+ final_comment = build_final_comment(existing_comment, new_comment)
119
+ "comment: \"#{escape_swift_string(final_comment)}\""
120
+ end
121
+ end
122
+
123
+ def build_final_comment(existing_comment, new_context)
124
+ context_line = "#{@context_prefix}#{new_context}"
125
+
126
+ if existing_comment.nil? || existing_comment.empty? || @context_mode == 'replace'
127
+ context_line
128
+ elsif !@context_prefix.empty? && existing_comment.include?(@context_prefix)
129
+ # Update existing context line (idempotent)
130
+ existing_comment.gsub(/#{Regexp.escape(@context_prefix)}[^\n]*/, context_line)
131
+ else
132
+ # Append context to existing comment
133
+ "#{existing_comment} #{context_line}"
134
+ end
135
+ end
136
+
137
+ def escape_swift_string(str)
138
+ str.gsub('\\', '\\\\')
139
+ .gsub('"', '\\"')
140
+ .gsub("\r", '\\r')
141
+ .gsub("\n", '\\n')
142
+ .gsub("\t", '\\t')
143
+ end
144
+
145
+ def unescape_swift_string(str)
146
+ escape_map = {
147
+ '"' => '"',
148
+ '\\' => '\\',
149
+ 'n' => "\n",
150
+ 'r' => "\r",
151
+ 't' => "\t"
152
+ }
153
+
154
+ str.gsub(/\\(["\\nrt])/) do
155
+ escape_map.fetch(Regexp.last_match(1))
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'csv'
5
+ require 'digest'
6
+ require 'fileutils'
7
+ require 'oj'
8
+ require 'concurrent'
9
+ require 'tty-progressbar'
10
+ require 'dotstrings'
11
+
12
+ require_relative 'i18n_context_generator/version'
13
+ require_relative 'i18n_context_generator/config'
14
+ require_relative 'i18n_context_generator/parsers/base'
15
+ require_relative 'i18n_context_generator/parsers/json_parser'
16
+ require_relative 'i18n_context_generator/parsers/yaml_parser'
17
+ require_relative 'i18n_context_generator/parsers/strings_parser'
18
+ require_relative 'i18n_context_generator/parsers/android_xml_parser'
19
+ require_relative 'i18n_context_generator/searcher'
20
+ require_relative 'i18n_context_generator/llm/client'
21
+ require_relative 'i18n_context_generator/llm/anthropic'
22
+ require_relative 'i18n_context_generator/llm/openai'
23
+ require_relative 'i18n_context_generator/writers/helpers'
24
+ require_relative 'i18n_context_generator/writers/csv_writer'
25
+ require_relative 'i18n_context_generator/writers/json_writer'
26
+ require_relative 'i18n_context_generator/writers/strings_writer'
27
+ require_relative 'i18n_context_generator/writers/android_xml_writer'
28
+ require_relative 'i18n_context_generator/writers/swift_writer'
29
+ require_relative 'i18n_context_generator/cache'
30
+ require_relative 'i18n_context_generator/git_diff'
31
+ require_relative 'i18n_context_generator/platform_validator'
32
+ require_relative 'i18n_context_generator/context_extractor'
33
+
34
+ module I18nContextGenerator
35
+ class Error < StandardError; end
36
+ end
metadata ADDED
@@ -0,0 +1,196 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: i18n-context-generator
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Automattic
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dotstrings
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: oj
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.16'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.16'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rexml
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: thor
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: tty-progressbar
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.18'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.18'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.13'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.13'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-rake
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rspec
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: A CLI tool that analyzes source code to extract contextual information
140
+ for translation keys, improving translation quality with AI-powered analysis.
141
+ email: mobile@automattic.com
142
+ executables:
143
+ - i18n-context-generator
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - LICENSE
148
+ - README.md
149
+ - exe/i18n-context-generator
150
+ - lib/i18n_context_generator.rb
151
+ - lib/i18n_context_generator/cache.rb
152
+ - lib/i18n_context_generator/cli.rb
153
+ - lib/i18n_context_generator/config.rb
154
+ - lib/i18n_context_generator/context_extractor.rb
155
+ - lib/i18n_context_generator/git_diff.rb
156
+ - lib/i18n_context_generator/llm/anthropic.rb
157
+ - lib/i18n_context_generator/llm/client.rb
158
+ - lib/i18n_context_generator/llm/openai.rb
159
+ - lib/i18n_context_generator/parsers/android_xml_parser.rb
160
+ - lib/i18n_context_generator/parsers/base.rb
161
+ - lib/i18n_context_generator/parsers/json_parser.rb
162
+ - lib/i18n_context_generator/parsers/strings_parser.rb
163
+ - lib/i18n_context_generator/parsers/yaml_parser.rb
164
+ - lib/i18n_context_generator/platform_validator.rb
165
+ - lib/i18n_context_generator/searcher.rb
166
+ - lib/i18n_context_generator/version.rb
167
+ - lib/i18n_context_generator/writers/android_xml_writer.rb
168
+ - lib/i18n_context_generator/writers/csv_writer.rb
169
+ - lib/i18n_context_generator/writers/helpers.rb
170
+ - lib/i18n_context_generator/writers/json_writer.rb
171
+ - lib/i18n_context_generator/writers/strings_writer.rb
172
+ - lib/i18n_context_generator/writers/swift_writer.rb
173
+ homepage: https://github.com/Automattic/i18n-context-generator
174
+ licenses:
175
+ - MPL-2.0
176
+ metadata: {}
177
+ post_install_message:
178
+ rdoc_options: []
179
+ require_paths:
180
+ - lib
181
+ required_ruby_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: 3.2.0
186
+ required_rubygems_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ requirements: []
192
+ rubygems_version: 3.4.10
193
+ signing_key:
194
+ specification_version: 4
195
+ summary: Extract translation context from source code using AI
196
+ test_files: []