auto-l18n 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9eaeb61d41c636035f955c83afb385e40a020e4806bcc2553b965032b3966c64
4
+ data.tar.gz: c062d54dd2f5b7e639eeb9304e31993aa0a7485703a4236868d86a731ccc1518
5
+ SHA512:
6
+ metadata.gz: ae940be66b33102dce802addaa5f2d3abacde2f3e934383b8ab0a663329a5672c3ef84065d5020332de26cca3f43831a2e214c61ffecfcae661b34d2e2249d0c
7
+ data.tar.gz: 31b3b158aa2d1978828874f1d30e7815e5e8b868e599beb3e93fb1c7f68335476386c03d198e3506ce967208f66dec2e985c4e03570fa3ff860fcc434928da01
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-10-26
4
+
5
+ - Initial release
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Nicolas Reiner
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,257 @@
1
+ # Auto::L18n
2
+
3
+ Automatically find and replace hardcoded text in Rails ERB view files with I18n translation calls. Auto::L18n scans your HTML/ERB files, detects hardcoded strings, and can automatically replace them with proper I18n `t()` calls while generating the corresponding locale YAML files.
4
+
5
+ ## Features
6
+
7
+ - 🔍 **Smart Detection** - Finds hardcoded text in ERB code, HTML content, attributes, and optionally JavaScript
8
+ - 🔄 **Automatic Replacement** - Replaces hardcoded strings with I18n translation calls
9
+ - 📝 **Locale File Generation** - Automatically creates/updates YAML locale files
10
+ - 🎯 **Intelligent Filtering** - Skips existing I18n calls, comments, and code-like patterns
11
+ - 🔧 **Highly Configurable** - Control what gets extracted and how
12
+ - 💻 **CLI & Programmatic API** - Use from command line or Ruby code
13
+ - 🧪 **Dry Run Mode** - Preview changes before applying them
14
+ - 💾 **Automatic Backups** - Creates backup files before modifying originals
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'auto-l18n'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install it yourself:
31
+
32
+ ```bash
33
+ gem install auto-l18n
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ### Find hardcoded text in a file:
39
+
40
+ ```ruby
41
+ require 'auto/l18n'
42
+
43
+ texts = Auto::L18n.find_text("app/views/posts/show.html.erb")
44
+ texts.each { |t| puts "- #{t}" }
45
+ ```
46
+
47
+ ### Replace hardcoded text with I18n calls:
48
+
49
+ ```ruby
50
+ # Preview changes (dry run)
51
+ result = Auto::L18n.auto_internationalize(
52
+ "app/views/posts/show.html.erb",
53
+ namespace: "views.posts.show",
54
+ dry_run: true
55
+ )
56
+
57
+ puts "Would replace #{result[:total_replaced]} strings"
58
+
59
+ # Apply changes
60
+ result = Auto::L18n.auto_internationalize(
61
+ "app/views/posts/show.html.erb",
62
+ namespace: "views.posts.show"
63
+ )
64
+ ```
65
+
66
+ ### Command Line:
67
+
68
+ ```bash
69
+ # Find hardcoded text
70
+ ruby exe/auto-l18n app/views/posts/show.html.erb
71
+
72
+ # Replace with I18n calls (dry run)
73
+ ruby exe/auto-l18n app/views/posts/show.html.erb \
74
+ --replace --namespace views.posts.show --dry-run
75
+
76
+ # Actually apply changes
77
+ ruby exe/auto-l18n app/views/posts/show.html.erb \
78
+ --replace --namespace views.posts.show
79
+ ```
80
+
81
+ ## Example Transformation
82
+
83
+ **Before:**
84
+ ```erb
85
+ <h1>Welcome to our blog</h1>
86
+ <p>Please <%= "sign in" %> to continue.</p>
87
+ <button title="Click here">Submit</button>
88
+ ```
89
+
90
+ **After:**
91
+ ```erb
92
+ <h1><%= t('views.posts.welcome_to_our_blog') %></h1>
93
+ <p>Please <%= t('views.posts.sign_in') %> to continue.</p>
94
+ <button title="<%= t('views.posts.click_here') %>">Submit</button>
95
+ ```
96
+
97
+ **Generated locale file (config/locales/en.yml):**
98
+ ```yaml
99
+ en:
100
+ views:
101
+ posts:
102
+ welcome_to_our_blog: "Welcome to our blog"
103
+ sign_in: "sign in"
104
+ click_here: "Click here"
105
+ ```
106
+
107
+ ## Documentation
108
+
109
+ - **[Quick Start Guide](QUICKSTART.md)** - Get up and running quickly
110
+ - **[API Documentation](API.md)** - Complete API reference and examples
111
+ - **[Demo Script](demo.rb)** - Run `ruby demo.rb` to see it in action
112
+
113
+ ## Main Methods
114
+
115
+ ### `Auto::L18n.find_text(path, options = {})`
116
+
117
+ Find all hardcoded text in a file.
118
+
119
+ ```ruby
120
+ # Simple usage
121
+ texts = Auto::L18n.find_text("app/views/posts/show.html.erb")
122
+
123
+ # With structured output (includes metadata)
124
+ findings = Auto::L18n.find_text("app/views/posts/show.html.erb", structured: true)
125
+ findings.each do |f|
126
+ puts "#{f.text} (#{f.type}) at line #{f.line}"
127
+ end
128
+
129
+ # With options
130
+ texts = Auto::L18n.find_text("app/views/posts/show.html.erb",
131
+ min_length: 3,
132
+ scan_js: true,
133
+ ignore_patterns: ['\d+', 'http']
134
+ )
135
+ ```
136
+
137
+ ### `Auto::L18n.exchange_text_for_l18n_placeholder(path, options = {})`
138
+
139
+ Replace hardcoded text in a single file with I18n calls.
140
+
141
+ ```ruby
142
+ result = Auto::L18n.exchange_text_for_l18n_placeholder(
143
+ "app/views/posts/show.html.erb",
144
+ namespace: "views.posts.show",
145
+ locale_path: "config/locales/en.yml",
146
+ dry_run: true # Preview first!
147
+ )
148
+ ```
149
+
150
+ ### `Auto::L18n.auto_internationalize(path, options = {})`
151
+
152
+ Main method that handles the complete workflow (find + replace).
153
+
154
+ ```ruby
155
+ # Single file
156
+ result = Auto::L18n.auto_internationalize(
157
+ "app/views/posts/show.html.erb",
158
+ namespace: "views.posts.show"
159
+ )
160
+
161
+ # Entire directory
162
+ result = Auto::L18n.auto_internationalize(
163
+ "app/views",
164
+ recursive: true,
165
+ namespace: "views",
166
+ dry_run: true
167
+ )
168
+ ```
169
+
170
+ ## CLI Options
171
+
172
+ ```bash
173
+ ruby exe/auto-l18n [options] [file]
174
+
175
+ Options:
176
+ -d, --directory=DIR Search files in DIR
177
+ -r, --recursive Search recursively
178
+ --ext=EXTS File extensions (default: .html.erb)
179
+ --replace Replace hardcoded text with I18n calls
180
+ --locale-path=PATH Locale file path (default: config/locales/en.yml)
181
+ --namespace=NS Translation key namespace (e.g., views.posts)
182
+ --dry-run Preview changes without modifying files
183
+ --no-backup Don't create backup files
184
+ -h, --help Show help
185
+ ```
186
+
187
+ ## What Gets Extracted
188
+
189
+ ✅ ERB string literals: `<%= "text" %>`
190
+ ✅ HTML text nodes: `<p>text</p>`
191
+ ✅ HTML attributes: `alt`, `title`, `placeholder`, `aria-label`, etc.
192
+ ✅ JavaScript strings (optional): `"text"`, `'text'`, `` `text` ``
193
+ ✅ Data attribute JSON values
194
+
195
+ ## What Gets Skipped
196
+
197
+ ❌ Existing I18n calls: `t('key')`, `I18n.t('key')`
198
+ ❌ Comments: `<!-- -->`, `<%# %>`
199
+ ❌ Short strings (< 2 chars by default)
200
+ ❌ Pure punctuation/symbols
201
+ ❌ File paths and code syntax
202
+ ❌ Custom patterns via `ignore_patterns`
203
+
204
+ ## Configuration Options
205
+
206
+ | Option | Description | Default |
207
+ |--------|-------------|---------|
208
+ | `namespace` | Prefix for translation keys | nil |
209
+ | `locale_path` | Path to locale YAML file | `"config/locales/en.yml"` |
210
+ | `locale` | Locale code | `"en"` |
211
+ | `dry_run` | Preview without modifying | `false` |
212
+ | `backup` | Create .backup files | `true` |
213
+ | `min_length` | Minimum string length | `2` |
214
+ | `ignore_patterns` | Regex patterns to exclude | `[]` |
215
+ | `extra_attrs` | Additional HTML attributes | `[]` |
216
+ | `scan_erb_code` | Extract from ERB blocks | `true` |
217
+ | `scan_js` | Extract from JavaScript | `false` |
218
+ | `recursive` | Process subdirectories | `false` |
219
+ | `file_pattern` | File pattern for directories | `"*.html.erb"` |
220
+
221
+ ## Usage
222
+
223
+ After installing the gem, you can use either the Ruby API or the CLI.
224
+
225
+ - Ruby API: see the Quick Start examples above or `QUICKSTART.md`.
226
+ - CLI: run `auto-l18n --help` for options, for example:
227
+
228
+ ```bash
229
+ # Find hardcoded text in a file
230
+ auto-l18n app/views/posts/show.html.erb
231
+
232
+ # Replace with I18n calls (dry run)
233
+ auto-l18n app/views/posts/show.html.erb \
234
+ --replace --namespace views.posts.show --dry-run
235
+
236
+ # Apply changes
237
+ auto-l18n app/views/posts/show.html.erb \
238
+ --replace --namespace views.posts.show
239
+ ```
240
+
241
+ ## Development
242
+
243
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
244
+
245
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
246
+
247
+ ## Contributing
248
+
249
+ Bug reports and pull requests are welcome on GitHub at https://github.com/NicolasReiner/auto-l18n. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/NicolasReiner/auto-l18n/blob/master/CODE_OF_CONDUCT.md).
250
+
251
+ ## License
252
+
253
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
254
+
255
+ ## Code of Conduct
256
+
257
+ Everyone interacting in the Auto::L18n project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/NicolasReiner/auto-l18n/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/exe/auto-l18n ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "pathname"
6
+
7
+ # Load the library
8
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
9
+ require "auto/l18n"
10
+
11
+ options = {
12
+ directory: nil,
13
+ recursive: false,
14
+ exts: [".html.erb"],
15
+ mode: "find", # find or replace
16
+ locale_path: "config/locales/en.yml",
17
+ namespace: nil,
18
+ dry_run: false,
19
+ backup: true
20
+ }
21
+
22
+ parser = OptionParser.new do |opts|
23
+ opts.banner = "Usage: bundle exec auto-l18n [options] [file]"
24
+
25
+ opts.on("-dDIR", "--directory=DIR", "Search files in DIR instead of a single file") do |d|
26
+ options[:directory] = d
27
+ end
28
+
29
+ opts.on("-r", "--recursive", "When used with --directory, search recursively") do
30
+ options[:recursive] = true
31
+ end
32
+
33
+ opts.on("--ext=EXTS", "Comma-separated list of extensions to scan (default: .html.erb)") do |e|
34
+ options[:exts] = e.split(",").map(&:strip)
35
+ end
36
+
37
+ opts.on("--replace", "Replace hardcoded text with I18n calls (default is to only find)") do
38
+ options[:mode] = "replace"
39
+ end
40
+
41
+ opts.on("--locale-path=PATH", "Path to locale file (default: config/locales/en.yml)") do |path|
42
+ options[:locale_path] = path
43
+ end
44
+
45
+ opts.on("--namespace=NS", "Namespace for translation keys (e.g., views.posts)") do |ns|
46
+ options[:namespace] = ns
47
+ end
48
+
49
+ opts.on("--dry-run", "Preview changes without modifying files") do
50
+ options[:dry_run] = true
51
+ end
52
+
53
+ opts.on("--no-backup", "Don't create backup files") do
54
+ options[:backup] = false
55
+ end
56
+
57
+ opts.on("-h", "--help", "Show this help message") do
58
+ puts opts
59
+ exit
60
+ end
61
+ end
62
+
63
+ parser.parse!(ARGV)
64
+
65
+ paths = []
66
+
67
+ if options[:directory]
68
+ dir = Pathname.new(options[:directory])
69
+ unless dir.directory?
70
+ STDERR.puts "Directory not found: #{dir}"
71
+ exit 1
72
+ end
73
+
74
+ if options[:recursive]
75
+ all = Dir.glob(File.join(dir.to_s, "**", "*"), File::FNM_DOTMATCH).select { |p| File.file?(p) }
76
+ else
77
+ all = Dir.children(dir.to_s).map { |c| File.join(dir.to_s, c) }.select { |p| File.file?(p) }
78
+ end
79
+
80
+ # Filter by extensions
81
+ exts = options[:exts].map { |x| x.start_with?('.') ? x : ".#{x}" }
82
+ paths = all.select { |p| exts.include?(File.extname(p)) || exts.any? { |e| p.end_with?(e) } }
83
+ elsif ARGV[0]
84
+ # single file path provided
85
+ file = ARGV[0]
86
+ unless File.file?(file)
87
+ STDERR.puts "File not found: #{file}"
88
+ exit 1
89
+ end
90
+ paths = [file]
91
+ else
92
+ # default: current directory, non-recursive
93
+ all = Dir.children(Dir.pwd).map { |c| File.join(Dir.pwd, c) }.select { |p| File.file?(p) }
94
+ exts = options[:exts].map { |x| x.start_with?('.') ? x : ".#{x}" }
95
+ paths = all.select { |p| exts.include?(File.extname(p)) || exts.any? { |e| p.end_with?(e) } }
96
+ end
97
+
98
+ if paths.empty?
99
+ puts "No files found to scan."
100
+ exit 0
101
+ end
102
+
103
+ if options[:mode] == "find"
104
+ # Original behavior: find and list hardcoded text
105
+ total = 0
106
+ paths.each do |p|
107
+ found = Auto::L18n.find_text(p)
108
+ next if found.empty?
109
+
110
+ total += found.size
111
+ puts "\nFile: #{p}"
112
+ found.each do |s|
113
+ puts " - #{s}"
114
+ end
115
+ end
116
+
117
+ puts "\nFound #{total} text occurrence#{'s' if total != 1}."
118
+ else
119
+ # New behavior: replace hardcoded text with I18n calls
120
+ puts options[:dry_run] ? "DRY RUN MODE - No files will be modified\n" : "Replacing hardcoded text...\n"
121
+
122
+ total_replaced = 0
123
+ total_keys = 0
124
+
125
+ paths.each do |p|
126
+ result = Auto::L18n.exchange_text_for_l18n_placeholder(p, {
127
+ locale_path: options[:locale_path],
128
+ namespace: options[:namespace],
129
+ dry_run: options[:dry_run],
130
+ backup: options[:backup]
131
+ })
132
+
133
+ next if result[:replaced] == 0
134
+
135
+ total_replaced += result[:replaced]
136
+ total_keys += result[:added_keys]
137
+
138
+ puts "\nFile: #{p}"
139
+ puts " Replaced: #{result[:replaced]} occurrence(s)"
140
+ puts " Added keys: #{result[:added_keys]}"
141
+
142
+ if options[:dry_run] && result[:keys]
143
+ puts " Keys that would be added:"
144
+ result[:keys].each { |k| puts " - #{k}" }
145
+ end
146
+ end
147
+
148
+ puts "\n#{options[:dry_run] ? 'Would replace' : 'Replaced'} #{total_replaced} occurrence(s) across #{paths.size} file(s)."
149
+ puts "#{options[:dry_run] ? 'Would add' : 'Added'} #{total_keys} translation key(s) to #{options[:locale_path]}."
150
+ puts "\nBackup files created with .backup extension." if !options[:dry_run] && options[:backup] && total_replaced > 0
151
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auto
4
+ module L18n
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/auto/l18n.rb ADDED
@@ -0,0 +1,652 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "l18n/version"
4
+ require "fileutils"
5
+ begin
6
+ require "nokogiri"
7
+ rescue LoadError
8
+ # We'll raise a clear error when trying to use HTML parsing if Nokogiri isn't available.
9
+ end
10
+
11
+ module Auto
12
+ module L18n
13
+ class Error < StandardError; end
14
+
15
+ # Represents a hardcoded text finding with metadata
16
+ Finding = Struct.new(:text, :type, :source, :line, :context, keyword_init: true)
17
+
18
+ # Extract visible hardcoded text from an HTML/ERB file.
19
+ # This focuses on view files (e.g. .html.erb). It will:
20
+ # - Extract hardcoded strings from ERB Ruby code
21
+ # - Strip ERB tags with placeholders to preserve structure
22
+ # - Skip I18n translation calls
23
+ # - Remove HTML comments
24
+ # - Parse the remaining HTML and collect visible text nodes and attribute values
25
+ # - Optionally scan JavaScript for hardcoded strings
26
+ # - Return structured findings with location metadata
27
+ #
28
+ # @param path [String] Path to the file to analyze
29
+ # @param options [Hash] Configuration options
30
+ # @option options [Boolean] :structured (false) Return Finding objects instead of strings
31
+ # @option options [Integer] :min_length (2) Minimum string length to consider
32
+ # @option options [Array<String>] :ignore_patterns ([]) Regex patterns to exclude
33
+ # @option options [Array<String>] :extra_attrs ([]) Additional HTML attributes to extract
34
+ # @option options [Boolean] :scan_erb_code (true) Extract strings from ERB Ruby code blocks
35
+ # @option options [Boolean] :scan_js (false) Extract strings from JavaScript blocks
36
+ #
37
+ # @return [Array<String>, Array<Finding>] Unique hardcoded strings or Finding objects
38
+ def self.find_text(path, options = {})
39
+ raise ArgumentError, "path must be a String" unless path.is_a?(String)
40
+ return [] unless File.file?(path)
41
+
42
+ unless defined?(Nokogiri)
43
+ raise Error, "Nokogiri is required for HTML parsing. Add `nokogiri` to your Gemfile or gemspec."
44
+ end
45
+
46
+ # Default options
47
+ opts = {
48
+ structured: false,
49
+ min_length: 2,
50
+ ignore_patterns: [],
51
+ extra_attrs: [],
52
+ scan_erb_code: true,
53
+ scan_js: false
54
+ }.merge(options)
55
+
56
+ raw = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
57
+
58
+ # Track line numbers for better reporting
59
+ line_map = build_line_map(raw)
60
+
61
+ results = []
62
+
63
+ # Helper to validate and record candidate text
64
+ record = lambda do |str, type:, source:, original_position: nil|
65
+ return if str.nil?
66
+
67
+ # Normalize whitespace
68
+ s = str.gsub(/\s+/, " ").strip
69
+ return if s.empty?
70
+ return if s.length < opts[:min_length]
71
+
72
+ # Skip our own placeholders
73
+ return if s.include?('⟦ERB') || s.include?('⟦I18N⟧')
74
+
75
+ # Skip if matches ignore patterns
76
+ opts[:ignore_patterns].each do |pattern|
77
+ return if s =~ /#{pattern}/
78
+ end
79
+
80
+ # Skip pure punctuation or symbols
81
+ return if s =~ /\A[\W_]*\z/
82
+
83
+ # Skip placeholder patterns (be more specific to avoid false positives)
84
+ return if s.include?('#{') || s.include?('%{')
85
+ # Only skip if multiple curly braces (likely interpolation)
86
+ return if s.scan(/\{/).size > 1 && s.scan(/\}/).size > 1
87
+
88
+ # Skip file paths
89
+ return if s =~ %r{\A\.?/?[\w\-]+(/[\w\-\.]+)+\z}
90
+
91
+ # Skip code-like syntax
92
+ return if s =~ /[;=>]{2,}/ || s =~ /function\s*\(/ || s =~ /\b(?:var|const|let)\s+\w+/
93
+
94
+ # Normalize quotes for comparison
95
+ normalized = s.tr('""''', %q{"''"})
96
+
97
+ # Try to estimate line number
98
+ line = estimate_line(original_position, line_map) if original_position
99
+
100
+ if opts[:structured]
101
+ results << Finding.new(
102
+ text: normalized,
103
+ type: type,
104
+ source: source,
105
+ line: line,
106
+ context: s # keep original for display
107
+ )
108
+ else
109
+ results << normalized
110
+ end
111
+ end
112
+
113
+ # PHASE 1: Extract hardcoded strings from ERB Ruby code blocks
114
+ if opts[:scan_erb_code]
115
+ raw.scan(/<%=?\s*(.*?)%>/m).each do |match|
116
+ code = match[0]
117
+ position = raw.index("<%") # Approximate position
118
+
119
+ # Skip if it's an I18n call
120
+ next if code =~ /\b(?:I18n\.)?(?:t|translate)\s*\(/
121
+
122
+ # Extract double-quoted strings
123
+ code.scan(/"((?:[^"\\]|\\.)*)"/m).each do |string_match|
124
+ unescaped = string_match[0].gsub(/\\(.)/, '\1')
125
+ record.call(
126
+ unescaped,
127
+ type: :erb_string,
128
+ source: "ERB block",
129
+ original_position: position
130
+ )
131
+ end
132
+
133
+ # Extract single-quoted strings
134
+ code.scan(/'((?:[^'\\]|\\.)*)'/).each do |string_match|
135
+ unescaped = string_match[0].gsub(/\\(.)/, '\1')
136
+ record.call(
137
+ unescaped,
138
+ type: :erb_string,
139
+ source: "ERB block",
140
+ original_position: position
141
+ )
142
+ end
143
+ end
144
+ end
145
+
146
+ # PHASE 2: Prepare HTML for Nokogiri parsing
147
+ # 1) Remove ERB comments
148
+ cleaned_erb = raw.gsub(/<%#.*?%>/m, " ⟦ERB_COMMENT⟧ ")
149
+
150
+ # 2) Skip I18n translation calls - replace with placeholder
151
+ # Matches: t("key"), t('key'), I18n.t("key"), translate("key"), etc.
152
+ cleaned_erb = cleaned_erb.gsub(/<%=?\s*(?:I18n\.)?(?:t|translate)\s*\([^)]+\)\s*%>/m, " ⟦I18N⟧ ")
153
+
154
+ # 3) Remove remaining ERB tags with unique placeholder to preserve spacing
155
+ cleaned_erb = cleaned_erb.gsub(/<%=?\s*.*?%>/m, " ⟦ERB⟧ ")
156
+
157
+ # 4) Remove HTML comments
158
+ cleaned_erb = cleaned_erb.gsub(/<!--.*?-->/m, " ")
159
+
160
+ # 5) Parse with Nokogiri
161
+ fragment = Nokogiri::HTML::DocumentFragment.parse(cleaned_erb)
162
+
163
+ # Standard attributes to extract
164
+ standard_attrs = %w[alt title placeholder aria-label aria-placeholder aria-description label]
165
+ # Additional attributes for form inputs and buttons
166
+ value_attrs = %w[value]
167
+ all_attrs = (standard_attrs + value_attrs + opts[:extra_attrs]).uniq
168
+
169
+ # PHASE 3: Extract from JavaScript blocks (optional)
170
+ if opts[:scan_js]
171
+ fragment.css('script').each do |script_node|
172
+ js_content = script_node.content
173
+
174
+ # Extract double-quoted strings
175
+ js_content.scan(/"((?:[^"\\]|\\.)*)"/m).each do |string_match|
176
+ unescaped = string_match[0].gsub(/\\(.)/, '\1')
177
+ record.call(
178
+ unescaped,
179
+ type: :js_string,
180
+ source: "JavaScript block",
181
+ original_position: nil
182
+ )
183
+ end
184
+
185
+ # Extract single-quoted strings
186
+ js_content.scan(/'((?:[^'\\]|\\.)*)'/).each do |string_match|
187
+ unescaped = string_match[0].gsub(/\\(.)/, '\1')
188
+ record.call(
189
+ unescaped,
190
+ type: :js_string,
191
+ source: "JavaScript block",
192
+ original_position: nil
193
+ )
194
+ end
195
+
196
+ # Extract template literals (backticks) - basic support
197
+ js_content.scan(/`([^`]*)`/).each do |string_match|
198
+ record.call(
199
+ string_match[0],
200
+ type: :js_template,
201
+ source: "JavaScript template literal",
202
+ original_position: nil
203
+ )
204
+ end
205
+ end
206
+ end
207
+
208
+ # PHASE 4: Collect text nodes (visible text)
209
+ fragment.traverse do |node|
210
+ # Skip script, style, and template tags
211
+ next if node.ancestors.any? { |a| %w[script style template].include?(a.name) }
212
+
213
+ if node.text? && !node.content.strip.empty?
214
+ # Skip if parent has hidden attribute
215
+ next if node.parent&.[]("hidden")
216
+
217
+ position = find_position_in_original(node.content, raw)
218
+ record.call(
219
+ node.content,
220
+ type: :text_node,
221
+ source: "text content",
222
+ original_position: position
223
+ )
224
+ end
225
+ end
226
+
227
+ # PHASE 5: Collect attributes
228
+ selector = all_attrs.map { |attr| "*[#{attr}]" }.join(', ')
229
+ fragment.css(selector).each do |el|
230
+ all_attrs.each do |attr|
231
+ next unless el[attr]
232
+
233
+ # Skip empty values or single characters for 'value' attribute
234
+ next if attr == 'value' && el[attr].length < 2
235
+
236
+ position = find_position_in_original(el[attr], raw)
237
+ record.call(
238
+ el[attr],
239
+ type: :attribute,
240
+ source: "#{el.name}[#{attr}]",
241
+ original_position: position
242
+ )
243
+ end
244
+ end
245
+
246
+ # PHASE 6: Extract from data-* JSON attributes
247
+ fragment.css('[data-config], [data-text], [data-message], [data-label]').each do |el|
248
+ el.attributes.each do |name, attr|
249
+ next unless name.start_with?('data-')
250
+ value = attr.value
251
+
252
+ # Try to parse as JSON
253
+ begin
254
+ require 'json'
255
+ parsed = JSON.parse(value)
256
+ extract_strings_from_json(parsed).each do |str|
257
+ record.call(
258
+ str,
259
+ type: :data_attribute,
260
+ source: "#{el.name}[#{name}]",
261
+ original_position: nil
262
+ )
263
+ end
264
+ rescue JSON::ParserError, LoadError
265
+ # Not JSON or JSON not available, skip
266
+ end
267
+ end
268
+ end
269
+
270
+ # Return unique results
271
+ if opts[:structured]
272
+ results.uniq { |f| [f.text, f.type] }
273
+ else
274
+ results.uniq
275
+ end
276
+ end
277
+
278
+ private
279
+
280
+ # Build a map of character positions to line numbers
281
+ def self.build_line_map(content)
282
+ lines = content.lines
283
+ map = []
284
+ pos = 0
285
+ lines.each_with_index do |line, idx|
286
+ map << [pos, idx + 1]
287
+ pos += line.length
288
+ end
289
+ map
290
+ end
291
+
292
+ # Estimate line number from character position
293
+ def self.estimate_line(position, line_map)
294
+ return nil unless position && line_map
295
+ line_map.reverse.each do |start_pos, line_num|
296
+ return line_num if position >= start_pos
297
+ end
298
+ 1
299
+ end
300
+
301
+ # Find approximate position of a string in the original content
302
+ def self.find_position_in_original(str, content)
303
+ # Simple indexOf approach - may not be perfect for duplicates
304
+ content.index(str)
305
+ end
306
+
307
+ # Recursively extract string values from JSON structures
308
+ def self.extract_strings_from_json(obj, results = [])
309
+ case obj
310
+ when String
311
+ results << obj unless obj.empty?
312
+ when Array
313
+ obj.each { |item| extract_strings_from_json(item, results) }
314
+ when Hash
315
+ obj.each_value { |value| extract_strings_from_json(value, results) }
316
+ end
317
+ results
318
+ end
319
+
320
+ # Exchange hardcoded text for I18n placeholders
321
+ #
322
+ # This method replaces hardcoded strings in a file with I18n translation calls
323
+ # and adds the translations to a locale file (default: en.yml).
324
+ #
325
+ # @param path [String] Path to the file to process
326
+ # @param options [Hash] Configuration options
327
+ # @option options [String] :locale_path Path to locale file (default: config/locales/en.yml)
328
+ # @option options [String] :locale (en) Locale code
329
+ # @option options [String] :namespace Namespace prefix for translation keys (e.g., 'views.posts')
330
+ # @option options [Boolean] :dry_run (false) Preview changes without modifying files
331
+ # @option options [Integer] :min_length (2) Minimum string length to consider
332
+ # @option options [Array<String>] :ignore_patterns ([]) Regex patterns to exclude
333
+ # @option options [Boolean] :backup (true) Create backup files before modifying
334
+ #
335
+ # @return [Hash] Summary of changes made
336
+ def self.exchange_text_for_l18n_placeholder(path, options = {})
337
+ raise ArgumentError, "path must be a String" unless path.is_a?(String)
338
+ raise ArgumentError, "File not found: #{path}" unless File.file?(path)
339
+
340
+ # Default options
341
+ opts = {
342
+ locale_path: "config/locales/en.yml",
343
+ locale: "en",
344
+ namespace: nil,
345
+ dry_run: false,
346
+ min_length: 2,
347
+ ignore_patterns: [],
348
+ backup: true
349
+ }.merge(options)
350
+
351
+ # Find all hardcoded text with structured data
352
+ findings = find_text(path, opts.merge(structured: true))
353
+
354
+ return { replaced: 0, added_keys: 0, message: "No hardcoded text found" } if findings.empty?
355
+
356
+ # Load or create locale file
357
+ locale_data = load_locale_file(opts[:locale_path], opts[:locale])
358
+
359
+ # Track changes
360
+ replacements = []
361
+ new_keys = []
362
+
363
+ # Read original file content
364
+ content = File.read(path, encoding: "UTF-8")
365
+ modified_content = content.dup
366
+
367
+ # Process findings in reverse order by position to maintain string positions
368
+ sorted_findings = findings.sort_by { |f| -(f.line || 0) }
369
+
370
+ sorted_findings.each_with_index do |finding, idx|
371
+ # Generate translation key
372
+ key = generate_translation_key(finding.text, finding.type, opts[:namespace], idx)
373
+
374
+ # Add to locale file
375
+ set_nested_key(locale_data, key, finding.text, opts[:locale])
376
+ new_keys << key
377
+
378
+ # Replace in content based on type
379
+ replacement = case finding.type
380
+ when :erb_string
381
+ # Replace strings in ERB blocks
382
+ replace_erb_string(modified_content, finding.text, key)
383
+ when :text_node
384
+ # Replace HTML text nodes
385
+ replace_text_node(modified_content, finding.context, key)
386
+ when :attribute
387
+ # Replace attribute values
388
+ replace_attribute(modified_content, finding.context, key)
389
+ when :js_string, :js_template
390
+ # Replace JavaScript strings
391
+ replace_js_string(modified_content, finding.text, key)
392
+ when :data_attribute
393
+ # Data attributes are complex, skip for now
394
+ nil
395
+ end
396
+
397
+ replacements << { text: finding.text, key: key, type: finding.type } if replacement
398
+ end
399
+
400
+ unless opts[:dry_run]
401
+ # Create backup
402
+ if opts[:backup]
403
+ backup_path = "#{path}.backup"
404
+ File.write(backup_path, content)
405
+ end
406
+
407
+ # Write modified file
408
+ File.write(path, modified_content)
409
+
410
+ # Write locale file
411
+ write_locale_file(opts[:locale_path], locale_data)
412
+ end
413
+
414
+ {
415
+ replaced: replacements.size,
416
+ added_keys: new_keys.size,
417
+ keys: new_keys,
418
+ replacements: replacements,
419
+ dry_run: opts[:dry_run]
420
+ }
421
+ end
422
+
423
+ # Automatically internationalize a file or directory
424
+ #
425
+ # This is the main entry point that combines finding and replacing hardcoded text.
426
+ # It will:
427
+ # 1. Find all hardcoded text in the file(s)
428
+ # 2. Replace them with I18n translation calls
429
+ # 3. Add translations to locale file
430
+ #
431
+ # @param path [String] Path to file or directory to process
432
+ # @param options [Hash] Configuration options (see exchange_text_for_l18n_placeholder)
433
+ # @option options [Boolean] :recursive (false) Process directories recursively
434
+ # @option options [String] :file_pattern (*.html.erb) File pattern to match in directories
435
+ #
436
+ # @return [Hash] Summary of all changes
437
+ def self.auto_internationalize(path, options = {})
438
+ raise ArgumentError, "path must be a String" unless path.is_a?(String)
439
+ raise ArgumentError, "Path not found: #{path}" unless File.exist?(path)
440
+
441
+ opts = {
442
+ recursive: false,
443
+ file_pattern: "*.html.erb"
444
+ }.merge(options)
445
+
446
+ results = []
447
+
448
+ if File.directory?(path)
449
+ # Process directory
450
+ pattern = opts[:recursive] ? "**/#{opts[:file_pattern]}" : opts[:file_pattern]
451
+ Dir.glob(File.join(path, pattern)).each do |file|
452
+ next unless File.file?(file)
453
+
454
+ puts "Processing: #{file}" unless opts[:dry_run]
455
+ result = exchange_text_for_l18n_placeholder(file, opts)
456
+ results << { file: file, result: result }
457
+ end
458
+ else
459
+ # Process single file
460
+ result = exchange_text_for_l18n_placeholder(path, opts)
461
+ results << { file: path, result: result }
462
+ end
463
+
464
+ # Summary
465
+ total_replaced = results.sum { |r| r[:result][:replaced] }
466
+ total_keys = results.sum { |r| r[:result][:added_keys] }
467
+
468
+ {
469
+ files_processed: results.size,
470
+ total_replaced: total_replaced,
471
+ total_keys: total_keys,
472
+ details: results
473
+ }
474
+ end
475
+
476
+ private
477
+
478
+ # Generate a translation key from text
479
+ def self.generate_translation_key(text, type, namespace, index)
480
+ # Sanitize text to create a valid key
481
+ base_key = text.downcase
482
+ .gsub(/[^\w\s-]/, '') # Remove non-word chars except spaces and hyphens
483
+ .gsub(/\s+/, '_') # Replace spaces with underscores
484
+ .gsub(/_+/, '_') # Collapse multiple underscores
485
+ .gsub(/^_|_$/, '') # Remove leading/trailing underscores
486
+ .slice(0, 50) # Limit length
487
+
488
+ # Add index if key would be too generic
489
+ base_key = "text_#{index}" if base_key.empty? || base_key.length < 3
490
+
491
+ # Build full key with namespace
492
+ parts = []
493
+ parts << namespace if namespace
494
+ parts << base_key
495
+
496
+ parts.join('.')
497
+ end
498
+
499
+ # Load locale file (YAML)
500
+ def self.load_locale_file(path, locale)
501
+ if File.exist?(path)
502
+ require 'yaml'
503
+ YAML.load_file(path) || { locale => {} }
504
+ else
505
+ { locale => {} }
506
+ end
507
+ end
508
+
509
+ # Write locale file (YAML)
510
+ def self.write_locale_file(path, data)
511
+ require 'yaml'
512
+
513
+ # Ensure directory exists
514
+ FileUtils.mkdir_p(File.dirname(path))
515
+
516
+ # Write with nice formatting
517
+ File.write(path, data.to_yaml)
518
+ end
519
+
520
+ # Set a nested key in a hash (e.g., "views.posts.title" => "Title")
521
+ def self.set_nested_key(hash, key_path, value, locale)
522
+ keys = key_path.split('.')
523
+
524
+ # Ensure locale root exists
525
+ hash[locale] ||= {}
526
+
527
+ # Navigate/create nested structure
528
+ current = hash[locale]
529
+ keys[0..-2].each do |key|
530
+ current[key] ||= {}
531
+ current = current[key]
532
+ end
533
+
534
+ # Set the value
535
+ current[keys.last] = value
536
+ end
537
+
538
+ # Replace a string in ERB code blocks
539
+ def self.replace_erb_string(content, text, key)
540
+ # Match both single and double quoted strings
541
+ escaped_text = Regexp.escape(text)
542
+
543
+ # Try double quotes first
544
+ pattern = /"#{escaped_text}"/
545
+ if content =~ pattern
546
+ content.gsub!(pattern, "t('#{key}')")
547
+ return true
548
+ end
549
+
550
+ # Try single quotes
551
+ pattern = /'#{escaped_text}'/
552
+ if content =~ pattern
553
+ content.gsub!(pattern, "t('#{key}')")
554
+ return true
555
+ end
556
+
557
+ false
558
+ end
559
+
560
+ # Replace text in HTML text nodes
561
+ def self.replace_text_node(content, text, key)
562
+ # Escape special regex characters but preserve the text structure
563
+ escaped = Regexp.escape(text)
564
+
565
+ # Look for the text outside of ERB tags
566
+ pattern = /(?<![<%=])(\s*)#{escaped}(\s*)(?!%>)/
567
+
568
+ if content =~ pattern
569
+ content.gsub!(pattern, "\\1<%= t('#{key}') %>\\2")
570
+ return true
571
+ end
572
+
573
+ false
574
+ end
575
+
576
+ # Replace attribute values
577
+ def self.replace_attribute(content, text, key)
578
+ escaped = Regexp.escape(text)
579
+
580
+ # Match attribute="text" or attribute='text'
581
+ pattern = /(\w+)=["']#{escaped}["']/
582
+
583
+ if content =~ pattern
584
+ content.gsub!(pattern, "\\1=\"<%= t('#{key}') %>\"")
585
+ return true
586
+ end
587
+
588
+ false
589
+ end
590
+
591
+ # Replace JavaScript strings
592
+ def self.replace_js_string(content, text, key)
593
+ escaped = Regexp.escape(text)
594
+
595
+ # Try double quotes
596
+ pattern = /"#{escaped}"/
597
+ if content =~ pattern
598
+ content.gsub!(pattern, "\"<%= t('#{key}') %>\"")
599
+ return true
600
+ end
601
+
602
+ # Try single quotes
603
+ pattern = /'#{escaped}'/
604
+ if content =~ pattern
605
+ content.gsub!(pattern, "'<%= t('#{key}') %>'")
606
+ return true
607
+ end
608
+
609
+ false
610
+ end
611
+
612
+ # Build a map of character positions to line numbers
613
+ def self.build_line_map(content)
614
+ lines = content.lines
615
+ map = []
616
+ pos = 0
617
+ lines.each_with_index do |line, idx|
618
+ map << [pos, idx + 1]
619
+ pos += line.length
620
+ end
621
+ map
622
+ end
623
+
624
+ # Estimate line number from character position
625
+ def self.estimate_line(position, line_map)
626
+ return nil unless position && line_map
627
+ line_map.reverse.each do |start_pos, line_num|
628
+ return line_num if position >= start_pos
629
+ end
630
+ 1
631
+ end
632
+
633
+ # Find approximate position of a string in the original content
634
+ def self.find_position_in_original(str, content)
635
+ # Simple indexOf approach - may not be perfect for duplicates
636
+ content.index(str)
637
+ end
638
+
639
+ # Recursively extract string values from JSON structures
640
+ def self.extract_strings_from_json(obj, results = [])
641
+ case obj
642
+ when String
643
+ results << obj unless obj.empty?
644
+ when Array
645
+ obj.each { |item| extract_strings_from_json(item, results) }
646
+ when Hash
647
+ obj.each_value { |value| extract_strings_from_json(value, results) }
648
+ end
649
+ results
650
+ end
651
+ end
652
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: auto-l18n
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolas Reiner
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-10-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.15'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '2.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '1.15'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ description: This gem provides a set of tools to streamline the process of adding
34
+ and managing translations in Rails applications.
35
+ email:
36
+ - nici.ferd@gmail.com
37
+ executables:
38
+ - auto-l18n
39
+ extensions: []
40
+ extra_rdoc_files: []
41
+ files:
42
+ - CHANGELOG.md
43
+ - CODE_OF_CONDUCT.md
44
+ - LICENSE.txt
45
+ - README.md
46
+ - Rakefile
47
+ - exe/auto-l18n
48
+ - lib/auto/l18n.rb
49
+ - lib/auto/l18n/version.rb
50
+ homepage: https://github.com/NicolasReiner/auto-l18n
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ allowed_push_host: https://rubygems.org
55
+ homepage_uri: https://github.com/NicolasReiner/auto-l18n
56
+ source_code_uri: https://github.com/NicolasReiner/auto-l18n
57
+ changelog_uri: https://github.com/NicolasReiner/auto-l18n/blob/master/CHANGELOG.md
58
+ bug_tracker_uri: https://github.com/NicolasReiner/auto-l18n/issues
59
+ rubygems_mfa_required: 'true'
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.2.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.4.20
76
+ signing_key:
77
+ specification_version: 4
78
+ summary: A gem to help with automatic localization for Rails applications.
79
+ test_files: []