jekyll-l10n 1.6.5 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 86534914442108c4e4abb039b16d3b8de6b13617c3f82526e005cdb2f94e673c
4
- data.tar.gz: 9753110b3e0523464f84e89163dd6ab4db3946adc7bf7082467d245561272e9e
3
+ metadata.gz: f6934d1bf9c34ff4b1806a1b3dde6c19398ee10d261a7a3aa7925716e63f556f
4
+ data.tar.gz: fde2a5801b9558384d7d6b0f9854526af3d5759d567cddce5aaa94c9f0873c78
5
5
  SHA512:
6
- metadata.gz: 60b30c77383e6b28c0974af6ac666abe5405473a4eac4ba83b3d1d681f7cb22d997ef6af7b584c37d22895a36e5669fd8670cfb330d862b59bae53ee047cebce
7
- data.tar.gz: 91748739e07c2f5ee60cedd5e4693cb926aa0c953015d7bf2afb3d47b2fc74c10af99be5d0bca3184ca33ef1e9088c93bedda663aa1c10a0b95abd0b446b89ef
6
+ metadata.gz: 5fd0cf1f00fdd2a0b986540f4c8aeefd2cff50fac3122b6285a92a9f45b317042b28d8f72d6eadadbdc90e1c283ad792b18edd681000799541b53a75dbdbef92
7
+ data.tar.gz: ec515247b53374ca812d0195815150276ce39c078356383f667a926ef313ec50fa909b316c90e03f85c3a1ede8b753f399c63542e290b3eda12035cf9b56973a
@@ -27,9 +27,13 @@ module Jekyll
27
27
  # Regex fragment for matching locale codes in URL path segments.
28
28
  # Accepts both underscore ('pt_BR') and hyphen ('zh-CN') subtag separators,
29
29
  # unlike LOCALE_PATTERN which only accepts underscore (strict validation form).
30
+ # Region subtag is case-tolerant ([a-zA-Z]{2}): generated URLs are lowercase
31
+ # (see LocaleUrlFormatter), but this fragment also detects already-localized
32
+ # prefixes in hand-authored hrefs or pre-existing uppercase URLs, so it must
33
+ # still recognize uppercase regions.
30
34
  # Compose into full path patterns rather than using standalone.
31
35
  # @return [String]
32
- LOCALE_CODE_SEGMENT = '[a-z]{2}(?:[_-][A-Z]{2})?'
36
+ LOCALE_CODE_SEGMENT = '[a-z]{2}(?:[_-][a-zA-Z]{2})?'
33
37
 
34
38
  # ## Translation Fallback Modes
35
39
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../utils/page_locales_config'
4
+ require_relative '../utils/locale_url_formatter'
5
+ require_relative '../utils/site_config_accessor'
4
6
 
5
7
  module Jekyll
6
8
  module L10n
@@ -84,18 +86,19 @@ module Jekyll
84
86
  #
85
87
  # Localized pages are generated copies in locale subdirectories that
86
88
  # shouldn't be re-extracted (extraction happens on original pages only).
89
+ # Since commit 3d2d4cd, country-subtag locales (e.g. es_ES) are written
90
+ # to BCP 47 hyphenated, lowercased URL directories (e.g. es-es/). Each
91
+ # configured locale is normalized through LocaleUrlFormatter.to_url_segment
92
+ # before comparison so both language-only (es) and country-subtag
93
+ # (es_ES → es-es) locales are correctly recognized.
87
94
  #
88
95
  # @param file_path [String] Path to file to check
89
96
  # @return [Boolean] True if file is in a locale subdirectory
90
97
  def skip_localized_page?(file_path)
91
98
  relative_path = file_path.sub(@dest, '')
92
99
 
93
- all_locales = @site.pages.map do |p|
94
- p.data.dig('with_locales_data', 'locales') || []
95
- end
96
- all_locales.flatten!
97
- all_locales.uniq!
98
- all_locales.any? { |locale| relative_path.start_with?("/#{locale}/") }
100
+ all_locales = SiteConfigAccessor.all_configured_locales(@site)
101
+ all_locales.any? { |locale| relative_path.start_with?("/#{LocaleUrlFormatter.to_url_segment(locale)}/") }
99
102
  end
100
103
 
101
104
  # Extract CSS selectors for element exclusion from configuration.
@@ -68,12 +68,17 @@ module Jekyll
68
68
  # Called once per build (from Extractor.extract_site) rather than per page,
69
69
  # reducing disk I/O and fuzzy-matching passes from O(pages) to O(1).
