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