extract_i18n 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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