70
70
  #
71
+ # Uses the union of all locales configured anywhere on the site (site-wide
72
+ # defaults plus any page-level front-matter overrides) so that page-only
73
+ # locales (e.g. es_ES declared only in page front matter) are included in the
74
+ # compendium merge, not left as orphaned per-page directories.
75
+ #
71
76
  # @return [void]
72
77
  def finalize_compendia
73
78
  return unless @site_config.update_compendium?
74
79
 
75
80
  po_manager = PoFileManager.new(@site, @site_config.locales_dir)
76
- CompendiumMerger.new(@site).merge_compendia(po_manager, @site_config)
81
+ CompendiumMerger.new(@site).merge_compendia(po_manager, merge_config)
77
82
  end
78
83
 
79
84
  # Translate compendia using LibreTranslate.
@@ -100,7 +105,21 @@ module Jekyll
100
105
  po_files_created
101
106
  end
102
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.
113
+ #
114
+ # @return [PageLocalesConfig]
115
+ 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 })
119
+ end
120
+
103
121
  private :save_po_files
122
+ private :merge_config
104
123
  end
105
124
  end
106
125
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../constants'
4
4
  require_relative 'regeneration_checker'
5
+ require_relative '../utils/locale_url_formatter'
5
6
 
6
7
  module Jekyll
7
8
  module L10n
@@ -112,7 +113,7 @@ module Jekyll
112
113
  end
113
114
 
114
115
  def create_localized_page(site, page, locale)
115
- new_dir = "/#{locale}#{page.dir}"
116
+ new_dir = "/#{LocaleUrlFormatter.to_url_segment(locale)}#{page.dir}"
116
117
  LocalizedPage.new(site: site, base: site.source, dir: new_dir, page: page,
117
118
  locale: locale)
118
119
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../utils/locale_url_formatter'
4
+
3
5
  module Jekyll
4
6
  module L10n
5
7
  # Localized Page - Represents a Jekyll page in a specific locale
@@ -26,7 +28,7 @@ module Jekyll
26
28
  #
27
29
  class LocalizedPage < Jekyll::Page
28
30
  # @!attribute [rw] locale
29
- # The BCP 47 locale code for this page variant (e.g., 'es', 'pt_BR')
31
+ # The canonical (POSIX/gettext) locale code for this page variant (e.g., 'es', 'pt_BR')
30
32
  # @return [String]
31
33
  # @!attribute [rw] lang
32
34
  # Alias for locale, used in Liquid templates
@@ -56,7 +58,7 @@ module Jekyll
56
58
  # @param dir [String] (keyword) The directory path (will be prefixed with locale,
57
59
  # e.g., '/es/path')
58
60
  # @param page [Jekyll::Page] (keyword) The source page to localize
59
- # @param locale [String] (keyword) The BCP 47 locale code (e.g., 'es', 'pt_BR', 'zh_CN')
61
+ # @param locale [String] (keyword) The canonical locale code (e.g., 'es', 'pt_BR', 'zh_CN')
60
62
  # @return [LocalizedPage] A new localized page instance
61
63
  # @note All parameters are keyword arguments and must be passed by name
62
64
  def initialize(site:, base:, dir:, page:, locale:)
@@ -82,25 +84,31 @@ module Jekyll
82
84
  # Get the URL placeholder substitutions for this localized page
83
85
  #
84
86
  # Overrides Jekyll::Page's url_placeholders to include the locale prefix in path generation.
87
+ # The locale segment is converted to BCP 47 hyphenated form for use in URLs.
85
88
  # This ensures that Jekyll's permalink logic respects the locale prefix.
86
89
  #
87
90
  # @return [Hash<Symbol, String>] Hash of placeholders with locale-prefixed path
88
91
  def url_placeholders
89
92
  placeholders = super
90
- placeholders[:path] = "/#{@locale}#{placeholders[:path]}" if placeholders[:path]
93
+ if placeholders[:path]
94
+ url_locale = LocaleUrlFormatter.to_url_segment(@locale)
95
+ placeholders[:path] = "/#{url_locale}#{placeholders[:path]}"
96
+ end
91
97
  placeholders
92
98
  end
93
99
 
94
100
  # Get the full URL for this localized page
95
101
  #
96
- # Returns the page's URL with a locale prefix. For example, if the source page URL is
97
- # '/path/to/page/', this returns '/es/path/to/page/' for locale 'es'.
102
+ # Returns the page's URL with a locale prefix in BCP 47 hyphenated form.
103
+ # For example, if the source page URL is '/path/to/page/', this returns
104
+ # '/es-ES/path/to/page/' for locale 'es_ES'.
98
105
  # For HTML pages, URLs end with a trailing slash. For assets, the file extension is preserved.
