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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +44 -0
  3. data/lib/jekyll-l10n/configuration/schema.rb +308 -0
  4. data/lib/jekyll-l10n/configuration.rb +165 -0
  5. data/lib/jekyll-l10n/constants.rb +45 -10
  6. data/lib/jekyll-l10n/errors.rb +7 -0
  7. data/lib/jekyll-l10n/extraction/compendium_merger.rb +9 -11
  8. data/lib/jekyll-l10n/extraction/compendium_translator.rb +27 -11
  9. data/lib/jekyll-l10n/extraction/config_loader.rb +4 -4
  10. data/lib/jekyll-l10n/extraction/coverage_report.rb +129 -0
  11. data/lib/jekyll-l10n/extraction/dom_text_extractor.rb +2 -31
  12. data/lib/jekyll-l10n/extraction/extractor.rb +48 -28
  13. data/lib/jekyll-l10n/extraction/html_string_extractor.rb +24 -10
  14. data/lib/jekyll-l10n/extraction/logger.rb +6 -5
  15. data/lib/jekyll-l10n/extraction/result_saver.rb +5 -10
  16. data/lib/jekyll-l10n/instrumentation.rb +172 -139
  17. data/lib/jekyll-l10n/jekyll/generator.rb +10 -22
  18. data/lib/jekyll-l10n/jekyll/localized_page.rb +27 -49
  19. data/lib/jekyll-l10n/jekyll/post_write_html_reprocessor.rb +42 -25
  20. data/lib/jekyll-l10n/jekyll/post_write_processor.rb +2 -1
  21. data/lib/jekyll-l10n/jekyll/regeneration_checker.rb +16 -10
  22. data/lib/jekyll-l10n/jekyll/url_filter.rb +8 -7
  23. data/lib/jekyll-l10n/po_file/entry_parser.rb +155 -0
  24. data/lib/jekyll-l10n/po_file/fuzzy_matcher.rb +67 -53
  25. data/lib/jekyll-l10n/po_file/loader.rb +20 -11
  26. data/lib/jekyll-l10n/po_file/manager.rb +32 -17
  27. data/lib/jekyll-l10n/po_file/merger.rb +2 -1
  28. data/lib/jekyll-l10n/po_file/path_builder.rb +11 -1
  29. data/lib/jekyll-l10n/po_file/reader.rb +40 -508
  30. data/lib/jekyll-l10n/po_file/writer.rb +31 -16
  31. data/lib/jekyll-l10n/translation/block_text_extractor.rb +3 -7
  32. data/lib/jekyll-l10n/translation/html_translator.rb +136 -157
  33. data/lib/jekyll-l10n/translation/libre_translate_languages.rb +73 -0
  34. data/lib/jekyll-l10n/translation/libre_translator.rb +134 -78
  35. data/lib/jekyll-l10n/translation/request_slots.rb +69 -0
  36. data/lib/jekyll-l10n/translation/translation_progress.rb +77 -0
  37. data/lib/jekyll-l10n/translation/translator.rb +20 -17
  38. data/lib/jekyll-l10n/utils/baseurl_normalizer.rb +108 -0
  39. data/lib/jekyll-l10n/utils/debug_logger.rb +4 -51
  40. data/lib/jekyll-l10n/utils/error_handler.rb +9 -24
  41. data/lib/jekyll-l10n/utils/external_link_icon_preserver.rb +5 -62
  42. data/lib/jekyll-l10n/utils/html_parser.rb +24 -31
  43. data/lib/jekyll-l10n/utils/html_text_utils.rb +146 -91
  44. data/lib/jekyll-l10n/utils/logger_formatter.rb +6 -4
  45. data/lib/jekyll-l10n/utils/msgstr_validator.rb +74 -0
  46. data/lib/jekyll-l10n/utils/page_locales_config.rb +126 -107
  47. data/lib/jekyll-l10n/utils/po_entry_converter.rb +0 -43
  48. data/lib/jekyll-l10n/utils/site_config_accessor.rb +56 -54
  49. data/lib/jekyll-l10n/utils/translation_resolver.rb +56 -6
  50. data/lib/jekyll-l10n/utils/url_path_builder.rb +5 -27
  51. data/lib/jekyll-l10n/utils/url_transformer.rb +5 -11
  52. data/lib/jekyll-l10n/version.rb +1 -1
  53. data/lib/jekyll-l10n.rb +9 -6
  54. 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
