extract_i18n 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3434dc96f1e175bb499bfcdf6d4bf1b5dcc5a1a76190639c8689c0eee069820
4
- data.tar.gz: a6bd0093f55639253d1f9dd5589f9ebdefda5197e4ccb04d9b01dd1ce2fade88
3
+ metadata.gz: 7e8c03edd94a9dd0fd99ecb17c2f2140b9ba96894a3e36b29ae623b5a79cfec8
4
+ data.tar.gz: 2c7bd0bae8c25e443d7f33c31dbdc5eb0832ad0d9bf2367a051555e16dcc8176
5
5
  SHA512:
6
- metadata.gz: 82b5167ce206cc40b507d9e7eafae30f8b04384670483ed328c8a924498c252581a87bf02cf03fa103e7591c13090b6a8c97046028af974c18f15489e328b23a
7
- data.tar.gz: 0e94e6d16ffe2e67d18be3d5a06d82051c3a713fdd03e0be8983b4f4a5f2097777f68935437605ef0cd788025c8dd5d4a0fbeb9c209c3d8074b07cf7915211c6
6
+ metadata.gz: a3b44e7bdc8c7b4db6a8391c6cdfc2b82ec9bde4c0fc4cf4feffc982cbeb016dec5ee979799a77b7ed96029693cc059082161882b8e7443aceee50be8651e9fe
7
+ data.tar.gz: 637756d6127ccb0a719e723a6d155206a5b87a485e3b91c2698bd8749ab24f73765df1fb66cf79ec3e1af253e3fe28c8333bf009ffce7db79ea58f535e1e13c1
data/.gitignore CHANGED
@@ -7,3 +7,4 @@
7
7
  /spec/reports/
8
8
  /tmp/
9
9
  spec/.examples.txt