99
106
  #
100
107
  # @return [String] The locale-prefixed URL with optional trailing slash
101
- # (e.g., '/es/path/to/page/' or '/es/assets/style.css')
108
+ # (e.g., '/es-ES/path/to/page/' or '/es-ES/assets/style.css')
102
109
  def url
103
- url = "/#{@locale}#{@original_url}"
110
+ url_locale = LocaleUrlFormatter.to_url_segment(@locale)
111
+ url = "/#{url_locale}#{@original_url}"
104
112
  # Only add trailing slash for directory URLs (no file extension)
105
113
  url += '/' unless url.end_with?('/') || url =~ /\.[a-zA-Z0-9]+$/
106
114
  url
@@ -109,8 +117,10 @@ module Jekyll
109
117
  # Get the destination file path for this localized page
110
118
  #
111
119
  # Computes the full file system path where this page's content will be written.
112
- # For HTML pages with URL '/es/path/to/page/', this returns 'dest/es/path/to/page/index.html'.
113
- # For assets with URL '/es/assets/style.css', this returns 'dest/es/assets/style.css'.
120
+ # For HTML pages with URL '/es-ES/path/to/page/', this returns
121
+ # 'dest/es-ES/path/to/page/index.html'.
122
+ # For assets with URL '/es-ES/assets/style.css', this returns
123
+ # 'dest/es-ES/assets/style.css'.
114
124
  #
115
125
  # @param dest [String, nil] The destination directory (defaults to nil, which uses
116
126
  # site config destination)
@@ -132,6 +142,7 @@ module Jekyll
132
142
  # Convert this localized page to Liquid template data
133
143
  #
134
144
  # Overrides Jekyll::Page's to_liquid to include locale and lang in template context.
145
+ # The url key contains the BCP 47 hyphenated form; locale and lang remain canonical.
135
146
  # This makes the page's locale available to Liquid templates for conditional rendering
136
147
  # and URL generation.
137
148
  #
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../utils/site_config_accessor'
4
4
  require_relative '../po_file/path_builder'
5
+ require_relative '../utils/locale_url_formatter'
5
6
 
6
7
  module Jekyll
7
8
  module L10n
@@ -84,8 +85,10 @@ module Jekyll
84
85
  end
85
86
 
86
87
  def dest_path(page, locale)
87
- # Replicate LocalizedPage destination logic without instantiation
88
- url = "/#{locale}#{page.url.chomp('/')}/"
88
+ # Replicate LocalizedPage destination logic without instantiation.
89
+ # Uses BCP 47 hyphenated locale segment to match LocalizedPage#destination output.
90
+ url_locale = LocaleUrlFormatter.to_url_segment(locale)
91
+ url = "/#{url_locale}#{page.url.chomp('/')}/"
89
92
  "#{@destination}#{url}index.html"
90
93
  end
91
94
 
@@ -3,6 +3,8 @@
3
3
  require 'liquid'
4
4
  require_relative '../constants'
5
5
  require_relative '../utils/error_handler'
6
+ require_relative '../utils/locale_name_resolver'
7
+ require_relative '../utils/locale_url_formatter'
6
8
 
7
9
  module Jekyll
8
10
  module L10n
@@ -15,25 +17,30 @@ module Jekyll
15
17
  # the filters available in all Jekyll templates.
16
18
  #
17
19
  # Key responsibilities:
18
- # - Generate locale-prefixed URLs for links
20
+ # - Generate locale-prefixed URLs in BCP 47 hyphenated form
19
21
  # - Switch between locale variants of the same page
20
- # - Validate locales against site configuration
22
+ # - Build structured locale-switcher menu data
23
+ # - Validate locales against site configuration (with form normalization)
21
24
  # - Handle edge cases (external URLs, anchors, protocols)
22
25
  #
23
26
  # @example Usage in Jekyll Liquid templates
24
27
  # <!-- Add locale prefix to URL -->
25
- # <a href="{{ '/about/' | locale_url: 'es' }}">Acerca de</a>
28
+ # <a href="{{ '/about/' | locale_url: 'es_ES' }}">Acerca de</a>
29
+ # <!-- Produces: /es-ES/about/ -->
26
30
  #
27
31
  # <!-- Switch to different locale variant -->
28
32
  # <a href="{{ page.url | switch_locale_url: 'fr' }}">Français</a>
