jekyll-polyglot 1.12.0 → 1.14.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: 431827884e33750d9871432f109f15ef806adc3c92ac12d69b4dd71d7f5b8c26
4
+ data.tar.gz: fbd2aa987e77785d37ad5578ef06b78fdd6f8de30e65007886c13f782605b51c
5
5
  SHA512:
6
- metadata.gz: 3bb811b6db49a5db36edfa1aade491de2cb4c068f8e7a255c9b636a682525fd525a98dedaa83666957b0f539ad51a32c512e2ffd494811b3e36583bd6b078ebd
7
- data.tar.gz: 002ffded1c695403ce4f9c036facd6235cac6c3e7764a4cf9c33c2a5a383918153283f923b4ee02dcdc34d1b118cf0434595369e7896b33d3dc6d16b58b6a6cd
6
+ metadata.gz: 6b28158712094d6870f20bec2882608de81f0188087a8c609da85a6fd7c94f2a12769a220448696984e2464d34b9eadacdd55732b4a84e767d812d153bf69cb5
7
+ data.tar.gz: 9280111b1d14364adf562230d8e84512224ad880c88d9797771ce9fdaf3a5fe474ba803b9cfb691687f81ea92b9d21b4bcd90b3a7950d1d2b46268f666081c4d
data/README.md CHANGED
@@ -30,17 +30,28 @@ languages: ["en", "sv", "de", "fr"]
30
30
  default_lang: "en"
31
31
  exclude_from_localization: ["javascript", "images", "css", "public", "sitemap", "CNAME"]
32
32
  parallel_localization: true
33
+ serial_default_lang: true
33
34
  url: https://polyglot.untra.io
34
35
  ```
35
36
  These configuration preferences indicate
36
37
  - what i18n languages you wish to support
37
38
  - what is your default "fallback" language for your content
38
39
  - what root level files/folders are excluded from localization, based on if their paths start with any of the excluded regexp substrings. (this is different from the jekyll `exclude: [ .gitignore ]` ; you should `exclude` files and directories in your repo you dont want in your built site at all, and `exclude_from_localization` files and directories you want to see in your built site, but not in your sublanguage sites.)
39
- - whether to run language processing in parallel or serial. Set to `false` if building on Windows hosts, or if Polyglot collides with other Jekyll plugins.
40
+ - whether to run language processing in parallel or serial. Set to `false` if building on Windows hosts, or if Polyglot collides with other Jekyll plugins. - If `parallel_localization` collides with other jekyll plugins, this setting makes polyglot build the default language first before processing the other language sites.
40
41
  - your jekyll website production url. Make sure this value is set; Polyglot requires this to relative site urls correctly, and to make functioning language switchers.
41
42
 
42
43
  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
44
 
45
+ #### Netlify _redirects localization
46
+ If you are deploying to Netlify and use a `_redirects` file, you can enable automatic localization of redirects:
47
+ ```yaml
48
+ localize_redirects: true
49
+ exclude_from_redirect_localization:
50
+ - /signin
51
+ - /app
52
+ ```
53
+ See [Localizing Netlify _redirects](#localizing-netlify-_redirects) for more details.
54
+
44
55
  ## How To Use It
45
56
  When adding new posts and pages, add to the YAML front matter:
46
57
  ```
