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
@@ -3,21 +3,27 @@
3
3
  require 'json'
4
4
  require 'net/http'
5
5
  require 'uri'
6
+ require_relative '../constants'
7
+ require_relative '../instrumentation'
6
8
  require_relative '../utils/error_handler'
7
9
  require_relative '../utils/logger_formatter'
10
+ require_relative 'libre_translate_languages'
11
+ require_relative 'request_slots'
12
+ require_relative 'translation_progress'
8
13
 
9
14
  module Jekyll
10
15
  module L10n
11
16
  # Translates compendium entries using the LibreTranslate API.
12
17
  #
13
18
  # LibreTranslator integrates with the LibreTranslate API to automatically
14
- # translate untranslated entries in compendium PO files. It handles batching,
15
- # retries with exponential backoff, progress logging, and API error handling.
16
- # Translations are written directly to POEntry objects in-place.
19
+ # translate untranslated entries in compendium PO files. It sends several
20
+ # requests at once, retries with exponential backoff, logs progress, and
21
+ # handles API errors. Translations are written directly to POEntry objects
22
+ # in-place.
17
23
  #
18
24
  # Key responsibilities:
19
- # * Send translation requests to LibreTranslate API
20
- # * Batch translations for efficiency
25
+ # * Map the site locale to a language code the server lists
26
+ # * Send translation requests to LibreTranslate API, several at once
21
27
  # * Handle API timeouts and failures with retries
22
28
  # * Log translation progress at configurable intervals
23
29
  # * Update POEntry msgstr with translated text
@@ -39,33 +45,43 @@ module Jekyll
39
45
  # - libretranslate_api_url [String] API endpoint URL
40
46
  # - libretranslate_api_key [String, nil] Optional API key
41
47
  # - libretranslate_timeout [Integer] Request timeout in seconds
42
- # - libretranslate_batch_size [Integer] Entries per batch request
48
+ # - libretranslate_batch_size [Integer] Most entries a worker takes at a time
49
+ # - libretranslate_concurrency [Integer] Requests sent at once
43
50
  # - libretranslate_retry_attempts [Integer] Max retry attempts
44
51
  # - libretranslate_retry_delay [Integer] Delay between retries in seconds
45
52
  # - libretranslate_progress_interval [Integer] Log progress every N entries
53
+ # - libretranslate_mark_fuzzy? [Boolean] Store translations as fuzzy entries
46
54
  def initialize(config)
47
55
  @config = config
56
+ @lock = Mutex.new
57
+ @stopped = false
48
58
  end
49
59
 
50
60
  # Translate a compendium to a target locale.
51
61
  #
52
62
  # Identifies untranslated entries (empty msgstr) and sends them to LibreTranslate
53
- # API for translation. Updates POEntry objects in-place with translated text.
54
- # Handles batching, retries, and progress logging.
63
+ # API for translation, libretranslate_concurrency requests at a time. Updates
64
+ # POEntry objects in-place with translated text. Skips the locale, with a
65
+ # warning, when the server doesn't list its language or the source language.
55
66
  #
56
67
  # @param po_entries [Array<GetText::POEntry>] Array of PO entries from compendium
57
- # @param target_locale [String] Target locale code (e.g., 'es', 'fr')
68
+ # @param target_locale [String] Target locale code (e.g., 'es', 'pt_BR')
58
69
  # @return [void]
59
70
  # @raise [TranslationError] If API request fails and stop_on_error is true
60
71
  # @raise [TranslationError] If max retry attempts exceeded
61
72
  def translate_compendium(po_entries, target_locale)
62
- translatable_count, empty_entries = count_translatable_entries(po_entries)
63
- return if empty_entries.empty?
73
+ empty_entries = untranslated_entries(po_entries)
74
+ return if empty_entries.empty? || @stopped
64
75
 