29
33
  #
34
+ # <!-- Build a language-switcher menu -->
35
+ # {% assign menu = page.url | locale_menu %}
36
+ #
30
37
  # @see Jekyll::L10n for filter registration (line 218 in lib/jekyll-l10n.rb)
31
38
  #
32
39
  module UrlFilter
33
40
  # Generate a locale-prefixed URL for the given URL and locale
34
41
  #
35
- # Adds a locale prefix to the given URL, making it suitable for use in locale-specific
36
- # links. For example, '/about/' becomes '/es/about/' for locale 'es'. The filter handles
42
+ # Adds a locale prefix in BCP 47 hyphenated form to the given URL. For example,
43
+ # '/about/' becomes '/es-ES/about/' for locale 'es_ES'. The filter handles
37
44
  # various edge cases and only applies the prefix when appropriate.
38
45
  #
39
46
  # The method performs several checks to determine if prefixing is appropriate:
@@ -42,12 +49,13 @@ module Jekyll
42
49
  # - External URLs, anchors, and relative paths are not modified
43
50
  #
44
51
  # @param url [String] The URL to prefix (e.g., '/about/', '/path/to/page/')
45
- # @param locale [String, nil] The locale code (e.g., 'es', 'pt_BR'). Defaults to nil
52
+ # @param locale [String, nil] The locale code (e.g., 'es_ES', 'pt_BR'). Defaults to nil
46
53
  # (uses current page's locale). If nil, uses page's locale from context.
47
- # @return [String] The locale-prefixed URL, or original URL if prefixing is not appropriate
54
+ # @return [String] The locale-prefixed URL in BCP 47 form, or original URL if
55
+ # prefixing is not appropriate
48
56
  # @example
49
- # <!-- Explicit locale -->
50
- # {{ '/about/' | locale_url: 'es' }} # => '/es/about/'
57
+ # <!-- Explicit underscore locale converted to hyphen in URL -->
58
+ # {{ '/about/' | locale_url: 'es_ES' }} # => '/es-ES/about/'
51
59
  #
52
60
  # <!-- Current page's locale -->
53
61
  # {{ '/about/' | locale_url }} # => '/es/about/' (if page locale is 'es')
@@ -55,29 +63,29 @@ module Jekyll
55
63
  locale ||= current_locale
56
64
  return url if should_skip?(url, locale)
57
65
 
58
- "/#{locale}#{url}"
66
+ url_segment = LocaleUrlFormatter.to_url_segment(locale)
67
+ "/#{url_segment}#{url}"
59
68
  end
60
69
 
61
70
  # Generate a URL for the given locale variant of the current page
62
71
  #
63
72
  # Switches the current URL to a different locale variant. For example, if the current
64
- # page is '/es/about/', calling this with 'fr' returns '/fr/about/'. This is useful
73
+ # page is '/es-ES/about/', calling this with 'fr' returns '/fr/about/'. This is useful
65
74
  # for building language switcher menus.
66
75
  #
67
76
  # This filter first extracts the base URL (without locale prefix) from the given URL,
68
77
  # then applies the target locale using the standard locale_url logic.
69
78
  #
70
79
  # @param url [String] The current URL (may or may not have a locale prefix)
71
- # @param target_locale [String] The target locale code (e.g., 'es', 'fr', 'pt_BR')
80
+ # @param target_locale [String] The target locale code (e.g., 'es_ES', 'fr', 'pt_BR')
72
81
  # @return [String] The URL for the target locale, or original URL if locale is invalid
73
82
  # @example
74
- # <!-- Current page is /es/about/, switch to French -->
83
+ # <!-- Current page is /es-ES/about/, switch to French -->
75
84
  # {{ page.url | switch_locale_url: 'fr' }} # => '/fr/about/'
76
85
  #
77
86
  # <!-- Build a language switcher -->
78
- # <a href="{{ page.url | switch_locale_url: 'es' }}">Español</a>
87
+ # <a href="{{ page.url | switch_locale_url: 'es_ES' }}">Español (España)</a>
79
88
  # <a href="{{ page.url | switch_locale_url: 'fr' }}">Français</a>
80
- # <a href="{{ page.url | switch_locale_url: 'pt' }}">Português</a>
81
89
  def switch_locale_url(url, target_locale)
82
90
  # Validate target locale is configured
83
91
  return url unless valid_target_locale?(target_locale)
@@ -89,6 +97,37 @@ module Jekyll
89
97
  locale_url(base_url_value, target_locale)
90
98
  end
91
99
 