@@ -82,6 +93,19 @@ Sample code for meta link generation:
82
93
  ```
83
94
 
84
95
 
96
+ #### Available and missing translations
97
+ _New in 1.13.0_
98
+
99
+ Polyglot exposes two arrays on every page describing its translation status:
100
+
101
+ - `page.available_languages` — language codes that have an actual translation of this page.
102
+ - `page.missing_languages` — languages that don't yet have a translation of this page.
103
+
104
+ `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.
105
+
106
+ Combine with [`page.rendered_lang`](#detecting-fallback-pages-with-pagerendered_lang) to also flag fallback content on the page itself.
107
+
108
+
85
109
  #### Using different permalinks per language
86
110
  _New in 1.7.0_
87
111
 
@@ -121,6 +145,25 @@ Lets say you are building your website. You have an `/about/` page written in *e
121
145
 
122
146
  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
147
 
148
+ #### Smart hreflang Generation
149
+
150
+ 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.
151
+
152
+ For example, if you have `/about.html` in English and Spanish but not French:
153
+ - The English page gets `hreflang="en"`, `hreflang="es"`, and `hreflang="x-default"`
154
+ - The Spanish page gets the same hreflang tags
155
+ - No `hreflang="fr"` is generated, even though a French fallback page exists
156
+
157
+ This behavior:
158
+ - Generates pages for all languages (fallback content is still served)
159
+ - Only advertises translations that actually exist via `hreflang` tags
160
+ - Always includes `hreflang` for the default language and `x-default`
161
+
162
+ Translation detection works via:
163
+ 1. **page_id matching**: Documents with the same `page_id` frontmatter are considered translations
164
+ 2. **permalink matching**: Documents with matching permalinks (and different `lang`) are considered translations
165
+ 3. **Searches both collections and standalone pages**: The `{% I18n_Headers %}` tag searches `site.collections` and `site.pages`
166
+
124
167
  ### Relativized Local Urls
125
168
  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
169
  ```md
@@ -159,6 +202,73 @@ becomes
159
202
  <p>Cliquez <a href="https://mywebsite.com/fr/">ici</a> pour aller à l'entrée du site.
160
203
  ```
161
204
 
205
+ #### Canonical URL Handling
206
+
207
+ 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:
208
+
209
+ - Points to the translated URL for pages with actual translations
210
+ - Points to the default language URL for fallback pages (pages without translations)
211
+ - Properly handles the `page_id` and permalink matching for translation detection
212
+
213
+ **Setup with jekyll-seo-tag:**
214
+
215
+ 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:
216
+
217
+ ```liquid
218
+ {% seo canonical=false %}
219
+ {% I18n_Headers %}
220
+ ```
221
+
222
+ The `canonical=false` option is available in jekyll-seo-tag v2.9.0+
223
+
224
+ **Fallback Canonical Behavior:**
225
+
226
+ To have fallback pages (pages without translations) point their canonical URL to the default language version, add to your `_config.yml`:
227
+
228
+ ```yaml
229
+ fallback_canonical_to_default_lang: true
230
+ ```
231
+
232
+ With this option enabled:
233
+ - Pages with actual translations: canonical points to the translated URL (e.g., `/es/sobre-nosotros/`)
234
+ - Fallback pages (no translation): canonical points to the default language URL (e.g., `/about/` instead of `/es/about/`)
235
+
236
+ This improves SEO by:
237
+ - Preventing search engines from indexing duplicate fallback content under multiple language URLs
238
+ - Consolidating SEO authority to the original content
239
+ - Signaling to search engines which version is the authoritative source
240
+
241
+ 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.
242
+
243
+ ### Localizing Netlify _redirects
244
+ _New in 1.13.0_
245
+
246
+ 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.
247
+
248
+ Polyglot can automatically generate language-prefixed versions of your redirects. Enable this feature in your `_config.yml`:
249
+
250
+ ```yaml
251
+ localize_redirects: true
252
+ exclude_from_redirect_localization:
253
+ - /signin
254
+ - /app
255
+ ```
256
+
257
+ With this configuration, a redirect like:
258
+ ```
259
+ /github https://github.com/org/repo 302
260
+ ```
261
+
262
+ Will automatically generate localized versions for all your configured languages:
263
+ ```
264
+ /github https://github.com/org/repo 302
265
+ /fr/github https://github.com/org/repo 302
266
+ /de/github https://github.com/org/repo 302
267
+ /sv/github https://github.com/org/repo 302
268
+ ```
269
+
270
+ 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.
271
+
162
272
  ### Disabling Url Relativizing
163
273
  _New in 1.4.0_
164
274
  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 +372,8 @@ This plugin stands out from other I18n Jekyll plugins.
262
372
  - provides the liquid tag `{{ site.default_lang }}` to get the default_lang I18n string.
263
373
  - 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
374
  - provides the liquid tag `{{ page.rendered_lang }}` to get the language the page content is actually rendered in (useful for detecting fallback pages).
375
+ - provides the liquid tag `{{ page.available_languages }}` to get the array of language codes a page has been translated into.
376
+ - 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
377
  - provides the liquid tag `{{ I18n_Headers }}` to append SEO bonuses to your website.
266
378
  - provides the liquid tag `{{ Unrelativized_Link href="/hello" }}` to make urls that do not get influenced by url correction regexes.
267
379
  - provides `site.data` localization for efficient rich text replacement.
@@ -350,7 +462,7 @@ These are talented and considerate software developers across the world that hav
350
462
  * [@george-gca](https://github.com/george-gca) [pt-BR support](https://polyglot.untra.io/pt-BR/2024/02/29/localized-variables.md)
351
463
  * [@PanderMusubi](https://github.com/PanderMusubi) - 1.12 / jekyll-minimal-mistakes-polyglot demo
352
464
  * [@GruberMarkus](https://github.com/GruberMarkus) - redirect anchor support
353
- * [@rathboma](https://github.com/rathboma) - page.rendered_lang / sublanguage redirects
465
+ * [@rathboma](https://github.com/rathboma) - page.rendered_lang / sublanguage redirects / serial_default_lang
354
466
  * [@manabu-nakamura](https://github.com/manabu-nakamura) - Japanese strings
355
467
 
356
468
  ### Other Websites Built with Polyglot
@@ -383,10 +495,14 @@ Feel free to open a PR and list your multilingual blog here you may want to shar
383
495
  * [x] - **site language**: chinese China `zh-CN`
384
496
  * [x] - **site language**: italian `it`
385
497
  * [x] - **site language**: turkish `tk`
386
- * [ ] - **site language**: chinese Taiwan `zh-TW`
387
- * [ ] - **site language**: portuguese Portugal `pt-PT`
388
- * [ ] - get whitelisted as an official github-pages jekyll plugin
498
+ * [x] - **site language**: ukrainian `uk`
499
+ * [x] - **site language**: hindi `hi`
500
+ * [x] - **site language**: chinese Taiwan `zh-TW`
501
+ * [x] - **site language**: portuguese Portugal `pt-PT`
389
502
  * [x] - update CI provider
503
+ * [ ] - get to 512 github stars
504
+ * [ ] - get whitelisted as an official github-pages jekyll plugin
505
+
390
506
 
391
507
  ## Copyright
392
508
  Copyright (c) Samuel Volin 2025. License: MIT
@@ -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,17 +1,18 @@
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, :serial_default_lang
8
7
  attr_accessor :file_langs, :active_lang
9
8
 
10
9
  def prepare
11
10
  @file_langs = {}
12
11
  fetch_languages
13
12
  @parallel_localization = config.fetch('parallel_localization', true)
13
+ @serial_default_lang = config.fetch('serial_default_lang', false)
14
14
  @lang_from_path = config.fetch('lang_from_path', false)
15
+ @fallback_canonical_to_default_lang = config.fetch('fallback_canonical_to_default_lang', false)
15
16
  @exclude_from_localization = config.fetch('exclude_from_localization', []).map do |e|
16
17
  if File.directory?(e) && e[-1] != '/'
17
18
  "#{e}/"
@@ -34,25 +35,32 @@ module Jekyll
34
35
  prepare
35
36
  all_langs = ([@default_lang] + @languages).uniq
36
37
  if @parallel_localization
38
+ if @serial_default_lang
39
+ # Run the default language in the parent first to prime
40
+ process_language @default_lang
41
+ langs_to_fork = @languages - [@default_lang]
42
+ else
43
+ langs_to_fork = all_langs
44
+ end
37
45
  nproc = Etc.nprocessors
38
46
  pids = {}
39
47
  begin
40
- all_langs.each do |lang|
48
+ langs_to_fork.each do |lang|
41
49
  pids[lang] = fork do
42
50
  process_language lang
43
51
  end
44
- while pids.length >= (lang == all_langs[-1] ? 1 : nproc)
52
+ while pids.length >= (lang == langs_to_fork[-1] ? 1 : nproc)
45
53
  sleep 0.1
46
54
  pids.map do |pid_lang, pid|
47
55
  next unless waitpid pid, Process::WNOHANG
48
56
 
49
57
  pids.delete pid_lang
50
- raise "Polyglot subprocess #{pid} (#{lang}) failed (#{$CHILD_STATUS.exitstatus})" unless $CHILD_STATUS.success?
58
+ raise "Polyglot subprocess #{pid} (#{pid_lang}) failed (#{$?.exitstatus})" unless $?.success?
51
59
  end
52
60
  end
53
61
  end
54
62
  rescue Interrupt
55
- all_langs.each do |lang|
63
+ langs_to_fork.each do |lang|
56
64
  next unless pids.key? lang
57
65
 
58
66
  puts "Killing #{pids[lang]} : #{lang}"
@@ -131,11 +139,9 @@ module Jekyll
131
139
  end
132
140
 
133
141
  segments = split_on_multiple_delimiters(doc.path)
134
- # loop through all segments and check if they match the language regex
135
142
  segments.each do |segment|
136
- if @languages.include?(segment)
137
- return segment
138
- end
143
+ match = @languages.find { |lang| lang.downcase == segment.downcase }
144
+ return match if match
139
145
  end
140
146
 
141
147
  nil
@@ -148,9 +154,27 @@ module Jekyll
148
154
  def coordinate_documents(docs)
149
155
  regex = document_url_regex
150
156
  approved = {}
157
+ # Build set of valid languages (default + configured)
158
+ valid_languages = ([@default_lang] + @languages).uniq
159
+
151
160
  docs.each do |doc|
152
- lang = doc.data['lang'] || derive_lang_from_path(doc) || @default_lang
161
+ # Get the explicitly declared language (frontmatter or path-derived)
162
+ explicit_lang = doc.data['lang'] || derive_lang_from_path(doc)
163
+ lang = explicit_lang || @default_lang
164
+
165
+ # Skip documents whose explicit lang is not in configured languages.
166
+ if explicit_lang && valid_languages.none? { |l| l.downcase == explicit_lang.downcase }
167
+ Jekyll.logger.warn "Polyglot:", "Skipping #{doc.relative_path} - lang '#{explicit_lang}' not in configured languages #{valid_languages.inspect}"
168
+ next
169
+ end
170
+
171
+ # If the doc lang matches a config language case-insensitively, use the config case
172
+ config_lang = @languages.find { |l| l.downcase == lang.downcase }
173
+ lang = config_lang if config_lang
174
+ doc.data['lang'] = lang if doc.data['lang'] && config_lang
175
+
153
176
  lang_exclusive = doc.data['lang-exclusive'] || []
177
+
154
178
  url = doc.url.gsub(regex, '/')
155
179
  page_id = doc.data['page_id'] || url
156
180
  doc.data['permalink'] = url if doc.data['permalink'].to_s.empty? && !doc.data['lang'].to_s.empty?
@@ -190,11 +214,12 @@ module Jekyll
190
214
  user_redirects = user_redirects.map do |redirect_path|
191
215
  # Normalize path to start with /
192
216
  redirect_path = "/#{redirect_path}" unless redirect_path.start_with?('/')
193
- # Only prefix if not already prefixed with this language
194
- if redirect_path.start_with?("/#{doc_lang}/")
195
- redirect_path
217
+ if redirect_path == "/#{doc_lang}"
218
+ '/'
219
+ elsif redirect_path.start_with?("/#{doc_lang}/")
220
+ redirect_path.delete_prefix("/#{doc_lang}")
196
221
  else
197
- "/#{doc_lang}#{redirect_path}"
222
+ redirect_path
198
223
  end
199
224
  end
200
225
  end
@@ -217,19 +242,63 @@ module Jekyll
217
242
  end
218
243
 
219
244
  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']
245
+ page_id = doc.data['page_id']
246
+ normalized_permalink = normalized_permalink_for_doc(doc)
247
+ translations = find_translations(page_id, normalized_permalink, docs)
248
+
249
+ doc.data['permalink_lang'] = translations
250
+ configured = ([@default_lang] + @languages).uniq
251
+ doc.data['available_languages'] = translations.keys
252
+ # missing_languages signals "a visitor in this lang would see different
253
+ # content than another lang's visitor". A single-source page falls back
254
+ # identically everywhere, so nothing is missing in that case.
255
+ doc.data['missing_languages'] =
256
+ translations.size > 1 ? (configured - translations.keys) : []
257
+ end
258
+
259
+ # Returns a hash of { lang => permalink } for all docs that are translations
260
+ # of the given page. Matches by page_id when present, otherwise by normalized
261
+ # permalink. Filters out languages not in the configured languages list.
262
+ # candidate_docs defaults to site.collections + site.pages so the helper can
263
+ # be called from Liquid render contexts where the caller doesn't already
264
+ # hold a docs array.
265
+ def find_translations(page_id, normalized_permalink, candidate_docs = nil)
266
+ candidate_docs ||= collections.values.flat_map(&:docs) + pages
267
+ valid_languages = ([@default_lang] + @languages).uniq
268
+
269
+ matching =
270
+ if !page_id.to_s.empty?
271
+ candidate_docs.select { |d| d.data['page_id'] == page_id }
272
+ elsif !normalized_permalink.to_s.empty?
273
+ candidate_docs.select { |d| normalized_permalink_for_doc(d) == normalized_permalink }
274
+ else
275
+ []
229
276
  end
277
+
278
+ matching.each_with_object({}) do |d, h|
279
+ explicit_lang = d.data['lang'] || derive_lang_from_path(d)
280
+ doclang = explicit_lang || @default_lang
281
+ next if explicit_lang && !valid_languages.include?(explicit_lang)
282
+
283
+ h[doclang] = d.data['permalink']
230
284
  end
231
285
  end
232
286
 
287
+ # Returns the doc's permalink with its own language prefix stripped, so it
288
+ # can be matched against sibling docs that share the same un-prefixed
289
+ # permalink. Returns nil when no usable permalink is present.
290
+ def normalized_permalink_for_doc(doc)
291
+ permalink = doc.data['permalink'] || (doc.respond_to?(:url) ? doc.url : nil)
292
+ return nil if permalink.to_s.empty?
293
+
294
+ permalink = "/#{permalink}" unless permalink.start_with?('/')
295
+ lang = doc.data['lang']
296
+ return permalink if lang.to_s.empty?
297
+
298
+ stripped = permalink.delete_prefix("/#{lang}/")
299
+ stripped.start_with?('/') ? stripped : "/#{stripped}"
300
+ end
301
+
233
302
  # performs any necessary operations on the documents before rendering them
234
303
  def process_documents(docs)
235
304
  # return if @active_lang == @default_lang
@@ -240,7 +309,10 @@ module Jekyll
240
309
  non_rel_regex = relative_url_regex(true)
241
310
  non_abs_regex = absolute_url_regex(url, true)
242
311
  docs.each do |doc|
243
- unless @active_lang == @default_lang then relativize_urls(doc, rel_regex) end
312
+ unless @active_lang == @default_lang || doc.output.nil?
313
+ localize_redirect_target(doc)
314
+ relativize_urls(doc, rel_regex)
315
+ end
244
316
  correct_nonrelativized_urls(doc, non_rel_regex)
245
317
  if url
246
318
  unless @active_lang == @default_lang then relativize_absolute_urls(doc, abs_regex, url) end
@@ -249,6 +321,26 @@ module Jekyll
249
321
  end
250
322
  end
251
323
 
324
+ # jekyll-redirect-from renders its target into (script location=, meta refresh url=)
325
+ # rewrite the redirect target before language lookaheads in the href regexes skip
326
+ def localize_redirect_target(doc)
327
+ return if @active_lang == @default_lang || doc.output.nil?
328
+
329
+ redirect = doc.data['redirect']
330
+ return unless redirect.is_a?(Hash)
331
+
332
+ target = redirect['to'].to_s
333
+ origin = "#{config['url']}#{@baseurl}"
334
+ return unless target.start_with?("#{origin}/")
335
+
336
+ path = target.delete_prefix(origin)
337
+ return if path.start_with?("/#{@active_lang}/")
338
+
339
+ localized = "#{origin}/#{@active_lang}#{path}"
340
+ doc.output = doc.output.gsub(target, localized)
341
+ redirect['to'] = localized
342
+ end
343
+
252
344
  # a regex that matches urls or permalinks with i18n prefixes or suffixes
253
345
  # matches /en/foo , .en/foo , foo.en/ and other simmilar default urls
254
346
  # made by jekyll when parsing documents without explicitly set permalinks
@@ -298,7 +390,9 @@ module Jekyll
298
390
  end
299
391
  end
300
392
  start = disabled ? 'ferh' : 'href'
301
- neglookbehind = disabled ? "" : "(?<!hreflang=\"#{@default_lang}\" |rel=\"canonical\" )"
393
+ # Build negative lookbehind to exclude hreflang URLs from relativization
394
+ # hreflang tags for default language and x-default should not be relativized
395
+ neglookbehind = disabled ? "" : "(?<!hreflang=\"#{@default_lang}\" |hreflang=\"x-default\" )"
302
396
  %r{#{neglookbehind}#{start}="?#{url}#{@baseurl}/((?:#{regex}[^,'"\s/?.]+\.?)*(?:/[^\]\[)("'\s]*)?)"}
303
397
  end
304
398
 
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.14.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-08-21 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
@@ -64,14 +65,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
64
65
  requirements:
65
66
  - - ">="
66
67
  - !ruby/object:Gem::Version
67
- version: 3.1.0
68
+ version: 3.2.0
68
69
  required_rubygems_version: !ruby/object:Gem::Requirement
69
70
  requirements:
70
71
  - - ">="
71
72
  - !ruby/object:Gem::Version
72
- version: 3.1.0
73
+ version: 3.2.0
73
74
  requirements: []
74
- rubygems_version: 3.3.27
75
+ rubygems_version: 3.4.19
75
76
  signing_key:
76
77
  specification_version: 4
77
78
  summary: I18n plugin for Jekyll Blogs