jekyll-l10n 1.7.1 → 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 (55) 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 +28 -58
  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 +72 -65
  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 +47 -61
  18. data/lib/jekyll-l10n/jekyll/localized_page.rb +27 -49
  19. data/lib/jekyll-l10n/jekyll/post_write_html_reprocessor.rb +53 -48
  20. data/lib/jekyll-l10n/jekyll/post_write_processor.rb +2 -1
  21. data/lib/jekyll-l10n/jekyll/regeneration_checker.rb +70 -50
  22. data/lib/jekyll-l10n/jekyll/url_filter.rb +13 -13
  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/page_translation_loader.rb +13 -29
  36. data/lib/jekyll-l10n/translation/request_slots.rb +69 -0
  37. data/lib/jekyll-l10n/translation/translation_progress.rb +77 -0
  38. data/lib/jekyll-l10n/translation/translator.rb +49 -78
  39. data/lib/jekyll-l10n/utils/baseurl_normalizer.rb +108 -0
  40. data/lib/jekyll-l10n/utils/debug_logger.rb +4 -51
  41. data/lib/jekyll-l10n/utils/error_handler.rb +9 -24
  42. data/lib/jekyll-l10n/utils/external_link_icon_preserver.rb +5 -62
  43. data/lib/jekyll-l10n/utils/html_parser.rb +24 -31
  44. data/lib/jekyll-l10n/utils/html_text_utils.rb +146 -91
  45. data/lib/jekyll-l10n/utils/logger_formatter.rb +6 -4
  46. data/lib/jekyll-l10n/utils/msgstr_validator.rb +74 -0
  47. data/lib/jekyll-l10n/utils/page_locales_config.rb +165 -223
  48. data/lib/jekyll-l10n/utils/po_entry_converter.rb +0 -43
  49. data/lib/jekyll-l10n/utils/site_config_accessor.rb +82 -42
  50. data/lib/jekyll-l10n/utils/translation_resolver.rb +56 -6
  51. data/lib/jekyll-l10n/utils/url_path_builder.rb +5 -27
  52. data/lib/jekyll-l10n/utils/url_transformer.rb +5 -11
  53. data/lib/jekyll-l10n/version.rb +1 -1
  54. data/lib/jekyll-l10n.rb +67 -176
  55. 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,58 +1,46 @@
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'
8
+ require_relative '../utils/site_config_accessor'
6
9
 
7
10
  module Jekyll
8
11
  module L10n
9
- # Localized Page Generator - Creates locale-prefixed copies of pages during Jekyll build
12
+ # Adds a copy of each localizable page for every configured locale.
10
13
  #
11
- # This is the main Jekyll integration point for the localization plugin. It runs as a
12
- # low-priority generator during the Jekyll build process, creating duplicate pages for each
13
- # configured locale. Each localized page inherits content and metadata from the source page
14
- # but includes locale-prefixed URLs.
14
+ # This is where the plugin starts in a Jekyll build. For each page with
15
+ # `with_locales: true`, it reads the page's merged `with_locales_data` (site
16
+ # defaults plus front matter, see SiteConfigAccessor.page_locales_data) and
17
+ # adds one LocalizedPage per locale, served under a locale prefix such as
18
+ # `/es/`. Translation happens later, when Jekyll renders those pages.
15
19
  #
16
- # Key responsibilities:
17
- # - Identify pages marked for localization (with_locales: true)
18
- # - Extract configured locales from page front matter
19
- # - Create LocalizedPage instances for each locale variant
20
- # - Optimize regeneration by skipping unchanged pages
20
+ # With incremental mode on (`jekyll_l10n.incremental`), RegenerationChecker
21
+ # decides which copies to rebuild; the Generator protects the output of the
22
+ # skipped ones from Jekyll's cleanup step.
21
23
  #
22
- # The generator respects Jekyll's incremental build mode and only regenerates pages when
23
- # the source page or configuration has changed, improving rebuild performance.
24
- #
25
- # @example Usage in Jekyll site configuration
26
- # # _config.yml
27
- # plugins:
28
- # - jekyll-l10n
29
- #
30
- # @example Marking pages for localization
31
- # # src/page.md
24
+ # @example Front matter that localizes a page into two locales
32
25
  # ---
33
26
  # with_locales: true
34
27
  # with_locales_data:
35
- # locales: [es, fr, pt, de]
28
+ # locales: [es, fr]
36
29
  # ---
37
30
  #
38
31
  class Generator < Jekyll::Generator
39
32
  priority :low
40
33
 
41
- # Generate localized pages for all marked pages in the Jekyll site
42
- #
43
- # This method is automatically called by Jekyll during the generate phase.
44
- # It iterates through all pages in the site, identifies those marked for localization,
45
- # and creates locale-specific variants.
46
- #
47
- # Returns early with no output if no pages are marked for localization.
34
+ # Jekyll calls this once per build. It does nothing when no page has
35
+ # `with_locales: true`.
48
36
  #
