auto-l18n 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9eaeb61d41c636035f955c83afb385e40a020e4806bcc2553b965032b3966c64
4
- data.tar.gz: c062d54dd2f5b7e639eeb9304e31993aa0a7485703a4236868d86a731ccc1518
3
+ metadata.gz: d827a80d2d978e8855592c2fe411c97f169a5c44b59d2a7e3a87da698bad39ad
4
+ data.tar.gz: 82096e37eb8ad1dad0473c5762c1cd04b5e2f078b952747212bc84656bbacab8
5
5
  SHA512:
6
- metadata.gz: ae940be66b33102dce802addaa5f2d3abacde2f3e934383b8ab0a663329a5672c3ef84065d5020332de26cca3f43831a2e214c61ffecfcae661b34d2e2249d0c
7
- data.tar.gz: 31b3b158aa2d1978828874f1d30e7815e5e8b868e599beb3e93fb1c7f68335476386c03d198e3506ce967208f66dec2e985c4e03570fa3ff860fcc434928da01
6
+ metadata.gz: b5cbf6914f5e5c1ae1c42d3f7a18895596b621280eee99071110234c878279096d49a226886b6f8a3a9c583fec8ef55c06d5652c18779fe8d2e63d978d58aa58
7
+ data.tar.gz: 945cb23e8fd293d78a81e89a717f3aea3ebfac24358b84df5bc0ac10bd36c765ecb681b8f49090b1888e2e1fa2a5d38ceacb1a3e792661f643d8470b2699bc1a
data/README.md CHANGED
@@ -50,7 +50,7 @@ texts.each { |t| puts "- #{t}" }
50
50
  # Preview changes (dry run)
51
51
  result = Auto::L18n.auto_internationalize(
52
52
  "app/views/posts/show.html.erb",
53
- namespace: "views.posts.show",
53
+ # namespace: "views.posts.show", # Optional – will be derived from the file path if omitted
54
54
  dry_run: true
55
55
  )
56
56
 
@@ -58,8 +58,7 @@ puts "Would replace #{result[:total_replaced]} strings"
58
58
 
59
59
  # Apply changes
60
60
  result = Auto::L18n.auto_internationalize(
61
- "app/views/posts/show.html.erb",
62
- namespace: "views.posts.show"
61
+ "app/views/posts/show.html.erb"
63
62
  )
64
63
  ```
65
64
 
@@ -70,12 +69,13 @@ result = Auto::L18n.auto_internationalize(
70
69
  ruby exe/auto-l18n app/views/posts/show.html.erb
71
70
 
72
71
  # Replace with I18n calls (dry run)
72
+ # Note: If --namespace is omitted, it will be derived from the file path (e.g., app/views/admin/users/show.html.erb -> views.admin.users.show)
73
73
  ruby exe/auto-l18n app/views/posts/show.html.erb \
74
- --replace --namespace views.posts.show --dry-run
74
+ --replace --dry-run
75
75
 
76
76
  # Actually apply changes
77
77
  ruby exe/auto-l18n app/views/posts/show.html.erb \
78
- --replace --namespace views.posts.show
78
+ --replace
79
79
  ```
80
80
 
81
81
  ## Example Transformation
@@ -178,7 +178,7 @@ Options:
178
178
  --ext=EXTS File extensions (default: .html.erb)
179
179
  --replace Replace hardcoded text with I18n calls
180
180
  --locale-path=PATH Locale file path (default: config/locales/en.yml)
181
- --namespace=NS Translation key namespace (e.g., views.posts)
181
+ --namespace=NS Translation key namespace (e.g., views.posts). If omitted, it is derived from the file path (folder hierarchy).
182
182
  --dry-run Preview changes without modifying files
183
183
  --no-backup Don't create backup files
184
184
  -h, --help Show help
Binary file
data/exe/auto-l18n CHANGED
@@ -42,7 +42,7 @@ parser = OptionParser.new do |opts|
42
42
  options[:locale_path] = path
43
43
  end
44
44
 
45
- opts.on("--namespace=NS", "Namespace for translation keys (e.g., views.posts)") do |ns|
45
+ opts.on("--namespace=NS", "Namespace for translation keys (e.g., views.posts). If omitted, it is derived from the file path (folder hierarchy).") do |ns|
46
46
  options[:namespace] = ns
47
47
  end
48
48
 
@@ -101,16 +101,47 @@ if paths.empty?
101
101
  end
