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
|
@@ -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
|
|
15
|
-
# retries with exponential backoff, progress
|
|
16
|
-
# Translations are written directly to POEntry objects
|
|
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
|
-
# *
|
|
20
|
-
# *
|
|
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]
|
|
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
|
|
54
|
-
#
|
|
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', '
|
|
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
|
-
|
|
63
|
-
return if empty_entries.empty?
|
|
73
|
+
empty_entries = untranslated_entries(po_entries)
|
|
74
|
+
return if empty_entries.empty? || @stopped
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
149
|
+
def languages
|
|
150
|
+
@languages ||= LibreTranslateLanguages.new(@config)
|
|
151
|
+
end
|
|
125
152
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|