49
- # @param site [Jekyll::Site] The Jekyll site object containing pages to process
37
+ # @param site [Jekyll::Site] The Jekyll site
50
38
  # @return [void]
51
- # @see LocalizedPage for details on page structure
39
+ # @see LocalizedPage
52
40
  def generate(site)
53
41
  return unless any_pages_to_localize?(site)
54
42
 
55
- Jekyll.logger.info 'Localization', 'Generating localized pages...'
43
+ Jekyll.logger.info Constants::LOG_PREFIX, 'Generating localized pages...'
56
44
  generate_localized_pages(site)
57
45
  end
58
46
 
@@ -61,6 +49,8 @@ module Jekyll
61
49
  def generate_localized_pages(site)
62
50
  original_pages = site.pages.dup
63
51
  checker = Jekyll::L10n::RegenerationChecker.new(site)
52
+ checker.warn_legacy_config
53
+ skipped_outputs = []
64
54
 
65
55
  original_pages.each do |page|
66
56
  next unless should_localize_page?(page)
@@ -68,48 +58,54 @@ module Jekyll
68
58
  locales = get_page_locales(page)
69
59
 
70
60
  locales.each do |locale|
71
- next unless valid_locale_code?(locale)
72
-
73
61
  if checker.should_regenerate?(page, locale)
74
62
  localized_page = create_localized_page(site, page, locale)
75
63
  site.pages << localized_page
64
+ else
65
+ skipped_outputs << checker.relative_dest_path(page, locale)
76
66
  end
77
67
  end
78
68
  end
69
+
70
+ keep_skipped_outputs(site, skipped_outputs)
71
+ end
72
+
73
+ # Jekyll's cleanup step deletes any output file that no page produced in
74
+ # this build, which includes skipped localized pages. Listing their output
75
+ # in site.keep_files keeps it.
76
+ #
77
+ # `jekyll serve` reuses the site and this generator between builds, so we
78
+ # first drop the entries added last time; otherwise they would stay kept
79
+ # after their page is rebuilt or deleted. We assign a new array because
80
+ # Jekyll shares the old one with site.config['keep_files'].
81
+ def keep_skipped_outputs(site, skipped_outputs)
82
+ previous = @kept_outputs || []
83
+ site.keep_files = (Array(site.keep_files) - previous) | skipped_outputs
84
+ @kept_outputs = skipped_outputs
79
85
  end
80
86
 
81
87
  def should_localize_page?(page)
82
88
  return false if page.data['localized'] == true
83
- return false unless page.data['with_locales'] == true
89
+ return false unless Configuration.localizable?(page)
84
90
 
85
- # Only localize HTML pages - exclude static assets
86
- # Check multiple conditions to be safe:
87
- # 1. output_ext must be .html
88
- # 2. path must not contain common asset directories
91
+ # Localize HTML pages only, never CSS, JavaScript, or other assets.
89
92
  is_html = page.output_ext == '.html'
90
93
  is_not_asset = !page.path.match?(%r{/(assets|vendor|node_modules|\..*)/}i)
91
94
 
92
- # Debug: Log which pages are being skipped
93
95
  if !is_html
94
- Jekyll.logger.debug 'Localization',
96
+ Jekyll.logger.debug Constants::LOG_PREFIX,
95
97
  "Skipping non-HTML: #{page.path} (ext: #{page.output_ext})"
96
98
  elsif !is_not_asset
97
- Jekyll.logger.debug 'Localization', "Skipping asset: #{page.path}"
99
+ Jekyll.logger.debug Constants::LOG_PREFIX, "Skipping asset: #{page.path}"
98
100
  end
99
101
 
100
102
  is_html && is_not_asset
101
103
  end
102
104
 
105
+ # Configuration::Schema has already dropped bad locale codes, with a
106
+ # warning, so the page is built for its other locales.
103
107
  def get_page_locales(page)
104
- data = page.data['with_locales_data']
105
-
106
- locales = if data.is_a?(Hash)
107
- data['locales'] || []
108
- else
109
- []
110
- end
111
-
112
- locales.select { |l| valid_locale_code?(l) }
108
+ PageLocalesConfig.new(SiteConfigAccessor.effective_page_data(page)).locales
113
109
  end
114
110
 
115
111
  def create_localized_page(site, page, locale)
@@ -118,18 +114,8 @@ module Jekyll
118
114
  locale: locale)
119
115
  end
120
116
 
121
- def valid_locale_code?(locale)
122
- return false unless locale.is_a?(String)
123
-
124
- locale.match?(Constants::LOCALE_PATTERN)
125
- end
126
-
127
- # Check if any pages in the site are marked for localization
128
- #
129
- # @param site [Jekyll::Site] The Jekyll site object
130
- # @return [Boolean] True if at least one page has with_locales set to true
131
117
  def any_pages_to_localize?(site)
132
- site.pages.any? { |page| page.data['with_locales'] == true }
118
+ site.pages.any? { |page| Configuration.localizable?(page) }
133
119
  end
134
120
  end
135
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