jekyll-polyglot 1.12.0 → 1.13.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: 36484f859398f365dc36080eb9a72a170606e0150726a6447349062ee5bbba66
4
- data.tar.gz: f09be79e1c07abc021f7d16fd6fc52da4f8dd04d0a424557163bebd6be509d99
3
+ metadata.gz: c41fff580d3b7b67c65668954a77168ecf5017f5c0ca7b3747bdcc67ddb7d8ed
4
+ data.tar.gz: 6666fb42d87a589c695229be0386cdd601511aee2115c6da472f2de3b829c8ef
5
5
  SHA512:
6
- metadata.gz: 3bb811b6db49a5db36edfa1aade491de2cb4c068f8e7a255c9b636a682525fd525a98dedaa83666957b0f539ad51a32c512e2ffd494811b3e36583bd6b078ebd
7
- data.tar.gz: 002ffded1c695403ce4f9c036facd6235cac6c3e7764a4cf9c33c2a5a383918153283f923b4ee02dcdc34d1b118cf0434595369e7896b33d3dc6d16b58b6a6cd
6
+ metadata.gz: 446131b28c107601f59f17e4d630c800c6c6b4c64290f00ecf417c407cfd1e3bb960f36a1994d84d7b438cd862d358216090556f198b89ab31a1f6043f842bc9
7
+ data.tar.gz: 4902c0e1a8de5060c667eade6806a3e3932eae52475c1a0ad1f4cb5fcb984086080e4c08bf7551eb923098c152e6a46ffe7d63475ec27f9acbe948bee48d8adb
data/README.md CHANGED
@@ -41,6 +41,16 @@ These configuration preferences indicate
41
41
 
42
42
  The optional `lang_from_path: true` option enables getting the page language from a filepath segment seperated by `/` or `.`, e.g `de/first-one.md`, or `_posts/zh_HK/use-second-segment.md` , if the lang frontmatter isn't defined.
43
43
 
