i18n_template 0.0.1
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.
- data/.gitignore +4 -0
- data/Gemfile.rails23_r187 +8 -0
- data/Gemfile.rails30_r193 +8 -0
- data/Gemfile.rails31_r193 +8 -0
- data/README.md +250 -0
- data/Rakefile +1 -0
- data/bin/i18n_template +5 -0
- data/i18n_template.gemspec +26 -0
- data/lib/i18n_template.rb +28 -0
- data/lib/i18n_template/document.rb +460 -0
- data/lib/i18n_template/extractor.rb +4 -0
- data/lib/i18n_template/extractor/base.rb +44 -0
- data/lib/i18n_template/extractor/gettext.rb +127 -0
- data/lib/i18n_template/extractor/plain.rb +43 -0
- data/lib/i18n_template/extractor/yaml.rb +53 -0
- data/lib/i18n_template/handler.rb +61 -0
- data/lib/i18n_template/node.rb +74 -0
- data/lib/i18n_template/railtie.rb +7 -0
- data/lib/i18n_template/runner.rb +61 -0
- data/lib/i18n_template/runner/base.rb +11 -0
- data/lib/i18n_template/runner/extract_phrases.rb +70 -0
- data/lib/i18n_template/tasks.rb +2 -0
- data/lib/i18n_template/translation.rb +62 -0
- data/lib/i18n_template/translator.rb +5 -0
- data/lib/i18n_template/translator/i18n.rb +24 -0
- data/lib/i18n_template/version.rb +3 -0
- data/test/abstract_unit.rb +11 -0
- data/test/document_test.rb +316 -0
- data/test/fixtures/handling_if_blocks.yml +23 -0
- data/test/fixtures/ignored_markup.yml +15 -0
- data/test/fixtures/incorrect_node_markup.yml +17 -0
- data/test/fixtures/nested_nodes.yml +16 -0
- data/test/fixtures/nested_wrapped_text.yml +15 -0
- data/test/fixtures/phrase_fully_ignored.yml +14 -0
- data/test/fixtures/phrase_with_embed_words_and_scriptlet.yml +17 -0
- data/test/fixtures/phrase_with_single_char_to_ignore.yml +19 -0
- data/test/fixtures/replacing_br_with_newline.yml +15 -0
- data/test/fixtures/skipping_ignored_blocks.yml +15 -0
- data/test/fixtures/spans_as_phrases.yml +18 -0
- data/test/fixtures/table.yml +35 -0
- data/test/fixtures/text_with_braces.yml +17 -0
- data/test/fixtures/text_with_brackets.yml +17 -0
- data/test/fixtures/wrapped_key_propagation.yml +15 -0
- data/test/fixtures/wrapping_eval_blocks.yml +17 -0
- data/test/fixtures_rendering_test.rb +46 -0
- data/test/inline_rendering_test.rb +27 -0
- data/test/support/i18n_test_case_helper.rb +12 -0
- data/test/templates/_footer.html.erb +3 -0
- data/test/templates/greeting.html.erb +1 -0
- data/test/templates/layouts/application.html.erb +5 -0
- data/test/templates/users/_account.html.erb +3 -0
- data/test/templates/users/_profile.html.erb +6 -0
- data/test/templates/users/index.html.erb +5 -0
- data/test/templates/users/show.html.erb +4 -0
- data/test/templates_rendering_test.rb +81 -0
- data/test/translate_test.rb +72 -0
- metadata +156 -0
@@ -0,0 +1,44 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
|
3
|
+
module I18nTemplate
|
4
|
+
module Extractor
|
5
|
+
class Base
|
6
|
+
class << self
|
7
|
+
def default_options
|
8
|
+
{
|
9
|
+
:glob => ['app/views/**/*.{erb,rhtml}'],
|
10
|
+
:format => 'gettext'
|
11
|
+
}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(options)
|
16
|
+
@options = self.class.default_options.dup
|
17
|
+
@options.merge!(options)
|
18
|
+
end
|
19
|
+
|
20
|
+
def call(source)
|
21
|
+
raise NotImplementedError, "'call' is not implemented by #{self.class.name}"
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def log(message)
|
27
|
+
puts message if @options[:verbose]
|
28
|
+
end
|
29
|
+
|
30
|
+
def extract_phrases(filename)
|
31
|
+
log "Processing #{filename}"
|
32
|
+
source = File.read(filename)
|
33
|
+
document = ::I18nTemplate::Document.new(source)
|
34
|
+
document.preprocess!
|
35
|
+
|
36
|
+
document.warnings.each { |warning| log(warning) } if @options[:verbose]
|
37
|
+
|
38
|
+
document.phrases
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module I18nTemplate
|
2
|
+
module Extractor
|
3
|
+
#
|
4
|
+
# Extract phrases to gettext format. E.g. pot/po files
|
5
|
+
#
|
6
|
+
# Extractor creates and updates:
|
7
|
+
# * {po_root}/{textdomain}.pot
|
8
|
+
# * {pr_root}/{lang}/{textdomain}.po
|
9
|
+
class Gettext < Base
|
10
|
+
|
11
|
+
I18nTemplate.extractors << self
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def format
|
15
|
+
'gettext'
|
16
|
+
end
|
17
|
+
|
18
|
+
def default_options
|
19
|
+
super.merge({
|
20
|
+
:po_root => 'po',
|
21
|
+
:textdomain => 'phrases'
|
22
|
+
})
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def initialize(options)
|
27
|
+
super(options)
|
28
|
+
|
29
|
+
@version = 'version 0.0.1'
|
30
|
+
@pot_file = File.join(@options[:po_root], "#{@options[:textdomain]}.pot")
|
31
|
+
@pot_file_tmp = "#{@pot_file}.tmp"
|
32
|
+
end
|
33
|
+
|
34
|
+
def call(paths)
|
35
|
+
# ensure root directory exists
|
36
|
+
FileUtils.mkdir_p(@options[:po_root])
|
37
|
+
|
38
|
+
# generate new temporary pot file
|
39
|
+
File.open(@pot_file_tmp, "w") do |f|
|
40
|
+
f.puts generate_pot_header
|
41
|
+
f.puts ""
|
42
|
+
f.puts generate_pot_body(paths)
|
43
|
+
end
|
44
|
+
|
45
|
+
# merge pot file
|
46
|
+
log("Merging #{@pot_file}")
|
47
|
+
if File.exist?(@pot_file)
|
48
|
+
merge_po_files(@pot_file, @pot_file_tmp)
|
49
|
+
else
|
50
|
+
FileUtils.cp(@pot_file_tmp, @pot_file)
|
51
|
+
end
|
52
|
+
|
53
|
+
# merge po files
|
54
|
+
Dir.glob("#{@options[:po_root]}/*/#{@options[:textdomain]}.po") do |po_file|
|
55
|
+
log("Merging #{po_file}")
|
56
|
+
concate_po_files(po_file, @pot_file_tmp)
|
57
|
+
end
|
58
|
+
|
59
|
+
# remove temporary pot file
|
60
|
+
FileUtils.rm(@pot_file_tmp)
|
61
|
+
end
|
62
|
+
|
63
|
+
protected
|
64
|
+
|
65
|
+
def generate_pot_header
|
66
|
+
time = Time.now.strftime("%Y-%m-%d %H:%M")
|
67
|
+
off = Time.now.utc_offset
|
68
|
+
sign = off <= 0 ? '-' : '+'
|
69
|
+
time += sprintf('%s%02d%02d', sign, *(off.abs / 60).divmod(60))
|
70
|
+
|
71
|
+
<<-TITLE
|
72
|
+
# SOME DESCRIPTIVE TITLE.
|
73
|
+
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
74
|
+
# This file is distributed under the same license as the PACKAGE package.
|
75
|
+
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
76
|
+
#
|
77
|
+
#, fuzzy
|
78
|
+
msgid ""
|
79
|
+
msgstr ""
|
80
|
+
"Project-Id-Version: PACKAGE VERSION\\n"
|
81
|
+
"POT-Creation-Date: #{time}\\n"
|
82
|
+
"PO-Revision-Date: #{time}\\n"
|
83
|
+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
|
84
|
+
"Language-Team: LANGUAGE <LL@li.org>\\n"
|
85
|
+
"MIME-Version: 1.0\\n"
|
86
|
+
"Content-Type: text/plain; charset=UTF-8\\n"
|
87
|
+
"Content-Transfer-Encoding: 8bit\\n"
|
88
|
+
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n"
|
89
|
+
TITLE
|
90
|
+
end
|
91
|
+
|
92
|
+
def generate_pot_body(paths)
|
93
|
+
sources = {}
|
94
|
+
|
95
|
+
paths.each do |path|
|
96
|
+
phrases = extract_phrases(path)
|
97
|
+
phrases.each do |phrase|
|
98
|
+
sources[phrase] ||= []
|
99
|
+
sources[phrase] << path
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
log("Extracted #{sources.keys.size} phrases")
|
104
|
+
|
105
|
+
data = ""
|
106
|
+
sources.sort.each do |phrase, paths|
|
107
|
+
data << "# #{paths.join(",")}\n"
|
108
|
+
data << "msgid #{phrase.inspect}\n"
|
109
|
+
data << "msgstr \"\"\n"
|
110
|
+
data << "\n"
|
111
|
+
end
|
112
|
+
data
|
113
|
+
end
|
114
|
+
|
115
|
+
def concate_po_files(def_po, ref_po)
|
116
|
+
command = "msgcat --output-file #{def_po} --use-first #{def_po} #{ref_po}"
|
117
|
+
system(command) or raise RuntimeError, "can't run #{command.inspect}"
|
118
|
+
end
|
119
|
+
|
120
|
+
def merge_po_files(def_po, ref_po)
|
121
|
+
command = "msgmerge --quiet --update #{def_po} #{ref_po}"
|
122
|
+
system(command) or raise RuntimeError, "can't run #{command.inspect}"
|
123
|
+
end
|
124
|
+
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module I18nTemplate
|
2
|
+
module Extractor
|
3
|
+
# Extract phrases to plain format:
|
4
|
+
# Each phrase per line
|
5
|
+
class Plain < Base
|
6
|
+
|
7
|
+
I18nTemplate.extractors << self
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def format
|
11
|
+
'plain'
|
12
|
+
end
|
13
|
+
|
14
|
+
def default_options
|
15
|
+
super.merge({
|
16
|
+
:output_file => 'phrases.txt'
|
17
|
+
})
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(paths)
|
22
|
+
sources = {}
|
23
|
+
|
24
|
+
paths.each do |path|
|
25
|
+
phrases = extract_phrases(path)
|
26
|
+
phrases.each do |phrase|
|
27
|
+
sources[phrase] ||= []
|
28
|
+
sources[phrase] << path
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
log "Extracting #{sources.keys.size} phrases to #{@options[:output_file]}"
|
33
|
+
File.open(@options[:output_file], "w") do |f|
|
34
|
+
sources.sort.each do |phrase, paths|
|
35
|
+
f << "# #{paths.join(",")}\n"
|
36
|
+
f << phrase
|
37
|
+
f << "\n"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module I18nTemplate
|
4
|
+
module Extractor
|
5
|
+
# Extract phrases to yaml format. E.g:
|
6
|
+
# config/locales/templates.yml
|
7
|
+
class Yaml < Base
|
8
|
+
|
9
|
+
I18nTemplate.extractors << self
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def format
|
13
|
+
'yaml'
|
14
|
+
end
|
15
|
+
|
16
|
+
def default_options
|
17
|
+
super.merge({
|
18
|
+
:locales_root => 'config/locales'
|
19
|
+
})
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def call(paths)
|
24
|
+
# ensure root directory exists
|
25
|
+
FileUtils.mkdir_p(@options[:locales_root])
|
26
|
+
output_file = File.join(@options[:locales_root], 'phrases.yml')
|
27
|
+
|
28
|
+
# extract phrases
|
29
|
+
phrases = []
|
30
|
+
paths.each do |path|
|
31
|
+
phrases += extract_phrases(path)
|
32
|
+
end
|
33
|
+
phrases.uniq!
|
34
|
+
|
35
|
+
# update phrases
|
36
|
+
log "Extracting #{phrases.size} phrases to #{output_file}"
|
37
|
+
data = File.exists?(output_file) ? YAML.load_file(output_file) : {}
|
38
|
+
data['en'] ||= {}
|
39
|
+
data.keys.each do |locale|
|
40
|
+
phrases.each do |phrase|
|
41
|
+
data[locale][phrase] ||= nil
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# store data
|
46
|
+
File.open(output_file, "w") do |f|
|
47
|
+
f << data.to_yaml
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module I18nTemplate
|
2
|
+
##
|
3
|
+
# Handler is ActionView wrapper for erb handler.
|
4
|
+
# If internationalize? returns true
|
5
|
+
# it calls erb handler with internationalized template
|
6
|
+
# otherwise it calls handler with regular template
|
7
|
+
class Handler
|
8
|
+
def initialize(options = {})
|
9
|
+
@options = options
|
10
|
+
@default_format = ::Mime::HTML
|
11
|
+
init_erb_handler
|
12
|
+
end
|
13
|
+
|
14
|
+
# default format
|
15
|
+
attr_reader :default_format
|
16
|
+
|
17
|
+
# erb handler
|
18
|
+
attr_reader :erb_handler
|
19
|
+
|
20
|
+
# call method implements ActionView::Template handler interface
|
21
|
+
def call(template)
|
22
|
+
if internationalize?(template)
|
23
|
+
document = ::I18nTemplate::Document.new(template.source)
|
24
|
+
document.process!
|
25
|
+
document.warnings.each { |warning| $stderr.puts warning } if @options[:verbose]
|
26
|
+
|
27
|
+
erb_handler.call(document)
|
28
|
+
else
|
29
|
+
erb_handler.call(template)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
# check if template source should be internationalized
|
36
|
+
#
|
37
|
+
# @note
|
38
|
+
# if you need more control inherite and override this method
|
39
|
+
def internationalize?(template)
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# init action_view erb handler
|
46
|
+
def init_erb_handler
|
47
|
+
require 'action_pack/version'
|
48
|
+
|
49
|
+
case ActionPack::VERSION::MAJOR
|
50
|
+
when 2
|
51
|
+
require 'action_view/template_handlers/erb'
|
52
|
+
@erb_handler = ::ActionView::TemplateHandlers::ERB
|
53
|
+
when 3
|
54
|
+
require 'action_view/template/handlers/erb'
|
55
|
+
@erb_handler = ::ActionView::Template::Handlers::ERB
|
56
|
+
else
|
57
|
+
raise NotImplementedError, "Can't init erb_handler for action_pack v#{ActionPack::VERSION::STRING}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module I18nTemplate
|
2
|
+
##
|
3
|
+
# Document processing node
|
4
|
+
class Node
|
5
|
+
# The array of children of this node. Not all nodes have children.
|
6
|
+
attr_reader :children
|
7
|
+
|
8
|
+
# The parent node of this node. All nodes have a parent, except for the
|
9
|
+
# root node.
|
10
|
+
attr_reader :parent
|
11
|
+
|
12
|
+
# The line number of the input where this node was begun
|
13
|
+
attr_reader :line
|
14
|
+
|
15
|
+
# The byte position in the input where this node was begun
|
16
|
+
attr_reader :position
|
17
|
+
|
18
|
+
# Node token
|
19
|
+
attr_reader :token
|
20
|
+
|
21
|
+
attr_accessor :tag
|
22
|
+
attr_accessor :phrase
|
23
|
+
|
24
|
+
# Create a new node as a child of the given parent.
|
25
|
+
def initialize(parent, line = 0, position = 0, token = nil, tag = nil, &block)
|
26
|
+
@parent = parent
|
27
|
+
@children = []
|
28
|
+
@line, @position = line, position
|
29
|
+
@token = token
|
30
|
+
@tag = tag
|
31
|
+
instance_eval(&block) if block_given?
|
32
|
+
end
|
33
|
+
|
34
|
+
# tag node?
|
35
|
+
def tag?
|
36
|
+
!@tag.nil?
|
37
|
+
end
|
38
|
+
|
39
|
+
# text node?
|
40
|
+
def text?
|
41
|
+
@tag.nil?
|
42
|
+
end
|
43
|
+
|
44
|
+
# root node?
|
45
|
+
def root?
|
46
|
+
parent == self
|
47
|
+
end
|
48
|
+
|
49
|
+
# Return descendant text if tag node is wrapper node for some text node
|
50
|
+
# @return 'text' for
|
51
|
+
# <div><span><b>text</b></span><div>
|
52
|
+
# @return nil for
|
53
|
+
# <div><span><b>text</b></span><p>data</p><div>
|
54
|
+
def wrapped_node_text
|
55
|
+
node = self
|
56
|
+
while node.children.size == 1
|
57
|
+
node = node.children.first
|
58
|
+
return node.token if node.text?
|
59
|
+
end
|
60
|
+
return nil
|
61
|
+
end
|
62
|
+
|
63
|
+
def descendants_text
|
64
|
+
output = ""
|
65
|
+
children.each do |child|
|
66
|
+
output << child.token if child.text?
|
67
|
+
child.children.each do |node|
|
68
|
+
output << node.descendants_text
|
69
|
+
end
|
70
|
+
end
|
71
|
+
output
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'i18n_template/runner/base'
|
2
|
+
require 'i18n_template/runner/extract_phrases'
|
3
|
+
|
4
|
+
require 'optparse'
|
5
|
+
module I18nTemplate
|
6
|
+
module Runner
|
7
|
+
class << self
|
8
|
+
|
9
|
+
def run
|
10
|
+
options = {}
|
11
|
+
|
12
|
+
option_parser = OptionParser.new do |op|
|
13
|
+
op.banner = "Usage: #{File.basename($0)} COMMAND [OPTIONS]"
|
14
|
+
|
15
|
+
I18nTemplate.runners.each do |runner|
|
16
|
+
op.separator ""
|
17
|
+
op.separator "#{runner.command} - #{runner.description}"
|
18
|
+
runner.add_options!(op, options)
|
19
|
+
end
|
20
|
+
|
21
|
+
op.separator ""
|
22
|
+
op.on(
|
23
|
+
"--verbose",
|
24
|
+
"turn on verbosity"
|
25
|
+
) { |v| options[:verbose] = true }
|
26
|
+
|
27
|
+
op.separator ""
|
28
|
+
op.on_tail("-h", "--help", "Show this message") { puts op; exit }
|
29
|
+
op.on_tail('-v', '--version', "Show version") { puts I18nTemplate::VERSION; exit }
|
30
|
+
end
|
31
|
+
|
32
|
+
begin
|
33
|
+
option_parser.parse!(ARGV)
|
34
|
+
rescue OptionParser::ParseError => e
|
35
|
+
warn e.message
|
36
|
+
puts option_parser
|
37
|
+
exit 1
|
38
|
+
end
|
39
|
+
|
40
|
+
I18nTemplate.runners.each do |runner|
|
41
|
+
runner.default_options.each do |key, value|
|
42
|
+
options[key] = value unless options.key?(key)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
command = ARGV.first
|
47
|
+
|
48
|
+
runner = I18nTemplate.runners.detect { |klass| klass.command == command }
|
49
|
+
|
50
|
+
unless runner
|
51
|
+
warn "Unknown command '#{command}'"
|
52
|
+
puts option_parser
|
53
|
+
exit 1
|
54
|
+
end
|
55
|
+
|
56
|
+
runner.new(options).run
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
end
|