jekyll-l10n 1.0.5
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/lib/jekyll-l10n/constants.rb +136 -0
- data/lib/jekyll-l10n/errors.rb +60 -0
- data/lib/jekyll-l10n/extraction/compendium_merger.rb +142 -0
- data/lib/jekyll-l10n/extraction/compendium_translator.rb +138 -0
- data/lib/jekyll-l10n/extraction/config_loader.rb +114 -0
- data/lib/jekyll-l10n/extraction/dom_attribute_extractor.rb +69 -0
- data/lib/jekyll-l10n/extraction/dom_text_extractor.rb +89 -0
- data/lib/jekyll-l10n/extraction/extractor.rb +153 -0
- data/lib/jekyll-l10n/extraction/html_string_extractor.rb +103 -0
- data/lib/jekyll-l10n/extraction/logger.rb +48 -0
- data/lib/jekyll-l10n/extraction/result_saver.rb +95 -0
- data/lib/jekyll-l10n/jekyll/file_sync.rb +110 -0
- data/lib/jekyll-l10n/jekyll/generator.rb +106 -0
- data/lib/jekyll-l10n/jekyll/localized_page.rb +150 -0
- data/lib/jekyll-l10n/jekyll/localized_page_mapper.rb +51 -0
- data/lib/jekyll-l10n/jekyll/page_locator.rb +59 -0
- data/lib/jekyll-l10n/jekyll/page_writer.rb +120 -0
- data/lib/jekyll-l10n/jekyll/post_write_html_reprocessor.rb +118 -0
- data/lib/jekyll-l10n/jekyll/post_write_processor.rb +71 -0
- data/lib/jekyll-l10n/jekyll/regeneration_checker.rb +123 -0
- data/lib/jekyll-l10n/jekyll/url_filter.rb +199 -0
- data/lib/jekyll-l10n/po_file/loader.rb +64 -0
- data/lib/jekyll-l10n/po_file/manager.rb +160 -0
- data/lib/jekyll-l10n/po_file/merger.rb +80 -0
- data/lib/jekyll-l10n/po_file/path_builder.rb +42 -0
- data/lib/jekyll-l10n/po_file/reader.rb +518 -0
- data/lib/jekyll-l10n/po_file/writer.rb +232 -0
- data/lib/jekyll-l10n/translation/block_text_extractor.rb +56 -0
- data/lib/jekyll-l10n/translation/html_translator.rb +229 -0
- data/lib/jekyll-l10n/translation/libre_translator.rb +226 -0
- data/lib/jekyll-l10n/translation/page_translation_loader.rb +99 -0
- data/lib/jekyll-l10n/translation/translator.rb +179 -0
- data/lib/jekyll-l10n/utils/debug_logger.rb +153 -0
- data/lib/jekyll-l10n/utils/error_handler.rb +67 -0
- data/lib/jekyll-l10n/utils/external_link_icon_preserver.rb +122 -0
- data/lib/jekyll-l10n/utils/file_operations.rb +55 -0
- data/lib/jekyll-l10n/utils/html_elements.rb +34 -0
- data/lib/jekyll-l10n/utils/html_parser.rb +52 -0
- data/lib/jekyll-l10n/utils/html_text_utils.rb +131 -0
- data/lib/jekyll-l10n/utils/logger_formatter.rb +114 -0
- data/lib/jekyll-l10n/utils/page_locales_config.rb +344 -0
- data/lib/jekyll-l10n/utils/po_entry_converter.rb +111 -0
- data/lib/jekyll-l10n/utils/site_config_accessor.rb +51 -0
- data/lib/jekyll-l10n/utils/text_normalizer.rb +47 -0
- data/lib/jekyll-l10n/utils/text_validator.rb +35 -0
- data/lib/jekyll-l10n/utils/translation_resolver.rb +115 -0
- data/lib/jekyll-l10n/utils/url_path_builder.rb +65 -0
- data/lib/jekyll-l10n/utils/url_transformer.rb +141 -0
- data/lib/jekyll-l10n/utils/xpath_reference_generator.rb +45 -0
- data/lib/jekyll-l10n/version.rb +10 -0
- data/lib/jekyll-l10n.rb +268 -0
- metadata +200 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../translation/page_translation_loader"
|
|
4
|
+
require_relative "../translation/html_translator"
|
|
5
|
+
require_relative "../utils/page_locales_config"
|
|
6
|
+
require_relative "../utils/file_operations"
|
|
7
|
+
require_relative "../utils/site_config_accessor"
|
|
8
|
+
require_relative "../utils/external_link_icon_preserver"
|
|
9
|
+
|
|
10
|
+
module Jekyll
|
|
11
|
+
module L10n
|
|
12
|
+
# Applies translations to localized HTML pages after Jekyll build.
|
|
13
|
+
#
|
|
14
|
+
# PostWriteHtmlReprocessor finds all localized HTML pages generated by the
|
|
15
|
+
# Generator, loads appropriate translations, applies them using HtmlTranslator,
|
|
16
|
+
# and rewrites the HTML files. It processes pages after Jekyll's standard
|
|
17
|
+
# build completes, during the post-write phase.
|
|
18
|
+
#
|
|
19
|
+
# Key responsibilities:
|
|
20
|
+
# * Find localized HTML pages in the build output
|
|
21
|
+
# * Load page translations (compendium + page-specific)
|
|
22
|
+
# * Create HtmlTranslator with proper configuration
|
|
23
|
+
# * Apply translations to HTML documents
|
|
24
|
+
# * Preserve external link icons from original HTML
|
|
25
|
+
# * Write translated HTML back to files
|
|
26
|
+
# * Handle errors gracefully with logging
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# reprocessor = PostWriteHtmlReprocessor.new(site)
|
|
30
|
+
# reprocessor.reprocess_localized_pages
|
|
31
|
+
# # Reads localized HTML, translates, preserves icons, writes back
|
|
32
|
+
class PostWriteHtmlReprocessor
|
|
33
|
+
attr_reader :site
|
|
34
|
+
|
|
35
|
+
# Initialize a new PostWriteHtmlReprocessor.
|
|
36
|
+
#
|
|
37
|
+
# @param site [Jekyll::Site] Jekyll site object
|
|
38
|
+
def initialize(site)
|
|
39
|
+
@site = site
|
|
40
|
+
@dest = SiteConfigAccessor.dest(@site)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reprocess all localized pages with translations.
|
|
44
|
+
#
|
|
45
|
+
# Finds all localized HTML pages that were written during the Jekyll build,
|
|
46
|
+
# loads their translations, applies translations to text and attributes,
|
|
47
|
+
# preserves external link icon styling from the original HTML, and writes
|
|
48
|
+
# the translated HTML back to disk.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
def reprocess_localized_pages
|
|
52
|
+
localized_files = find_localized_html_files
|
|
53
|
+
return if localized_files.empty?
|
|
54
|
+
|
|
55
|
+
msg = "Applying translations to #{localized_files.length} localized pages..."
|
|
56
|
+
Jekyll.logger.info "Localization", msg
|
|
57
|
+
|
|
58
|
+
localized_files.each do |html_path, locale, original_url|
|
|
59
|
+
translate_html_file(html_path, locale, original_url)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def find_localized_html_files
|
|
66
|
+
results = []
|
|
67
|
+
|
|
68
|
+
@site.pages.each do |page|
|
|
69
|
+
next unless page.data["localized"] == true
|
|
70
|
+
|
|
71
|
+
locale = page.data["locale"]
|
|
72
|
+
original_url = page.data["original_url"]
|
|
73
|
+
html_path = page.destination(@dest)
|
|
74
|
+
|
|
75
|
+
results << [html_path, locale, original_url] if File.exist?(html_path)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
results
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# rubocop:disable Metrics/AbcSize
|
|
82
|
+
def translate_html_file(html_path, locale, original_url)
|
|
83
|
+
# rubocop:enable Metrics/AbcSize
|
|
84
|
+
original_html = FileOperations.read_utf8(html_path)
|
|
85
|
+
original_page = find_original_page(original_url)
|
|
86
|
+
|
|
87
|
+
return unless original_page
|
|
88
|
+
|
|
89
|
+
config = PageLocalesConfig.new(original_page.data)
|
|
90
|
+
translations = PageTranslationLoader.load(@site, locale, original_url, config)
|
|
91
|
+
|
|
92
|
+
return if translations.nil? || translations.empty?
|
|
93
|
+
|
|
94
|
+
translator = HtmlTranslator.new(
|
|
95
|
+
config.fallback_mode,
|
|
96
|
+
config.translatable_attributes,
|
|
97
|
+
:debug_logging => config.debug_logging?
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
baseurl = @site.config["baseurl"] || ""
|
|
101
|
+
translated_html = translator.translate(original_html, translations, locale, baseurl)
|
|
102
|
+
|
|
103
|
+
if translated_html
|
|
104
|
+
# Preserve external link icons from the original HTML after translation
|
|
105
|
+
translated_html = ExternalLinkIconPreserver.preserve(original_html, translated_html)
|
|
106
|
+
FileOperations.write_utf8(html_path, translated_html)
|
|
107
|
+
end
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
error_msg = "Failed to reprocess #{html_path}: #{e.message}"
|
|
110
|
+
Jekyll.logger.warn "Localization", error_msg
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def find_original_page(url)
|
|
114
|
+
@site.pages.find { |p| p.url == url && p.data["localized"] != true }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../extraction/extractor"
|
|
4
|
+
require_relative "post_write_html_reprocessor"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module L10n
|
|
8
|
+
# Post-Write Processor - Extracts strings and applies translations after Jekyll build
|
|
9
|
+
#
|
|
10
|
+
# This class handles the final localization phase of the Jekyll build process. It runs
|
|
11
|
+
# after Jekyll has written all HTML files to disk (via the site:post_write hook).
|
|
12
|
+
#
|
|
13
|
+
# The processor coordinates two main operations:
|
|
14
|
+
# 1. String Extraction: Finds new and modified translatable strings in generated HTML
|
|
15
|
+
# 2. HTML Reprocessing: Applies translations to localized page variants
|
|
16
|
+
#
|
|
17
|
+
# Key responsibilities:
|
|
18
|
+
# - Invoke the Extractor to scan generated HTML for translatable content
|
|
19
|
+
# - Update PO files with new/changed strings
|
|
20
|
+
# - Reprocess localized HTML if PO files were modified
|
|
21
|
+
# - Provide logging of extraction results
|
|
22
|
+
#
|
|
23
|
+
# This is invoked automatically by Jekyll during the post_write phase and should not be
|
|
24
|
+
# called directly in most use cases.
|
|
25
|
+
#
|
|
26
|
+
# This runs in phase 5 of the Jekyll build pipeline (Post-Write Phase). See the "Build
|
|
27
|
+
# Pipeline" section in lib/jekyll-l10n.rb for the complete workflow.
|
|
28
|
+
#
|
|
29
|
+
# @example Manual invocation (advanced)
|
|
30
|
+
# processor = PostWriteProcessor.new(site)
|
|
31
|
+
# processor.process_localizations
|
|
32
|
+
#
|
|
33
|
+
# @see Jekyll::L10n for Build Pipeline documentation
|
|
34
|
+
# @see Jekyll::L10n::Extractor for details on string extraction
|
|
35
|
+
# @see Jekyll::L10n::PostWriteHtmlReprocessor for details on translation application
|
|
36
|
+
#
|
|
37
|
+
class PostWriteProcessor
|
|
38
|
+
# @!attribute [r] site
|
|
39
|
+
# The Jekyll site object with all generated pages
|
|
40
|
+
# @return [Jekyll::Site]
|
|
41
|
+
attr_reader :site
|
|
42
|
+
|
|
43
|
+
# Initialize the post-write processor
|
|
44
|
+
#
|
|
45
|
+
# @param site [Jekyll::Site] The Jekyll site object
|
|
46
|
+
def initialize(site)
|
|
47
|
+
@site = site
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Process all localizations for the site
|
|
51
|
+
#
|
|
52
|
+
# Runs the extraction and reprocessing pipeline:
|
|
53
|
+
# 1. Extracts translatable strings from all HTML files
|
|
54
|
+
# 2. Updates PO files with new entries and modified translations
|
|
55
|
+
# 3. If PO files were created or updated, reprocess localized pages to apply translations
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
# @note This is automatically called by Jekyll's site:post_write hook
|
|
59
|
+
def process_localizations
|
|
60
|
+
extractor = Extractor.new(@site)
|
|
61
|
+
extraction_result = extractor.extract_site
|
|
62
|
+
|
|
63
|
+
# Reprocess localized HTML if PO files were created/updated
|
|
64
|
+
if extraction_result && extraction_result[:po_files_created].positive?
|
|
65
|
+
reprocessor = PostWriteHtmlReprocessor.new(@site)
|
|
66
|
+
reprocessor.reprocess_localized_pages
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../utils/site_config_accessor"
|
|
4
|
+
require_relative "../po_file/path_builder"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module L10n
|
|
8
|
+
# Regeneration Checker - Optimizes incremental builds by skipping unchanged pages
|
|
9
|
+
#
|
|
10
|
+
# This class implements incremental build optimization for localized pages. When Jekyll's
|
|
11
|
+
# incremental build mode is enabled, it checks whether a specific locale variant of a page
|
|
12
|
+
# needs to be regenerated based on source modification times.
|
|
13
|
+
#
|
|
14
|
+
# The checker compares modification times of:
|
|
15
|
+
# - Source page file
|
|
16
|
+
# - Page-specific PO translation file
|
|
17
|
+
# - Compendium (shared translations) PO file
|
|
18
|
+
# - Jekyll configuration (_config.yml)
|
|
19
|
+
#
|
|
20
|
+
# If any of these are newer than the output HTML, the page is regenerated.
|
|
21
|
+
# This significantly speeds up rebuilds when only a few files have changed.
|
|
22
|
+
#
|
|
23
|
+
# @example Usage
|
|
24
|
+
# checker = RegenerationChecker.new(site)
|
|
25
|
+
# if checker.should_regenerate?(page, 'es')
|
|
26
|
+
# # Create localized_page and add to site.pages
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
class RegenerationChecker
|
|
30
|
+
# @!visibility private
|
|
31
|
+
DEFAULT_LOCALES_DIR = "_locales"
|
|
32
|
+
|
|
33
|
+
# Initialize the regeneration checker
|
|
34
|
+
#
|
|
35
|
+
# @param site [Jekyll::Site] The Jekyll site object
|
|
36
|
+
# @param locales_dir [String] Directory containing PO files (defaults to
|
|
37
|
+
# DEFAULT_LOCALES_DIR = "_locales")
|
|
38
|
+
def initialize(site, locales_dir = DEFAULT_LOCALES_DIR)
|
|
39
|
+
@site = site
|
|
40
|
+
@locales_dir = locales_dir
|
|
41
|
+
@source = SiteConfigAccessor.source(@site)
|
|
42
|
+
@destination = @site&.config&.dig("destination") || ""
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Determine if a localized page variant should be regenerated
|
|
46
|
+
#
|
|
47
|
+
# Returns true if:
|
|
48
|
+
# - Incremental builds are disabled (always regenerate)
|
|
49
|
+
# - Output file doesn't exist yet
|
|
50
|
+
# - Source page has been modified since output was generated
|
|
51
|
+
# - PO translation files have been modified since output was generated
|
|
52
|
+
# - Jekyll configuration has been modified since output was generated
|
|
53
|
+
#
|
|
54
|
+
# Returns false if incremental builds are enabled and output is up-to-date.
|
|
55
|
+
#
|
|
56
|
+
# @param page [Jekyll::Page] The source page to check
|
|
57
|
+
# @param locale [String] The locale code for the variant (e.g., 'es', 'fr')
|
|
58
|
+
# @return [Boolean] true if the page should be regenerated, false if output is current
|
|
59
|
+
def should_regenerate?(page, locale)
|
|
60
|
+
return true unless incremental_enabled?
|
|
61
|
+
return true unless dest_path_exists?(page, locale)
|
|
62
|
+
|
|
63
|
+
dest_mtime = File.mtime(dest_path(page, locale))
|
|
64
|
+
|
|
65
|
+
source_modified?(page, dest_mtime) ||
|
|
66
|
+
po_files_modified?(page, locale, dest_mtime) ||
|
|
67
|
+
config_modified?(dest_mtime)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def incremental_enabled?
|
|
73
|
+
return false if @site&.config.nil?
|
|
74
|
+
|
|
75
|
+
localization_config = @site.config.dig("localization_gettext", "with_locales_data",
|
|
76
|
+
"incremental") ||
|
|
77
|
+
@site.config.dig("localization_gettext", "incremental") ||
|
|
78
|
+
false
|
|
79
|
+
localization_config == true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dest_path_exists?(page, locale)
|
|
83
|
+
File.exist?(dest_path(page, locale))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def dest_path(page, locale)
|
|
87
|
+
# Replicate LocalizedPage destination logic without instantiation
|
|
88
|
+
url = "/#{locale}#{page.url.chomp("/")}/"
|
|
89
|
+
"#{@destination}#{url}index.html"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def source_modified?(page, dest_mtime)
|
|
93
|
+
page_mtime = File.mtime(page.path)
|
|
94
|
+
page_mtime > dest_mtime
|
|
95
|
+
rescue StandardError
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def po_files_modified?(page, locale, dest_mtime)
|
|
100
|
+
# Check page-specific PO file
|
|
101
|
+
page_po_path = PoPathBuilder.build(@source, @locales_dir, locale, page.path)
|
|
102
|
+
return true if file_modified?(page_po_path, dest_mtime)
|
|
103
|
+
|
|
104
|
+
# Check compendium PO file
|
|
105
|
+
compendium_po_path = PoPathBuilder.build(@source, @locales_dir, locale, nil)
|
|
106
|
+
file_modified?(compendium_po_path, dest_mtime)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def config_modified?(dest_mtime)
|
|
110
|
+
config_path = File.join(@source, "_config.yml")
|
|
111
|
+
file_modified?(config_path, dest_mtime)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def file_modified?(file_path, dest_mtime)
|
|
115
|
+
return false unless File.exist?(file_path)
|
|
116
|
+
|
|
117
|
+
File.mtime(file_path) > dest_mtime
|
|
118
|
+
rescue StandardError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "liquid"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module L10n
|
|
7
|
+
# URL Filter - Liquid filters for locale-aware URL generation
|
|
8
|
+
#
|
|
9
|
+
# Provides Liquid filters for use in Jekyll templates to generate locale-prefixed URLs.
|
|
10
|
+
# These filters are automatically registered with Liquid during plugin initialization.
|
|
11
|
+
#
|
|
12
|
+
# This module is designed to be included in Liquid::Template's filter registry, making
|
|
13
|
+
# the filters available in all Jekyll templates.
|
|
14
|
+
#
|
|
15
|
+
# Key responsibilities:
|
|
16
|
+
# - Generate locale-prefixed URLs for links
|
|
17
|
+
# - Switch between locale variants of the same page
|
|
18
|
+
# - Validate locales against site configuration
|
|
19
|
+
# - Handle edge cases (external URLs, anchors, protocols)
|
|
20
|
+
#
|
|
21
|
+
# @example Usage in Jekyll Liquid templates
|
|
22
|
+
# <!-- Add locale prefix to URL -->
|
|
23
|
+
# <a href="{{ '/about/' | locale_url: 'es' }}">Acerca de</a>
|
|
24
|
+
#
|
|
25
|
+
# <!-- Switch to different locale variant -->
|
|
26
|
+
# <a href="{{ page.url | switch_locale_url: 'fr' }}">Français</a>
|
|
27
|
+
#
|
|
28
|
+
# @see Jekyll::L10n for filter registration (line 218 in lib/jekyll-l10n.rb)
|
|
29
|
+
#
|
|
30
|
+
module UrlFilter
|
|
31
|
+
# Generate a locale-prefixed URL for the given URL and locale
|
|
32
|
+
#
|
|
33
|
+
# Adds a locale prefix to the given URL, making it suitable for use in locale-specific
|
|
34
|
+
# links. For example, '/about/' becomes '/es/about/' for locale 'es'. The filter handles
|
|
35
|
+
# various edge cases and only applies the prefix when appropriate.
|
|
36
|
+
#
|
|
37
|
+
# The method performs several checks to determine if prefixing is appropriate:
|
|
38
|
+
# - Invalid locale codes (nil, empty, or 'en') are skipped
|
|
39
|
+
# - URLs already localized are not prefixed again
|
|
40
|
+
# - External URLs, anchors, and relative paths are not modified
|
|
41
|
+
#
|
|
42
|
+
# @param url [String] The URL to prefix (e.g., '/about/', '/path/to/page/')
|
|
43
|
+
# @param locale [String, nil] The locale code (e.g., 'es', 'pt_BR'). Defaults to nil
|
|
44
|
+
# (uses current page's locale). If nil, uses page's locale from context.
|
|
45
|
+
# @return [String] The locale-prefixed URL, or original URL if prefixing is not appropriate
|
|
46
|
+
# @example
|
|
47
|
+
# <!-- Explicit locale -->
|
|
48
|
+
# {{ '/about/' | locale_url: 'es' }} # => '/es/about/'
|
|
49
|
+
#
|
|
50
|
+
# <!-- Current page's locale -->
|
|
51
|
+
# {{ '/about/' | locale_url }} # => '/es/about/' (if page locale is 'es')
|
|
52
|
+
def locale_url(url, locale = nil)
|
|
53
|
+
locale ||= current_locale
|
|
54
|
+
return url if should_skip?(url, locale)
|
|
55
|
+
|
|
56
|
+
"/#{locale}#{url}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Generate a URL for the given locale variant of the current page
|
|
60
|
+
#
|
|
61
|
+
# Switches the current URL to a different locale variant. For example, if the current
|
|
62
|
+
# page is '/es/about/', calling this with 'fr' returns '/fr/about/'. This is useful
|
|
63
|
+
# for building language switcher menus.
|
|
64
|
+
#
|
|
65
|
+
# This filter first extracts the base URL (without locale prefix) from the given URL,
|
|
66
|
+
# then applies the target locale using the standard locale_url logic.
|
|
67
|
+
#
|
|
68
|
+
# @param url [String] The current URL (may or may not have a locale prefix)
|
|
69
|
+
# @param target_locale [String] The target locale code (e.g., 'es', 'fr', 'pt_BR')
|
|
70
|
+
# @return [String] The URL for the target locale, or original URL if locale is invalid
|
|
71
|
+
# @example
|
|
72
|
+
# <!-- Current page is /es/about/, switch to French -->
|
|
73
|
+
# {{ page.url | switch_locale_url: 'fr' }} # => '/fr/about/'
|
|
74
|
+
#
|
|
75
|
+
# <!-- Build a language switcher -->
|
|
76
|
+
# <a href="{{ page.url | switch_locale_url: 'es' }}">Español</a>
|
|
77
|
+
# <a href="{{ page.url | switch_locale_url: 'fr' }}">Français</a>
|
|
78
|
+
# <a href="{{ page.url | switch_locale_url: 'pt' }}">Português</a>
|
|
79
|
+
def switch_locale_url(url, target_locale)
|
|
80
|
+
# Validate target locale is configured
|
|
81
|
+
return url unless valid_target_locale?(target_locale)
|
|
82
|
+
|
|
83
|
+
# Get base URL without locale prefix
|
|
84
|
+
base_url_value = base_url(url)
|
|
85
|
+
|
|
86
|
+
# Apply target locale using existing locale_url logic
|
|
87
|
+
locale_url(base_url_value, target_locale)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def current_locale
|
|
93
|
+
page = @context.registers[:page]
|
|
94
|
+
return nil unless page
|
|
95
|
+
|
|
96
|
+
if page.is_a?(Hash)
|
|
97
|
+
page.dig("data", "locale")
|
|
98
|
+
else
|
|
99
|
+
page&.data&.[]("locale")
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
Jekyll.logger.warn "Localization", "Error retrieving current locale: #{e.message}"
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def should_skip?(url, locale)
|
|
107
|
+
url_str = url.to_s
|
|
108
|
+
|
|
109
|
+
skip_checks = [
|
|
110
|
+
invalid_locale?(locale),
|
|
111
|
+
already_localized?(url_str),
|
|
112
|
+
external_url?(url_str),
|
|
113
|
+
protocol_url?(url_str),
|
|
114
|
+
anchor_only?(url_str),
|
|
115
|
+
relative_path?(url_str),
|
|
116
|
+
!root_relative_path?(url_str),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
skip_checks.any?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def invalid_locale?(locale)
|
|
123
|
+
locale.nil? || locale.empty? || locale == "en"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def already_localized?(url_str)
|
|
127
|
+
%r!^/[a-z]{2}(?:[_-][A-Z]{2})?(?=/|\?)!.match?(url_str)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def external_url?(url_str)
|
|
131
|
+
%r!^https?://!.match?(url_str)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def protocol_url?(url_str)
|
|
135
|
+
%r!^[a-z]+:!.match?(url_str)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def anchor_only?(url_str)
|
|
139
|
+
url_str.start_with?("#")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def relative_path?(url_str)
|
|
143
|
+
url_str.start_with?("..")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def root_relative_path?(url_str)
|
|
147
|
+
url_str.start_with?("/")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def valid_target_locale?(locale)
|
|
151
|
+
return false if locale.nil? || locale.empty?
|
|
152
|
+
return true if locale == "en" # English is always valid
|
|
153
|
+
|
|
154
|
+
# Get configured locales from page or site config
|
|
155
|
+
locales_config = configured_locales
|
|
156
|
+
locales_config.include?(locale)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def configured_locales
|
|
160
|
+
page = @context.registers[:page]
|
|
161
|
+
|
|
162
|
+
# Try to get from page data first (most specific)
|
|
163
|
+
if page
|
|
164
|
+
locales = if page.is_a?(Hash)
|
|
165
|
+
page.dig("with_locales_data", "locales")
|
|
166
|
+
else
|
|
167
|
+
page.data&.dig("with_locales_data", "locales")
|
|
168
|
+
end
|
|
169
|
+
return locales if locales && !locales.empty?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Fallback to empty array if not configured
|
|
173
|
+
[]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def base_url(url)
|
|
177
|
+
page = @context.registers[:page]
|
|
178
|
+
|
|
179
|
+
# Try to get original_url from page data (preferred method)
|
|
180
|
+
if page
|
|
181
|
+
original = if page.is_a?(Hash)
|
|
182
|
+
page.dig("data", "original_url") || page["original_url"]
|
|
183
|
+
else
|
|
184
|
+
page.data&.[]("original_url")
|
|
185
|
+
end
|
|
186
|
+
return original if original
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Fallback: strip locale prefix from current URL
|
|
190
|
+
strip_locale_from_url(url)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def strip_locale_from_url(url)
|
|
194
|
+
# Strip leading locale prefix like /es/, /fr/, /pt_BR/, /zh-CN/
|
|
195
|
+
url.sub(%r!^/([a-z]{2}(?:[_-][A-Z]{2})?)(?=/|$)!, "")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reader"
|
|
4
|
+
require_relative "manager"
|
|
5
|
+
require_relative "path_builder"
|
|
6
|
+
|
|
7
|
+
module Jekyll
|
|
8
|
+
module L10n
|
|
9
|
+
# Loads PO files from disk with automatic caching.
|
|
10
|
+
#
|
|
11
|
+
# PoFileLoader handles loading PO files and managing the global in-memory cache
|
|
12
|
+
# to avoid redundant disk I/O. It constructs proper file paths for locale and
|
|
13
|
+
# page combinations, checks the cache first, and reads from disk only when needed.
|
|
14
|
+
#
|
|
15
|
+
# Key responsibilities:
|
|
16
|
+
# * Check global cache for already-loaded files
|
|
17
|
+
# * Construct file paths for locale/page combinations
|
|
18
|
+
# * Load PO files from disk when not cached
|
|
19
|
+
# * Parse PO file content into translation hashes
|
|
20
|
+
# * Cache parsed translations for reuse
|
|
21
|
+
# * Handle file-not-found gracefully (return empty hash)
|
|
22
|
+
# * Log errors during loading
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# translations = PoFileLoader.load('_site', '_locales', 'es', 'docs/index.html')
|
|
26
|
+
# # Returns cached or newly-loaded translations for that page/locale
|
|
27
|
+
#
|
|
28
|
+
# @see Jekyll::L10n::PoFileManager for caching implementation details
|
|
29
|
+
class PoFileLoader
|
|
30
|
+
# Load a PO file with caching.
|
|
31
|
+
#
|
|
32
|
+
# First checks the global PoFileManager cache for already-loaded translations.
|
|
33
|
+
# If not found, constructs the file path, loads from disk if file exists,
|
|
34
|
+
# parses the PO file, caches the result, and returns. Returns empty hash
|
|
35
|
+
# if file doesn't exist or if an error occurs during loading.
|
|
36
|
+
#
|
|
37
|
+
# @param source [String] Site source directory
|
|
38
|
+
# @param locales_dir [String] Locales directory name (e.g., '_locales')
|
|
39
|
+
# @param locale [String] Target locale code (e.g., 'es', 'fr')
|
|
40
|
+
# @param page_path [String, nil] Optional page path for page-specific file.
|
|
41
|
+
# If nil, loads compendium.
|
|
42
|
+
# @return [Hash] Translation hash { msgid => msgstr } or empty hash if not found
|
|
43
|
+
def self.load(source, locales_dir, locale, page_path)
|
|
44
|
+
cache_key = "#{locale}:#{page_path}"
|
|
45
|
+
cached = PoFileManager.cache[cache_key]
|
|
46
|
+
return cached if cached
|
|
47
|
+
|
|
48
|
+
po_path = PoPathBuilder.build(source, locales_dir, locale, page_path)
|
|
49
|
+
return {} unless File.exist?(po_path)
|
|
50
|
+
|
|
51
|
+
load_and_cache(cache_key, po_path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.load_and_cache(cache_key, po_path)
|
|
55
|
+
translations = PoFileReader.parse(po_path)
|
|
56
|
+
PoFileManager.cache[cache_key] = translations
|
|
57
|
+
translations
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Jekyll.logger.warn "Localization", "Error loading PO file #{po_path}: #{e.message}"
|
|
60
|
+
{}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|