102
102
 
103
103
  if options[:mode] == "find"
104
- # Original behavior: find and list hardcoded text
104
+ # Find and list hardcoded text; in --dry-run also show the keys that would be used
105
105
  total = 0
106
106
  paths.each do |p|
107
107
  found = Auto::L18n.find_text(p)
108
108
  next if found.empty?
109
109
 
110
- total += found.size
111
110
  puts "\nFile: #{p}"
112
- found.each do |s|
113
- puts " - #{s}"
111
+ # List found strings
112
+ total += found.size
113
+ found.each { |s| puts " - #{s}" }
114
+
115
+ # In dry-run, show the keys that would be used for replacement
116
+ if options[:dry_run]
117
+ structured = Auto::L18n.find_text(p, structured: true)
118
+ # mimic the sort used by exchange_text_for_l18n_placeholder (by line desc)
119
+ sorted = structured.sort_by { |f| -(f.line || 0) }
120
+
121
+ # Determine effective namespace as in the library code
122
+ effective_ns = options[:namespace]
123
+ if (effective_ns.nil? || effective_ns.empty?)
124
+ # Call private helper via send to derive from path
125
+ begin
126
+ effective_ns = Auto::L18n.send(:derive_namespace_from_path, p, {})
127
+ effective_ns = nil if effective_ns.nil? || effective_ns.empty?
128
+ rescue NoMethodError
129
+ # Fallback: no derived namespace available
130
+ effective_ns = options[:namespace]
131
+ end
132
+ end
133
+
134
+ keys = sorted.each_with_index.map do |f, idx|
135
+ begin
136
+ Auto::L18n.send(:generate_translation_key, f.text, f.type, effective_ns, idx)
137
+ rescue NoMethodError
138
+ # Extremely unlikely; just show placeholder when method not accessible
139
+ "generated_key_#{idx}"
140
+ end
141
+ end
142
+
143
+ puts " Keys that would be used:"
144
+ keys.each { |k| puts " - #{k}" }
114
145
  end
115
146
  end
116
147
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Auto
4
4
  module L18n
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.2"
6
6
  end
7
7
  end
data/lib/auto/l18n.rb CHANGED
@@ -64,7 +64,7 @@ module Auto
64
64
  record = lambda do |str, type:, source:, original_position: nil|
65
65
  return if str.nil?
66
66
 
67
- # Normalize whitespace
67
+ # Normalize whitespace
68
68
  s = str.gsub(/\s+/, " ").strip
69
69
  return if s.empty?
70
70
  return if s.length < opts[:min_length]
@@ -85,14 +85,63 @@ module Auto
85
85
  # Only skip if multiple curly braces (likely interpolation)
86
86
  return if s.scan(/\{/).size > 1 && s.scan(/\}/).size > 1
87
87
 
88
- # Skip file paths
88
+
89
+ # - Common field names that often appear as technical tokens rather than UI text
90
+ return if s.downcase == 'email'
91
+ # Skip file paths
89
92
  return if s =~ %r{\A\.?/?[\w\-]+(/[\w\-\.]+)+\z}
93
+
94
+ # Heuristics to avoid common non-translatable strings
95
+ # - CSS class/id-like tokens (snake/kebab case, possibly space-separated list)
96
+ if (s.include?('_') || s.include?('-')) && s =~ /\A[a-z0-9 _\-]+\z/
97
+ # e.g., "btn btn-secondary", "group_show", "event-link"
98
+ return
99
+ end
100
+
101
+ # - CamelCase or lowerCamelCase identifiers (likely variable/ID names)
102
+ return if s =~ /\A(?:[a-z]+(?:[A-Z][a-z0-9]+)+|[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+)\z/
103
+
104
+ # - Hex colors like #fff or #4CAF50
105
+ return if s =~ /\A#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/
106
+
107
+ # - File names with extensions (e.g., JA_Logo.png, Chart.bundle)
108
+ return if s =~ /\A[\w\-]+(\.[A-Za-z0-9]{2,6})+\z/
109
+
110
+ # - Date/time format strings (strftime-style like %Y-%m-%d, %I:%M %p)
111
+ return if s =~ /%[a-zA-Z]/
112
+
113
+ # - Mostly numeric values (amounts, percentages)
114
+ return if s =~ /\A\d+(?:[.,]\d+)?(?:%|[a-zA-Z]*)?\z/
115
+
116
+ # - Rails-style parameter names with bracket notation used in form helpers
117
+ # e.g., "webhook_event_call[event][]", "permission_ids[]"
118
+ return if s =~ /\A[a-zA-Z0-9_]+(?:\[[^\]]*\])+(?:\[\])?\z/
119
+
120
+ # - Common developer token seen as IDs/variables but not visible copy
121
+ return if s == 'breadcrumb'
122
+
123
+ # - MIME types (single or comma-separated), e.g., "image/*", "image/png,image/jpeg"
124
+ return if s =~ /\A(?:[a-zA-Z0-9.+-]+\/[a-zA-Z0-9+*.-]+)(?:\s*,\s*[a-zA-Z0-9.+-]+\/[a-zA-Z0-9+*.-]+)*\z/
125
+
126
+ # - CSS inline style declarations (property: value; ...)
127
+ # Detect at least one property:value; pair
128
+ return if s =~ /\b[a-zA-Z\-]+\s*:\s*[^;]+;/
90
129
 
