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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +257 -0
- data/Rakefile +4 -0
- data/exe/auto-l18n +151 -0
- data/lib/auto/l18n/version.rb +7 -0
- data/lib/auto/l18n.rb +652 -0
- metadata +79 -0
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
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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
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
|
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: []
|