100
+ # Build the full locale-switcher menu for the current page.
101
+ #
102
+ # Returns one entry per locale available to the current page (via the
103
+ # existing #configured_locales resolution — site-wide with_locales_data.locales
104
+ # config, or a page-level front-matter override — always including English
105
+ # even if not explicitly configured). Each entry carries everything a theme
106
+ # needs to render a menu item with zero locale-specific logic of its own:
107
+ # a display name, a switch-target URL, and whether it's the page's current
108
+ # locale.
109
+ #
110
+ # @param url [String] The current page's URL (e.g. page.url)
111
+ # @return [Array<Hash>] Array of { 'code', 'name', 'url', 'current' } hashes,
112
+ # one per locale. +code+ is the canonical underscore form (e.g. "es_ES"),
113
+ # +name+ is the ISO-resolved display name (e.g. "Spanish (Spain)"),
114
+ # +url+ is what {#switch_locale_url} produces for that locale, and
115
+ # +current+ is +true+ only for the locale matching the page's own locale.
116
+ # @example
117
+ # {{ page.url | locale_menu }}
118
+ # # => [{"code"=>"en","name"=>"English","url"=>"/about/","current"=>false},
119
+ # # {"code"=>"es_ES","name"=>"Spanish (Spain)","url"=>"/es-ES/about/","current"=>true}]
120
+ def locale_menu(url)
121
+ menu_locales.map do |locale|
122
+ {
123
+ 'code' => locale,
124
+ 'name' => LocaleNameResolver.resolve(locale),
125
+ 'url' => switch_locale_url(url, locale),
126
+ 'current' => locale == (current_locale || 'en')
127
+ }
128
+ end
129
+ end
130
+
92
131
  private
93
132
 
94
133
  def current_locale
@@ -96,7 +135,7 @@ module Jekyll
96
135
  return nil unless page
97
136
 
98
137
  if page.is_a?(Hash)
99
- page.dig('data', 'locale')
138
+ page.dig('data', 'locale') || page['locale']
100
139
  else
101
140
  page&.data&.[]('locale')
102
141
  end
@@ -155,7 +194,11 @@ module Jekyll
155
194
 
156
195
  # Get configured locales from page or site config
157
196
  locales_config = configured_locales
158
- locales_config.include?(locale)
197
+
198
+ # Normalize both the target locale and each configured locale to BCP 47 form
199
+ # before comparing, so 'es-ES' matches 'es_ES' and vice versa.
200
+ url_locale = LocaleUrlFormatter.to_url_segment(locale)
201
+ locales_config.any? { |l| LocaleUrlFormatter.to_url_segment(l) == url_locale }
159
202
  end
160
203
 
161
204
  def configured_locales
@@ -175,6 +218,18 @@ module Jekyll
175
218
  []
176
219
  end
177
220
 
221
+ # Locales for the menu: whatever #configured_locales resolves for the
222
+ # current page, always including English even if not explicitly listed
223
+ # (English pages never carry a locale prefix, but must still appear as a
224
+ # switch target).
225
+ #
226
+ # @return [Array<String>]
227
+ def menu_locales
228
+ locales = configured_locales.dup
229
+ locales.unshift('en') unless locales.include?('en')
230
+ locales
231
+ end
232
+
178
233
  def base_url(url)
179
234
  page = @context.registers[:page]
180
235
 
@@ -172,7 +172,7 @@ module Jekyll
172
172
 
173
173
  texts = [entry.msgid]
174
174
  translations = make_api_request(texts, target_locale)
175
- entry.msgstr = translations[0] || ''
175
+ entry.msgstr = translations&.first || ''
176
176
  end
177
177
  rescue StandardError => e
178
178
  ErrorHandler.log_error('translate_batch', e)
@@ -225,6 +225,13 @@ module Jekyll
225
225
 
226
226
  def handle_api_response(response)
227
227
  response_data = JSON.parse(response.body)
228
+
229
+ if response_data['error']
230
+ Jekyll.logger.warn 'Localization',
231
+ "[LibreTranslator] API rejected request: #{response_data['error']}"
232
+ return nil
233
+ end
234
+
228
235
  translated = response_data['translatedText']
229
236
  translation_str = translated.is_a?(Array) ? translated.inspect : translated