91
130
  # Skip code-like syntax
92
- return if s =~ /[;=>]{2,}/ || s =~ /function\s*\(/ || s =~ /\b(?:var|const|let)\s+\w+/
131
+ return if s =~ /[;=>]{2,}/ || s =~ /function\s*\(/ || s =~ /\b(?:var|const|let)\s+\w+/
132
+ # Method-call chains typical of JS (e.g., this.form.submit(); or foo.bar())
133
+ return if s =~ /[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+\s*\([^)]*\)\s*;?/
93
134
 
94
135
  # Normalize quotes for comparison
95
136
  normalized = s.tr('""''', %q{"''"})
137
+
138
+ # Skip common non-visible tokens that appear as ERB strings (helper options, class toggles)
139
+ if type == :erb_string
140
+ # Single lowercase identifier, typical for CSS classes or symbols used in helpers
141
+ return if s =~ /\A[a-z][a-z0-9_\-]*\z/
142
+ # Known nav/resource tokens commonly used as classes or symbols
143
+ return if %w[active members events partners permissions groups].include?(s)
144
+ end
96
145
 
97
146
  # Try to estimate line number
98
147
  line = estimate_line(original_position, line_map) if original_position
@@ -118,9 +167,40 @@ module Auto
118
167
 
119
168
  # Skip if it's an I18n call
120
169
  next if code =~ /\b(?:I18n\.)?(?:t|translate)\s*\(/
170
+
171
+ # Skip asset/helper calls where strings are not visible user text
172
+ # e.g., stylesheet_link_tag "webhooks", media: "all", "data-turbo-track": "reload"
173
+ next if code =~ /\b(?:stylesheet_link_tag|javascript_include_tag|javascript_pack_tag|stylesheet_pack_tag|asset_path|image_tag|image_pack_tag|font_path|font_url|asset_url)\b/
174
+
175
+ # Skip render calls where string literals represent partial/template names, not visible text
176
+ # e.g., render 'form', render partial: 'form'
177
+ next if code =~ /\brender\b/
178
+
179
+ # Skip check_box_tag and radio_button_tag: string args are names/values, not visible copy
180
+ next if code =~ /\b(?:check_box_tag|radio_button_tag)\b/
181
+
182
+ # Helper-aware extraction: only consider visible-content helpers
183
+ # Allow: link_to, button_to, submit_tag, label_tag, content_tag, form builder label/submit
184
+ helper_in_code = (
185
+ code =~ /\b(?:link_to|button_to|submit_tag|label_tag|content_tag)\b/ ||
186
+ code =~ /\b\w+\.(?:label|submit)\b/
187
+ )
188
+
189
+ # If no known content-bearing helper is present, skip extracting strings from this ERB block
190
+ unless helper_in_code
191
+ next
192
+ end
121
193
 
122
194
  # Extract double-quoted strings
123
195
  code.scan(/"((?:[^"\\]|\\.)*)"/m).each do |string_match|
196
+ # Skip inline conditional class injections like: "calculated" if condition
197
+ # or other inline if/unless patterns composed solely of a literal
198
+ next if code.strip =~ /\A["'][a-z0-9 _\-]+["']\s+(?:if|unless)\b/i
199
+ # Skip option-hash values like: class: "...", id: "...", data: "...", style: "..."
200
+ literal = string_match[0]
201
+ escaped_lit = Regexp.escape(literal)
202
+ next if code =~ /\b[a-zA-Z_]\w*\s*:\s*"#{escaped_lit}"/
203
+ next if code =~ /"#{escaped_lit}"\s*=>/
124
204
  unescaped = string_match[0].gsub(/\\(.)/, '\1')
125
205
  record.call(
126
206
  unescaped,
@@ -132,6 +212,13 @@ module Auto
132
212
 
133
213
  # Extract single-quoted strings
134
214
  code.scan(/'((?:[^'\\]|\\.)*)'/).each do |string_match|
215
+ # Skip inline conditional class injections like: 'calculated' if condition
216
+ next if code.strip =~ /\A["'][a-z0-9 _\-]+["']\s+(?:if|unless)\b/i
217
+ # Skip option-hash values like: class: '...'
218
+ literal = string_match[0]
219
+ escaped_lit = Regexp.escape(literal)
220
+ next if code =~ /\b[a-zA-Z_]\w*\s*:\s*'#{escaped_lit}'/
221
+ next if code =~ /'#{escaped_lit}'\s*=>/
135
222
  unescaped = string_match[0].gsub(/\\(.)/, '\1')
136
223
  record.call(
137
224
  unescaped,
@@ -229,10 +316,30 @@ module Auto
229
316
  fragment.css(selector).each do |el|
230
317
  all_attrs.each do |attr|
231
318
  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
-
319
+
320
+ # Skip title attributes (tooltips) per project convention to reduce false positives
321
+ next if attr == 'title'
322
+
323
+ # Special handling for the 'value' attribute:
324
+ # - Only treat as visible text for input types that display their value as a label
325
+ # (submit, button, reset). For all other cases (hidden, text, radio, checkbox,
326
+ # option values, etc.) the value is not a visible label and should be ignored.
327
+ if attr == 'value'
328
+ tag = el.name.to_s.downcase
329
+ if tag == 'input'
330
+ input_type = (el['type'] || '').downcase
331
+ visible_button_types = %w[submit button reset]
332
+ # If not a visible button-like input, skip capturing the value
333
+ next unless visible_button_types.include?(input_type)
334
+ else
335
+ # For non-input tags (e.g., option, button), don't treat value as visible label
336
+ next
337
+ end
338
+ end
339
+
340
+ # Skip empty values or single characters (after applying 'value' rules above)
341
+ next if el[attr].strip.length < 2
342
+
236
343
  position = find_position_in_original(el[attr], raw)
237
344
  record.call(
238
345
  el[attr],
@@ -317,7 +424,7 @@ module Auto
317
424
  results
318
425
  end
319
426
 
320
- # Exchange hardcoded text for I18n placeholders
427
+ # Exchange hardcoded text for I18n placeholders
321
428
  #
322
429
  # This method replaces hardcoded strings in a file with I18n translation calls
323
430
  # and adds the translations to a locale file (default: en.yml).
@@ -333,7 +440,7 @@ module Auto
333
440
  # @option options [Boolean] :backup (true) Create backup files before modifying
334
441
  #
335
442
  # @return [Hash] Summary of changes made
336
- def self.exchange_text_for_l18n_placeholder(path, options = {})
443
+ def self.exchange_text_for_l18n_placeholder(path, options = {})
337
444
  raise ArgumentError, "path must be a String" unless path.is_a?(String)
338
445
  raise ArgumentError, "File not found: #{path}" unless File.file?(path)
339
446
 
@@ -342,6 +449,9 @@ module Auto
342
449
  locale_path: "config/locales/en.yml",
343
450
  locale: "en",
344
451
  namespace: nil,
452
+ namespace_from_path: true, # derive namespace from folder/file path when none provided
453
+ path_root: nil, # optional root to strip (e.g., "app/views")
454
+ namespace_prefix: nil, # optional prefix to prepend (e.g., "views")
345
455
  dry_run: false,
346
456
  min_length: 2,
347
457
  ignore_patterns: [],
@@ -367,9 +477,17 @@ module Auto
367
477
  # Process findings in reverse order by position to maintain string positions
368
478
  sorted_findings = findings.sort_by { |f| -(f.line || 0) }
369
479
 
480
+ # Determine effective namespace: user-provided or derived from file path
481
+ effective_namespace = opts[:namespace]
482
+ if (effective_namespace.nil? || effective_namespace.empty?) && opts[:namespace_from_path]
483
+ effective_namespace = derive_namespace_from_path(path, opts)
484
+ # Normalize to nil if empty
485
+ effective_namespace = nil if effective_namespace.nil? || effective_namespace.empty?
486
+ end
487
+
370
488
  sorted_findings.each_with_index do |finding, idx|
371
489
  # Generate translation key
372
- key = generate_translation_key(finding.text, finding.type, opts[:namespace], idx)
490
+ key = generate_translation_key(finding.text, finding.type, effective_namespace, idx)
373
491
 
374
492
  # Add to locale file
375
493
  set_nested_key(locale_data, key, finding.text, opts[:locale])
@@ -496,6 +614,64 @@ module Auto
496
614
  parts.join('.')
497
615
  end
498
616
 
617
+ # Derive a namespace from the file path, producing a dotted hierarchy
618
+ # Examples:
619
+ # - app/views/admin/users/show.html.erb -> "views.admin.users.show"
620
+ # - app/views/posts/index.html.erb -> "views.posts.index"
621
+ # - src/templates/home.html.erb (with namespace_prefix: nil) -> "src.templates.home"
622
+ # - test/test.html.erb (no root) -> "test.test"
623
+ def self.derive_namespace_from_path(path, options = {})
624
+ require 'pathname'
625
+ abs_path = File.expand_path(path)
626
+
627
+ # Resolve root to strip
628
+ root = options[:path_root]
629
+ prefix = options[:namespace_prefix]
630
+
631
+ # Auto-detect common Rails views root and default prefix
632
+ if root.nil? && abs_path.include?(File.join('app', 'views'))
633
+ root = abs_path.split(File.join('app', 'views')).first + File.join('app', 'views')
634
+ prefix ||= 'views'
635
+ end
636
+
637
+ rel_path = nil
638
+ if root && abs_path.start_with?(File.expand_path(root) + File::SEPARATOR)
639
+ rel_path = Pathname.new(abs_path).relative_path_from(Pathname.new(File.expand_path(root))).to_s
640
+ else
641
+ # Try relative to current working directory
642
+ cwd = Dir.pwd
643
+ if abs_path.start_with?(cwd + File::SEPARATOR)
644
+ rel_path = abs_path.sub(cwd + File::SEPARATOR, '')
645
+ else
646
+ rel_path = File.basename(abs_path)
647
+ end
648
+ end
649
+
650
+ # Remove all extensions (handle multi-extensions like .html.erb)
651
+ base = rel_path.dup
652
+ loop do
653
+ ext = File.extname(base)
654
+ break if ext.nil? || ext.empty?
655
+ base = base.chomp(ext)
656
+ end
657
+
658
+ # Split into segments, normalize, and join with dots
659
+ segments = base.split(File::SEPARATOR).map do |seg|
660
+ seg.downcase
661
+ .gsub(/[^\w\-\.]+/, '_')
662
+ .gsub(/[\-\.]/, '_')
663
+ .gsub(/_+/, '_')
664
+ .gsub(/^_|_$/, '')
665
+ end.reject(&:empty?)
666
+
667
+ derived = segments.join('.')
668
+ if prefix && !prefix.to_s.empty?
669
+ [prefix, derived].reject(&:empty?).join('.')
670
+ else
671
+ derived
672
+ end
673
+ end
674
+
499
675
  # Load locale file (YAML)
500
676
  def self.load_locale_file(path, locale)
501
677
  if File.exist?(path)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: auto-l18n
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Reiner
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-10-27 00:00:00.000000000 Z
11
+ date: 2025-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -44,6 +44,7 @@ files:
44
44
  - LICENSE.txt
45
45
  - README.md
46
46
  - Rakefile
47
+ - auto-l18n-0.1.0.gem
47
48
  - exe/auto-l18n
48
49
  - lib/auto/l18n.rb
49
50
  - lib/auto/l18n/version.rb
@@ -56,7 +57,7 @@ metadata:
56
57
  source_code_uri: https://github.com/NicolasReiner/auto-l18n
57
58
  changelog_uri: https://github.com/NicolasReiner/auto-l18n/blob/master/CHANGELOG.md
58
59
  bug_tracker_uri: https://github.com/NicolasReiner/auto-l18n/issues
59
- rubygems_mfa_required: 'true'
60
+ rubygems_mfa_required: 'false'
60
61
  post_install_message:
61
62
  rdoc_options: []
62
63
  require_paths: