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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'jekyll'
4
+
3
5
  module Jekyll
4
6
  module L10n
5
7
  # OpenTelemetry instrumentation facade for jekyll-l10n.
@@ -7,7 +9,7 @@ module Jekyll
7
9
  # All tracing is configured centrally in TRACED_METHODS — no span code lives
8
10
  # in business logic classes. To add a span: append one entry. To rename a
9
11
  # method: update the one entry. When a method is removed from its class the
10
- # stale entry raises NoMethodError in tests, signalling the entry to delete.
12
+ # stale entry fails the TRACED_METHODS spec, signalling the entry to delete.
11
13
  #
12
14
  # Requires opentelemetry-api at runtime; falls back to a no-op if absent.
13
15
  # Users opt in to real tracing by adding opentelemetry-sdk and
@@ -18,6 +20,161 @@ module Jekyll
18
20
  module Instrumentation
19
21
  TRACER_NAME = 'jekyll-l10n'
20
22
 
23
+ # -- Entry point -------------------------------------------------------
24
+
25
+ # Returns true when OTel is requested via standard environment variables.
26
+ #
27
+ # install! guards on this so the prepend wrappers are only applied when
28
+ # a real exporter is configured. In CI and local tests (no OTel env vars)
29
+ # business logic classes are untouched, keeping allow_any_instance_of stubs
30
+ # and other RSpec mechanics fully functional.
31
+ def self.enabled?
32
+ ENV.key?('OTEL_EXPORTER_OTLP_ENDPOINT') || ENV.key?('OTEL_SERVICE_NAME')
33
+ end
34
+
35
+ # Installs wrappers on all classes listed in TRACED_METHODS using Module#prepend.
36
+ #
37
+ # Called once at plugin load time (end of jekyll-l10n.rb, after all requires),
38
+ # but only when enabled? returns true. Use OTEL_EXPORTER_OTLP_ENDPOINT or
39
+ # OTEL_SERVICE_NAME to activate tracing.
40
+ def self.install!
41
+ return if @installed
42
+
43
+ @installed = true
44
+ setup_sdk!
45
+ TRACED_METHODS.group_by { |e| e[0] }.each do |class_name, entries|
46
+ prepend_wrappers(resolve_class(class_name), entries)
47
+ end
48
+ end
49
+
50
+ # -- Install phase -----------------------------------------------------
51
+
52
+ # Configures the OTel SDK from standard env vars if the SDK gem is available.
53
+ #
54
+ # Called from install! so that spans are exported to the configured backend
55
+ # without requiring sites to add a separate initializer. Silently no-ops when
56
+ # opentelemetry-sdk or opentelemetry-exporter-otlp is not installed.
57
+ #
58
+ # Uses BatchSpanProcessor, which exports from a background thread, so the
59
+ # build never waits on the collector while a span ends (SimpleSpanProcessor
60
+ # exports synchronously, inside every traced call — costly here, where each
61
+ # localized page produces several spans). Spans still reach the collector
62
+ # before Jekyll exits: the post_write hook below runs after every
63
+ # :low-or-higher post_write hook, this plugin's :high one included, and
64
+ # force-flushes the batch; at_exit shuts the provider down, exporting
65
+ # anything left (e.g. between serve rebuilds).
66
+ #
67
+ # jekyll-theme-centos loads several instrumented plugins in one process,
68
+ # and OpenTelemetry::SDK.configure unconditionally overwrites the global
69
+ # tracer_provider on every call — so only the first plugin to reach this
70
+ # method (see sdk_already_configured?) actually configures the SDK; every
71
+ # later plugin's call becomes a no-op and reuses that provider. The
72
+ # at_exit and post_write hooks below are therefore registered once, by
73
+ # whichever plugin wins, and cover every plugin's spans, not just this one's.
74
+ def self.setup_sdk!
75
+ return if sdk_already_configured?
76
+
77
+ # :nocov:
78
+ require 'opentelemetry/sdk'
79
+ require 'opentelemetry/exporter/otlp'
80
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter.new
81
+ processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
82
+ OpenTelemetry::SDK.configure { |c| c.add_span_processor(processor) }
83
+
84
+ at_exit { OpenTelemetry.tracer_provider&.shutdown }
85
+ Jekyll::Hooks.register(:site, :post_write, priority: 0) { OpenTelemetry.tracer_provider&.force_flush }
86
+ rescue LoadError
87
+ nil
88
+ # :nocov:
89
+ end
90
+
91
+ # True when some plugin (this one or another) already installed a real
92
+ # SDK-backed tracer_provider, in which case setup_sdk! must not run again.
93
+ def self.sdk_already_configured?
94
+ defined?(OpenTelemetry::SDK::Trace::TracerProvider) &&
95
+ OpenTelemetry.tracer_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
96
+ end
97
+
98
+ # Resolves a dot-separated class name to a constant; returns nil on NameError.
99
+ # Public, unlike the other install! helpers, so specs can check TRACED_METHODS entries.
100
+ def self.resolve_class(name)
101
+ name.split('::').reduce(Object) { |m, c| m.const_get(c) }
102
+ rescue NameError
103
+ nil
104
+ end
105
+
106
+ # Applies instance and class-method wrappers to klass; skips if klass is nil.
107
+ def self.prepend_wrappers(klass, entries)
108
+ return unless klass
109
+
110
+ class_entries, instance_entries = entries.partition { |e| e[1] == :class }
111
+ klass.prepend(build_wrapper_module(instance_entries)) unless instance_entries.empty?
112
+ klass.singleton_class.prepend(build_wrapper_module(class_entries)) unless class_entries.empty?
113
+ end
114
+
115
+ # Builds an anonymous module that wraps each listed method in a span.
116
+ def self.build_wrapper_module(entries)
117
+ Module.new do
118
+ entries.each do |_class_name, method_type, method_name, span_name, attr_proc|
119
+ define_method(method_name) do |*args, **kwargs, &blk|
120
+ Instrumentation.instrument(span_name) do |span|
121
+ result = super(*args, **kwargs, &blk)
122
+ attr_proc&.call(span, self, args, result)
123
+ result
124
+ rescue StandardError => e
125
+ span.record_exception(e)
126
+ raise
127
+ end
128
+ end
129
+ private method_name if method_type == :private
130
+ end
131
+ end
132
+ end
133
+
134
+ private_class_method :build_wrapper_module, :prepend_wrappers, :setup_sdk!, :sdk_already_configured?
135
+
136
+ # -- Runtime API -------------------------------------------------------
137
+
138
+ # Returns the active OTel tracer, or a no-op tracer if opentelemetry-api is absent.
139
+ def self.tracer
140
+ @tracer ||=
141
+ if defined?(OpenTelemetry)
142
+ # :nocov:
143
+ OpenTelemetry.tracer_provider.tracer(TRACER_NAME, Jekyll::L10n::VERSION)
144
+ # :nocov:
145
+ else
146
+ NoopTracer.new
147
+ end
148
+ end
149
+
150
+ # Wraps a block in an OTel span.
151
+ #
152
+ # @param span_name [String] Dot-separated span name (e.g. 'l10n.extract_page')
153
+ # @param attributes [Hash] Initial span attributes
154
+ # @yieldparam span [OpenTelemetry::Trace::Span, NoopSpan] Active span
155
+ # @return [Object] The return value of the block
156
+ def self.instrument(span_name, attributes: {}, &block)
157
+ tracer.in_span(span_name, attributes: attributes, &block)
158
+ end
159
+
160
+ # Thread.new that keeps the caller's trace context, so spans created in
161
+ # the thread nest under the span that started it. OTel tracks the
162
+ # current span per thread, and a plain Thread.new starts without one.
163
+ def self.traced_thread(&block)
164
+ return Thread.new(&block) unless defined?(OpenTelemetry::Context)
165
+
166
+ context = OpenTelemetry::Context.current
167
+ Thread.new { OpenTelemetry::Context.with_current(context, &block) }
168
+ end
169
+
170
+ # Resets the cached tracer and installation flag. Call in tests after changing OTel configuration.
171
+ def self.reset!
172
+ @tracer = nil
173
+ @installed = false
174
+ end
175
+
176
+ # -- Registry ----------------------------------------------------------
177
+
21
178
  # Central instrumentation registry.
22
179
  #
23
180
  # Each row: [class_name, method_type, method_name, span_name, attribute_proc]
@@ -48,7 +205,7 @@ module Jekyll
48
205
  end
49
206
 
50
207
  TRACED_METHODS = [
51
- # ── Jekyll integration ────────────────────────────────────────────────
208
+ # -- Jekyll integration -----------------------------------------------
52
209
  ['Jekyll::L10n::Generator', :instance, :generate, 'l10n.generate',
53
210
  lambda { |span, _this, args, _result|
54
211
  span.set_attribute('l10n.site_page_count', args[0]&.pages&.size || 0)
@@ -64,7 +221,7 @@ module Jekyll
64
221
  span.set_attribute('l10n.page_url', this.page.url.to_s)
65
222
  }],
66
223
 
67
- # ── Extraction pipeline ───────────────────────────────────────────────
224
+ # -- Extraction pipeline ----------------------------------------------
68
225
  ['Jekyll::L10n::Extractor', :instance, :extract_site, 'l10n.extract_site',
69
226
  lambda { |span, _this, _args, result|
70
227
  span.set_attribute('l10n.file_count', Instrumentation.hash_val(result, :files_processed))
@@ -102,7 +259,7 @@ module Jekyll
102
259
  span.set_attribute('l10n.locale_count', config.locales.size) if config.respond_to?(:locales)
103
260
  }],
104
261
 
105
- # ── Translation pipeline ──────────────────────────────────────────────
262
+ # -- Translation pipeline ---------------------------------------------
106
263
  ['Jekyll::L10n::PostWriteHtmlReprocessor', :instance, :reprocess_localized_pages,
107
264
  'l10n.reprocess_localized_pages', nil],
108
265
 
@@ -134,7 +291,7 @@ module Jekyll
134
291
  span.set_attribute('l10n.batch_size', args[0].is_a?(Array) ? args[0].size : 1)
135
292
  }],