230
237
  Jekyll.logger.debug 'Localization', "[LibreTranslator] Parsed translation: #{translation_str}"
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'locale/info'
4
+
5
+ module Jekyll
6
+ module L10n
7
+ # Resolves a canonical locale code into a human-readable display name using
8
+ # ISO 639-1 language names and ISO 3166-1 country names from the +locale+ gem.
9
+ #
10
+ # Supported input forms:
11
+ # - Language-only: <tt>"es"</tt> → <tt>"Spanish"</tt>
12
+ # - Language + country subtag (POSIX underscore form): <tt>"es_ES"</tt> → <tt>"Spanish (Spain)"</tt>,
13
+ # <tt>"pt_BR"</tt> → <tt>"Portuguese (Brazil)"</tt>
14
+ #
15
+ # Falls back to the raw code segment when the locale gem cannot resolve a
16
+ # language or region code. This is a defensive-only path — every locale that
17
+ # reaches this class from plugin-managed configuration has already passed
18
+ # +Constants::LOCALE_PATTERN+ validation, so unknown codes are not expected
19
+ # in normal operation.
20
+ #
21
+ # @example Language-only codes
22
+ # Jekyll::L10n::LocaleNameResolver.resolve('es') # => "Spanish"
23
+ # Jekyll::L10n::LocaleNameResolver.resolve('en') # => "English"
24
+ # Jekyll::L10n::LocaleNameResolver.resolve('pt') # => "Portuguese"
25
+ #
26
+ # @example Country-subtag codes
27
+ # Jekyll::L10n::LocaleNameResolver.resolve('es_ES') # => "Spanish (Spain)"
28
+ # Jekyll::L10n::LocaleNameResolver.resolve('pt_BR') # => "Portuguese (Brazil)"
29
+ #
30
+ # @example Fallback for unresolvable codes (defensive)
31
+ # Jekyll::L10n::LocaleNameResolver.resolve('xx') # => "xx"
32
+ # Jekyll::L10n::LocaleNameResolver.resolve('xx_YY') # => "xx (YY)"
33
+ class LocaleNameResolver
34
+ # Resolve a canonical locale code to a human-readable display name.
35
+ #
36
+ # @param locale [String, nil] The locale code to resolve (e.g. <tt>"es"</tt>,
37
+ # <tt>"es_ES"</tt>, <tt>"pt_BR"</tt>). +nil+ or empty string returns an
38
+ # empty string without raising.
39
+ # @return [String] The resolved display name, or the raw code segment(s) if
40
+ # the locale gem cannot resolve either part.
41
+ def self.resolve(locale)
42
+ language_code, country_code = locale.to_s.split('_', 2)
43
+ language_name = Locale::Info.get_language(language_code)&.name || language_code
44
+ return language_name unless country_code
45
+
46
+ country_name = Locale::Info.get_region(country_code)&.name || country_code
47
+ "#{language_name} (#{country_name})"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module L10n
5
+ # Converts a canonical (gettext/POSIX-style) locale identifier, e.g. "es_ES",
6
+ # into the BCP 47 form used in generated URLs and hrefs, e.g. "es-es".
7
+ # Lowercased per RFC 5646 ss2.1.1 (subtag case carries no meaning; this
8
+ # is a display-convention choice, not a standard requirement). Idempotent.
9
+ class LocaleUrlFormatter
10
+ def self.to_url_segment(locale)
11
+ locale.to_s.tr('_', '-').downcase
12
+ end
13
+ end
14
+ end
15
+ end
@@ -17,7 +17,7 @@ module Jekyll
17
17
  #
18
18
  # Key responsibilities:
19
19
  # - Parse `with_locales_data` from page front matter
20
- # - Validate locale codes against ISO 639-1/2 format
20
+ # - Validate locale codes against ISO 639-1 (with optional ISO 3166-1 alpha-2 country) format
21
21
  # - Validate LibreTranslate configuration when enabled
22
22
  # - Provide getter methods with sensible defaults
23
23
  # - Raise detailed validation errors for invalid configurations
@@ -87,7 +87,8 @@ module Jekyll
87
87
  # Get the list of locales configured for this page
88
88
  #
89
89
  # Returns the locales specified in `with_locales_data.locales`, or an empty array
90
- # if not configured. All returned locales are guaranteed to match ISO 639-1/2 format.
90
+ # if not configured. All returned locales are guaranteed to match ISO 639-1 format,
91
+ # optionally suffixed with an ISO 3166-1 alpha-2 country code.
91
92
  #
92
93
  # @return [Array<String>] BCP 47 locale codes (e.g., ['es', 'fr', 'pt_BR'])
93
94
  def locales
@@ -307,7 +308,7 @@ module Jekyll
307
308
  <<~ERROR
308
309
  Invalid locale codes in #{@page_path}: #{invalid_codes.join(', ')}