65
- log_translation_progress(empty_entries.length, translatable_count, target_locale)
66
- start_time = Time.now
67
- process_translation_batches(empty_entries, target_locale, start_time)
68
- log_translation_complete(empty_entries.length, target_locale, start_time)
76
+ api_source = api_locale_for(@config.libretranslate_source_locale, target_locale, empty_entries.length)
77
+ api_target = api_source && api_locale_for(target_locale, target_locale, empty_entries.length)
78
+ return unless api_target
79
+
80
+ log_translation_start(empty_entries.length, target_locale)
81
+ progress = TranslationProgress.new(total: empty_entries.length, locale: target_locale,
82
+ interval: @config.libretranslate_progress_interval)
83
+ process_translation_batches(empty_entries, api_target, api_source, progress)
84
+ progress.log_summary
69
85
  end
70
86
 
71
87
  private
@@ -99,7 +115,7 @@ module Jekyll
99
115
 
100
116
  delay_str = delay.round(2)
101
117
  Jekyll.logger.warn(
102
- 'Localization',
118
+ Constants::LOG_PREFIX,
103
119
  "Retrying translation (attempt #{attempts}/#{max_attempts}) after #{delay_str}s: " \
104
120
  "#{e.message}"
105
121
  )
@@ -108,85 +124,122 @@ module Jekyll
108
124
  end
109
125
  end
110
126
 
111
- def count_translatable_entries(po_entries)
112
- translatable_count = po_entries.count { |e| !e.msgid.strip.empty? }
113
- empty_count = po_entries.select { |entry| entry.msgstr.empty? && !entry.msgid.strip.empty? }
114
- [translatable_count, empty_count]
127
+ def untranslated_entries(po_entries)
128
+ po_entries.select { |entry| entry.msgstr.empty? && !entry.msgid.strip.empty? }
115
129
  end
116
130
 
117
- def log_translation_progress(empty_count, _translatable_count, target_locale)
118
- Jekyll.logger.info 'Localization',
119
- "Translating #{empty_count} entries for #{target_locale}"
131
+ # The server's code for locale (the source or target locale), or nil to
132
+ # skip target_locale.
133
+ def api_locale_for(locale, target_locale, entry_count)
134
+ api_locale = languages.api_code(locale)
135
+ unless api_locale
136
+ Jekyll.logger.warn Constants::LOG_PREFIX,
137
+ "[LibreTranslator] LibreTranslate does not support #{locale}; " \
138
+ "skipping #{entry_count} entries for #{target_locale}"
139
+ end
140
+ api_locale
141
+ rescue LibreTranslateLanguages::Unavailable => e
142
+ raise TranslationError, e.message if @config.libretranslate_stop_on_error?
143
+
144
+ Jekyll.logger.warn Constants::LOG_PREFIX,
145
+ "[LibreTranslator] #{e.message}; skipping #{entry_count} entries for #{target_locale}"
146
+ nil
120
147
  end
121
148
 
122
- def log_periodic_progress(current_num, total_entries, target_locale, start_time)
123
- elapsed = Time.now - start_time
124
- percent = (current_num.to_f / total_entries * 100).round
149
+ def languages
150
+ @languages ||= LibreTranslateLanguages.new(@config)
151
+ end
125
152
 
126
- Jekyll.logger.info 'Localization',
127
- "[LibreTranslator] Progress: #{current_num}/#{total_entries} " \
128
- "(#{percent}%) translated for #{target_locale} [#{elapsed.round(1)}s]"
153
+ def log_translation_start(entry_count, target_locale)
154
+ Jekyll.logger.info Constants::LOG_PREFIX,
155
+ "Translating #{entry_count} entries for #{target_locale}"
129
156
  end
130
157
 