136
293
 
137
- # ── Utilities ─────────────────────────────────────────────────────────
294
+ # -- Utilities --------------------------------------------------------
138
295
  ['Jekyll::L10n::HtmlParser', :class, :parse_document, 'l10n.html_parse',
139
296
  lambda { |span, _this, args, _result|
140
297
  span.set_attribute('l10n.html_size_bytes', args[0].bytesize)
@@ -151,7 +308,16 @@ module Jekyll
151
308
  ['Jekyll::L10n::ExternalLinkIconPreserver', :class, :preserve,
152
309
  'l10n.icon_preserve', nil],
153
310
 
154
- # ── PO file operations ────────────────────────────────────────────────
311
+ # reject_invalid runs once per PO file load; valid? runs per entry, too often to trace
312
+ ['Jekyll::L10n::MsgstrValidator', :class, :reject_invalid, 'l10n.msgstr_validate',
313
+ lambda { |span, _this, args, result|
314
+ entry_count = Instrumentation.hash_size(args[0])
315
+ span.set_attribute('l10n.file_path', args[1].to_s)
316
+ span.set_attribute('l10n.entry_count', entry_count)
317
+ span.set_attribute('l10n.invalid_count', entry_count - Instrumentation.hash_size(result))
318
+ }],
319
+
320
+ # -- PO file operations -----------------------------------------------
155
321
  ['Jekyll::L10n::PoFileReader', :instance, :parse_for_translation,
156
322
  'l10n.po_file_read',
157
323
  lambda { |span, this, _args, result|
@@ -167,139 +333,6 @@ module Jekyll
167
333
  ].freeze
168
334
  # :nocov:
169
335
 
170
- # Returns the active OTel tracer, or a no-op tracer if opentelemetry-api is absent.
171
- def self.tracer
172
- @tracer ||=
173
- if defined?(OpenTelemetry)
174
- # :nocov:
175
- OpenTelemetry.tracer_provider.tracer(TRACER_NAME, Jekyll::L10n::VERSION)
176
- # :nocov:
177
- else
178
- NoopTracer.new
179
- end
180
- end
181
-
182
- # Wraps a block in an OTel span.
183
- #
184
- # @param span_name [String] Dot-separated span name (e.g. 'l10n.extract_page')
185
- # @param attributes [Hash] Initial span attributes
186
- # @yieldparam span [OpenTelemetry::Trace::Span, NoopSpan] Active span
187
- # @return [Object] The return value of the block
188
- def self.instrument(span_name, attributes: {}, &block)
189
- tracer.in_span(span_name, attributes: attributes, &block)
190
- end
191
-
192
- # Resets the cached tracer and installation flag. Call in tests after changing OTel configuration.
193
- def self.reset!
194
- @tracer = nil
195
- @installed = false
196
- end
197
-
198
- # Returns true when OTel is requested via standard environment variables.
199
- #
200
- # install! guards on this so the prepend wrappers are only applied when
201
- # a real exporter is configured. In CI and local tests (no OTel env vars)
202
- # business logic classes are untouched, keeping allow_any_instance_of stubs
203
- # and other RSpec mechanics fully functional.
204
- def self.enabled?
205
- ENV.key?('OTEL_EXPORTER_OTLP_ENDPOINT') || ENV.key?('OTEL_SERVICE_NAME')
206
- end
207
-
208
- # Installs wrappers on all classes listed in TRACED_METHODS using Module#prepend.
209
- #
210
- # Called once at plugin load time (end of jekyll-l10n.rb, after all requires),
211
- # but only when enabled? returns true. Use OTEL_EXPORTER_OTLP_ENDPOINT or
212
- # OTEL_SERVICE_NAME to activate tracing.
213
- def self.install!
214
- return if @installed
215
-
216
- @installed = true
217
- setup_sdk!
218
- TRACED_METHODS.group_by { |e| e[0] }.each do |class_name, entries|
219
- prepend_wrappers(resolve_class(class_name), entries)
220
- end
221
- end
222
-
223
- # Configures the OTel SDK from standard env vars if the SDK gem is available.
224
- #
225
- # Called from install! so that spans are exported to the configured backend
226
- # without requiring sites to add a separate initializer. Silently no-ops when
227
- # opentelemetry-sdk or opentelemetry-exporter-otlp is not installed.
228
- #
229
- # SimpleSpanProcessor is used instead of the SDK default (BatchSpanProcessor)
230
- # because Jekyll is a short-lived CLI process. BatchSpanProcessor exports on a
231
- # 5-second schedule; outer spans that close last (post_write, generate) are
232
- # silently dropped when the process exits before the next flush. Simple exports
233
- # each span synchronously the moment it closes, guaranteeing no span loss.
234
- #
235
- # jekyll-theme-centos loads several instrumented plugins in one process,
236
- # and OpenTelemetry::SDK.configure unconditionally overwrites the global
237
- # tracer_provider on every call — so only the first plugin to reach this
238
- # method (see sdk_already_configured?) actually configures the SDK; every
239
- # later plugin's call becomes a no-op and reuses that provider. The
240
- # at_exit and post_write hooks below are therefore registered once, by
241
- # whichever plugin wins, and cover every plugin's spans, not just this one's.
242
- def self.setup_sdk!
243
- return if sdk_already_configured?
244
-
245
- # :nocov:
246
- require 'opentelemetry/sdk'
247
- require 'opentelemetry/exporter/otlp'
248
- exporter = OpenTelemetry::Exporter::OTLP::Exporter.new
249
- processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
250
- OpenTelemetry::SDK.configure { |c| c.add_span_processor(processor) }
251
-
252
- at_exit { OpenTelemetry.tracer_provider&.shutdown }
253
- Jekyll::Hooks.register(:site, :post_write, priority: 0) { OpenTelemetry.tracer_provider&.force_flush }
254
- rescue LoadError
255
- nil
256
- # :nocov:
257
- end
258
-
259
- # True when some plugin (this one or another) already installed a real
260
- # SDK-backed tracer_provider, in which case setup_sdk! must not run again.
261
- def self.sdk_already_configured?
262
- defined?(OpenTelemetry::SDK::Trace::TracerProvider) &&
263
- OpenTelemetry.tracer_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
264
- end
265
-
266
- # Resolves a dot-separated class name to a constant; returns nil on NameError.
267
- def self.resolve_class(name)
268
- name.split('::').reduce(Object) { |m, c| m.const_get(c) }
269
- rescue NameError
270
- nil
271
- end
272
-
273
- # Applies instance and class-method wrappers to klass; skips if klass is nil.
274
- def self.prepend_wrappers(klass, entries)
275
- return unless klass
276
-
277
- class_entries, instance_entries = entries.partition { |e| e[1] == :class }
278
- klass.prepend(build_wrapper_module(instance_entries)) unless instance_entries.empty?
279
- klass.singleton_class.prepend(build_wrapper_module(class_entries)) unless class_entries.empty?
280
- end
281
-
282
- # Builds an anonymous module that wraps each listed method in a span.
283
- def self.build_wrapper_module(entries)
284
- Module.new do
285
- entries.each do |_class_name, method_type, method_name, span_name, attr_proc|
286
- define_method(method_name) do |*args, **kwargs, &blk|
287
- Instrumentation.instrument(span_name) do |span|
288
- result = super(*args, **kwargs, &blk)
289
- attr_proc&.call(span, self, args, result)
290
- result
291
- rescue StandardError => e
292
- span.record_exception(e)
293
- raise
294
- end
295
- end
296
- private method_name if method_type == :private
297
- end
298
- end
299
- end
300
-
301
- private_class_method :build_wrapper_module, :prepend_wrappers, :setup_sdk!, :sdk_already_configured?
302
-
303
336
  # No-op tracer used when opentelemetry-api is not loaded.
304
337
  class NoopTracer
305
338
  def in_span(_name, **_opts)
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../configuration'
3
4
  require_relative '../constants'
4
5
  require_relative 'regeneration_checker'
5
6
  require_relative '../utils/locale_url_formatter'
7
+ require_relative '../utils/page_locales_config'
6
8
  require_relative '../utils/site_config_accessor'
7
9
 
8
10
  module Jekyll
@@ -38,7 +40,7 @@ module Jekyll
38
40
  def generate(site)
39
41
  return unless any_pages_to_localize?(site)
40
42
 
41
- Jekyll.logger.info 'Localization', 'Generating localized pages...'
43
+ Jekyll.logger.info Constants::LOG_PREFIX, 'Generating localized pages...'
42
44
  generate_localized_pages(site)
43
45
  end
44
46
 
@@ -56,8 +58,6 @@ module Jekyll
56
58
  locales = get_page_locales(page)
57
59
 
58
60
  locales.each do |locale|
59
- next unless valid_locale_code?(locale)
60
-
61
61
  if checker.should_regenerate?(page, locale)
62
62
  localized_page = create_localized_page(site, page, locale)
63
63
  site.pages << localized_page
@@ -86,32 +86,26 @@ module Jekyll
86
86
 
87
87
  def should_localize_page?(page)
88
88
  return false if page.data['localized'] == true
89
- return false unless page.data['with_locales'] == true
89
+ return false unless Configuration.localizable?(page)
90
90
 
91
91
  # Localize HTML pages only, never CSS, JavaScript, or other assets.
92
92
  is_html = page.output_ext == '.html'
93
93
  is_not_asset = !page.path.match?(%r{/(assets|vendor|node_modules|\..*)/}i)
94
94
 
95
95
  if !is_html
96
- Jekyll.logger.debug 'Localization',
96
+ Jekyll.logger.debug Constants::LOG_PREFIX,
97
97
  "Skipping non-HTML: #{page.path} (ext: #{page.output_ext})"
98
98
  elsif !is_not_asset
99
- Jekyll.logger.debug 'Localization', "Skipping asset: #{page.path}"
99
+ Jekyll.logger.debug Constants::LOG_PREFIX, "Skipping asset: #{page.path}"
100
100
  end
101
101
 
102
102
  is_html && is_not_asset
103
103
  end
104
104
 
105
+ # Configuration::Schema has already dropped bad locale codes, with a
106
+ # warning, so the page is built for its other locales.
105
107
  def get_page_locales(page)
106
- data = SiteConfigAccessor.page_locales_data(page)
107
-
108
- locales = if data.is_a?(Hash)
109
- data['locales'] || []
110
- else
111
- []
112
- end
113
-
114
- locales.select { |l| valid_locale_code?(l) }
108
+ PageLocalesConfig.new(SiteConfigAccessor.effective_page_data(page)).locales
115
109
  end
116
110
 
117
111
  def create_localized_page(site, page, locale)
@@ -120,14 +114,8 @@ module Jekyll
120
114
  locale: locale)
121
115
  end
122
116
 
123
- def valid_locale_code?(locale)
124
- return false unless locale.is_a?(String)
125
-
126
- locale.match?(Constants::LOCALE_PATTERN)
127
- end
128
-
129
117
  def any_pages_to_localize?(site)
130
- site.pages.any? { |page| page.data['with_locales'] == true }
118
+ site.pages.any? { |page| Configuration.localizable?(page) }
131
119
  end
132
120
  end
133
121
  end
@@ -39,7 +39,13 @@ module Jekyll
39
39
  # @!attribute [rw] original_url
40
40
  # The URL of the source page before locale prefix was added
41
41
  # @return [String]
42
- attr_accessor :locale, :lang, :original_page, :original_url
42
+ # @!attribute [rw] translations_digest
43
+ # Digest of the translations hash the page was rendered with. Set by
44
+ # Translator after a successful translation; read by PostWriteHtmlReprocessor
45
+ # to skip pages whose translations have not changed since the render pass.
46
+ # Instance attribute only — must not leak into Liquid.
47
+ # @return [Integer, nil]
48
+ attr_accessor :locale, :lang, :original_page, :original_url, :translations_digest
43
49
 
44
50
  # Initialize a new localized page variant
45
51
  #
@@ -81,62 +87,34 @@ module Jekyll
81
87
  setup_localized_data(page, locale)
82
88
  end
83
89
 
84
- # Get the URL placeholder substitutions for this localized page
85
- #
86
- # Overrides Jekyll::Page's url_placeholders to include the locale prefix in path generation.
87
- # The locale segment is converted to BCP 47 hyphenated form for use in URLs.
88
- # This ensures that Jekyll's permalink logic respects the locale prefix.
89
- #
90
- # @return [Hash<Symbol, String>] Hash of placeholders with locale-prefixed path
91
- def url_placeholders
92
- placeholders = super
93
- if placeholders[:path]
94
- url_locale = LocaleUrlFormatter.to_url_segment(@locale)
95
- placeholders[:path] = "/#{url_locale}#{placeholders[:path]}"
96
- end
97
- placeholders
98
- end
99
-
100
90
  # Get the full URL for this localized page
101
91
  #
102
- # Returns the page's URL with a locale prefix in BCP 47 hyphenated form.
103
- # For example, if the source page URL is '/path/to/page/', this returns
104
- # '/es-ES/path/to/page/' for locale 'es_ES'.
105
- # For HTML pages, URLs end with a trailing slash. For assets, the file extension is preserved.
92
+ # The English page's URL with the locale segment (BCP 47 hyphenated form)
93
+ # in front, and nothing else changed: the page's permalink decides the
94
+ # URL's shape, so '/about', '/about/' and '/about.html' become
95
+ # '/es-es/about', '/es-es/about/' and '/es-es/about.html' for 'es_ES'.
96
+ # Jekyll::Page#destination then writes the page where that URL says, by
97
+ # the same rule as the English page.
106
98
  #
107
- # @return [String] The locale-prefixed URL with optional trailing slash
108
- # (e.g., '/es-ES/path/to/page/' or '/es-ES/assets/style.css')
99
+ # @return [String] The locale-prefixed URL
109
100
  def url
110
- url_locale = LocaleUrlFormatter.to_url_segment(@locale)
111
- url = "/#{url_locale}#{@original_url}"
112
- # Only add trailing slash for directory URLs (no file extension)
113
- url += '/' unless url.end_with?('/') || url =~ /\.[a-zA-Z0-9]+$/
114
- url
101
+ "/#{LocaleUrlFormatter.to_url_segment(@locale)}#{@original_url}"
115
102
  end
116
103
 
117
- # Get the destination file path for this localized page
104
+ # Write the page, replacing the empty directory an earlier release left
105
+ # at its path.
118
106
  #
119
- # Computes the full file system path where this page's content will be written.
120
- # For HTML pages with URL '/es-ES/path/to/page/', this returns
121
- # 'dest/es-ES/path/to/page/index.html'.
122
- # For assets with URL '/es-ES/assets/style.css', this returns
123
- # 'dest/es-ES/assets/style.css'.
107
+ # Earlier releases wrote a page whose URL ends in a file name to a
108
+ # directory of that name (es/nav.html/index.html). Jekyll's cleanup
109
+ # removes the old file but keeps the directory, because the new file
110
+ # has the same path.
124
111
  #
125
- # @param dest [String, nil] The destination directory (defaults to nil, which uses
126
- # site config destination)
127
- # @return [String] The full file system path for the output file
128
- def destination(dest = nil)
129
- dest ||= @site.config['destination']
130
- url_path = url
131
-
132
- # Only use index.html for HTML pages
133
- if @output_ext == '.html'
134
- url_path += '/' unless url_path.end_with?('/')
135
- "#{dest}#{url_path}index.html"
136
- else
137
- # For assets, preserve the file extension
138
- "#{dest}#{url_path}"
139
- end
112
+ # @param dest [String] The destination directory
113
+ # @return [void]
114
+ def write(dest)
115
+ path = destination(dest)
116
+ Dir.rmdir(path) if File.directory?(path) && Dir.empty?(path)
117
+ super
140
118
  end
141
119
 
142
120
  # Convert this localized page to Liquid template data
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../constants'
3
4
  require_relative '../translation/page_translation_loader'
4
5
  require_relative '../translation/html_translator'
5
6
  require_relative '../utils/page_locales_config'
@@ -32,20 +33,29 @@ module Jekyll
32
33
  @dest = SiteConfigAccessor.dest(@site)
33
34
  end
34
35
 
35
- # Reads each localized file, translates it, keeps the external link icons
36
- # of the English page, and writes it back.
36
+ # Reads each localized file whose translations changed since the render
37
+ # pass, translates it, keeps the external link icons of the English page,
38
+ # and writes it back. Pages that carry a matching +translations_digest+
39
+ # from the Translator are skipped.
40
+ #
41
+ # Logs "Applied new translations to N of M localized pages" only when at
42
+ # least one page was reprocessed.
37
43
  #
38
44
  # @return [void]
39
45
  def reprocess_localized_pages
40
46
  localized_files = find_localized_html_files
41
47
  return if localized_files.empty?
42
48
 
43
- msg = "Applying translations to #{localized_files.length} localized pages..."
44
- Jekyll.logger.info 'Localization', msg
45
-
46
- localized_files.each do |html_path, locale, original_url|
47
- translate_html_file(html_path, locale, original_url)
49
+ reprocessed = 0
50
+ localized_files.each do |html_path, locale, original_url, page|
51
+ reprocessed += 1 if translate_html_file(html_path, locale, original_url, page)
48
52
  end
53
+
54
+ return unless reprocessed.positive?
55
+
56
+ msg = "Applied new translations to #{reprocessed} of #{localized_files.length} " \
57
+ 'localized pages'
58
+ Jekyll.logger.info Constants::LOG_PREFIX, msg
49
59
  end
50
60
 
51
61
  private
@@ -60,16 +70,13 @@ module Jekyll
60
70
  original_url = page.data['original_url']
61
71
  html_path = page.destination(@dest)
62
72
 
63
- results << [html_path, locale, original_url] if File.exist?(html_path)
73
+ results << [html_path, locale, original_url, page] if File.exist?(html_path)
64
74
  end
65
75
 
66
76
  results
67
77
  end
68
78
 
69
- # rubocop:disable Metrics/AbcSize
70
- def translate_html_file(html_path, locale, original_url)
71
- # rubocop:enable Metrics/AbcSize
72
- original_html = FileOperations.read_utf8(html_path)
79
+ def translate_html_file(html_path, locale, original_url, page = nil)
73
80
  original_page = find_original_page(original_url)
74
81
 
75
82
  return unless original_page
@@ -79,23 +86,33 @@ module Jekyll
79
86
 
80
87
  return if translations.nil? || translations.empty?
81
88
 
82
- translator = HtmlTranslator.new(
83
- config.fallback_mode,
84
- config.translatable_attributes,
85
- debug_logging: config.debug_logging?
86
- )
89
+ # Skip when the page was already rendered with these same translations
90
+ return if skip_reprocess?(page, translations)
87
91
 
88
- baseurl = @site.config['baseurl'] || ''
89
- translated_html = translator.translate(original_html, translations, locale, baseurl)
92
+ translator = HtmlTranslator.from_config(config)
90
93
 
91
- if translated_html
92
- # Preserve external link icons from the original HTML after translation
93
- translated_html = ExternalLinkIconPreserver.preserve(original_html, translated_html)
94
- FileOperations.write_utf8(html_path, translated_html)
95
- end
94
+ write_translated_html(html_path, translator, translations, locale)
96
95
  rescue StandardError => e
97
96
  error_msg = "Failed to reprocess #{html_path}: #{e.message}"
98
- Jekyll.logger.warn 'Localization', error_msg
97
+ Jekyll.logger.warn Constants::LOG_PREFIX, error_msg
98
+ end
99
+
100
+ def skip_reprocess?(page, translations)
101
+ page.respond_to?(:translations_digest) &&
102
+ page.translations_digest == translations.hash
103
+ end
104
+
105
+ def write_translated_html(html_path, translator, translations, locale)
106
+ # Read only now, so pages skipped above cost no file read.
107
+ original_html = FileOperations.read_utf8(html_path)
108
+ baseurl = @site.config['baseurl'] || ''
109
+ translated_html = translator.translate(original_html, translations, locale, baseurl)
110
+ return unless translated_html
111
+
112
+ # Preserve external link icons from the original HTML after translation
113
+ translated_html = ExternalLinkIconPreserver.preserve(original_html, translated_html)
114
+ FileOperations.write_utf8(html_path, translated_html)
115
+ true
99
116
  end
100
117
 
101
118
  def find_original_page(url)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../configuration'
3
4
  require_relative '../extraction/extractor'
4
5
  require_relative 'post_write_html_reprocessor'
5
6
 
@@ -77,7 +78,7 @@ module Jekyll
77
78
  #
78
79
  # @return [Boolean] True if at least one page has with_locales set to true
79
80
  def any_pages_localized?
80
- @site.pages.any? { |page| page.data['with_locales'] == true }
81
+ @site.pages.any? { |page| Configuration.localizable?(page) }
81
82
  end
82
83
  end
83
84
  end