309
310
 
310
- Expected ISO 639-1/2 format (e.g., 'es', 'fr', 'pt_BR')
311
+ Expected ISO 639-1 language code, optionally with ISO 3166-1 alpha-2 country (e.g., 'es', 'fr', 'pt_BR')
311
312
  Valid pattern: ^[a-z]{2}(_[A-Z]{2})?$
312
313
 
313
314
  Common mistakes:
@@ -328,7 +329,7 @@ module Jekyll
328
329
  'LibreTranslate is enabled but libretranslate_api_url is not configured'
329
330
  end
330
331
 
331
- # Validate source locale format (ISO 639-1/2)
332
+ # Validate source locale format (ISO 639-1, optionally with ISO 3166-1 alpha-2 country)
332
333
  unless LOCALE_PATTERN.match?(libretranslate_source_locale)
333
334
  raise Jekyll::Errors::InvalidConfigurationError,
334
335
  "Invalid libretranslate_source_locale: #{libretranslate_source_locale}"
@@ -10,12 +10,14 @@ module Jekyll
10
10
  #
11
11
  # Key responsibilities:
12
12
  # * Extract localization data from site configuration
13
+ # * Compute the union of all configured locales across site and page front matter
13
14
  # * Access site source directory
14
15
  # * Access site destination directory
15
16
  # * Handle both Jekyll site objects and hash-based doubles
16
17
  #
17
18
  # @example
18
19
  # locales_data = SiteConfigAccessor.extract_locales_data(site)
20
+ # all_locales = SiteConfigAccessor.all_configured_locales(site)
19
21
  # source = SiteConfigAccessor.source(site)
20
22
  # dest = SiteConfigAccessor.dest(site)
21
23
  class SiteConfigAccessor
@@ -33,6 +35,27 @@ module Jekyll
33
35
  entry&.dig('values', 'with_locales_data') || {}
34
36
  end
35
37
 
38
+ # Compute the union of all locales configured anywhere on the site.
39
+ #
40
+ # Combines locales from two sources:
41
+ # - The site-wide `with_locales_data.locales` default (from `_config.yml`)
42
+ # - Any page-level `with_locales_data.locales` front-matter override, since
43
+ # a page-level override replaces the site default for that page rather than
44
+ # inheriting it, so page scanning alone misses site-wide locales when few
45
+ # or no ordinary pages are present.
46
+ #
47
+ # This lets callers discover locales that exist only as a page-level override,
48
+ # not declared in site config, while also retaining all site-wide locales.
49
+ #
50
+ # @param site [Jekyll::Site, Hash] Jekyll site object or hash double
51
+ # @return [Array<String>] Unique locale codes configured anywhere on the site
52
+ def self.all_configured_locales(site)
53
+ site_wide_locales = extract_locales_data(site)['locales'] || []
54
+ pages = site.is_a?(Hash) ? site['pages'] : site.pages
55
+ page_locales = pages.flat_map { |p| p.data.dig('with_locales_data', 'locales') || [] }
56
+ (site_wide_locales + page_locales).uniq
57
+ end
58
+
36
59
  # Get the site source directory.
37
60
  #
38
61
  # @param site [Jekyll::Site, Hash] Jekyll site object or hash double
@@ -3,19 +3,21 @@
3
3
  require 'nokogiri'
4
4
  require_relative '../constants'
5
5
  require_relative 'html_parser'
6
+ require_relative 'locale_url_formatter'
6
7
 
7
8
  module Jekyll
8
9
  module L10n
9
10
  # Transforms relative URLs in HTML to include locale prefixes.
10
11
  #
11
12
  # UrlTransformer modifies href attributes in links to prefix them with
12
- # the target locale (e.g., /docs/page.html becomes /es/docs/page.html).
13
+ # the target locale in BCP 47 hyphenated form (e.g., /docs/page.html becomes
14
+ # /es-ES/docs/page.html for a canonical "es_ES" locale).
13
15
  # It preserves external links, anchors, mailto, tel links, and links
14
16
  # already containing locale prefixes. Skips English locale (default language).
15
17
  #
16
18
  # Key responsibilities:
17
19
  # * Identify relative links to transform
18
- # * Add locale prefix to href values
20
+ # * Add locale prefix in BCP 47 form to href values
19
21
  # * Preserve external links and special URLs
20
22
  # * Skip already-localized URLs
21
23
  # * Handle baseurl paths correctly
@@ -23,20 +25,25 @@ module Jekyll
23
25
  #
24
26
  # @example
