jekyll-l10n 2.0.0 → 3.0.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 +4 -4
- data/README.md +44 -0
- data/lib/jekyll-l10n/configuration/schema.rb +308 -0
- data/lib/jekyll-l10n/configuration.rb +165 -0
- data/lib/jekyll-l10n/constants.rb +45 -10
- data/lib/jekyll-l10n/errors.rb +7 -0
- data/lib/jekyll-l10n/extraction/compendium_merger.rb +9 -11
- data/lib/jekyll-l10n/extraction/compendium_translator.rb +27 -11
- data/lib/jekyll-l10n/extraction/config_loader.rb +4 -4
- data/lib/jekyll-l10n/extraction/coverage_report.rb +129 -0
- data/lib/jekyll-l10n/extraction/dom_text_extractor.rb +2 -31
- data/lib/jekyll-l10n/extraction/extractor.rb +48 -28
- data/lib/jekyll-l10n/extraction/html_string_extractor.rb +24 -10
- data/lib/jekyll-l10n/extraction/logger.rb +6 -5
- data/lib/jekyll-l10n/extraction/result_saver.rb +5 -10
- data/lib/jekyll-l10n/instrumentation.rb +172 -139
- data/lib/jekyll-l10n/jekyll/generator.rb +10 -22
- data/lib/jekyll-l10n/jekyll/localized_page.rb +27 -49
- data/lib/jekyll-l10n/jekyll/post_write_html_reprocessor.rb +42 -25
- data/lib/jekyll-l10n/jekyll/post_write_processor.rb +2 -1
- data/lib/jekyll-l10n/jekyll/regeneration_checker.rb +16 -10
- data/lib/jekyll-l10n/jekyll/url_filter.rb +8 -7
- data/lib/jekyll-l10n/po_file/entry_parser.rb +155 -0
- data/lib/jekyll-l10n/po_file/fuzzy_matcher.rb +67 -53
- data/lib/jekyll-l10n/po_file/loader.rb +20 -11
- data/lib/jekyll-l10n/po_file/manager.rb +32 -17
- data/lib/jekyll-l10n/po_file/merger.rb +2 -1
- data/lib/jekyll-l10n/po_file/path_builder.rb +11 -1
- data/lib/jekyll-l10n/po_file/reader.rb +40 -508
- data/lib/jekyll-l10n/po_file/writer.rb +31 -16
- data/lib/jekyll-l10n/translation/block_text_extractor.rb +3 -7
- data/lib/jekyll-l10n/translation/html_translator.rb +136 -157
- data/lib/jekyll-l10n/translation/libre_translate_languages.rb +73 -0
- data/lib/jekyll-l10n/translation/libre_translator.rb +134 -78
- data/lib/jekyll-l10n/translation/request_slots.rb +69 -0
- data/lib/jekyll-l10n/translation/translation_progress.rb +77 -0
- data/lib/jekyll-l10n/translation/translator.rb +20 -17
- data/lib/jekyll-l10n/utils/baseurl_normalizer.rb +108 -0
- data/lib/jekyll-l10n/utils/debug_logger.rb +4 -51
- data/lib/jekyll-l10n/utils/error_handler.rb +9 -24
- data/lib/jekyll-l10n/utils/external_link_icon_preserver.rb +5 -62
- data/lib/jekyll-l10n/utils/html_parser.rb +24 -31
- data/lib/jekyll-l10n/utils/html_text_utils.rb +146 -91
- data/lib/jekyll-l10n/utils/logger_formatter.rb +6 -4
- data/lib/jekyll-l10n/utils/msgstr_validator.rb +74 -0
- data/lib/jekyll-l10n/utils/page_locales_config.rb +126 -107
- data/lib/jekyll-l10n/utils/po_entry_converter.rb +0 -43
- data/lib/jekyll-l10n/utils/site_config_accessor.rb +56 -54
- data/lib/jekyll-l10n/utils/translation_resolver.rb +56 -6
- data/lib/jekyll-l10n/utils/url_path_builder.rb +5 -27
- data/lib/jekyll-l10n/utils/url_transformer.rb +5 -11
- data/lib/jekyll-l10n/version.rb +1 -1
- data/lib/jekyll-l10n.rb +9 -6
- metadata +12 -3
|
@@ -75,17 +75,23 @@ module Jekyll
|
|
|
75
75
|
|
|
76
76
|
po_file[entry[:msgid]] = po_entry
|
|
77
77
|
rescue StandardError => e
|
|
78
|
-
|
|
79
|
-
errors << "Error adding '#{entry[:msgid][0..truncate_length]}': " \
|
|
80
|
-
"#{e.class} - #{e.message}"
|
|
78
|
+
errors << entry_error(entry, e)
|
|
81
79
|
end
|
|
80
|
+
warn_merge_errors(errors)
|
|
81
|
+
end
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
def self.warn_merge_errors(errors)
|
|
84
|
+
return if errors.empty?
|
|
84
85
|
|
|
85
|
-
Jekyll.logger.warn
|
|
86
|
+
Jekyll.logger.warn Constants::LOG_PREFIX,
|
|
86
87
|
"#{errors.length} errors during merging: #{errors.join(', ')}"
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
def self.entry_error(entry, error)
|
|
91
|
+
truncate_length = Jekyll::L10n::Constants::LOG_TRUNCATE_LONG
|
|
92
|
+
"Error adding '#{entry[:msgid][0..truncate_length]}': #{error.class} - #{error.message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
89
95
|
def self.build_fuzzy_candidates(existing_po, new_msgids)
|
|
90
96
|
existing_po.reject do |old_msgid, entry|
|
|
91
97
|
old_msgid.empty? ||
|
|
@@ -113,23 +119,32 @@ module Jekyll
|
|
|
113
119
|
def self.set_po_entry_msgstr(po_entry, existing_entry, new_msgstr,
|
|
114
120
|
fuzzy_candidates = {})
|
|
115
121
|
if existing_entry.is_a?(Hash)
|
|
116
|
-
po_entry
|
|
117
|
-
po_entry.flag = 'fuzzy' if existing_entry[:fuzzy]
|
|
118
|
-
po_entry.previous = existing_entry[:previous_msgid] if existing_entry[:previous_msgid]
|
|
122
|
+
apply_existing_entry(po_entry, existing_entry)
|
|
119
123
|
elsif existing_entry
|
|
120
124
|
po_entry.msgstr = existing_entry
|
|
121
125
|
else
|
|
122
|
-
|
|
123
|
-
if match
|
|
124
|
-
po_entry.msgstr = match[:msgstr]
|
|
125
|
-
po_entry.flag = 'fuzzy'
|
|
126
|
-
po_entry.previous = match[:msgid]
|
|
127
|
-
else
|
|
128
|
-
po_entry.msgstr = new_msgstr || ''
|
|
129
|
-
end
|
|
126
|
+
apply_fuzzy_match_or_new(po_entry, new_msgstr, fuzzy_candidates)
|
|
130
127
|
end
|
|
131
128
|
end
|
|
132
129
|
|
|
130
|
+
def self.apply_existing_entry(po_entry, existing_entry)
|
|
131
|
+
po_entry.msgstr = existing_entry[:msgstr] || ''
|
|
132
|
+
po_entry.flag = 'fuzzy' if existing_entry[:fuzzy]
|
|
133
|
+
po_entry.previous = existing_entry[:previous_msgid] if existing_entry[:previous_msgid]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.apply_fuzzy_match_or_new(po_entry, new_msgstr, fuzzy_candidates)
|
|
137
|
+
match = PoFuzzyMatcher.find_match(po_entry.msgid, fuzzy_candidates)
|
|
138
|
+
unless match
|
|
139
|
+
po_entry.msgstr = new_msgstr || ''
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
po_entry.msgstr = match[:msgstr]
|
|
144
|
+
po_entry.flag = 'fuzzy'
|
|
145
|
+
po_entry.previous = match[:msgid]
|
|
146
|
+
end
|
|
147
|
+
|
|
133
148
|
def self.header(locale)
|
|
134
149
|
header_str = "Language: #{locale}\nMIME-Version: 1.0\nContent-Type: text/plain; "
|
|
135
150
|
"#{header_str}charset=UTF-8\nContent-Transfer-Encoding: 8bit\n"
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative '../utils/html_text_utils'
|
|
4
4
|
require_relative '../utils/html_elements'
|
|
5
|
-
require_relative '../utils/text_validator'
|
|
6
5
|
|
|
7
6
|
module Jekyll
|
|
8
7
|
module L10n
|
|
@@ -27,19 +26,16 @@ module Jekyll
|
|
|
27
26
|
|
|
28
27
|
# Extract normalized block text from an element.
|
|
29
28
|
#
|
|
30
|
-
# Returns
|
|
31
|
-
#
|
|
32
|
-
# icon tags, normalizes whitespace, and validates. HTML entities are
|
|
29
|
+
# Returns the msgid extraction gives the element (HtmlTextUtils.block_text),
|
|
30
|
+
# or nil if it is not a content element or has no msgid. HTML entities are
|
|
33
31
|
# preserved verbatim to match the keys produced by the extraction pipeline.
|
|
34
32
|
#
|
|
35
33
|
# @param node [Nokogiri::XML::Element] DOM element to extract from
|
|
36
34
|
# @return [String, nil] Normalized text from element, or nil if not valid
|
|
37
35
|
def extract(node)
|
|
38
36
|
return nil unless extractable?(node)
|
|
39
|
-
return nil if HtmlTextUtils.layout_block_children?(node)
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
TextValidator.valid?(text) ? text : nil
|
|
38
|
+
HtmlTextUtils.block_text(node)
|
|
43
39
|
end
|
|
44
40
|
|
|
45
41
|
def extractable?(node)
|
|
@@ -9,6 +9,7 @@ require_relative '../utils/html_elements'
|
|
|
9
9
|
require_relative '../utils/html_parser'
|
|
10
10
|
require_relative '../utils/text_validator'
|
|
11
11
|
require_relative '../utils/html_text_utils'
|
|
12
|
+
require_relative '../utils/baseurl_normalizer'
|
|
12
13
|
require_relative 'block_text_extractor'
|
|
13
14
|
|
|
14
15
|
module Jekyll
|
|
@@ -22,13 +23,13 @@ module Jekyll
|
|
|
22
23
|
# translations for elements with complete translations and preserves URL transformations.
|
|
23
24
|
#
|
|
24
25
|
# Key responsibilities:
|
|
25
|
-
# * Parse full HTML documents
|
|
26
|
+
# * Parse full HTML documents (through HtmlParser)
|
|
26
27
|
# * Translate text nodes using normalized text for matching
|
|
27
28
|
# * Translate HTML attributes (title, alt, aria-label, placeholder, aria-description)
|
|
28
29
|
# * Apply fallback modes when translations are missing (english/marker/empty)
|
|
29
30
|
# * Handle block-level translations for content elements
|
|
31
|
+
# * Look strings up without the site baseurl, as extraction stores them
|
|
30
32
|
# * Transform relative URLs to locale-prefixed URLs
|
|
31
|
-
# * Remove auto-inserted meta charset tags from serialized HTML
|
|
32
33
|
#
|
|
33
34
|
# @example
|
|
34
35
|
# translator = HtmlTranslator.new('english', ['title', 'alt'])
|
|
@@ -55,6 +56,14 @@ module Jekyll
|
|
|
55
56
|
@debug_logging = debug_logging
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# Build a translator from a page's localization settings.
|
|
60
|
+
#
|
|
61
|
+
# @param config [PageLocalesConfig] The page's settings
|
|
62
|
+
# @return [HtmlTranslator]
|
|
63
|
+
def self.from_config(config)
|
|
64
|
+
new(config.fallback_mode, config.translatable_attributes, debug_logging: config.debug_logging?)
|
|
65
|
+
end
|
|
66
|
+
|
|
58
67
|
# Translate an HTML document to a specific locale.
|
|
59
68
|
#
|
|
60
69
|
# Parses the HTML document, applies translations to text nodes and attributes,
|
|
@@ -66,36 +75,28 @@ module Jekyll
|
|
|
66
75
|
# or metadata hashes with :msgstr, :reference, :fuzzy keys
|
|
67
76
|
# @param locale [String] Target locale code (defaults to "en"; e.g., 'es', 'fr')
|
|
68
77
|
# @param baseurl [String] Base URL for relative URL transformation (defaults to "")
|
|
69
|
-
# @return [String] Translated HTML with URLs transformed
|
|
78
|
+
# @return [String] Translated HTML with URLs transformed
|
|
70
79
|
def translate(html, translations, locale = 'en', baseurl = '')
|
|
71
|
-
# Use HtmlParser to properly parse full HTML documents while preserving
|
|
72
|
-
# DOCTYPE, html tag, and document structure. Any auto-inserted meta tags are
|
|
73
|
-
# removed by HtmlParser.remove_meta_charset after serialization.
|
|
74
|
-
# See: spec/regression/nokogiri_meta_tag_spec.rb for regression tests
|
|
75
80
|
doc = HtmlParser.parse_document(html)
|
|
76
81
|
|
|
82
|
+
# Block text of each content element, keyed by node identity, so a
|
|
83
|
+
# paragraph with several text nodes is extracted once instead of up to
|
|
84
|
+
# twice per text node. Cleared in the ensure block below.
|
|
85
|
+
@block_texts = {}.compare_by_identity
|
|
86
|
+
|
|
87
|
+
# Look strings up without the baseurl, as extraction stored them.
|
|
88
|
+
baseurl_state = BaseurlNormalizer.strip(doc, baseurl)
|
|
77
89
|
translate_node(doc, translations)
|
|
90
|
+
BaseurlNormalizer.restore(doc, baseurl_state)
|
|
78
91
|
|
|
79
|
-
# Transform URLs on the document
|
|
80
|
-
# and preserve the correct DOCTYPE and HTML structure. This prevents Nokogiri from
|
|
81
|
-
# downgrading to HTML 4.0 DOCTYPE when parsing the serialized HTML again.
|
|
82
|
-
# See: spec/jekyll-l10n/utils/url_transformer_spec.rb for tests
|
|
92
|
+
# Transform URLs on the parsed document, so the page is parsed once.
|
|
83
93
|
UrlTransformer.transform_document(doc, locale, baseurl)
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# Matches: <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
89
|
-
HtmlParser.remove_meta_charset(result)
|
|
95
|
+
doc.to_html
|
|
96
|
+
ensure
|
|
97
|
+
@block_texts = nil
|
|
90
98
|
end
|
|
91
99
|
|
|
92
|
-
# Inline element tags that are indexed and restored during block-level translation.
|
|
93
|
-
# Includes translatable-content tags (replaced with <g> in Phase 2) and
|
|
94
|
-
# literal-content tags (class/style stripped but tag preserved). Derived from the
|
|
95
|
-
# canonical taxonomy in HtmlTextUtils so extraction and injection never diverge.
|
|
96
|
-
INLINE_ELEMENT_TAGS = (HtmlTextUtils::PLACEHOLDERED_INLINE_TAGS +
|
|
97
|
-
HtmlTextUtils::LITERAL_INLINE_TAGS).freeze
|
|
98
|
-
|
|
99
100
|
private
|
|
100
101
|
|
|
101
102
|
def translate_node(node, translations)
|
|
@@ -114,7 +115,8 @@ module Jekyll
|
|
|
114
115
|
log_text_node_debug(text) if should_log_text_debug?(text)
|
|
115
116
|
|
|
116
117
|
normalized_text = TextNormalizer.normalize(text)
|
|
117
|
-
translated = TranslationResolver.resolve(node, normalized_text, translations
|
|
118
|
+
translated = TranslationResolver.resolve(node, normalized_text, translations,
|
|
119
|
+
block_texts: @block_texts)
|
|
118
120
|
|
|
119
121
|
return if apply_block_level_translation?(node, normalized_text, translations)
|
|
120
122
|
|
|
@@ -126,113 +128,149 @@ module Jekyll
|
|
|
126
128
|
node.content = apply_fallback(original_content, translated)
|
|
127
129
|
end
|
|
128
130
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
next if value.nil? || value.empty?
|
|
131
|
+
def should_skip_translation?(text)
|
|
132
|
+
!TextValidator.valid?(text)
|
|
133
|
+
end
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
def should_log_text_debug?(text)
|
|
136
|
+
@debug_logging && text.include?('attribute')
|
|
137
|
+
end
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
def log_text_node_debug(text)
|
|
140
|
+
truncate_length = Jekyll::L10n::Constants::LOG_TRUNCATE_MEDIUM
|
|
141
|
+
message = '[HtmlTranslator] Processing text node: ' \
|
|
142
|
+
"#{text[0..truncate_length]}... (#{text.length} chars)"
|
|
143
|
+
Jekyll.logger.info Constants::LOG_PREFIX, message
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def apply_block_level_translation?(node, normalized_text, translations)
|
|
147
|
+
ancestor = TranslationResolver.find_content_element_ancestor(node)
|
|
148
|
+
return false unless ancestor
|
|
149
|
+
|
|
150
|
+
return false if TranslationResolver.contains_protected_elements?(ancestor)
|
|
151
|
+
|
|
152
|
+
block_text = TranslationResolver.block_text_for(ancestor, @block_texts)
|
|
153
|
+
return false unless block_text && block_text != normalized_text
|
|
154
|
+
|
|
155
|
+
translation = select_block_translation(node, ancestor, normalized_text, block_text,
|
|
156
|
+
translations)
|
|
157
|
+
return false unless translation
|
|
158
|
+
|
|
159
|
+
apply_block_level_translation(ancestor, translation, translations)
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# When text is directly inside a content element, prefer its direct translation
|
|
164
|
+
# (the msgstr may itself contain HTML, e.g. a heading with an anchor).
|
|
165
|
+
# When text is nested inside inline element(s), only apply if the full block
|
|
166
|
+
# text is an explicit translation key.
|
|
167
|
+
def select_block_translation(node, ancestor, normalized_text, block_text, translations)
|
|
168
|
+
if node.parent == ancestor
|
|
169
|
+
translations[normalized_text] || translations[block_text]
|
|
170
|
+
else
|
|
171
|
+
translations[block_text]
|
|
140
172
|
end
|
|
141
173
|
end
|
|
142
174
|
|
|
143
|
-
def apply_block_level_translation(parent, translation)
|
|
175
|
+
def apply_block_level_translation(parent, translation, translations)
|
|
144
176
|
if @debug_logging
|
|
145
|
-
Jekyll.logger.info
|
|
177
|
+
Jekyll.logger.info Constants::LOG_PREFIX,
|
|
146
178
|
"[HtmlTranslator] BLOCK-LEVEL TRANSLATION for <#{parent.name}>"
|
|
147
179
|
truncate_length = Jekyll::L10n::Constants::LOG_TRUNCATE_LONG
|
|
148
|
-
Jekyll.logger.info
|
|
180
|
+
Jekyll.logger.info Constants::LOG_PREFIX,
|
|
149
181
|
"[HtmlTranslator] Translation: #{translation[0..truncate_length]}..."
|
|
150
182
|
end
|
|
151
183
|
|
|
152
|
-
inject_translation_preserving_structure(parent, translation)
|
|
184
|
+
inject_translation_preserving_structure(parent, translation, translations)
|
|
153
185
|
end
|
|
154
186
|
|
|
155
|
-
def inject_translation_preserving_structure(parent, translation)
|
|
156
|
-
|
|
187
|
+
def inject_translation_preserving_structure(parent, translation, translations)
|
|
188
|
+
markup = HtmlTextUtils.inline_markup(parent)
|
|
157
189
|
preserved_blocks = parent.css('pre').map(&:dup)
|
|
158
190
|
preserved_anchors = parent.css('.heading-anchor').map(&:dup)
|
|
159
|
-
parent.children.
|
|
160
|
-
parent
|
|
161
|
-
restore_inline_elements(parent, original_inline)
|
|
191
|
+
parent.children = HtmlParser.parse_fragment(translation, context: parent)
|
|
192
|
+
restore_markup(parent, markup, translations)
|
|
162
193
|
preserved_blocks.each { |b| parent.add_child(b) }
|
|
163
194
|
preserved_anchors.each { |a| parent.add_child(a) } if parent.css('.heading-anchor').empty?
|
|
195
|
+
# Evict the rewritten parent from the block text cache: its text content
|
|
196
|
+
# has changed and any cached value is now stale.
|
|
197
|
+
@block_texts.delete(parent)
|
|
164
198
|
end
|
|
165
199
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
end
|
|
200
|
+
# Put back the markup inline_markup took out of the block's text.
|
|
201
|
+
def restore_markup(parent, markup, translations)
|
|
202
|
+
restored = expand_placeholders(parent, markup.placeholders) +
|
|
203
|
+
restore_literal_attributes(parent, markup.literals)
|
|
204
|
+
restored.each { |element| translate_restored_attributes(element, translations) }
|
|
172
205
|
end
|
|
173
206
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
207
|
+
# Turn each <g id="N"> and <x id="N"> back into the element it stands for,
|
|
208
|
+
# with that element's tag and attributes, innermost first so an outer
|
|
209
|
+
# placeholder moves already-restored children. A placeholder whose id has
|
|
210
|
+
# no element is unwrapped to its children, so stray tags never render.
|
|
211
|
+
#
|
|
212
|
+
# @return [Array<Nokogiri::XML::Element>] The restored elements
|
|
213
|
+
def expand_placeholders(parent, placeholders)
|
|
214
|
+
parent.css('g[id], x[id]').reverse.filter_map do |placeholder|
|
|
215
|
+
tag, attrs = placeholders[placeholder['id']]
|
|
216
|
+
unless tag
|
|
217
|
+
placeholder.replace(placeholder.children)
|
|
218
|
+
next
|
|
182
219
|
end
|
|
220
|
+
|
|
221
|
+
element = placeholder.document.create_element(tag)
|
|
222
|
+
attrs.each { |name, value| element[name] = value }
|
|
223
|
+
element.children = placeholder.children
|
|
224
|
+
placeholder.replace(element)
|
|
225
|
+
element
|
|
183
226
|
end
|
|
184
227
|
end
|
|
185
228
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
229
|
+
# Restored elements carry the page's attributes, and the walk never visits
|
|
230
|
+
# them. Translate the ones that have a translation; the others may already
|
|
231
|
+
# have been translated before the block was rewritten.
|
|
232
|
+
def translate_restored_attributes(element, translations)
|
|
233
|
+
@translatable_attrs.each do |attr_name|
|
|
234
|
+
value = element[attr_name]&.strip
|
|
235
|
+
next unless value && TextValidator.valid?(value)
|
|
236
|
+
|
|
237
|
+
translated = translations[TextNormalizer.normalize(value)]
|
|
238
|
+
element[attr_name] = translated if translated && !translated.empty?
|
|
192
239
|
end
|
|
193
|
-
index
|
|
194
240
|
end
|
|
195
241
|
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
parent.css('g[id]').each do |g|
|
|
207
|
-
orig = placeholdered[g['id'].to_i - 1]
|
|
208
|
-
g.replace(orig ? build_inline_element(g, orig) : g.children)
|
|
242
|
+
# Give literal elements (<code> etc.) their attributes back by position.
|
|
243
|
+
#
|
|
244
|
+
# @return [Array<Nokogiri::XML::Element>] The elements given attributes
|
|
245
|
+
def restore_literal_attributes(parent, literals)
|
|
246
|
+
literal_elements = parent.css(HtmlTextUtils::LITERAL_INLINE_TAGS.join(','))
|
|
247
|
+
literal_elements.zip(literals).filter_map do |element, (tag, attrs)|
|
|
248
|
+
next unless tag == element.name
|
|
249
|
+
|
|
250
|
+
attrs.each { |name, value| element[name] = value }
|
|
251
|
+
element
|
|
209
252
|
end
|
|
210
253
|
end
|
|
211
254
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
node
|
|
255
|
+
def log_translation_debug_info(text, normalized_text, translated, translations)
|
|
256
|
+
translation_data = DebugLogger::TranslationData.new(text: text,
|
|
257
|
+
normalized_text: normalized_text,
|
|
258
|
+
translated: translated,
|
|
259
|
+
translations: translations)
|
|
260
|
+
DebugLogger.log_translation_details(self, translation_data)
|
|
219
261
|
end
|
|
220
262
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
orig[:attrs].each do |name, val|
|
|
234
|
-
el[name] = val if structural.include?(name)
|
|
235
|
-
end
|
|
263
|
+
def translate_node_attributes(node, translations)
|
|
264
|
+
@translatable_attrs.each do |attr_name|
|
|
265
|
+
value = node[attr_name]
|
|
266
|
+
next if value.nil? || value.empty?
|
|
267
|
+
|
|
268
|
+
value = value.strip
|
|
269
|
+
next unless TextValidator.valid?(value)
|
|
270
|
+
|
|
271
|
+
normalized_value = TextNormalizer.normalize(value)
|
|
272
|
+
translated = translations[normalized_value]
|
|
273
|
+
node[attr_name] = apply_fallback(value, translated)
|
|
236
274
|
end
|
|
237
275
|
end
|
|
238
276
|
|
|
@@ -271,65 +309,6 @@ module Jekyll
|
|
|
271
309
|
msgid
|
|
272
310
|
end
|
|
273
311
|
end
|
|
274
|
-
|
|
275
|
-
def log_text_node_debug(text)
|
|
276
|
-
truncate_length = Jekyll::L10n::Constants::LOG_TRUNCATE_MEDIUM
|
|
277
|
-
message = '[HtmlTranslator] Processing text node: ' \
|
|
278
|
-
"#{text[0..truncate_length]}... (#{text.length} chars)"
|
|
279
|
-
Jekyll.logger.info 'Localization', message
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def should_skip_translation?(text)
|
|
283
|
-
!TextValidator.valid?(text)
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def should_log_text_debug?(text)
|
|
287
|
-
@debug_logging && text.include?('attribute')
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def apply_block_level_translation?(node, normalized_text, translations)
|
|
291
|
-
ancestor = TranslationResolver.find_content_element_ancestor(node)
|
|
292
|
-
return false unless ancestor
|
|
293
|
-
|
|
294
|
-
return false if TranslationResolver.contains_protected_elements?(ancestor)
|
|
295
|
-
|
|
296
|
-
block_text = BlockTextExtractor.extract(ancestor)
|
|
297
|
-
return false unless block_text && block_text != normalized_text
|
|
298
|
-
|
|
299
|
-
translation = select_block_translation(node, ancestor, normalized_text, block_text,
|
|
300
|
-
translations)
|
|
301
|
-
return false unless translation
|
|
302
|
-
|
|
303
|
-
apply_block_level_translation(ancestor, translation)
|
|
304
|
-
true
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# When text is directly inside a content element, prefer its direct translation
|
|
308
|
-
# (the msgstr may itself contain HTML, e.g. a heading with an anchor).
|
|
309
|
-
# When text is nested inside inline element(s), only apply if the full block
|
|
310
|
-
# text is an explicit translation key.
|
|
311
|
-
def select_block_translation(node, ancestor, normalized_text, block_text, translations)
|
|
312
|
-
if node.parent == ancestor
|
|
313
|
-
translations[normalized_text] || translations[block_text]
|
|
314
|
-
else
|
|
315
|
-
translations[block_text]
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def log_translation_debug_info(text, normalized_text, translated, translations)
|
|
320
|
-
translation_data = DebugLogger::TranslationData.new(text: text,
|
|
321
|
-
normalized_text: normalized_text,
|
|
322
|
-
translated: translated,
|
|
323
|
-
translations: translations)
|
|
324
|
-
DebugLogger.log_translation_details(self, translation_data)
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
private :log_text_node_debug, :should_skip_translation?, :should_log_text_debug?,
|
|
328
|
-
:apply_block_level_translation?, :select_block_translation,
|
|
329
|
-
:log_translation_debug_info,
|
|
330
|
-
:inject_translation_preserving_structure, :restore_inline_elements,
|
|
331
|
-
:walk_inline_elements, :index_original_inline_elements,
|
|
332
|
-
:expand_g_placeholders, :build_inline_element, :restore_structural_attributes
|
|
333
312
|
end
|
|
334
313
|
end
|
|
335
314
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Jekyll
|
|
8
|
+
module L10n
|
|
9
|
+
# Maps site locales to the language codes a LibreTranslate server lists
|
|
10
|
+
# at /languages. Sites use codes like es_ES and pt_BR; the server uses
|
|
11
|
+
# es and pt-BR, and rejects codes it doesn't list.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# languages = LibreTranslateLanguages.new(config)
|
|
15
|
+
# languages.api_code('pt_BR') # => "pt-BR"
|
|
16
|
+
# languages.api_code('es_ES') # => "es"
|
|
17
|
+
# languages.api_code('xx') # => nil
|
|
18
|
+
class LibreTranslateLanguages
|
|
19
|
+
# The language list could not be read from the server.
|
|
20
|
+
class Unavailable < StandardError; end
|
|
21
|
+
|
|
22
|
+
# @param config [PageLocalesConfig] Provides libretranslate_api_url and
|
|
23
|
+
# libretranslate_timeout
|
|
24
|
+
def initialize(config)
|
|
25
|
+
@config = config
|
|
26
|
+
@lock = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The server's code for a locale: the same language and region if the
|
|
30
|
+
# server lists it, else the base language.
|
|
31
|
+
#
|
|
32
|
+
# @param locale [String] Site locale, such as es_ES
|
|
33
|
+
# @return [String, nil] The server's code, or nil if it lists neither
|
|
34
|
+
# @raise [Unavailable] If the language list can't be read
|
|
35
|
+
def api_code(locale)
|
|
36
|
+
wanted = locale.tr('_', '-')
|
|
37
|
+
base = wanted.split('-').first
|
|
38
|
+
codes.find { |code| code.casecmp?(wanted) } || codes.find { |code| code.casecmp?(base) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Read once and kept; a failed read is tried again on the next call.
|
|
44
|
+
def codes
|
|
45
|
+
@lock.synchronize { @codes ||= fetch_codes }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch_codes
|
|
49
|
+
response = request_languages
|
|
50
|
+
raise Unavailable, "#{languages_uri} answered HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
51
|
+
|
|
52
|
+
JSON.parse(response.body).map { |language| language.fetch('code') }
|
|
53
|
+
rescue JSON::ParserError, TypeError, KeyError, NoMethodError
|
|
54
|
+
raise Unavailable, "#{languages_uri} did not return a language list"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def request_languages
|
|
58
|
+
uri = languages_uri
|
|
59
|
+
timeout = @config.libretranslate_timeout
|
|
60
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
|
|
61
|
+
open_timeout: timeout, read_timeout: timeout) do |http|
|
|
62
|
+
http.get(uri.request_uri)
|
|
63
|
+
end
|
|
64
|
+
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
65
|
+
raise Unavailable, "could not read #{uri}: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def languages_uri
|
|
69
|
+
URI("#{@config.libretranslate_api_url}/languages")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|