jekyll-l10n 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +44 -0
- data/lib/jekyll-l10n/configuration/schema.rb +308 -0
- data/lib/jekyll-l10n/configuration.rb +165 -0
- data/lib/jekyll-l10n/constants.rb +45 -10
- data/lib/jekyll-l10n/errors.rb +7 -0
- data/lib/jekyll-l10n/extraction/compendium_merger.rb +9 -11
- data/lib/jekyll-l10n/extraction/compendium_translator.rb +27 -11
- data/lib/jekyll-l10n/extraction/config_loader.rb +4 -4
- data/lib/jekyll-l10n/extraction/coverage_report.rb +129 -0
- data/lib/jekyll-l10n/extraction/dom_text_extractor.rb +2 -31
- data/lib/jekyll-l10n/extraction/extractor.rb +48 -28
- data/lib/jekyll-l10n/extraction/html_string_extractor.rb +24 -10
- data/lib/jekyll-l10n/extraction/logger.rb +6 -5
- data/lib/jekyll-l10n/extraction/result_saver.rb +5 -10
- data/lib/jekyll-l10n/instrumentation.rb +172 -139
- data/lib/jekyll-l10n/jekyll/generator.rb +10 -22
- data/lib/jekyll-l10n/jekyll/localized_page.rb +27 -49
- data/lib/jekyll-l10n/jekyll/post_write_html_reprocessor.rb +42 -25
- data/lib/jekyll-l10n/jekyll/post_write_processor.rb +2 -1
- data/lib/jekyll-l10n/jekyll/regeneration_checker.rb +16 -10
- data/lib/jekyll-l10n/jekyll/url_filter.rb +8 -7
- data/lib/jekyll-l10n/po_file/entry_parser.rb +155 -0
- data/lib/jekyll-l10n/po_file/fuzzy_matcher.rb +67 -53
- data/lib/jekyll-l10n/po_file/loader.rb +20 -11
- data/lib/jekyll-l10n/po_file/manager.rb +32 -17
- data/lib/jekyll-l10n/po_file/merger.rb +2 -1
- data/lib/jekyll-l10n/po_file/path_builder.rb +11 -1
- data/lib/jekyll-l10n/po_file/reader.rb +40 -508
- data/lib/jekyll-l10n/po_file/writer.rb +31 -16
- data/lib/jekyll-l10n/translation/block_text_extractor.rb +3 -7
- data/lib/jekyll-l10n/translation/html_translator.rb +136 -157
- data/lib/jekyll-l10n/translation/libre_translate_languages.rb +73 -0
- data/lib/jekyll-l10n/translation/libre_translator.rb +134 -78
- data/lib/jekyll-l10n/translation/request_slots.rb +69 -0
- data/lib/jekyll-l10n/translation/translation_progress.rb +77 -0
- data/lib/jekyll-l10n/translation/translator.rb +20 -17
- data/lib/jekyll-l10n/utils/baseurl_normalizer.rb +108 -0
- data/lib/jekyll-l10n/utils/debug_logger.rb +4 -51
- data/lib/jekyll-l10n/utils/error_handler.rb +9 -24
- data/lib/jekyll-l10n/utils/external_link_icon_preserver.rb +5 -62
- data/lib/jekyll-l10n/utils/html_parser.rb +24 -31
- data/lib/jekyll-l10n/utils/html_text_utils.rb +146 -91
- data/lib/jekyll-l10n/utils/logger_formatter.rb +6 -4
- data/lib/jekyll-l10n/utils/msgstr_validator.rb +74 -0
- data/lib/jekyll-l10n/utils/page_locales_config.rb +126 -107
- data/lib/jekyll-l10n/utils/po_entry_converter.rb +0 -43
- data/lib/jekyll-l10n/utils/site_config_accessor.rb +56 -54
- data/lib/jekyll-l10n/utils/translation_resolver.rb +56 -6
- data/lib/jekyll-l10n/utils/url_path_builder.rb +5 -27
- data/lib/jekyll-l10n/utils/url_transformer.rb +5 -11
- data/lib/jekyll-l10n/version.rb +1 -1
- data/lib/jekyll-l10n.rb +9 -6
- metadata +12 -3
|
@@ -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
|
-
|
|
3
|
+
require_relative 'entry_parser'
|
|
4
4
|
require_relative '../utils/file_operations'
|
|
5
5
|
|
|
6
6
|
module Jekyll
|
|
7
7
|
module L10n
|
|
8
|
-
#
|
|
8
|
+
# Reads GNU Gettext PO files into translation hashes.
|
|
9
9
|
#
|
|
10
|
-
# PoFileReader
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
17
|
-
# *
|
|
18
|
-
# *
|
|
19
|
-
# *
|
|
20
|
-
# *
|
|
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
|
-
#
|
|
28
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
141
|
-
def
|
|
142
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
440
|
-
|
|
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
|
-
#
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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 ||
|
|
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
|