25
27
  # html = '<a href="/docs/page.html">Link</a>'
26
- # transformed = UrlTransformer.transform(html, 'es', '/baseurl')
27
- # # Returns '<a href="/baseurl/es/docs/page.html">Link</a>'
28
+ # transformed = UrlTransformer.transform(html, 'es_ES', '/baseurl')
29
+ # # Returns '<a href="/baseurl/es-ES/docs/page.html">Link</a>'
28
30
  class UrlTransformer
29
31
  class << self
30
32
  # Transform all relative URLs in HTML to include locale prefix.
31
33
  #
32
- # Parses HTML document, identifies relative links, adds locale prefix,
33
- # removes auto-inserted meta tags, and returns transformed HTML.
34
+ # Parses HTML document, identifies relative links, adds locale prefix
35
+ # in BCP 47 hyphenated form, removes auto-inserted meta tags, and
36
+ # returns transformed HTML.
34
37
  #
35
38
  # @param html [String] HTML content with URLs to transform
36
- # @param locale [String] Target locale code (e.g., 'es', 'fr')
39
+ # @param locale [String] Target locale code (e.g., 'es', 'es_ES', 'fr')
37
40
  # @param baseurl [String] Base URL for site (e.g., '/baseurl')
38
41
  # @return [String] HTML with locale-prefixed URLs
39
42
  def transform(html, locale, baseurl)
43
+ # Convert canonical locale to BCP 47 hyphenated form for URL usage.
44
+ # This is idempotent: already-hyphenated input is unchanged.
45
+ locale = LocaleUrlFormatter.to_url_segment(locale)
46
+
40
47
  return html if should_skip_transform?(locale)
41
48
 
42
49
  # Use Nokogiri::HTML to properly parse full HTML documents while preserving
@@ -58,10 +65,14 @@ module Jekyll
58
65
  # Useful when document is already parsed to avoid re-parsing.
59
66
  #
60
67
  # @param doc [Nokogiri::HTML::Document] Parsed HTML document
61
- # @param locale [String] Target locale code
68
+ # @param locale [String] Target locale code (canonical or BCP 47 form)
62
69
  # @param baseurl [String] Base URL for site
63
70
  # @return [void]
64
71
  def transform_document(doc, locale, baseurl)
72
+ # Convert canonical locale to BCP 47 hyphenated form for URL usage.
73
+ # This is idempotent: already-hyphenated input is unchanged.
74
+ locale = LocaleUrlFormatter.to_url_segment(locale)
75
+
65
76
  return if should_skip_transform?(locale)
66
77
 
67
78
  doc.css('a[href]').each do |link|
@@ -142,7 +153,7 @@ module Jekyll
142
153
 
143
154
  def locale_prefix?(path)
144
155
  # Check if the path contains a locale code
145
- # After stripping baseurl, this detects: /es/, /fr/, /pt_BR/, /zh-CN/, etc.
156
+ # After stripping baseurl, this detects: /es/, /fr/, /pt-BR/, /zh-CN/, etc.
146
157
  path.match?(%r{/#{Constants::LOCALE_CODE_SEGMENT}(?:/|$)}o)
147
158
  end
148
159
 
@@ -6,6 +6,6 @@ module Jekyll
6
6
  # Regenerated alongside the gemspec on every `make jekyll-l10n.gemspec`
7
7
  # run (locally or in CI), so it always matches spec.version in the built
8
8
  # gem. Any hand edit is overwritten on the next run.
9
- VERSION = '1.6.5'
9
+ VERSION = '1.7.0'
10
10
  end
11
11
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-l10n
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.5
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alain Reguera Delgado
@@ -49,6 +49,26 @@ dependencies:
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '5.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: locale
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '2.1'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '2.1'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '3.0'
52
72
  - !ruby/object:Gem::Dependency
53
73
  name: nokogiri
54
74
  requirement: !ruby/object:Gem::Requirement
@@ -256,6 +276,8 @@ files:
256
276
  - lib/jekyll-l10n/utils/html_elements.rb
257
277
  - lib/jekyll-l10n/utils/html_parser.rb
258
278
  - lib/jekyll-l10n/utils/html_text_utils.rb
279
+ - lib/jekyll-l10n/utils/locale_name_resolver.rb
280
+ - lib/jekyll-l10n/utils/locale_url_formatter.rb
259
281
  - lib/jekyll-l10n/utils/logger_formatter.rb
260
282
  - lib/jekyll-l10n/utils/page_locales_config.rb
261
283
  - lib/jekyll-l10n/utils/po_entry_converter.rb