131
- def log_translation_complete(entries_translated, target_locale, start_time)
132
- elapsed = Time.now - start_time
133
- throughput = entries_translated.positive? ? (entries_translated / elapsed).round(1) : 0
158
+ # Workers take batches from a shared queue until it is empty. A worker
159
+ # that fails stops this translator, so the workers of every locale stop
160
+ # after their current request, and its error is raised once this
161
+ # locale's workers have stopped. A batch
162
+ # holds at most an equal share of the entries, so a batch size larger
163
+ # than that share doesn't leave workers without one.
164
+ def process_translation_batches(empty_entries, api_locale, api_source, progress)
165
+ queue = Queue.new
166
+ share = (empty_entries.size.to_f / @config.libretranslate_concurrency).ceil
167
+ empty_entries.each_slice([@config.libretranslate_batch_size, share].min) { |batch| queue << batch }
168
+ queue.close
134
169
 
135
- Jekyll.logger.info 'Localization',
136
- "[LibreTranslator] Completed: #{entries_translated} entries " \
137
- "translated for #{target_locale} [#{elapsed.round(1)}s, " \
138
- "~#{throughput} entries/sec]"
170
+ workers = Array.new([@config.libretranslate_concurrency, queue.size].min) do
171
+ Instrumentation.traced_thread { work_through(queue, api_locale, api_source, progress) }
172
+ end
173
+ join_workers(workers)
139
174
  end
140
175
 
141
- def process_translation_batches(empty_entries, target_locale, start_time)
142
- batch_size = @config.libretranslate_batch_size
143
- progress_interval = @config.libretranslate_progress_interval
144
-
145
- empty_entries.each_slice(batch_size).with_index do |batch, batch_num|
146
- start_num = (batch_num * batch_size) + 1
147
- retry_with_backoff do
148
- translate_batch(batch, target_locale, start_num, empty_entries.length,
149
- start_time, progress_interval)
150
- end
176
+ def work_through(queue, api_locale, api_source, progress)
177
+ Thread.current.report_on_exception = false
178
+ while !@stopped && (batch = queue.pop)
179
+ translate_batch(batch, api_locale, api_source, progress)
151
180
  end
181
+ rescue StandardError
182
+ @stopped = true
183
+ raise
152
184
  end
153
185
 
154
- # rubocop:disable Metrics/ParameterLists
155
- def translate_batch(batch, target_locale, start_num, total_entries, start_time,
156
- progress_interval)
157
- # rubocop:enable Metrics/ParameterLists
158
- batch.each_with_index do |entry, index|
159
- current_num = start_num + index
186
+ def join_workers(workers)
187
+ first_error = nil
188
+ workers.each do |worker|
189
+ worker.join
190
+ rescue StandardError => e
191
+ first_error ||= e
192
+ end
193
+ raise first_error if first_error
194
+ end
160
195
 
161
- # Log progress at intervals and at the end
162
- if progress_interval.positive? &&
163
- ((current_num % progress_interval).zero? || current_num == total_entries)
164
- log_periodic_progress(current_num, total_entries, target_locale, start_time)
165
- end
196
+ # A failed request is retried on its own, so a retry never resends
197
+ # entries that were already translated.
198
+ def translate_batch(batch, api_locale, api_source, progress)
199
+ batch.each do |entry|
200
+ break if @stopped
166
201
 
167
- # Move per-entry logs to TRACE level for deep debugging
168
202
  LoggerFormatter.trace_if_enabled(
169
- @config, 'LibreTranslator',
170
- "Translating entry #{current_num}/#{total_entries}: #{entry.msgid[0..50]}"
203
+ @config, 'LibreTranslator', "Translating entry to #{api_locale}: #{entry.msgid[0..50]}"
171
204
  )
172
205
 
173
- texts = [entry.msgid]
174
- translations = make_api_request(texts, target_locale)
175
- entry.msgstr = translations&.first || ''
206
+ translations = retry_with_backoff do
207
+ request_slots.hold(api_locale) { make_api_request([entry.msgid], api_locale, api_source) }
208
+ end
209
+ msgstr = translations&.first || ''
210
+ store_translation(entry, msgstr)
211
+ progress.record(!msgstr.empty?)
176
212
  end
177
213
  rescue StandardError => e
178
214
  ErrorHandler.log_error('translate_batch', e)
179
215
  raise
180
216
  end
181
217
 
