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.
- 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 +28 -58
- 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 +72 -65
- 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 +47 -61
- data/lib/jekyll-l10n/jekyll/localized_page.rb +27 -49
- data/lib/jekyll-l10n/jekyll/post_write_html_reprocessor.rb +53 -48
- data/lib/jekyll-l10n/jekyll/post_write_processor.rb +2 -1
- data/lib/jekyll-l10n/jekyll/regeneration_checker.rb +70 -50
- data/lib/jekyll-l10n/jekyll/url_filter.rb +13 -13
- 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/page_translation_loader.rb +13 -29
- 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 +49 -78
- 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 +165 -223
- data/lib/jekyll-l10n/utils/po_entry_converter.rb +0 -43
- data/lib/jekyll-l10n/utils/site_config_accessor.rb +82 -42
- 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 +67 -176
- metadata +12 -3
|
@@ -1,83 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
require_relative '../configuration'
|
|
5
|
+
require_relative '../constants'
|
|
3
6
|
require_relative 'config_loader'
|
|
4
7
|
require_relative 'result_saver'
|
|
5
8
|
require_relative 'logger'
|
|
6
9
|
require_relative 'html_string_extractor'
|
|
10
|
+
require_relative 'coverage_report'
|
|
7
11
|
require_relative '../utils/file_operations'
|
|
8
12
|
require_relative '../utils/site_config_accessor'
|
|
9
13
|
require_relative '../utils/error_handler'
|
|
10
14
|
|
|
11
15
|
module Jekyll
|
|
12
16
|
module L10n
|
|
13
|
-
#
|
|
17
|
+
# Collects translatable strings from the built site into PO files.
|
|
14
18
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# the
|
|
19
|
+
# PostWriteProcessor runs it after Jekyll writes the site. Each build it:
|
|
20
|
+
# 1. Reads every English HTML file in the destination. It skips locale
|
|
21
|
+
# folders such as `es/`.
|
|
22
|
+
# 2. Writes one PO file per page with the page's text and translatable
|
|
23
|
+
# attributes.
|
|
24
|
+
# 3. Merges those files into one compendium per locale (unless
|
|
25
|
+
# `update_compendium` is false). The merge deletes the page files.
|
|
26
|
+
# 4. Machine-translates the compendia when LibreTranslate is on.
|
|
27
|
+
# 5. Prints a summary and each locale's translation coverage unless
|
|
28
|
+
# `logging.show_statistics` is false.
|
|
29
|
+
# 6. Fails the build when a locale's coverage is below
|
|
30
|
+
# `translation.min_coverage`.
|
|
19
31
|
#
|
|
20
|
-
#
|
|
21
|
-
# 1. Scans all HTML files in Jekyll output directory (_site/)
|
|
22
|
-
# 2. For each HTML file, extracts translatable text and attributes
|
|
23
|
-
# 3. Normalizes text for consistent matching across builds
|
|
24
|
-
# 4. Creates or updates page-specific PO files in _locales/ directory
|
|
25
|
-
# 5. Optionally applies automatic translations via LibreTranslate API
|
|
32
|
+
# It always processes the whole site. Incremental mode does not affect it.
|
|
26
33
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# - Extract text and attributes from HTML with file location references
|
|
30
|
-
# - Create and update PO files with extracted strings
|
|
31
|
-
# - Log extraction statistics and progress
|
|
32
|
-
# - Coordinate with LibreTranslate for automatic translation
|
|
33
|
-
#
|
|
34
|
-
# @example Usage (typically invoked by PostWriteProcessor)
|
|
35
|
-
# extractor = Extractor.new(site)
|
|
36
|
-
# result = extractor.extract_site
|
|
34
|
+
# @example
|
|
35
|
+
# Extractor.new(site).extract_site
|
|
37
36
|
# # => { files_processed: 42, strings_extracted: 237, po_files_created: 3 }
|
|
38
37
|
#
|
|
39
|
-
# @see
|
|
40
|
-
# @see
|
|
38
|
+
# @see ExtractionResultSaver writes and merges the PO files
|
|
39
|
+
# @see HtmlStringExtractor finds strings in one HTML file
|
|
41
40
|
#
|
|
42
41
|
class Extractor
|
|
43
42
|
# @!attribute [r] site
|
|
44
|
-
# The Jekyll site object with generated pages
|
|
45
43
|
# @return [Jekyll::Site]
|
|
46
44
|
attr_reader :site
|
|
47
45
|
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# Sets up configuration and result saving infrastructure for extraction.
|
|
51
|
-
#
|
|
52
|
-
# @param site [Jekyll::Site] The Jekyll site object
|
|
46
|
+
# @param site [Jekyll::Site] A site that Jekyll has already written
|
|
53
47
|
def initialize(site)
|
|
54
48
|
@site = site
|
|
55
49
|
@source = SiteConfigAccessor.source(@site)
|
|
56
50
|
@dest = SiteConfigAccessor.dest(@site)
|
|
57
51
|
@config_loader = ExtractionConfigLoader.new(@site, @dest)
|
|
58
52
|
@result_saver = ExtractionResultSaver.new(@site)
|
|
53
|
+
@extracted_msgids = Set.new
|
|
59
54
|
end
|
|
60
55
|
|
|
61
|
-
#
|
|
56
|
+
# Runs all the steps in the class description.
|
|
62
57
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
# @return [Hash<Symbol, Integer>] Statistics hash with keys:
|
|
68
|
-
# - :files_processed - Number of HTML files processed
|
|
69
|
-
# - :strings_extracted - Total strings extracted
|
|
70
|
-
# - :po_files_created - Number of PO files created/updated
|
|
71
|
-
# @example
|
|
72
|
-
# result = extractor.extract_site
|
|
73
|
-
# puts "Processed #{result[:files_processed]} files"
|
|
58
|
+
# @return [Hash<Symbol, Integer>] Counts under :files_processed,
|
|
59
|
+
# :strings_extracted, and :po_files_created (files created or updated)
|
|
60
|
+
# @raise [Errors::CoverageError] When a locale is below `translation.min_coverage`
|
|
74
61
|
def extract_site
|
|
75
|
-
Jekyll.logger.info
|
|
62
|
+
Jekyll.logger.info Constants::LOG_PREFIX, 'Extracting translatable strings...'
|
|
76
63
|
start_time = Time.now
|
|
77
64
|
stats = process_all_html_files
|
|
78
65
|
@result_saver.finalize_compendia
|
|
79
66
|
translate_all_compendia
|
|
80
|
-
ExtractionLogger.log_summary(stats, Time.now - start_time)
|
|
67
|
+
ExtractionLogger.log_summary(stats, Time.now - start_time) if show_statistics?
|
|
68
|
+
check_coverage
|
|
81
69
|
stats
|
|
82
70
|
end
|
|
83
71
|
|
|
@@ -106,20 +94,6 @@ module Jekyll
|
|
|
106
94
|
end
|
|
107
95
|
end
|
|
108
96
|
|
|
109
|
-
def process_file(file_path)
|
|
110
|
-
return default_stats unless @config_loader.valid_for_extraction?(file_path)
|
|
111
|
-
|
|
112
|
-
config = @config_loader.load_page_config(file_path)
|
|
113
|
-
entries = extract_strings_from_file(file_path, config)
|
|
114
|
-
return default_stats if entries.empty?
|
|
115
|
-
|
|
116
|
-
page_path = construct_page_path(file_path)
|
|
117
|
-
@result_saver.save_results(config, entries, page_path)
|
|
118
|
-
rescue StandardError => e
|
|
119
|
-
ExtractionLogger.log_error(file_path, e)
|
|
120
|
-
default_stats
|
|
121
|
-
end
|
|
122
|
-
|
|
123
97
|
def default_stats
|
|
124
98
|
{ files_processed: 0, strings_extracted: 0, po_files_created: 0 }
|
|
125
99
|
end
|
|
@@ -130,24 +104,57 @@ module Jekyll
|
|
|
130
104
|
html = FileOperations.read_utf8(file_path)
|
|
131
105
|
exclude_selectors = @config_loader.extract_exclude_selectors(config)
|
|
132
106
|
|
|
133
|
-
extractor = HtmlStringExtractor.new(config.translatable_attributes, exclude_selectors
|
|
107
|
+
extractor = HtmlStringExtractor.new(config.translatable_attributes, exclude_selectors,
|
|
108
|
+
baseurl: @site.config['baseurl'] || '')
|
|
134
109
|
extractor.extract(html, @dest, file_path)
|
|
135
110
|
end
|
|
136
111
|
|
|
112
|
+
# Machine translation is a site-wide step: it reads the `jekyll_l10n`
|
|
113
|
+
# settings and covers every locale the site uses.
|
|
114
|
+
#
|
|
115
|
+
# @return [PageLocalesConfig, nil] The site config, or nil when
|
|
116
|
+
# LibreTranslate is off
|
|
137
117
|
def find_libretranslate_config
|
|
138
118
|
return nil unless @site.respond_to?(:pages)
|
|
139
119
|
|
|
140
|
-
@site
|
|
141
|
-
|
|
120
|
+
config = SiteConfigAccessor.site_config(@site, all_locales: true)
|
|
121
|
+
config if config.libretranslate_enabled?
|
|
122
|
+
end
|
|
142
123
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# per-page body called from the html_files loop in process_all_html_files
|
|
127
|
+
def process_file(file_path)
|
|
128
|
+
return default_stats unless @config_loader.valid_for_extraction?(file_path)
|
|
129
|
+
|
|
130
|
+
config = @config_loader.load_page_config(file_path)
|
|
131
|
+
entries = extract_strings_from_file(file_path, config)
|
|
132
|
+
return default_stats if entries.empty?
|
|
133
|
+
|
|
134
|
+
@extracted_msgids.merge(entries.map { |entry| entry[:msgid] })
|
|
146
135
|
|
|
147
|
-
|
|
136
|
+
page_path = construct_page_path(file_path)
|
|
137
|
+
@result_saver.save_results(config, entries, page_path)
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
ExtractionLogger.log_error(file_path, e)
|
|
140
|
+
default_stats
|
|
148
141
|
end
|
|
149
142
|
|
|
150
|
-
|
|
143
|
+
# Runs after machine translation, so its entries count, and outside
|
|
144
|
+
# ErrorHandler, so a CoverageError fails the build.
|
|
145
|
+
def check_coverage
|
|
146
|
+
config = SiteConfigAccessor.site_config(@site, all_locales: true)
|
|
147
|
+
report = CoverageReport.new(@site, config, @extracted_msgids)
|
|
148
|
+
report.warn_invalid
|
|
149
|
+
report.log if config.show_statistics?
|
|
150
|
+
report.enforce!(config.min_coverage)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The summary covers the whole site, so read the site-wide setting, not a
|
|
154
|
+
# page's.
|
|
155
|
+
def show_statistics?
|
|
156
|
+
SiteConfigAccessor.site_config(@site).show_statistics?
|
|
157
|
+
end
|
|
151
158
|
|
|
152
159
|
def construct_page_path(file_path)
|
|
153
160
|
file_path.sub("#{@dest}/", '')
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../constants'
|
|
3
4
|
require_relative 'dom_text_extractor'
|
|
4
5
|
require_relative 'dom_attribute_extractor'
|
|
5
6
|
require_relative 'logger'
|
|
7
|
+
require_relative '../utils/baseurl_normalizer'
|
|
8
|
+
require_relative '../utils/html_elements'
|
|
9
|
+
require_relative '../utils/html_parser'
|
|
6
10
|
|
|
7
11
|
module Jekyll
|
|
8
12
|
module L10n
|
|
@@ -27,7 +31,7 @@ module Jekyll
|
|
|
27
31
|
# entries = extractor.extract(html_content, '_site', 'docs/index.html')
|
|
28
32
|
# # Returns array of hash entries with :msgid, :msgstr, :reference keys
|
|
29
33
|
class HtmlStringExtractor
|
|
30
|
-
attr_reader :translatable_attrs, :exclude_selectors
|
|
34
|
+
attr_reader :translatable_attrs, :exclude_selectors, :baseurl
|
|
31
35
|
|
|
32
36
|
# Initialize a new HtmlStringExtractor.
|
|
33
37
|
#
|
|
@@ -35,9 +39,12 @@ module Jekyll
|
|
|
35
39
|
# (e.g., ['title', 'alt', 'aria-label', 'placeholder', 'aria-description'])
|
|
36
40
|
# @param exclude_selectors [Array<String>] CSS selectors for elements to skip
|
|
37
41
|
# during extraction (e.g., ['script', 'style', '.no-translate'])
|
|
38
|
-
|
|
42
|
+
# @param baseurl [String] (keyword) Site baseurl, kept out of msgids
|
|
43
|
+
# (see {BaseurlNormalizer})
|
|
44
|
+
def initialize(translatable_attrs, exclude_selectors, baseurl: '')
|
|
39
45
|
@translatable_attrs = translatable_attrs
|
|
40
46
|
@exclude_selectors = exclude_selectors
|
|
47
|
+
@baseurl = baseurl
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# Extract translatable strings from HTML.
|
|
@@ -57,7 +64,8 @@ module Jekyll
|
|
|
57
64
|
def extract(html, dest, file_path)
|
|
58
65
|
entries = []
|
|
59
66
|
|
|
60
|
-
doc =
|
|
67
|
+
doc = HtmlParser.parse_document(html)
|
|
68
|
+
BaseurlNormalizer.strip(doc, @baseurl)
|
|
61
69
|
walk_dom(doc.root, file_path, entries, dest)
|
|
62
70
|
|
|
63
71
|
entries.uniq { |e| e[:msgid] }
|
|
@@ -65,17 +73,23 @@ module Jekyll
|
|
|
65
73
|
|
|
66
74
|
private
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
# Below a content element that has a msgid, inline text is translated only
|
|
77
|
+
# as part of that msgid, so it gets none of its own (covered). A nested
|
|
78
|
+
# content element is looked up by its own msgid, so it is extracted again
|
|
79
|
+
# and decides coverage for its own descendants.
|
|
80
|
+
def walk_dom(node, file_path, entries, dest, covered: false)
|
|
69
81
|
return if node.nil? || should_exclude_element?(node)
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
block = node.element? && HtmlElements::CONTENT_ELEMENTS.include?(node.name)
|
|
84
|
+
entry = process_text_content(node, file_path, entries, dest) if block || !covered
|
|
72
85
|
process_attributes(node, file_path, entries, dest)
|
|
73
|
-
process_children(node, file_path, entries, dest)
|
|
86
|
+
process_children(node, file_path, entries, dest, covered: block ? !entry.nil? : covered)
|
|
74
87
|
end
|
|
75
88
|
|
|
76
89
|
def process_text_content(node, file_path, entries, dest)
|
|
77
90
|
entry = DomTextExtractor.extract(node, file_path, dest)
|
|
78
91
|
entries << entry if entry
|
|
92
|
+
entry
|
|
79
93
|
end
|
|
80
94
|
|
|
81
95
|
def process_attributes(node, file_path, entries, dest)
|
|
@@ -83,8 +97,8 @@ module Jekyll
|
|
|
83
97
|
entries.concat(attr_entries)
|
|
84
98
|
end
|
|
85
99
|
|
|
86
|
-
def process_children(node, file_path, entries, dest)
|
|
87
|
-
node.children.each { |child| walk_dom(child, file_path, entries, dest) }
|
|
100
|
+
def process_children(node, file_path, entries, dest, covered: false)
|
|
101
|
+
node.children.each { |child| walk_dom(child, file_path, entries, dest, covered: covered) }
|
|
88
102
|
end
|
|
89
103
|
|
|
90
104
|
def should_exclude_element?(node)
|
|
@@ -92,10 +106,10 @@ module Jekyll
|
|
|
92
106
|
|
|
93
107
|
@exclude_selectors.any? { |selector| node.matches?(selector) }
|
|
94
108
|
rescue Nokogiri::CSS::SyntaxError => e
|
|
95
|
-
Jekyll.logger.warn
|
|
109
|
+
Jekyll.logger.warn Constants::LOG_PREFIX, "CSS selector syntax error: #{e.message}"
|
|
96
110
|
false
|
|
97
111
|
rescue StandardError => e
|
|
98
|
-
Jekyll.logger.warn
|
|
112
|
+
Jekyll.logger.warn Constants::LOG_PREFIX, "Selector matching error (continuing): #{e.message}"
|
|
99
113
|
false
|
|
100
114
|
end
|
|
101
115
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../constants'
|
|
3
4
|
require_relative '../utils/error_handler'
|
|
4
5
|
|
|
5
6
|
module Jekyll
|
|
@@ -39,11 +40,11 @@ module Jekyll
|
|
|
39
40
|
# @param elapsed [Float] Time elapsed in seconds
|
|
40
41
|
# @return [void]
|
|
41
42
|
def self.log_summary(stats, elapsed)
|
|
42
|
-
Jekyll.logger.info
|
|
43
|
-
Jekyll.logger.info
|
|
44
|
-
Jekyll.logger.info
|
|
45
|
-
Jekyll.logger.info
|
|
46
|
-
Jekyll.logger.info
|
|
43
|
+
Jekyll.logger.info Constants::LOG_PREFIX, 'Extraction complete:'
|
|
44
|
+
Jekyll.logger.info Constants::LOG_PREFIX, " Files processed: #{stats[:files_processed]}"
|
|
45
|
+
Jekyll.logger.info Constants::LOG_PREFIX, " Strings extracted: #{stats[:strings_extracted]}"
|
|
46
|
+
Jekyll.logger.info Constants::LOG_PREFIX, " PO files created/updated: #{stats[:po_files_created]}"
|
|
47
|
+
Jekyll.logger.info Constants::LOG_PREFIX, " Time: #{elapsed.round(2)}s"
|
|
47
48
|
end
|
|
48
49
|
end
|
|
49
50
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../configuration'
|
|
3
4
|
require_relative '../po_file/manager'
|
|
4
5
|
require_relative '../utils/page_locales_config'
|
|
5
6
|
require_relative '../utils/site_config_accessor'
|
|
@@ -33,8 +34,7 @@ module Jekyll
|
|
|
33
34
|
# @param site [Jekyll::Site] Jekyll site object
|
|
34
35
|
def initialize(site)
|
|
35
36
|
@site = site
|
|
36
|
-
|
|
37
|
-
@site_config = PageLocalesConfig.new({ 'with_locales_data' => with_locales_data })
|
|
37
|
+
@site_config = SiteConfigAccessor.site_config(@site)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
# Save extraction results to PO files.
|
|
@@ -105,17 +105,12 @@ module Jekyll
|
|
|
105
105
|
po_files_created
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
# All other site-wide settings (locales_dir, extraction, translation,
|
|
112
|
-
# logging, etc.) are preserved unchanged; only the locale list is widened.
|
|
108
|
+
# The site config with every locale any page uses, not only the
|
|
109
|
+
# site-wide list (see SiteConfigAccessor.all_configured_locales).
|
|
113
110
|
#
|
|
114
111
|
# @return [PageLocalesConfig]
|
|
115
112
|
def merge_config
|
|
116
|
-
|
|
117
|
-
merged_data = (@site_config.data['with_locales_data'] || {}).merge('locales' => all_locales)
|
|
118
|
-
PageLocalesConfig.new({ 'with_locales_data' => merged_data })
|
|
113
|
+
SiteConfigAccessor.site_config(@site, all_locales: true)
|
|
119
114
|
end
|
|
120
115
|
|
|
121
116
|
private :save_po_files
|