10
+ Gemfile.lock
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # ExtractI18n
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/extract_i18n.svg)](https://badge.fury.io/rb/extract_i18n)
4
+
3
5
  CLI helper program to automatically extract bare text strings into Rails I18n interactively.
4
6
 
5
7
  Useful when adding i18n to a medium/large Rails app.
@@ -7,13 +9,14 @@ Useful when adding i18n to a medium/large Rails app.
7
9
  This Gem **supports** the following source files:
8
10
 
9
11
  - Ruby files (controllers, models etc.) via Ruby-Parser, e.g. walking all Ruby Strings
10
- - Slim Views (via Regexp parser by SlimKeyfy)
12
+ - Slim Views (via Regexp parser by [SlimKeyfy](https://github.com/phrase/slimkeyfy) (MIT License))
11
13
  - Vue Pug views
12
14
  - Pug is very similar to slim and thus relatively good extractable via Regexp.
15
+ - ERB views
16
+ - by vendoring/extending https://github.com/ProGM/i18n-html_extractor (MIT License)
13
17
 
14
18
  CURRENTLY THERE IS **NO SUPPORT** FOR:
15
19
 
16
- - erb ( integrating/forking https://github.com/zigzag/ready_for_i18n or https://github.com/ProGM/i18n-html_extractor)
17
20
  - haml ( integrating https://github.com/shaiguitar/haml-i18n-extractor)
18
21
  - vue html templates ([Check out my vue pug converting script](https://gist.github.com/zealot128/6c41df1d33a810856a557971a04989f6))
19
22
 
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
26
  spec.require_paths = ["lib"]
27
27
 
28
+ spec.add_runtime_dependency 'nokogiri'
28
29
  spec.add_runtime_dependency 'parser', '>= 2.6'
29
30
  spec.add_runtime_dependency 'slim'
30
31
  spec.add_runtime_dependency 'tty-prompt'
@@ -4,11 +4,14 @@ require "extract_i18n/version"
4
4
 
5
5
  require "zeitwerk"
6
6
  loader = Zeitwerk::Loader.for_gem
7
+ loader.inflector.inflect(
8
+ "html_extractor" => "HTMLExtractor",
9
+ )
7
10
  loader.setup # ready!
8
11
 
9
12
  module ExtractI18n
10
13
  class << self
11
- attr_accessor :strip_path, :ignore_hash_keys, :ignore_functions, :ignorelist
14
+ attr_accessor :strip_path, :ignore_hash_keys, :ignore_functions, :ignorelist, :html_fields_with_plaintext
12
15
  end
13
16
 
14
17
  self.strip_path = %r{^app/(javascript|controllers|views)|^lib|^src|^app}
@@ -20,6 +23,7 @@ module ExtractI18n
20
23
  '_',
21
24
  '::'
22
25
  ]
26
+ self.html_fields_with_plaintext = %w[title placeholder alt label aria-label modal-title]
23
27
 
24
28
  def self.key(string, length: 25)
25
29
  string.strip.
@@ -0,0 +1,43 @@
1
+ module ExtractI18n::Adapters
2
+ class ErbAdapter < Adapter
3
+ def run(original_content)
4
+ unless valid_erb?(original_content)
5
+ puts "ERB invalid!"
6
+ return original_content
7
+ end
8
+ document = ExtractI18n::HTMLExtractor::ErbDocument.parse_string(original_content)
9
+ nodes_to_translate = ExtractI18n::HTMLExtractor::Match::Finder.new(document).matches
10
+ nodes_to_translate.each { |node|
11
+ next if node.text == ""
12
+
13
+ process_change(node)
14
+ }
15
+ result = document.save
16
+
17
+ result
18
+ end
19
+
20
+ def valid_erb?(content)
21
+ Parser::CurrentRuby.parse(ERB.new(content).src)
22
+ true
23
+ rescue StandardError => e
24
+ warn e.inspect
25
+ false
26
+ end
27
+
28
+ def process_change(node)
29
+ change = ExtractI18n::SourceChange.new(
30
+ i18n_key: "#{@file_key}.#{ExtractI18n.key(node.text.strip)}",
31
+ i18n_string: node.text,
32
+ interpolate_arguments: {},
33
+ source_line: node.to_s,
34
+ remove: node.text,
35
+ t_template: %{ t('%s') },
36
+ interpolation_type: :ruby
37
+ )
38
+ if @on_ask.call(change)
39
+ node.replace_text!(change.key, change.i18n_t)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,83 @@
1
+ require 'nokogiri'
2
+
3
+ module ExtractI18n
4
+ module HTMLExtractor
5
+ class ErbDocument
6
+ ERB_REGEXPS = [
7
+ TwoWayRegexp.new(/<%=(?<inner_text>.+?)%>/m, /@@=(?<inner_text>[a-z0-9\-\._]+)@@/m),
8
+ TwoWayRegexp.new(/<%#(?<inner_text>.+?)%>/m, /@@#(?<inner_text>[a-z0-9\-\._]+)@@/m),
9
+ TwoWayRegexp.new(/<%(?<inner_text>.+?)%>/m, /@@(?<inner_text>[a-z0-9\-\._]+)@@/m)
10
+ ].freeze
11
+
12
+ attr_reader :erb_directives
13
+
14
+ def initialize(document, erb_directives)
15
+ @document = document
16
+ @erb_directives = erb_directives
17
+ end
18
+
19
+ def save
20
+ result = @document.to_html(indent: 2, encoding: 'UTF-8')
21
+ ERB_REGEXPS.each do |regexp|
22
+ regexp.inverse_replace!(result) do |string_format, data|
23
+ string_format % { inner_text: erb_directives[data[:inner_text]] }
24
+ end
25
+ end
26
+ result
27
+ end
28
+
29
+ def method_missing(name, *args, &block)
30
+ @document.public_send(name, *args, &block) if @document.respond_to? name
31
+ end
32
+
33
+ class <<self
34
+ def parse(filename, verbose: false)
35
+ file_content = ''
36
+ File.open(filename) do |file|
37
+ file.read(nil, file_content)
38
+ return parse_string(file_content, verbose: verbose)
39
+ end
40
+ end
41
+
42
+ def parse_string(string, verbose: false)
43
+ erb_directives = extract_erb_directives! string
44
+ document = create_document(string)
45
+ log_errors(document.errors, string) if verbose
46
+ ErbDocument.new(document, erb_directives)
47
+ end
48
+
49
+ private
50
+
51
+ def create_document(file_content)
52
+ if file_content.start_with?('<!DOCTYPE')
53
+ Nokogiri::HTML(file_content)
54
+ else
55
+ Nokogiri::HTML.fragment(file_content)
56
+ end
57
+ end
58
+
59
+ def log_errors(errors, file_content)
60
+ return if errors.empty?
61
+ text = file_content.split("\n")
62
+ errors.each do |e|
63
+ puts "Error at line #{e.line}: #{e}".red
64
+ puts text[e.line - 1]
65
+ end
66
+ end
67
+
68
+ def extract_erb_directives!(text)
69
+ erb_directives = {}
70
+
71
+ ERB_REGEXPS.each do |regexp|
72
+ regexp.replace!(text) do |string_format, data|
73
+ key = SecureRandom.uuid
74
+ erb_directives[key] = data[:inner_text]
75
+ string_format % { inner_text: key }
76
+ end
77
+ end
78
+ erb_directives
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,43 @@
1
+ module ExtractI18n
2
+ module HTMLExtractor
3
+ module Match
4
+ class Finder
5
+ attr_reader :document
6
+
7
+ def initialize(document)
8
+ @document = document
9
+ end
10
+
11
+ def matches
12
+ erb_nodes(document) + plain_text_nodes(document) + form_fields(document)
13
+ end
14
+
15
+ private
16
+
17
+ def erb_nodes(document)
18
+ document.erb_directives.map do |fragment_id, _|
19
+ ErbDirectiveMatch.create(document, fragment_id)
20
+ end.flatten.compact
21
+ end
22
+
23
+ def plain_text_nodes(document)
24
+ leaf_nodes.map! { |node| PlainTextMatch.create(document, node) }.flatten.compact
25
+ end
26
+
27
+ def form_fields(document)
28
+ ExtractI18n.html_fields_with_plaintext.flat_map do |field|
29
+ document.
30
+ css("[#{field}]").
31
+ select { |input| input[field] && !input[field].empty? }.
32
+ reject { |n| n[field] =~ /\@\@(=?)[a-z0-9\-]+\@\@/ }.
33
+ flat_map { |node| AttributeMatch.create(document, node, field) }
34
+ end.compact
35
+ end
36
+
37
+ def leaf_nodes
38
+ @leaf_nodes ||= document.css('*:not(:has(*))').select { |n| n.text && !n.text.empty? }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ module ExtractI18n
2
+ module HTMLExtractor
3
+ module Match
4
+ class AttributeMatch < BaseMatch
5
+ def initialize(document, node, text, attribute)
6
+ super(document, node, text)
7
+ @attribute = attribute
8
+ end
9
+
10
+ def self.create(document, node, attribute)
11
+ if node[attribute] && !node[attribute].empty?
12
+ [new(document, node, node[attribute], attribute)]
13
+ else
14
+ []
15
+ end
16
+ end
17
+
18
+ def replace_text!(key, i18n_t)
19
+ document.erb_directives[key] = i18n_t
20
+ node[@attribute] = "@@=#{key}@@"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,18 @@
1
+ module ExtractI18n
2
+ module HTMLExtractor
3
+ module Match
4
+ class BaseMatch < NodeMatch
5
+ attr_reader :node
6
+
7
+ def initialize(document, node, text)
8
+ super(document, text)
9
+ @node = node
10
+ end
11
+
12
+ def replace_text!
13
+ node.content = translation_key_object
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ module ExtractI18n
2
+ module HTMLExtractor
3
+ module Match
4
+ class ErbDirectiveMatch < NodeMatch
5
+ REGEXPS = [
6
+ [/^([ \t]*link_to )(("[^"]+")|('[^']+'))/, '\1%s', 2],
7
+ [/^([ \t]*link_to (.*),[ ]?title:[ ]?)(("[^"]+")|('[^']+'))/, '\1%s', 3],
8
+ [/^([ \t]*[a-z_]+\.[a-z_]+_field (.*),[ ]?placeholder:[ ]?)(("[^"]+")|('[^']+'))/, '\1%s', 3],
9
+ [/^([ \t]*[a-z_]+\.text_area (.*),[ ]?placeholder:[ ]?)(("[^"]+")|('[^']+'))/, '\1%s', 3],
10
+ [/^([ \t]*[a-z_]+\.submit )(("[^"]+")|('[^']+'))/, '\1%s', 2],
11
+ [/^([ \t]*[a-z_]+\.label\s+\:[a-z_]+\,\s+)(("[^"]+")|('[^']+'))/, '\1%s', 2]
12
+ ].freeze
13
+
14
+ def initialize(document, fragment_id, text, regexp)
15
+ super(document, text)
16
+ @fragment_id = fragment_id
17
+ @regexp = regexp
18
+ end
19
+
20
+ def replace_text!(key, i18n_t)
21
+ document.erb_directives[@fragment_id].gsub!(@regexp[0], @regexp[1] % i18n_t.strip)
22
+ end
23
+
24
+ def self.create(document, fragment_id)
25
+ REGEXPS.map do |r|
26
+ match = document.erb_directives[fragment_id].match(r[0])
27
+ new(document, fragment_id, match[r[2]][1...-1], r) if match && match[r[2]]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ module ExtractI18n
2
+ module HTMLExtractor
3
+ module Match
4
+ class NodeMatch
5
+ attr_reader :document, :text
6
+
7
+ def initialize(document, text)
8
+ @document = document
9
+ @text = text
10
+ end
11
+
12
+ def translation_key_object
13
+ "t('.#{key}')"
14
+ end
15
+
16
+ def replace_text!
17
+ raise NotImplementedError
18
+ end
19
+
20
+ attr_writer :key
21
+
22
+ def key
23
+ @key ||= text.parameterize.underscore
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ module ExtractI18n
2
+ module HTMLExtractor
3
+ module Match
4
+ class PlainTextMatch < BaseMatch
5
+ def self.create(document, node)
6
+ return nil if node.name.start_with?('script')
7
+ node.text.split(/\@\@(=?)[a-z0-9\-]+\@\@/).map! do |text|
8
+ new(document, node, text.strip) if !text.nil? && !text.empty?
9
+ end
10
+ end
11
+
12
+ def replace_text!(key, i18n_t)
13
+ document.erb_directives[key] = i18n_t
14
+ node.content = node.content.gsub(text, "@@=#{key}@@")
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,94 @@
1
+ module ExtractI18
2
+ module HTMLExtractor
3
+ class Runner
4
+ include Cli
5
+
6
+ def initialize(args = {})
7
+ @files = file_list_from_pattern(args[:file_pattern])
8
+ @locale = args[:locale].presence
9
+ @verbose = args[:verbose]
10
+ end
11
+
12
+ def run_interactive
13
+ each_translation do |file, document, node|
14
+ puts "Found \"#{node.text}\" in #{file}:#{node.text}".green
15
+ next unless confirm 'Create a translation?', 'Yes', 'No', default: 'Yes'
16
+
17
+ node.key = prompt 'Choose i18n key', default: node.key
18
+ node.replace_text!
19
+
20
+ document.save!(file)
21
+
22
+ add_translations! node.key, node.text, default_locale: @locale
23
+ puts
24
+ end
25
+ end
26
+
27
+ def run
28
+ each_translation do |file, document, node|
29
+ puts "Found \"#{node.text}\" in #{file}:#{node.text}".green
30
+ node.replace_text!
31
+ document.save!(file)
32
+
33
+ add_translation! I18n.default_locale, node.key, node.text
34
+ end
35
+ end
36
+
37
+ def test_run
38
+ each_translation do |file, _, node|
39
+ puts "Found \"#{node.text}\" in #{file}:#{node.text}".green
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def file_list_from_pattern(pattern)
46
+ if pattern.present?
47
+ Dir[Rails.root.join(pattern)]
48
+ else
49
+ Dir[Rails.root.join('app', 'views', '**', '*.erb')] -
50
+ Dir[Rails.root.join('app', 'views', '**', '*.*.*.erb')]
51
+ end
52
+ end
53
+
54
+ def add_translations!(key, text, default_locale: nil)
55
+ return prompt_and_add_translation!(default_locale, key, default_text: text) if default_locale
56
+ prompt_and_add_translation!(I18n.default_locale, key, default_text: text)
57
+
58
+ I18n.available_locales.each do |locale|
59
+ next if locale == I18n.default_locale
60
+
61
+ prompt_and_add_translation!(locale.to_s, key)
62
+ end
63
+ end
64
+
65
+ def prompt_and_add_translation!(locale, key, default_text: nil)
66
+ out_text = prompt "Choose #{locale} value", default: default_text
67
+ add_translation! locale, key, out_text
68
+ end
69
+
70
+ def add_translation!(locale, key, value)
71
+ new_keys = i18n.missing_keys(locales: [locale]).set_each_value!(value)
72
+ i18n.data.merge! new_keys
73
+ puts "Added t(.#{key}), translated in #{locale} as #{value}:".green
74
+ puts new_keys.inspect
75
+ end
76
+
77
+ def i18n
78
+ I18n::Tasks::BaseTask.new
79
+ end
80
+
81
+ def each_translation
82
+ @files.each do |file|
83
+ document = I18n::HTMLExtractor::ErbDocument.parse file
84
+ nodes_to_translate = extract_all_nodes_to_translate(document)
85
+ nodes_to_translate.each { |node| yield(file, document, node) }
86
+ end
87
+ end
88
+
89
+ def extract_all_nodes_to_translate(document)
90
+ Match::Finder.new(document).matches
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,70 @@
1
+ module ExtractI18n
2
+ module HTMLExtractor
3
+ class TwoWayRegexp
4
+ attr_reader :from, :to
5
+
6
+ def initialize(from, to)
7
+ @from = from
8
+ @to = to
9
+ end
10
+
11
+ def replace(text)
12
+ if block_given?
13
+ text.gsub(@from) do |matched_text|
14
+ yield(to_as_format, Regexp.last_match, matched_text)
15
+ end
16
+ else
17
+ text.gsub(@from, reverse_to)
18
+ end
19
+ end
20
+
21
+ def replace!(text)
22
+ if block_given?
23
+ text.gsub!(@from) do |matched_text|
24
+ yield(to_as_format, Regexp.last_match, matched_text)
25
+ end
26
+ else
27
+ text.gsub!(@from, reverse_to)
28
+ end
29
+ end
30
+
31
+ def inverse_replace(text)
32
+ if block_given?
33
+ text.gsub(@to) do |matched_text|
34
+ yield(from_as_format, Regexp.last_match, matched_text)
35
+ end
36
+ else
37
+ text.gsub(@to, reverse_from)
38
+ end
39
+ end
40
+
41
+ def inverse_replace!(text)
42
+ if block_given?
43
+ text.gsub!(@to) do |matched_text|
44
+ yield(from_as_format, Regexp.last_match, matched_text)
45
+ end
46
+ else
47
+ text.gsub!(@to, reverse_from)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def to_as_format
54
+ @to_as_format ||= @to.source.gsub('%', '%%').gsub!(/\(\?<([a-z_]+)>.*\)/, '%{\1}')
55
+ end
56
+
57
+ def from_as_format
58
+ @from_as_format ||= @from.source.gsub('%', '%%').gsub!(/\(\?<([a-z_]+)>.*\)/, '%{\1}')
59
+ end
60
+
61
+ def reverse_from
62
+ @reverse_from ||= @from.source.gsub(/\(\?<([a-z_]+)>.*\)/, '\k{\1}')
63
+ end
64
+
65
+ def reverse_to
66
+ @reverse_to ||= @to.source.gsub(/\(\?<([a-z_]+)>.*\)/, '\k{\1}')
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,3 +1,3 @@
1
1
  module ExtractI18n
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: extract_i18n
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Wienert
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-10-06 00:00:00.000000000 Z
11
+ date: 2020-10-07 00:00:00.000000000 Z
12
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: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: parser
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -117,12 +131,22 @@ files:
117
131
  - extract_i18n.gemspec
118
132
  - lib/extract_i18n.rb
119
133
  - lib/extract_i18n/adapters/adapter.rb
134
+ - lib/extract_i18n/adapters/erb_adapter.rb
120
135
  - lib/extract_i18n/adapters/ruby_adapter.rb
121
136
  - lib/extract_i18n/adapters/slim_adapter.rb
122
137
  - lib/extract_i18n/adapters/slim_adapter_wip.rb
123
138
  - lib/extract_i18n/adapters/vue_adapter.rb
124
139
  - lib/extract_i18n/cli.rb
125
140
  - lib/extract_i18n/file_processor.rb
141
+ - lib/extract_i18n/html_extractor/erb_document.rb
142
+ - lib/extract_i18n/html_extractor/match.rb
143
+ - lib/extract_i18n/html_extractor/match/attribute_match.rb
144
+ - lib/extract_i18n/html_extractor/match/base_match.rb
145
+ - lib/extract_i18n/html_extractor/match/erb_directive_match.rb
146
+ - lib/extract_i18n/html_extractor/match/node_match.rb
147
+ - lib/extract_i18n/html_extractor/match/plain_text_match.rb
148
+ - lib/extract_i18n/html_extractor/runner.rb
149
+ - lib/extract_i18n/html_extractor/two_way_regexp.rb
126
150
  - lib/extract_i18n/slimkeyfy/slim_transformer.rb
127
151
  - lib/extract_i18n/slimkeyfy/vue_transformer.rb
128
152
  - lib/extract_i18n/slimkeyfy/whitespacer.rb