218
+ # Locales are translated at once, each with its own workers, so a
219
+ # worker takes one of libretranslate_concurrency slots, shared by every
220
+ # locale, for each request. Slots are keyed by the server's language
221
+ # code, so locales that use the same language model share its turns.
222
+ def request_slots
223
+ @lock.synchronize { @request_slots ||= RequestSlots.new(@config.libretranslate_concurrency) }
224
+ end
225
+
226
+ # With libretranslate_mark_fuzzy, a machine translation is stored as a
227
+ # fuzzy entry, which pages don't use until a person reviews it. Its
228
+ # msgstr is no longer empty, so later builds don't send it again.
229
+ def store_translation(entry, msgstr)
230
+ entry.msgstr = msgstr
231
+ entry.flag = 'fuzzy' if !msgstr.empty? && @config.libretranslate_mark_fuzzy?
232
+ end
233
+
234
+ # A new connection per request: workers send at once, and one
235
+ # Net::HTTP object must not be shared between threads.
182
236
  def http_config
183
- @http_config ||= begin
184
- uri = URI(@config.libretranslate_api_url)
185
- http = Net::HTTP.new(uri.host, uri.port)
186
- http.read_timeout = @config.libretranslate_timeout
187
- http.open_timeout = @config.libretranslate_timeout
188
- http
189
- end
237
+ uri = URI(@config.libretranslate_api_url)
238
+ http = Net::HTTP.new(uri.host, uri.port)
239
+ http.use_ssl = uri.scheme == 'https'
240
+ http.read_timeout = @config.libretranslate_timeout
241
+ http.open_timeout = @config.libretranslate_timeout
242
+ http
190
243
  end
191
244
 
192
245
  def api_headers
@@ -198,12 +251,15 @@ module Jekyll
198
251
  headers
199
252
  end
200
253
 
201
- def make_api_request(text, target_locale)
254
+ # target_locale and source_locale are codes the server lists. The
255
+ # target comes before the source so the l10n.libretranslate_batch span
256
+ # (see Instrumentation) records it as l10n.locale.
257
+ def make_api_request(text, target_locale, source_locale)
202
258
  uri = URI("#{@config.libretranslate_api_url}/translate")
203
- Jekyll.logger.debug 'Localization', "[LibreTranslator] Requesting #{uri}"
204
- request = build_request(uri, text, target_locale)
259
+ Jekyll.logger.debug Constants::LOG_PREFIX, "[LibreTranslator] Requesting #{uri}"
260
+ request = build_request(uri, text, target_locale, source_locale)
205
261
  response = http_config.request(request)
206
- Jekyll.logger.debug 'Localization', "[LibreTranslator] Response code: #{response.code}"
262
+ Jekyll.logger.debug Constants::LOG_PREFIX, "[LibreTranslator] Response code: #{response.code}"
207
263
  handle_api_response(response)
208
264
  rescue Net::ReadTimeout, Net::OpenTimeout => e
209
265
  raise TranslationError, "API timeout: #{e.message}"
@@ -212,11 +268,11 @@ module Jekyll
212
268
  nil
213
269
  end
214
270
 
215
- def build_request(uri, text, target_locale)
271
+ def build_request(uri, text, target_locale, source_locale)
216
272
  request = Net::HTTP::Post.new(uri, api_headers)
217
273
  request.body = {
218
274
  q: text,
219
- source: @config.libretranslate_source_locale,
275
+ source: source_locale,
220
276
  target: target_locale,
221
277
  format: @config.libretranslate_format
222
278
  }.to_json
@@ -227,14 +283,14 @@ module Jekyll
227
283
  response_data = JSON.parse(response.body)
228
284
 
229
285
  if response_data['error']
230
- Jekyll.logger.warn 'Localization',
286
+ Jekyll.logger.warn Constants::LOG_PREFIX,
231
287
  "[LibreTranslator] API rejected request: #{response_data['error']}"
232
288
  return nil
233
289
  end
234
290
 
235
291
  translated = response_data['translatedText']
236
292
  translation_str = translated.is_a?(Array) ? translated.inspect : translated
