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,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
- # String Extraction Orchestrator - Finds translatable strings in generated HTML
17
+ # Collects translatable strings from the built site into PO files.
14
18
  #
15
- # The Extractor is the main entry point for the string extraction workflow. It scans all
16
- # generated HTML files after Jekyll's build, identifies translatable content (text nodes
17
- # and configurable HTML attributes), and creates or updates GNU Gettext PO files with
18
- # the extracted strings.
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
- # The extraction workflow:
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
- # Key responsibilities:
28
- # - Load and validate extraction configuration from pages
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 Jekyll::L10n::ExtractionResultSaver for PO file creation and updates
40
- # @see Jekyll::L10n::HtmlStringExtractor for text and attribute extraction
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
- # Initialize the string extractor
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
- # Extract all translatable strings from the generated site
56
+ # Runs all the steps in the class description.
62
57
  #
63
- # Main entry point for extraction. Scans all HTML files in the build output,
64
- # extracts translatable strings and attributes, creates/updates PO files,
65
- # and optionally translates strings via LibreTranslate API.
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 'Localization', 'Extracting translatable strings...'
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.pages.each do |page|
141
- next unless page.data['with_locales'] == true
120
+ config = SiteConfigAccessor.site_config(@site, all_locales: true)
121
+ config if config.libretranslate_enabled?
122
+ end
142
123
 
143
- config = @config_loader.load_page_config(page.destination(''))
144
- return config if config.libretranslate_enabled?
145
- end
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
- nil
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
- private
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
- def initialize(translatable_attrs, exclude_selectors)
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 = Nokogiri::HTML(html)
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
- def walk_dom(node, file_path, entries, dest)
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
- process_text_content(node, file_path, entries, dest)
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 'Localization', "CSS selector syntax error: #{e.message}"
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 'Localization', "Selector matching error (continuing): #{e.message}"
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 'Localization', 'Extraction complete:'
43
- Jekyll.logger.info 'Localization', " Files processed: #{stats[:files_processed]}"
44
- Jekyll.logger.info 'Localization', " Strings extracted: #{stats[:strings_extracted]}"
45
- Jekyll.logger.info 'Localization', " PO files created/updated: #{stats[:po_files_created]}"
46
- Jekyll.logger.info 'Localization', " Time: #{elapsed.round(2)}s"
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
- with_locales_data = SiteConfigAccessor.extract_locales_data(@site)
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
- # Build a PageLocalesConfig whose locale list is the union of site-wide
109
- # defaults and every page-level front-matter locale override.
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
- all_locales = SiteConfigAccessor.all_configured_locales(@site)
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