- truncate_length = Jekyll::L10n::Constants::LOG_TRUNCATE_LONG
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
- return unless errors.any?
83
+ def self.warn_merge_errors(errors)
84
+ return if errors.empty?
84
85
 
85
- Jekyll.logger.warn 'Localization',
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.msgstr = existing_entry[:msgstr] || ''
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
- match = PoFuzzyMatcher.find_match(po_entry.msgid, fuzzy_candidates)
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 nil if element is not extractable or if extracted text fails
31
- # validation. Clones the node, removes nested block elements and empty
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
- text = HtmlTextUtils.extract_with_inline_tags(node)
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 while preserving DOCTYPE and structure
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 and meta charset removed
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 object before serialization to avoid double-parsing
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
- result = doc.to_html
86
-
87
- # Remove the auto-inserted meta tag by libxml2 during HTML serialization
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 translate_node_attributes(node, translations)
130
- @translatable_attrs.each do |attr_name|
131
- value = node[attr_name]
132
- next if value.nil? || value.empty?
131
+ def should_skip_translation?(text)
132
+ !TextValidator.valid?(text)
133
+ end
133
134
 
134
- value = value.strip
135
- next unless TextValidator.valid?(value)
135
+ def should_log_text_debug?(text)
136
+ @debug_logging && text.include?('attribute')
137
+ end
136
138
 
137
- normalized_value = TextNormalizer.normalize(value)
138
- translated = translations[normalized_value]
139
- node[attr_name] = apply_fallback(value, translated)
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 'Localization',
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 'Localization',
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
- original_inline = index_original_inline_elements(parent)
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.each(&:remove)
160
- parent.inner_html = translation
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
- def restore_inline_elements(parent, original_inline)
167
- if parent.css('g[id]').any?
168
- expand_g_placeholders(parent, original_inline)
169
- else
170
- restore_structural_attributes(parent, original_inline)
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
- def walk_inline_elements(node, &block)
175
- node.children.each do |child|
176
- next unless child.element?
177
-
178
- if INLINE_ELEMENT_TAGS.include?(child.name)
179
- yield child
180
- else
181
- walk_inline_elements(child, &block)
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
- def index_original_inline_elements(parent)
187
- index = {}
188
- n = 1
189
- walk_inline_elements(parent) do |el|
190
- index[n] = { tag: el.name, attrs: el.attributes.transform_values(&:value) }
191
- n += 1
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
- # Phase 2 path: expand <g id="N"> placeholders to original elements with full
197
- # attributes. The id corresponds to the Nth *placeholdered* inline element in the
198
- # source DOM. Literal-content elements (<code>, <var>, <kbd>, <samp>) are never
199
- # placeholdered and are excluded from the numbering, matching the extraction side
200
- # in HtmlTextUtils.top_level_placeholdered_inline_elements. A <g> whose id has no
201
- # source counterpart is unwrapped to its children so stray <g> tags never render.
202
- def expand_g_placeholders(parent, original_inline)
203
- placeholdered = original_inline.values.select do |orig|
204
- HtmlTextUtils::PLACEHOLDERED_INLINE_TAGS.include?(orig[:tag])
205
- end
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
- # Build a replacement element for a <g> placeholder from a source-DOM spec,
213
- # copying the original tag name and all attributes and moving the <g> children in.
214
- def build_inline_element(placeholder, orig)
215
- node = placeholder.document.create_element(orig[:tag])
216
- orig[:attrs].each { |name, val| node[name] = val }
217
- placeholder.children.each { |child| node.add_child(child.dup) }
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
- # Phase 1 path: restore structural attributes on injected elements by position.
222
- # Matches each inline element in the translated content to its original counterpart
223
- # by sequential order and overwrites structural attrs (class, style, id, etc.)
224
- # with the values from the source DOM.
225
- def restore_structural_attributes(parent, original_inline)
226
- structural = Constants::STRUCTURAL_PASSTHROUGH_ATTRS
227
- n = 1
228
- walk_inline_elements(parent) do |el|
229
- orig = original_inline[n]
230
- n += 1
231
- next unless orig && el.name == orig[:tag]
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