237
- Jekyll.logger.debug 'Localization', "[LibreTranslator] Parsed translation: #{translation_str}"
293
+ Jekyll.logger.debug Constants::LOG_PREFIX, "[LibreTranslator] Parsed translation: #{translation_str}"
238
294
  translated
239
295
  rescue JSON::ParserError, NoMethodError => e
240
296
  raise TranslationError, "Invalid API response: #{e.message}"
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module L10n
5
+ # Limits how many requests are in flight at once, keyed by language. A
6
+ # freed slot goes to the waiting key with the fewest slots, not to the
7
+ # caller that waited longest, so keys that wait at the same time share
8
+ # the slots instead of taking turns in bursts.
9
+ #
10
+ # @example
11
+ # slots = RequestSlots.new(4)
12
+ # slots.hold('es') { send_request }
13
+ class RequestSlots
14
+ # @param limit [Integer] Slots available at once
15
+ def initialize(limit)
16
+ @free = limit
17
+ @held = Hash.new(0)
18
+ @waiting = Hash.new(0)
19
+ @lock = Mutex.new
20
+ @changed = ConditionVariable.new
21
+ end
22
+
23
+ # Run the block while holding a slot for key.
24
+ #
25
+ # @param key [String] Language the request is for
26
+ # @return [Object] The value of the block
27
+ def hold(key)
28
+ acquire(key)
29
+ begin
30
+ yield
31
+ ensure
32
+ release(key)
33
+ end
34
+ end
35
+
36
+ # Wait for a slot for key and take it.
37
+ #
38
+ # @param key [String] Language the request is for
39
+ # @return [void]
40
+ def acquire(key)
41
+ @lock.synchronize do
42
+ @waiting[key] += 1
43
+ @changed.wait(@lock) until @free.positive? && fewest_held?(key)
44
+ @waiting.delete(key) if (@waiting[key] -= 1).zero?
45
+ @free -= 1
46
+ @held[key] += 1
47
+ end
48
+ end
49
+
50
+ # Give back a slot taken for key.
51
+ #
52
+ # @param key [String] Language the request was for
53
+ # @return [void]
54
+ def release(key)
55
+ @lock.synchronize do
56
+ @free += 1
57
+ @held[key] -= 1
58
+ @changed.broadcast
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def fewest_held?(key)
65
+ @held[key] <= @waiting.keys.map { |waiting_key| @held[waiting_key] }.min
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../constants'
4
+
5
+ module Jekyll
6
+ module L10n
7
+ # Counts the entries machine-translated for one locale and logs progress
8
+ # and a summary. Worker threads share one instance, so counting is
9
+ # synchronized.
10
+ #
11
+ # @example
12
+ # progress = TranslationProgress.new(total: 20, locale: 'es', interval: 10)
13
+ # progress.record(true) # the entry got a translation
14
+ # progress.record(false) # the server returned none
15
+ # progress.log_summary
16
+ class TranslationProgress
17
+ # @param total [Integer] Entries to translate
18
+ # @param locale [String] Locale named in the log messages
19
+ # @param interval [Integer] Log progress every this many entries; 0 turns it off
20
+ def initialize(total:, locale:, interval:)
21
+ @total = total
22
+ @locale = locale
23
+ @interval = interval
24
+ @done = 0
25
+ @translated = 0
26
+ @start_time = Time.now
27
+ @lock = Mutex.new
28
+ end
29
+
30
+ # Count one finished entry, logging progress when it is due.
31
+ #
32
+ # @param translated [Boolean] Whether the entry got a translation
33
+ # @return [void]
34
+ def record(translated)
35
+ done = @lock.synchronize do
36
+ @translated += 1 if translated
37
+ @done += 1
38
+ end
39
+ log_progress(done) if progress_due?(done)
40
+ end
41
+
42
+ # Log how many entries got a translation, how many failed, and the
43
+ # throughput.
44
+ #
45
+ # @return [void]
46
+ def log_summary
47
+ elapsed = Time.now - @start_time
48
+ throughput = @done.positive? ? (@done / elapsed).round(1) : 0
49
+ failed = @done - @translated
50
+ failures = failed.positive? ? ", #{failed} failed" : ''
51
+
52
+ Jekyll.logger.info Constants::LOG_PREFIX,
53
+ "[LibreTranslator] Completed: #{@translated}/#{@total} entries " \
54
+ "translated for #{@locale}#{failures} [#{elapsed.round(1)}s, " \
55
+ "~#{throughput} entries/sec]"
56
+ end
57
+
58
+ private
59
+
60
+ # Progress is logged every interval entries and after the last one.
61
+ def progress_due?(done)
62
+ return false unless @interval.positive?
63
+
64
+ (done % @interval).zero? || done == @total
65
+ end
66
+
67
+ def log_progress(done)
68
+ elapsed = Time.now - @start_time
69
+ percent = (done.to_f / @total * 100).round
70
+
71
+ Jekyll.logger.info Constants::LOG_PREFIX,
72
+ "[LibreTranslator] Progress: #{done}/#{@total} " \
73
+ "(#{percent}%) translated for #{@locale} [#{elapsed.round(1)}s]"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -67,11 +67,7 @@ module Jekyll
67
67
  end