44
+ #### Netlify _redirects localization
45
+ If you are deploying to Netlify and use a `_redirects` file, you can enable automatic localization of redirects:
46
+ ```yaml
47
+ localize_redirects: true
48
+ exclude_from_redirect_localization:
49
+ - /signin
50
+ - /app
51
+ ```
52
+ See [Localizing Netlify _redirects](#localizing-netlify-_redirects) for more details.
53
+
44
54
  ## How To Use It
45
55
  When adding new posts and pages, add to the YAML front matter:
46
56
  ```
@@ -82,6 +92,19 @@ Sample code for meta link generation:
82
92
  ```
83
93
 
84
94
 
95
+ #### Available and missing translations
96
+ _New in 1.13.0_
97
+
98
+ Polyglot exposes two arrays on every page describing its translation status:
99
+
100
+ - `page.available_languages` — language codes that have an actual translation of this page.
101
+ - `page.missing_languages` — languages that don't yet have a translation of this page.
102
+
103
+ `missing_languages` is intentionally empty for pages that have no per-language translations at all: a single-source page falls back to identical content for every visitor, so there is nothing missing to flag. Only pages that already have *at least one* translations report the gaps.
104
+
105
+ Combine with [`page.rendered_lang`](#detecting-fallback-pages-with-pagerendered_lang) to also flag fallback content on the page itself.
106
+
107
+
85
108
  #### Using different permalinks per language
86
109
  _New in 1.7.0_
87
110
 
@@ -121,6 +144,25 @@ Lets say you are building your website. You have an `/about/` page written in *e
121
144
 
122
145
  No worries. Polyglot ensures the sitemap of your *english* site matches your *french* site, matches your *swedish* and *german* sites too. In this case, because you specified a `default_lang` variable in your `_config.yml`, all sites missing their languages' counterparts will fallback to your `default_lang`, so content is preserved across different languages of your site.
123
146
 
147
+ #### Smart hreflang Generation
148
+
149
+ Polyglot only generates `hreflang` tags for languages that have actual translations. This improves SEO correctness by not advertising language alternatives that don't actually exist.
150
+
151
+ For example, if you have `/about.html` in English and Spanish but not French:
152
+ - The English page gets `hreflang="en"`, `hreflang="es"`, and `hreflang="x-default"`
153
+ - The Spanish page gets the same hreflang tags
154
+ - No `hreflang="fr"` is generated, even though a French fallback page exists
155
+
156
+ This behavior:
157
+ - Generates pages for all languages (fallback content is still served)
158
+ - Only advertises translations that actually exist via `hreflang` tags
159
+ - Always includes `hreflang` for the default language and `x-default`
160
+
161
+ Translation detection works via:
162
+ 1. **page_id matching**: Documents with the same `page_id` frontmatter are considered translations
163
+ 2. **permalink matching**: Documents with matching permalinks (and different `lang`) are considered translations
164
+ 3. **Searches both collections and standalone pages**: The `{% I18n_Headers %}` tag searches `site.collections` and `site.pages`
165
+
124
166
  ### Relativized Local Urls
125
167
  No need to meticulously manage anchor tags to link to your correct language. Polyglot modifies how pages get written to the site so your *french* links keep visitors on your *french* blog.
126
168
  ```md
@@ -159,6 +201,73 @@ becomes
159
201
  <p>Cliquez <a href="https://mywebsite.com/fr/">ici</a> pour aller à l'entrée du site.
160
202
  ```
161
203
 
204
+ #### Canonical URL Handling
205
+
206
+ For proper canonical URL handling on multilingual sites, we recommend using Polyglot's `{% I18n_Headers %}` tag for canonical URLs instead of jekyll-seo-tag's default canonical output. This provides intelligent canonical URL generation that:
207
+
208
+ - Points to the translated URL for pages with actual translations
209
+ - Points to the default language URL for fallback pages (pages without translations)
210
+ - Properly handles the `page_id` and permalink matching for translation detection
211
+
212
+ **Setup with jekyll-seo-tag:**
213
+
214
+ If you're using [jekyll-seo-tag](https://github.com/jekyll/jekyll-seo-tag), you can disable its canonical output and let Polyglot handle it:
215
+
216
+ ```liquid
217
+ {% seo canonical=false %}
218
+ {% I18n_Headers %}
219
+ ```
220
+
221
+ The `canonical=false` option is available in jekyll-seo-tag v2.9.0+
222
+
223
+ **Fallback Canonical Behavior:**
224
+
225
+ To have fallback pages (pages without translations) point their canonical URL to the default language version, add to your `_config.yml`:
226
+
227
+ ```yaml
228
+ fallback_canonical_to_default_lang: true
229
+ ```
230
+
231
+ With this option enabled:
232
+ - Pages with actual translations: canonical points to the translated URL (e.g., `/es/sobre-nosotros/`)
233
+ - Fallback pages (no translation): canonical points to the default language URL (e.g., `/about/` instead of `/es/about/`)
234
+
235
+ This improves SEO by:
236
+ - Preventing search engines from indexing duplicate fallback content under multiple language URLs
237
+ - Consolidating SEO authority to the original content
238
+ - Signaling to search engines which version is the authoritative source
239
+
240
+ Note: `hreflang` URLs pointing to the default language or `x-default` are intentionally NOT relativized, as they should always point to the canonical language-specific URLs.
241
+
242
+ ### Localizing Netlify _redirects
243
+ _New in 1.13.0_
244
+
245
+ When using Polyglot with [Netlify](https://www.netlify.com/), redirect rules defined in a [Netlify `_redirects` file](https://docs.netlify.com/manage/routing/redirects/overview/#syntax-for-the-_redirects-file) will get relativized (e.g., `/github` becomes `/fr/github` on French pages). However the Netlify `_redirects` file only contains the redirect base paths, which causes 404 errors for localized URLs.
246
+
247
+ Polyglot can automatically generate language-prefixed versions of your redirects. Enable this feature in your `_config.yml`:
248
+
249
+ ```yaml
250
+ localize_redirects: true
251
+ exclude_from_redirect_localization:
252
+ - /signin
253
+ - /app
254
+ ```
255
+
256
+ With this configuration, a redirect like:
257
+ ```
258
+ /github https://github.com/org/repo 302
259
+ ```
260
+
261
+ Will automatically generate localized versions for all your configured languages:
262
+ ```
263
+ /github https://github.com/org/repo 302
264
+ /fr/github https://github.com/org/repo 302
265
+ /de/github https://github.com/org/repo 302
266
+ /sv/github https://github.com/org/repo 302
267
+ ```
268
+
269
+ Paths listed in `exclude_from_redirect_localization` will not be localized, which is useful for authentication endpoints or app URLs that should only exist at the root level.
270
+
162
271
  ### Disabling Url Relativizing
163
272
  _New in 1.4.0_
164
273
  If you dont want a href attribute to be relativized (such as for making [a language switcher](https://github.com/untra/polyglot/blob/main/site/_includes/sidebar.html#L40)), you can use the block tag:
@@ -262,6 +371,8 @@ This plugin stands out from other I18n Jekyll plugins.
262
371
  - provides the liquid tag `{{ site.default_lang }}` to get the default_lang I18n string.
263
372
  - provides the liquid tag `{{ site.active_lang }}` to get the I18n language string the website was built for. Alternative names for `active_lang` can be configured via `config.lang_vars`.
264
373
  - provides the liquid tag `{{ page.rendered_lang }}` to get the language the page content is actually rendered in (useful for detecting fallback pages).
374
+ - provides the liquid tag `{{ page.available_languages }}` to get the array of language codes a page has been translated into.
375
+ - provides the liquid tag `{{ page.missing_languages }}` to get the array of configured languages a page has not been translated into (empty when the page has no real translations and falls back identically everywhere).
265
376
  - provides the liquid tag `{{ I18n_Headers }}` to append SEO bonuses to your website.
266
377
  - provides the liquid tag `{{ Unrelativized_Link href="/hello" }}` to make urls that do not get influenced by url correction regexes.
267
378
  - provides `site.data` localization for efficient rich text replacement.
@@ -383,6 +494,8 @@ Feel free to open a PR and list your multilingual blog here you may want to shar
383
494
  * [x] - **site language**: chinese China `zh-CN`
384
495
  * [x] - **site language**: italian `it`
385
496
  * [x] - **site language**: turkish `tk`
497
+ * [x] - **site language**: ukrainian `uk`
498
+ * [x] - **site language**: hindi `hi`
386
499
  * [ ] - **site language**: chinese Taiwan `zh-TW`
387
500
  * [ ] - **site language**: portuguese Portugal `pt-PT`
388
501
  * [ ] - get whitelisted as an official github-pages jekyll plugin
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hook to localize Netlify _redirects file for multilingual sites.
4
+ # When enabled, generates language-prefixed versions of each redirect.
5
+ #
6
+ # Configuration:
7
+ # localize_redirects: true # Enable the feature
8
+ # exclude_from_redirect_localization: # Optional: paths to skip
9
+ # - /signin
10
+ # - /app
11
+ #
12
+ # Example:
13
+ # Input: /github https://github.com/org/repo 302
14
+ # Output: /github https://github.com/org/repo 302
15
+ # /es/github https://github.com/org/repo 302
16
+ # /de/github https://github.com/org/repo 302
17
+ # ...
18
+
19
+ Jekyll::Hooks.register :polyglot, :post_write do |site|
20
+ hook_redirects(site)
21
+ end
22
+
23
+ def hook_redirects(site)
24
+ return unless site.config.fetch('localize_redirects', false)
25
+
26
+ redirects_path = File.join(site.source, '_redirects')
27
+ return unless File.exist?(redirects_path)
28
+
29
+ exclusions = site.config.fetch('exclude_from_redirect_localization', [])
30
+ lines = File.readlines(redirects_path)
31
+ localized_lines = []
32
+
33
+ lines.each do |line|
34
+ # Always include the original line
35
+ localized_lines << line
36
+
37
+ # Skip comments and empty lines
38
+ stripped = line.strip
39
+ next if stripped.empty? || stripped.start_with?('#')
40
+
41
+ # Parse the redirect line: /source /target [status_code]
42
+ parts = stripped.split(/\s+/)
43
+ next if parts.length < 2
44
+
45
+ source = parts[0]
46
+
47
+ # Skip if source is in exclusion list
48
+ next if exclusions.include?(source)
49
+
50
+ # Only process paths that start with /
51
+ next unless source.start_with?('/')
52
+
53
+ # Skip if source already has a language prefix
54
+ next if site.languages.any? { |lang| source.start_with?("/#{lang}/") || source == "/#{lang}" }
55
+
56
+ # Add localized versions for non-default languages
57
+ site.languages.each do |lang|
58
+ next if lang == site.default_lang
59
+
60
+ localized_source = "/#{lang}#{source}"
61
+ destination = parts[1]
62
+
63
+ # Localize destination if it's an internal path (starts with /)
64
+ # but not if it's an external URL (contains ://)
65
+ localized_destination = if destination.start_with?('/') && !destination.include?('://')
66
+ "/#{lang}#{destination}"
67
+ else
68
+ destination
69
+ end
70
+
71
+ rest = parts.length > 2 ? " #{parts[2..].join(' ')}" : ''
72
+ localized_lines << "#{localized_source} #{localized_destination}#{rest}\n"
73
+ end
74
+ end
75
+
76
+ # Write to destination
77
+ dest_path = File.join(site.dest, '_redirects')
78
+ File.write(dest_path, localized_lines.join)
79
+ end
@@ -1,2 +1,3 @@
1
1
  require_relative 'hooks/coordinate'
2
2
  require_relative 'hooks/process'
3
+ require_relative 'hooks/redirects'
@@ -12,53 +12,78 @@ module Jekyll
12
12
  def render(context)
13
13
  site = context.registers[:site]
14
14
  page = context.registers[:page]
15
- permalink = page['permalink'] || page['url'] || ''
16
- permalink = "/#{permalink}" unless permalink.start_with?("/")
17
- page_id = page['page_id']
15
+ permalink = normalize_permalink(page['permalink'] || page['url'] || '')
16
+ normalized_permalink = strip_lang_prefix(permalink, site.active_lang)
18
17
  permalink_lang = page['permalink_lang']
18
+ site_url = resolve_site_url(site)
19
+
20
+ lang_to_permalink = build_lang_to_permalink(site, page['page_id'], normalized_permalink)
21
+
22
+ canonical_tag(site, site_url, lang_to_permalink, permalink_lang, normalized_permalink) +
23
+ hreflang_tags(site, site_url, lang_to_permalink, permalink_lang, normalized_permalink)
24
+ end
25
+
26
+ private
27
+
28
+ def normalize_permalink(permalink)
29
+ permalink.start_with?('/') ? permalink : "/#{permalink}"
30
+ end
31
+
32
+ def strip_lang_prefix(permalink, active_lang)
33
+ stripped = permalink.delete_prefix("/#{active_lang}/")
34
+ stripped.start_with?('/') ? stripped : "/#{stripped}"
35
+ end
36
+
37
+ def resolve_site_url(site)
38
+ return @url unless @url.empty?
39
+
19
40
  baseurl = site.config['baseurl'] || ''
20
- site_url = @url.empty? ? site.config['url'] + baseurl : @url
21
- i18n = ""
41
+ site.config['url'] + baseurl
42
+ end
22
43
 
23
- # Find all documents with the same page_id
24
- docs_with_same_id = site.collections.values
25
- .flat_map(&:docs)
26
- .filter { |doc| !doc.data['page_id'].nil? }
27
- .select { |doc| doc.data['page_id'] == page_id }
44
+ def build_lang_to_permalink(site, page_id, normalized_permalink)
45
+ site.find_translations(page_id, normalized_permalink)
46
+ end
28
47
 
29
- # Build a hash of lang => permalink for all matching docs
30
- lang_to_permalink = docs_with_same_id.to_h { |doc| [doc.data['lang'], doc.data['permalink']] }
48
+ def lookup_permalink(lang_to_permalink, permalink_lang, lang)
49
+ lang_to_permalink[lang] || (permalink_lang && permalink_lang[lang])
50
+ end
31
51
 
32
- # Canonical should always point to the current page's permalink (active_lang)
52
+ def with_lang_prefix(permalink, lang)
53
+ permalink.start_with?("/#{lang}/") ? permalink : "/#{lang}#{permalink}"
54
+ end
55
+
56
+ def canonical_tag(site, site_url, lang_to_permalink, permalink_lang, normalized_permalink)
33
57
  current_lang = site.active_lang
34
- current_permalink = lang_to_permalink[current_lang] || (permalink_lang && permalink_lang[current_lang]) || permalink
35
- current_permalink = "/#{current_permalink}" unless current_permalink.start_with?("/")
36
- # Don't add language prefix if it's already in the permalink
37
- canonical_permalink = if current_lang == site.default_lang
38
- current_permalink
58
+ has_translation = lookup_permalink(lang_to_permalink, permalink_lang, current_lang)
59
+ use_default = site.fallback_canonical_to_default_lang && !has_translation && current_lang != site.default_lang
60
+
61
+ canonical = if use_default
62
+ normalize_permalink(lookup_permalink(lang_to_permalink, permalink_lang, site.default_lang) || normalized_permalink)
63
+ elsif current_lang == site.default_lang
64
+ normalize_permalink(lookup_permalink(lang_to_permalink, permalink_lang, current_lang) || normalized_permalink)
39
65
  else
40
- current_permalink.start_with?("/#{current_lang}/") ? current_permalink : "/#{current_lang}#{current_permalink}"
66
+ current = normalize_permalink(lookup_permalink(lang_to_permalink, permalink_lang, current_lang) || normalized_permalink)
67
+ with_lang_prefix(current, current_lang)
41
68
  end
42
- i18n += "<link rel=\"canonical\" href=\"#{site_url}#{canonical_permalink}\"/>\n"
43
-
44
- # Get the default language permalink for x-default
45
- default_lang_permalink = lang_to_permalink[site.default_lang] || (permalink_lang && permalink_lang[site.default_lang]) || permalink
46
- default_lang_permalink = "/#{default_lang_permalink}" unless default_lang_permalink.start_with?("/")
47
-
48
- site.languages.each do |lang|
49
- alt_permalink = lang_to_permalink[lang] || (permalink_lang && permalink_lang[lang]) || permalink
50
- alt_permalink = "/#{alt_permalink}" unless alt_permalink.start_with?("/")
51
- i18n += if lang == site.default_lang
52
- "<link rel=\"alternate\" hreflang=\"#{lang}\" href=\"#{site_url}#{alt_permalink}\"/>\n" \
53
- "<link rel=\"alternate\" hreflang=\"x-default\" href=\"#{site_url}#{default_lang_permalink}\"/>\n"
69
+ "<link rel=\"canonical\" href=\"#{site_url}#{canonical}\"/>\n"
70
+ end
71
+
72
+ def hreflang_tags(site, site_url, lang_to_permalink, permalink_lang, normalized_permalink)
73
+ default_permalink = normalize_permalink(lookup_permalink(lang_to_permalink, permalink_lang, site.default_lang) || normalized_permalink)
74
+
75
+ site.languages.map do |lang|
76
+ has_translation = lookup_permalink(lang_to_permalink, permalink_lang, lang)
77
+ next nil if !has_translation && lang != site.default_lang
78
+
79
+ alt = normalize_permalink(lookup_permalink(lang_to_permalink, permalink_lang, lang) || normalized_permalink)
80
+ if lang == site.default_lang
81
+ "<link rel=\"alternate\" hreflang=\"#{lang}\" href=\"#{site_url}#{alt}\"/>\n" \
82
+ "<link rel=\"alternate\" hreflang=\"x-default\" href=\"#{site_url}#{default_permalink}\"/>\n"
54
83
  else
55
- # For non-default languages, use the language-specific permalink directly
56
- # Don't add the language prefix if it's already in the permalink
57
- lang_permalink = alt_permalink.start_with?("/#{lang}/") ? alt_permalink : "/#{lang}#{alt_permalink}"
58
- "<link rel=\"alternate\" hreflang=\"#{lang}\" href=\"#{site_url}#{lang_permalink}\"/>\n"
84
+ "<link rel=\"alternate\" hreflang=\"#{lang}\" href=\"#{site_url}#{with_lang_prefix(alt, lang)}\"/>\n"
59
85
  end
60
- end
61
- i18n
86
+ end.compact.join
62
87
  end
63
88
  end
64
89
  end
@@ -1,10 +1,9 @@
1
- require 'English'
2
1
  require 'etc'
3
2
 
4
3
  include Process
5
4
  module Jekyll
6
5
  class Site
7
- attr_reader :default_lang, :languages, :exclude_from_localization, :lang_vars, :lang_from_path
6
+ attr_reader :default_lang, :languages, :exclude_from_localization, :lang_vars, :lang_from_path, :fallback_canonical_to_default_lang
8
7
  attr_accessor :file_langs, :active_lang
9
8
 
10
9
  def prepare
@@ -12,6 +11,7 @@ module Jekyll
12
11
  fetch_languages
13
12
  @parallel_localization = config.fetch('parallel_localization', true)
14
13
  @lang_from_path = config.fetch('lang_from_path', false)
14
+ @fallback_canonical_to_default_lang = config.fetch('fallback_canonical_to_default_lang', false)
15
15
  @exclude_from_localization = config.fetch('exclude_from_localization', []).map do |e|
16
16
  if File.directory?(e) && e[-1] != '/'
17
17
  "#{e}/"
@@ -47,7 +47,7 @@ module Jekyll
47
47
  next unless waitpid pid, Process::WNOHANG
48
48
 
49
49
  pids.delete pid_lang
50
- raise "Polyglot subprocess #{pid} (#{lang}) failed (#{$CHILD_STATUS.exitstatus})" unless $CHILD_STATUS.success?
50
+ raise "Polyglot subprocess #{pid} (#{pid_lang}) failed (#{$?.exitstatus})" unless $?.success?
51
51
  end
52
52
  end
53
53
  end
@@ -131,11 +131,9 @@ module Jekyll
131
131
  end
132
132
 
133
133
  segments = split_on_multiple_delimiters(doc.path)
134
- # loop through all segments and check if they match the language regex
135
134
  segments.each do |segment|
136
- if @languages.include?(segment)
137
- return segment
138
- end
135
+ match = @languages.find { |lang| lang.downcase == segment.downcase }
136
+ return match if match
139
137
  end
140
138
 
141
139
  nil
@@ -148,9 +146,32 @@ module Jekyll
148
146
  def coordinate_documents(docs)
149
147
  regex = document_url_regex
150
148
  approved = {}
149
+ # Build set of valid languages (default + configured)
150
+ valid_languages = ([@default_lang] + @languages).uniq
151
+
151
152
  docs.each do |doc|
152
- lang = doc.data['lang'] || derive_lang_from_path(doc) || @default_lang
153
+ # Get the explicitly declared language (frontmatter or path-derived)
154
+ explicit_lang = doc.data['lang'] || derive_lang_from_path(doc)
155
+ lang = explicit_lang || @default_lang
156
+
157
+ # FILTER: Skip documents whose explicit lang is not in configured languages.
158
+ # Check the explicit value (not the fallback) so that documents with an
159
+ # unconfigured lang like 'de' are excluded even if normalization would
160
+ # map them to default_lang. Compare case-insensitively so case-mismatched
161
+ # frontmatter (e.g. 'pt-br' vs configured 'pt-BR') is normalized below
162
+ # rather than rejected here.
163
+ if explicit_lang && valid_languages.none? { |l| l.downcase == explicit_lang.downcase }
164
+ Jekyll.logger.warn "Polyglot:", "Skipping #{doc.relative_path} - lang '#{explicit_lang}' not in configured languages #{valid_languages.inspect}"
165
+ next
166
+ end
167
+
168
+ # If the doc lang matches a config language case-insensitively, use the config case
169
+ config_lang = @languages.find { |l| l.downcase == lang.downcase }
170
+ lang = config_lang if config_lang
171
+ doc.data['lang'] = lang if doc.data['lang'] && config_lang
172
+
153
173
  lang_exclusive = doc.data['lang-exclusive'] || []
174
+
154
175
  url = doc.url.gsub(regex, '/')
155
176
  page_id = doc.data['page_id'] || url
156
177
  doc.data['permalink'] = url if doc.data['permalink'].to_s.empty? && !doc.data['lang'].to_s.empty?
@@ -217,19 +238,63 @@ module Jekyll
217
238
  end
218
239
 
219
240
  def assignPageLanguagePermalinks(doc, docs)
220
- pageId = doc.data['page_id']
221
- if !pageId.nil? && !pageId.empty?
222
- unless doc.data['permalink_lang'] then doc.data['permalink_lang'] = {} end
223
- permalinkDocs = docs.select do |dd|
224
- dd.data['page_id'] == pageId
225
- end
226
- permalinkDocs.each do |dd|
227
- doclang = dd.data['lang'] || derive_lang_from_path(dd) || @default_lang
228
- doc.data['permalink_lang'][doclang] = dd.data['permalink']
241
+ page_id = doc.data['page_id']
242
+ normalized_permalink = normalized_permalink_for_doc(doc)
243
+ translations = find_translations(page_id, normalized_permalink, docs)
244
+
245
+ doc.data['permalink_lang'] = translations
246
+ configured = ([@default_lang] + @languages).uniq
247
+ doc.data['available_languages'] = translations.keys
248
+ # missing_languages signals "a visitor in this lang would see different
249
+ # content than another lang's visitor". A single-source page falls back
250
+ # identically everywhere, so nothing is missing in that case.
251
+ doc.data['missing_languages'] =
252
+ translations.size > 1 ? (configured - translations.keys) : []
253
+ end
254
+
255
+ # Returns a hash of { lang => permalink } for all docs that are translations
256
+ # of the given page. Matches by page_id when present, otherwise by normalized
257
+ # permalink. Filters out languages not in the configured languages list.
258
+ # candidate_docs defaults to site.collections + site.pages so the helper can
259
+ # be called from Liquid render contexts where the caller doesn't already
260
+ # hold a docs array.
261
+ def find_translations(page_id, normalized_permalink, candidate_docs = nil)
262
+ candidate_docs ||= collections.values.flat_map(&:docs) + pages
263
+ valid_languages = ([@default_lang] + @languages).uniq
264
+
265
+ matching =
266
+ if !page_id.to_s.empty?
267
+ candidate_docs.select { |d| d.data['page_id'] == page_id }
268
+ elsif !normalized_permalink.to_s.empty?
269
+ candidate_docs.select { |d| normalized_permalink_for_doc(d) == normalized_permalink }
270
+ else
271
+ []
229
272
  end
273
+
274
+ matching.each_with_object({}) do |d, h|
275
+ explicit_lang = d.data['lang'] || derive_lang_from_path(d)
276
+ doclang = explicit_lang || @default_lang
277
+ next if explicit_lang && !valid_languages.include?(explicit_lang)
278
+
279
+ h[doclang] = d.data['permalink']
230
280
  end
231
281
  end
232
282
 
283
+ # Returns the doc's permalink with its own language prefix stripped, so it
284
+ # can be matched against sibling docs that share the same un-prefixed
285
+ # permalink. Returns nil when no usable permalink is present.
286
+ def normalized_permalink_for_doc(doc)
287
+ permalink = doc.data['permalink'] || (doc.respond_to?(:url) ? doc.url : nil)
288
+ return nil if permalink.to_s.empty?
289
+
290
+ permalink = "/#{permalink}" unless permalink.start_with?('/')
291
+ lang = doc.data['lang']
292
+ return permalink if lang.to_s.empty?
293
+
294
+ stripped = permalink.delete_prefix("/#{lang}/")
295
+ stripped.start_with?('/') ? stripped : "/#{stripped}"
296
+ end
297
+
233
298
  # performs any necessary operations on the documents before rendering them
234
299
  def process_documents(docs)
235
300
  # return if @active_lang == @default_lang
@@ -298,7 +363,9 @@ module Jekyll
298
363
  end
299
364
  end
300
365
  start = disabled ? 'ferh' : 'href'
301
- neglookbehind = disabled ? "" : "(?<!hreflang=\"#{@default_lang}\" |rel=\"canonical\" )"
366
+ # Build negative lookbehind to exclude hreflang URLs from relativization
367
+ # hreflang tags for default language and x-default should not be relativized
368
+ neglookbehind = disabled ? "" : "(?<!hreflang=\"#{@default_lang}\" |hreflang=\"x-default\" )"
302
369
  %r{#{neglookbehind}#{start}="?#{url}#{@baseurl}/((?:#{regex}[^,'"\s/?.]+\.?)*(?:/[^\]\[)("'\s]*)?)"}
303
370
  end
304
371
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-polyglot
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Volin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-31 00:00:00.000000000 Z
11
+ date: 2026-05-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -44,6 +44,7 @@ files:
44
44
  - lib/jekyll/polyglot/hooks/assets-toggle.rb
45
45
  - lib/jekyll/polyglot/hooks/coordinate.rb
46
46
  - lib/jekyll/polyglot/hooks/process.rb
47
+ - lib/jekyll/polyglot/hooks/redirects.rb
47
48
  - lib/jekyll/polyglot/liquid.rb
48
49
  - lib/jekyll/polyglot/liquid/tags/i18n_headers.rb
49
50
  - lib/jekyll/polyglot/liquid/tags/static_href.rb