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
@@ -40,7 +40,7 @@ module Jekyll
40
40
  File.join(source, locales_dir, "#{locale}.po")
41
41
  else
42
42
  # Build the normal path
43
- normal_path = File.join(source, locales_dir, locale, "#{page_path}.po")
43
+ normal_path = File.join(locale_dir(source, locales_dir, locale), "#{page_path}.po")
44
44
 
45
45
  # Validate against path traversal using expanded paths
46
46
  base_path = File.expand_path(File.join(source, locales_dir))
@@ -57,6 +57,16 @@ module Jekyll
57
57
  normal_path
58
58
  end
59
59
  end
60
+
61
+ # Build the directory that holds a locale's page-specific PO files.
62
+ #
63
+ # @param source [String] Site source directory
64
+ # @param locales_dir [String] Locales directory name (e.g., '_locales')
65
+ # @param locale [String] Locale code (e.g., 'es', 'fr')
66
+ # @return [String] {source}/{locales_dir}/{locale}
67
+ def self.locale_dir(source, locales_dir, locale)
68
+ File.join(source, locales_dir, locale)
69
+ end
60
70
  end
61
71
  end
62
72
  end
@@ -1,53 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'gettext/po'
3
+ require_relative 'entry_parser'
4
4
  require_relative '../utils/file_operations'
5
5
 
6
6
  module Jekyll
7
7
  module L10n
8
- # Parses GNU Gettext PO files into translation hashes.
8
+ # Reads GNU Gettext PO files into translation hashes.
9
9
  #
10
- # PoFileReader reads and parses PO files in standard GNU Gettext format, supporting
11
- # multiple parsing modes: simple (msgid -> msgstr), with references (for debugging),
12
- # and with merge metadata (including fuzzy flags). It handles multi-line strings,
13
- # comment extraction, and various escape sequences. Both instance-based and
14
- # class-based APIs are supported for backward compatibility.
10
+ # PoFileReader picks the file or inline text to read and the shape of the
11
+ # result; PoEntryParser does the parsing. Each parse method has a class
12
+ # version that takes a file path.
15
13
  #
16
- # Key responsibilities:
17
- # * Parse PO files into translation hashes
18
- # * Handle msgid/msgstr pairs with continuation lines
19
- # * Extract and preserve reference comments (file location references)
20
- # * Extract and preserve fuzzy flags during merging
21
- # * Parse multi-line strings with proper escaping
22
- # * Support three modes: simple, with_references, and for_merge
23
- # * Handle both file paths and inline content strings
14
+ # Result shapes:
15
+ # * parse — { msgid => msgstr }
16
+ # * parse_with_references — { msgid => { msgstr:, reference: } }
17
+ # * parse_for_merge — { msgid => { msgstr:, reference:, fuzzy:, previous_msgid: } }
18
+ # * parse_for_translation — { msgid => msgstr }, without fuzzy entries
24
19
  #
25
20
  # @example
26
21
  # reader = PoFileReader.new('_locales/es.po')
27
- # simple = reader.parse # { "msgid" => "msgstr" }
28
- # with_refs = reader.parse_with_references # { "msgid" => { msgstr: "...", reference:
29
- # "..." } }
30
- # for_merge = reader.parse_for_merge # { "msgid" => { msgstr: "...", reference:
31
- # "...", fuzzy: false } }
32
- # rubocop:disable Metrics/ClassLength
22
+ # reader.parse # => { "Hello" => "Hola" }
23
+ # reader.parse_for_merge # => { "Hello" => { msgstr: "Hola", fuzzy: false, ... } }
33
24
  class PoFileReader