68
68
 
69
69
  def apply_translations_to_page(original_page, config, translations, locale, baseurl)
70
- translator = HtmlTranslator.new(
71
- config.fallback_mode,
72
- config.translatable_attributes,
73
- debug_logging: config.debug_logging?
74
- )
70
+ translator = HtmlTranslator.from_config(config)
75
71
 
76
72
  translated_html = apply_translations(translator, translations, locale, baseurl)
77
73
  return unless translated_html
@@ -79,6 +75,7 @@ module Jekyll
79
75
  # Preserve external link icons from the original page after translation
80
76
  translated_html = preserve_external_link_icons(original_page.output, translated_html)
81
77
  @page.output = translated_html
78
+ @page.translations_digest = translations.hash if @page.respond_to?(:translations_digest=)
82
79
  end
83
80
 
84
81
  def preserve_external_link_icons(original_html, translated_html)
@@ -102,18 +99,24 @@ module Jekyll
102
99
  end
103
100
 
104
101
  def should_translate?
105
- has_data = @page.data
106
- has_locale = has_data && @page.data['locale']
107
- has_original_url = has_data && @page.data['original_url']
108
- has_output = @page.output
109
- output_not_empty = !@page.output&.empty?
110
-
111
- msg = "should_translate? data=#{has_data}, locale=#{has_locale}, " \
112
- "original_url=#{has_original_url}, output=#{has_output}, " \
113
- "not_empty=#{output_not_empty}"
114
- LoggerFormatter.debug_if_enabled('Translator', msg)
115
-
116
- has_data && has_locale && has_original_url && has_output && output_not_empty
102
+ checks = translation_checks
103
+ details = checks.map { |name, value| "#{name}=#{value}" }.join(', ')
104
+ LoggerFormatter.debug_if_enabled('Translator', "should_translate? #{details}")
105
+
106
+ checks.values.all?
107
+ end
108
+
109
+ # Each value must be truthy for the page to be translated.
110
+ def translation_checks
111
+ data = @page.data
112
+ output = @page.output
113
+ {
114
+ data: data,
115
+ locale: data && data['locale'],
116
+ original_url: data && data['original_url'],
117
+ output: output,
118
+ not_empty: !output&.empty?
119
+ }
117
120
  end
118
121
 