34
- # rubocop:enable Metrics/ClassLength
35
- MSGID_PATTERN = /^msgid ['"](.*)['"] *$/.freeze unless const_defined?(:MSGID_PATTERN)
36
- MSGSTR_PATTERN = /^msgstr ['"](.*)['"] *$/.freeze unless const_defined?(:MSGSTR_PATTERN)
37
- NO_REFERENCE = nil unless const_defined?(:NO_REFERENCE)
38
-
39
25
  attr_reader :po_path
40
26
 
41
- # Initialize a new PoFileReader.
42
- #
43
- # Accepts either a file path (if file exists) or inline PO content. Determines
44
- # which based on whether the path exists in the filesystem. Defaults to nil,
45
- # which initializes the reader with empty content.
46
- #
47
- # @param po_path_or_content [String, nil] File path to PO file or inline content
48
- # string (defaults to nil)
27
+ # @param po_path_or_content [String, nil] Path to a PO file, or PO text when
28
+ # no file exists at that path
49
29
  def initialize(po_path_or_content = nil)
50
- # Support both file path and content string
51
30
  if po_path_or_content && File.exist?(po_path_or_content.to_s)
52
31
  @po_path = po_path_or_content
53
32
  @content = nil
@@ -57,508 +36,61 @@ module Jekyll
57
36
  end
58
37
  end
59
38
 
60
- # Parse PO file into simple translation hash.
61
- #
62
- # Returns hash mapping msgid strings to msgstr strings (no metadata).
63
- #
64
- # @return [Hash] Simple translation hash { msgid => msgstr }
65
- def parse
66
- content = load_content
67
- process_po_lines_instance(content, false)
68
- end
69
-
70
- # Parse PO file with reference comments preserved.
71
- #
72
- # Returns hash mapping msgid strings to metadata hashes containing msgstr
73
- # and reference (file location for debugging).
74
- #
75
- # @return [Hash] Translation hash with references { msgid => { msgstr: "...",
76
- # reference: "..." } }
77
- def parse_with_references
78
- content = load_content
79
- process_po_lines_instance(content, true)
80
- end
81
-
82
- # Parse PO file with all metadata for merging.
83
- #
84
- # Returns hash mapping msgid strings to metadata hashes containing msgstr,
85
- # reference, and fuzzy flag. Used when merging with existing translations.
86
- #
87
- # @return [Hash] Translation hash with merge metadata { msgid => { msgstr: "...",
88
- # reference: "...", fuzzy: false } }
89
- def parse_for_merge
90
- content = load_content
91
- process_po_lines_instance(content, :merge)
92
- end
93
-
94
- # Parse a PO file for translation injection, excluding fuzzy entries.
95
- #
96
- # Parses with full merge metadata (including fuzzy flags), then strips fuzzy
97
- # entries and returns a simple msgid → msgstr hash. Fuzzy entries are treated
98
- # as untranslated per the GNU Gettext standard: msgfmt skips them by default
99
- # and they should fall through to the active fallback mode.
100
- #
101
- # @return [Hash] Simple translation hash { msgid => msgstr }, fuzzy entries omitted
102
- def parse_for_translation
103
- parse_for_merge
104
- .reject { |_msgid, v| v.is_a?(Hash) && v[:fuzzy] }
105
- .transform_values { |v| v.is_a?(Hash) ? v[:msgstr] : v }
106
- end
107
-
108
- # Parse a PO file (class method, for backward compatibility).
109
- #
110
- # @param po_path [String] Path to PO file
111
- # @return [Hash] Simple translation hash
39
+ # @param po_path [String] Path to a PO file
40
+ # @return [Hash] { msgid => msgstr }
112
41
  def self.parse(po_path)
113
42
  new(po_path).parse
114
43
  end
115
44
 
116
- # Parse a PO file with references (class method, for backward compatibility).
117
- #
118
- # @param po_path [String] Path to PO file
119
- # @return [Hash] Translation hash with references
45
+ # @param po_path [String] Path to a PO file
46
+ # @return [Hash] { msgid => { msgstr:, reference: } }
120
47
  def self.parse_with_references(po_path)
121
48
  new(po_path).parse_with_references
122
49
  end
123
50
 
124
- # Parse a PO file for merging (class method, for backward compatibility).
125
- #
126
- # @param po_path [String] Path to PO file
127
- # @return [Hash] Translation hash with merge metadata
51
+ # @param po_path [String] Path to a PO file
52
+ # @return [Hash] { msgid => { msgstr:, reference:, fuzzy:, previous_msgid: } }
128
53
  def self.parse_for_merge(po_path)
129
54
  new(po_path).parse_for_merge
130
55
  end
131
56
 
132
- # Parse a PO file for translation injection, excluding fuzzy entries (class method).
133
- #
134
- # @param po_path [String] Path to PO file
135
- # @return [Hash] Simple translation hash { msgid => msgstr }, fuzzy entries omitted
57
+ # @param po_path [String] Path to a PO file
58
+ # @return [Hash] { msgid => msgstr }, without fuzzy entries
136
59
  def self.parse_for_translation(po_path)
137
60
  new(po_path).parse_for_translation
138
61
  end
139
62
 
140
- # Backward compatibility wrapper
141
- def self.process_po_lines(content)
142
- process_po_lines_internal(content, false)
143
- end
144
-
145
- # Backward compatibility wrapper
146
- def self.process_line(lines, idx, translations)
147
- process_line_internal(lines, idx, translations, false)
148
- end
149
-
150
- def self.msgid_line?(line)
151
- line.start_with?('msgid ')
152
- end
153
-
154
- # Splits PO file content into individual lines
155
- def self.split_lines(content)
156
- content.split("\n")
157
- end
158
-
159
- # Skips blank lines and comments before processing entries
160
- def self.skip_blank_and_comments(lines, idx)
161
- return idx if idx >= lines.length
162
-
163
- line = lines[idx].strip
164
- if line.empty? || line.start_with?('#')
165
- idx + 1
166
- else
167
- idx
168
- end
169
- end
170
-
171
- # Unified method for processing msgid/msgstr pairs
172
- # with optional reference and fuzzy metadata
173
- # with_mode: false (default, simple format), true (with reference), :merge (with both)
174
- # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize, Metrics/PerceivedComplexity
175
- def self.process_msgid_msgstr_pair(lines, start_idx, translations,
176
- reference: nil, fuzzy: nil, previous_msgid: nil,
177
- with_mode: false)
178
- # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize, Metrics/PerceivedComplexity
179
- # Handle nil sentinel values (from NO_REFERENCE constant)
180
- reference = nil if reference == NO_REFERENCE
181
- fuzzy = nil if fuzzy == NO_REFERENCE
182
-
183
- msgid = extract_msgid_and_continuation(lines, start_idx)
184
- msgid_value = msgid[:value]
185
- i = msgid[:next_line]
186
-
187
- if i < lines.length && lines[i].strip.start_with?('msgstr ')
188
- msgstr = extract_msgstr_and_continuation(lines, i)
189
- msgstr_value = msgstr[:value]
190
- i = msgstr[:next_line]
191
-
192
- # Always use metadata format if in reference or merge mode
193
- with_metadata = with_mode == true || with_mode == :merge || !reference.nil? || !fuzzy.nil?
194
- store_translation(
195
- translations, msgid_value, msgstr_value,
196
- reference: reference, fuzzy: fuzzy, previous_msgid: previous_msgid,
197
- with_metadata: with_metadata
198
- )
199
- else
200
- i += 1
201
- end
202
-
203
- i
204
- end
205
-
206
- # Backward compatibility alias
207
- def self.process_msgid_msgstr_pair_with_metadata(lines, start_idx, translations,
208
- reference: nil, fuzzy: nil)
209
- process_msgid_msgstr_pair(lines, start_idx, translations, reference: reference,
210
- fuzzy: fuzzy, with_mode: :merge)
211
- end
212
-
213
- # Backward compatibility alias
214
- def self.process_msgid_msgstr_pair_with_reference(lines, start_idx, translations, reference)
215
- process_msgid_msgstr_pair(
216
- lines, start_idx, translations,
217
- reference: reference, fuzzy: nil, with_mode: true
218
- )
219
- end
220
-
221
- # Backward compatibility alias
222
- def self.process_msgid_msgstr_pair_internal(lines, start_idx, translations,
223
- reference = nil, fuzzy = nil)
224
- process_msgid_msgstr_pair(lines, start_idx, translations, reference: reference,
225
- fuzzy: fuzzy)
226
- end
227
-
228
- def self.process_po_lines_with_references(content)
229
- process_po_lines_internal(content, true)
230
- end
231
-
232
- def self.process_po_lines_for_merge(content)
233
- process_po_lines_internal(content, :merge)
234
- end
235
-
236
- def self.process_po_lines_internal(content, with_references)
237
- translations = {}
238
- lines = split_lines(content)
239
- i = 0
240
-
241
- while i < lines.length
242
- i = case with_references
243
- when true
244
- process_line_internal(lines, i, translations, true)
245
- when :merge
246
- process_line_internal(lines, i, translations, :merge)
247
- else
248
- process_line_internal(lines, i, translations, false)
249
- end
250
- end
251
-
252
- translations
253
- end
254
-
255
- # Backward compatibility wrappers
256
- def self.process_line_with_reference(lines, idx, translations)
257
- process_line_internal(lines, idx, translations, true)
258
- end
259
-
260
- def self.process_line_for_merge(lines, idx, translations)
261
- process_line_internal(lines, idx, translations, :merge)
262
- end
263
-
264
- def self.process_line_internal(lines, idx, translations, with_references)
265
- return idx + 1 if idx >= lines.length
266
-
267
- idx = skip_blank_and_comments(lines, idx)
268
- return idx if idx >= lines.length
269
-
270
- line = lines[idx].strip
271
-
272
- if msgid_line?(line)
273
- process_msgid_with_references(lines, idx, translations, with_references)
274
- else
275
- idx + 1
276
- end
277
- end
278
-
279
- def self.process_msgid_with_references(lines, idx, translations, with_references)
280
- case with_references
281
- when true
282
- reference = extract_reference_before_msgid(lines, idx)
283
- process_msgid_msgstr_pair(
284
- lines, idx, translations,
285
- reference: reference, fuzzy: nil, with_mode: true
286
- )
287
- when :merge
288
- reference, fuzzy, previous_msgid = extract_reference_and_fuzzy_before_msgid(lines, idx)
289
- process_msgid_msgstr_pair(
290
- lines, idx, translations,
291
- reference: reference, fuzzy: fuzzy, previous_msgid: previous_msgid, with_mode: :merge
292
- )
293
- else
294
- process_msgid_msgstr_pair(lines, idx, translations, reference: nil, fuzzy: nil,
295
- with_mode: false)
296
- end
297
- end
298
-
299
- # Unified metadata extraction: extracts reference, fuzzy flag, and previous msgid.
300
- # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
301
- def self.extract_metadata_before_msgid(lines, msgid_idx, include_fuzzy: false)
302
- reference = nil
303
- fuzzy = false
304
- previous_msgid = nil
305
- comments_end = msgid_idx - 1
306
-
307
- while comments_end >= 0
308
- comment_line = lines[comments_end].strip
309
- break unless comment_line.start_with?('#') || comment_line.empty?
310
-
311
- reference = extract_reference_from_line(comment_line) || reference
312
- previous_msgid = extract_previous_msgid_from_line(comment_line) || previous_msgid if include_fuzzy
313
- fuzzy = true if include_fuzzy && fuzzy_line?(comment_line)
314
-
315
- comments_end -= 1
316
- end
317
-
318
- include_fuzzy ? [reference, fuzzy, previous_msgid] : reference
319
- end
320
-
321
- def self.extract_reference_from_line(comment_line)
322
- comment_line.sub(/^#:\s*/, '').strip if comment_line.start_with?('#:')
323
- end
324
-
325
- # Parse a #| msgid "..." previous-msgid comment line.
326
- def self.extract_previous_msgid_from_line(comment_line)
327
- return nil unless comment_line.start_with?('#|') && comment_line.include?('msgid')
328
-
329
- raw = comment_line.sub(/^#\|\s*msgid\s*/, '').strip
330
- # Strip surrounding quotes and unescape
331
- if raw.start_with?('"') && raw.end_with?('"')
332
- unescape_string(raw[1...-1], '"')
333
- elsif raw.start_with?("'") && raw.end_with?("'")
334
- unescape_string(raw[1...-1], "'")
335
- else
336
- raw
337
- end
338
- end
339
-
340
- def self.fuzzy_line?(comment_line)
341
- comment_line.start_with?('#,') && comment_line.include?('fuzzy')
342
- end
343
-
344
- # Backward compatibility wrapper
345
- def self.extract_reference_before_msgid(lines, msgid_idx)
346
- extract_metadata_before_msgid(lines, msgid_idx, include_fuzzy: false)
347
- end
348
-
349
- # Backward compatibility wrapper — now returns [reference, fuzzy, previous_msgid]
350
- def self.extract_reference_and_fuzzy_before_msgid(lines, msgid_idx)
351
- extract_metadata_before_msgid(lines, msgid_idx, include_fuzzy: true)
352
- end
353
-
354
- def self.extract_msgid_and_continuation(lines, start_idx)
355
- extract_po_field(lines, start_idx, MSGID_PATTERN)
356
- end
357
-
358
- def self.extract_msgstr_and_continuation(lines, start_idx)
359
- extract_po_field(lines, start_idx, MSGSTR_PATTERN)
360
- end
361
-
362
- def self.extract_po_field(lines, start_idx, pattern)
363
- line = lines[start_idx].strip
364
-
365
- match = line.match(pattern)
366
- if match
367
- delimiter = line[match.begin(1) - 1]
368
- values = [unescape_string(match[1], delimiter)]
369
- else
370
- values = []
371
- delimiter = '"'
372
- end
373
-
374
- collect_continuation_lines(lines, start_idx + 1, values, delimiter)
375
- end
376
-
377
- def self.collect_continuation_lines(lines, start_idx, values, delimiter)
378
- idx = start_idx
379
-
380
- while idx < lines.length
381
- cont_line = lines[idx].strip
382
-
383
- break if stop_collecting?(cont_line)
384
-
385
- break unless continuation_line?(cont_line)
386
-
387
- unescaped = unescape_string(cont_line[1...-1], delimiter)
388
- values << unescaped
389
- idx += 1
390
-
391
- end
392
-
393
- combined_value = values.join
394
-
395
- { value: combined_value, next_line: idx }
396
- end
397
-
398
- def self.continuation_line?(line)
399
- (line.start_with?('"') && line.end_with?('"')) ||
400
- (line.start_with?("'") && line.end_with?("'"))
63
+ # @return [Hash] { msgid => msgstr }
64
+ def parse
65
+ PoEntryParser.parse(load_content, :simple)
401
66
  end
402
67
 
403
- # Unescape PO file string values containing escape sequences.
404
- #
405
- # === IMPORTANT: Order Matters for Correctness ===
406
- # Must unescape escaped quotes BEFORE unescaping backslashes.
407
- # Why: If we unescape backslashes first, we lose information about which
408
- # backslashes were part of escape sequences vs. literal text.
409
- #
410
- # Example with wrong order (backslash first):
411
- # Input: "Say \\" Hello" (should be: Say \ Hello with closing quote)
412
- # Wrong: "\\\\" -> "\\" -> Remove quotes -> "Say \" Hello" (quote not closed!)
68
+ # The reference is the `#:` comment, kept to trace a string to its page.
413
69
  #
414
- # Correct order (quote first):
415
- # Input: "Say \\" Hello"
416
- # Right: "\\" -> " " (two backslashes become one literal backslash) -> "Say \ Hello"
417
- #
418
- # Single vs Double quotes matter:
419
- # - Single quotes: Use \\' and \\\\
420
- # - Double quotes: Use \\" and \\\\
421
- def self.unescape_string(str, delimiter)
422
- if delimiter == "'"
423
- str.gsub("\\'", "'").gsub('\\\\', '\\')
424
- else
425
- str.gsub('\\"', '"').gsub('\\\\', '\\')
426
- end
427
- end
428
-
429
- def self.read_po_file(po_path)
430
- FileOperations.read_utf8(po_path)
70
+ # @return [Hash] { msgid => { msgstr:, reference: } }, reference only when present
71
+ def parse_with_references
72
+ PoEntryParser.parse(load_content, :references)
431
73
  end
432
74
 
433
- # Store translation with optional metadata (reference location and fuzzy flag).
434
- #
435
- # === Three Storage Formats ===
436
- # 1. Simple: { msgid => msgstr }
437
- # Used during translation lookup - minimal memory, fastest access
75
+ # Keeps the fuzzy flag and previous msgid, so merging does not lose them.
438
76
  #
439
- # 2. With Reference: { msgid => { msgstr: "...", reference: "file.html:10" } }
440
- # Used during extraction - preserves source location for debugging
441
- #
442
- # 3. With Merge Metadata: { msgid => { msgstr: "...", reference: "...", fuzzy: false } }
443
- # Used during merging - tracks fuzzy flag for incomplete translations
444
- #
445
- # Why three formats instead of one?
446
- # - Memory efficiency: Simple format used most often (translation lookup)
447
- # - Flexibility: Can handle different parsing modes without wasting storage
448
- # - Backward compatibility: Supports legacy calling conventions
449
- # rubocop:disable Metrics/ParameterLists
450
- def self.store_translation(translations, msgid, msgstr, reference: nil, fuzzy: nil,
451
- previous_msgid: nil, with_metadata: false)
452
- # rubocop:enable Metrics/ParameterLists
453
- return if msgid.nil? || msgstr.nil? || msgid.empty?
454
-
455
- translations[msgid] = build_translation_entry(msgstr, reference, fuzzy,
456
- previous_msgid, with_metadata: with_metadata)
457
- end
458
-
459
- # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
460
- def self.build_translation_entry(msgstr, reference, fuzzy, previous_msgid = nil,
461
- with_metadata: false)
462
- # Simple format when no metadata requested and none provided
463
- return msgstr if !with_metadata && reference.nil? && fuzzy.nil? && previous_msgid.nil?
464
-
465
- # Build metadata hash based on what's provided
466
- entry = { msgstr: msgstr }
467
- entry[:reference] = reference unless reference.nil?
468
- entry[:fuzzy] = fuzzy unless fuzzy.nil?
469
- entry[:previous_msgid] = previous_msgid unless previous_msgid.nil?
470
- entry[:comment] = nil if !fuzzy.nil? || !reference.nil?
471
- entry
472
- end
473
-
474
- # Kept for backward compatibility with existing tests
475
- # Supports both positional and keyword argument calling styles
476
- def self.store_translation_internal(translations, msgid, msgstr, reference: nil, fuzzy: nil)
477
- # Handle nil sentinel values (from NO_REFERENCE constant)
478
- reference = nil if reference == NO_REFERENCE
479
- fuzzy = nil if fuzzy == NO_REFERENCE
480
- store_translation(translations, msgid, msgstr, reference: reference, fuzzy: fuzzy)
481
- end
482
-
483
- # Backward compatibility wrappers for old method signatures
484
- def self.store_translation_with_reference(translations, msgid, msgstr, reference:)
485
- store_translation(translations, msgid, msgstr, reference: reference, fuzzy: nil,
486
- with_metadata: true)
487
- end
488
-
489
- def self.store_translation_with_fuzzy(translations, msgid, msgstr, fuzzy:)
490
- store_translation(translations, msgid, msgstr, reference: nil, fuzzy: fuzzy,
491
- with_metadata: true)
492
- end
493
-
494
- def self.store_translation_with_reference_and_fuzzy(translations, msgid, msgstr,
495
- reference:, fuzzy:)
496
- store_translation(translations, msgid, msgstr, reference: reference, fuzzy: fuzzy,
497
- with_metadata: true)
77
+ # @return [Hash] { msgid => { msgstr:, reference:, fuzzy:, previous_msgid: } }
78
+ def parse_for_merge
79
+ PoEntryParser.parse(load_content, :merge)
498
80
  end
499
81
 
500
- # Determines if we should stop collecting continuation lines
501
- def self.stop_collecting?(line)
502
- line.empty? ||
503
- line.start_with?('#') ||
504
- line.start_with?('msgid') ||
505
- line.start_with?('msgstr')
82
+ # Fuzzy entries are left out: GNU Gettext treats them as untranslated
83
+ # (msgfmt skips them), so they fall through to the fallback mode.
84
+ #
85
+ # @return [Hash] { msgid => msgstr }
86
+ def parse_for_translation
87
+ parse_for_merge.reject { |_msgid, entry| entry[:fuzzy] }.transform_values { |entry| entry[:msgstr] }
506
88
  end
507
89
 
508
90
  private
509
91
 
510
92
  def load_content
511
- @content || self.class.read_po_file(@po_path)
512
- end
513
-
514
- def process_po_lines_instance(content, with_references)
515
- translations = {}
516
- lines = self.class.split_lines(content)
517
- i = 0
518
-
519
- while i < lines.length
520
- i = case with_references
521
- when true
522
- process_line_instance(lines, i, translations, true)
523
- when :merge
524
- process_line_instance(lines, i, translations, :merge)
525
- else
526
- process_line_instance(lines, i, translations, false)
527
- end
528
- end
529
-
530
- translations
531
- end
532
-
533
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
534
- def process_line_instance(lines, idx, translations, with_references)
535
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
536
- return idx + 1 if idx >= lines.length
537
-
538
- idx = self.class.skip_blank_and_comments(lines, idx)
539
- return idx if idx >= lines.length
540
-
541
- line = lines[idx].strip
542
-
543
- if self.class.msgid_line?(line)
544
- case with_references
545
- when true
546
- reference = self.class.extract_reference_before_msgid(lines, idx)
547
- self.class.process_msgid_msgstr_pair(lines, idx, translations, reference: reference,
548
- fuzzy: nil, with_mode: true)
549
- when :merge
550
- reference, fuzzy, previous_msgid = self.class.extract_reference_and_fuzzy_before_msgid(lines, idx)
551
- self.class.process_msgid_msgstr_pair(lines, idx, translations, reference: reference,
552
- fuzzy: fuzzy,
553
- previous_msgid: previous_msgid,
554
- with_mode: :merge)
555
- else
556
- self.class.process_msgid_msgstr_pair(lines, idx, translations, reference: nil,
557
- fuzzy: nil, with_mode: false)
558
- end
559
- else
560
- idx + 1
561
- end
93
+ @content || FileOperations.read_utf8(@po_path)
562
94
  end
563
95
  end
564
96
  end