119
122
  def find_original_page_by_url(url)
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Jekyll
6
+ module L10n
7
+ # Keeps the site baseurl out of extracted strings.
8
+ #
9
+ # A string with markup, such as a logo <img> in a link, carries its URLs
10
+ # into the msgid. Those URLs include the site baseurl, which changes between
11
+ # builds (local, branch previews, production). Without this step the same
12
+ # string gets a new msgid for every baseurl, which retires the old entry,
13
+ # adds a fuzzy one, and changes every compendium on each such build.
14
+ #
15
+ # Extraction strips the baseurl from URL attributes before reading strings.
16
+ # Translation strips it the same way, so lookups match those msgids, and
17
+ # restores it after translations are applied, including in markup that
18
+ # came from a msgstr.
19
+ #
20
+ # @example
21
+ # state = BaseurlNormalizer.strip(doc, '/docs')
22
+ # # <img src="/docs/logo.svg"> now reads <img src="/logo.svg">
23
+ # BaseurlNormalizer.restore(doc, state)
24
+ # # and reads <img src="/docs/logo.svg"> again
25
+ module BaseurlNormalizer
26
+ # Attributes whose value is a single URL.
27
+ URL_ATTRIBUTES = %w[href src poster].freeze
28
+
29
+ URL_SELECTOR = URL_ATTRIBUTES.map { |attr| "[#{attr}]" }.join(',').freeze
30
+ private_constant :URL_SELECTOR
31
+
32
+ # What strip changed, for restore.
33
+ #
34
+ # originals: element => { attribute => value before strip }
35
+ # stripped: value after strip => value before strip
36
+ # unchanged: root-relative values strip did not touch
37
+ State = Struct.new(:originals, :stripped, :unchanged)
38
+
39
+ module_function
40
+
41
+ # Remove the baseurl from URL attributes that start with it.
42
+ #
43
+ # Only whole path segments match: with baseurl "/docs", "/docs" and
44
+ # "/docs/page/" are stripped, "/docsite/" is not.
45
+ #
46
+ # @param doc [Nokogiri::XML::Node] Document or element (modified in place)
47
+ # @param baseurl [String, nil] Site baseurl, e.g. "/docs"
48
+ # @return [State, nil] What changed, or nil when there is no baseurl
49
+ def strip(doc, baseurl)
50
+ return nil if baseurl.nil? || baseurl.empty? || baseurl == '/'
51
+
52
+ state = State.new({}.compare_by_identity, {}, Set.new)
53
+ each_url_attribute(doc) do |element, attr, value|
54
+ path = path_below(value, baseurl)
55
+ if path
56
+ strip_attribute(state, element, attr, path)
57
+ elsif value.start_with?('/')
58
+ state.unchanged << value
59
+ end
60
+ end
61
+ state
62
+ end
63
+
64
+ # Put the baseurl back.
65
+ #
66
+ # Elements strip changed get their own values back. Other elements, such
67
+ # as markup injected from a msgstr, get the baseurl back on values strip
68
+ # produced, unless the page also had that same value without a baseurl.
69
+ #
70
+ # @param doc [Nokogiri::XML::Node] Document or element (modified in place)
71
+ # @param state [State, nil] The result of {.strip}
72
+ # @return [void]
73
+ def restore(doc, state)
74
+ return unless state
75
+
76
+ each_url_attribute(doc) do |element, attr, value|
77
+ original = state.originals.dig(element, attr)
78
+ original ||= state.stripped[value] unless state.unchanged.include?(value)
79
+ element[attr] = original if original
80
+ end
81
+ end
82
+
83
+ def strip_attribute(state, element, attr, path)
84
+ value = element[attr]
85
+ (state.originals[element] ||= {})[attr] = value
86
+ state.stripped[path] = value
87
+ element[attr] = path
88
+ end
89
+
90
+ def path_below(value, baseurl)
91
+ return '/' if value == baseurl
92
+
93
+ value[baseurl.length..] if value.start_with?("#{baseurl}/")
94
+ end
95
+
96
+ def each_url_attribute(doc)
97
+ doc.css(URL_SELECTOR).each do |element|
98
+ URL_ATTRIBUTES.each do |attr|
99
+ value = element[attr]
100
+ yield element, attr, value if value
101
+ end
102
+ end
103
+ end
104
+
105
+ private_class_method :strip_attribute, :path_below, :each_url_attribute
106
+ end
107
+ end
108
+ end