Post

如何使用 Polyglot 在 Jekyll 部落格支援多語言 (1) - 套用 Polyglot 外掛程式 & 實作 hreflang alt 標籤、sitemap 和語言選擇按鈕

介紹在基於 'jekyll-theme-chirpy' 的 Jekyll 部落格中套用 Polyglot 外掛程式以實現多語言支援的過程。這篇文章是該系列的第一篇,涵蓋了套用 Polyglot 外掛程式並修改 html 標頭和 sitemap 的部分。

如何使用 Polyglot 在 Jekyll 部落格支援多語言 (1) - 套用 Polyglot 外掛程式 & 實作 hreflang alt 標籤、sitemap 和語言選擇按鈕

概述

大約 4 個月前的 2024 年 7 月初,我在這個透過 Github Pages 託管的 Jekyll 部落格中套用了 Polyglot 外掛程式,以實現多語言支援。 這個系列將分享在 Chirpy 主題中套用 Polyglot 外掛程式的過程中遇到的錯誤及其解決方法,以及考慮 SEO 的 html 標頭和 sitemap.xml 的編寫方法。 這個系列由兩篇文章組成,您正在閱讀的是該系列的第一篇。

需求

  • 必須能夠按語言路徑(例如 /posts/ko//posts/ja/)區分並提供建置結果(網頁)。
  • 為了盡可能減少多語言支援所需的額外時間和精力,即使不在原始 Markdown 檔案的 YAML front matter 中逐一指定 ‘lang’ 和 ‘permalink’ 標籤,也應能在建置時根據該檔案所在的本地路徑(例如 /_posts/ko//_posts/ja/)自動識別語言。
  • 網站內每個頁面的標頭部分應包含適當的 Content-Language 元標籤和 hreflang 替代標籤,以滿足 Google 多語言搜尋的 SEO 指南。
  • 必須能在 sitemap.xml 中提供網站內支援每種語言的所有頁面連結,且不遺漏,而 sitemap.xml 本身應只存在於根路徑中,不得重複。
  • Chirpy 主題提供的所有功能都必須在每種語言的頁面上正常運作,如果不正常,則必須進行修正。
    • ‘Recently Updated’、’Trending Tags’ 功能正常運作
    • 使用 GitHub Actions 的建置過程中不會出現錯誤
    • 部落格右上角的文章搜尋功能正常運作

套用 Polyglot 外掛程式

由於 Jekyll 不原生支援多語言部落格,為了滿足上述需求實現多語言部落格,需要使用外部外掛程式。經過搜尋,發現 Polyglot 被廣泛用於多語言網站實現,且能滿足大部分上述需求,因此採用了該外掛程式。

安裝外掛程式

我正在使用 Bundler,所以在 Gemfile 中添加了以下內容:

1
2
3
group :jekyll_plugins do
   gem "jekyll-polyglot"
end

之後在終端機中執行 bundle update,就會自動完成安裝。

如果不使用 Bundler,也可以在終端機中使用 gem install jekyll-polyglot 命令直接安裝 gem,然後在 _config.yml 中添加外掛程式,如下所示:

1
2
plugins:
  - jekyll-polyglot

配置設定

接下來,打開 _config.yml 檔案並添加以下內容:

1
2
3
4
5
6
# Polyglot Settings
languages: ["en", "ko", "ja", "zh-TW", "es", "pt-BR", "fr", "de"]
default_lang: "en"
exclude_from_localization: ["javascript", "images", "css", "public", "assets", "sitemap"]
parallel_localization: false
lang_from_path: true
  • languages:想要支援的語言列表
  • default_lang:預設的 fallback 語言
  • exclude_from_localization:指定要從本地化對象中排除的根檔案/資料夾路徑字串正則表達式
  • parallel_localization:布林值,指定是否在建置過程中並行處理多語言
  • lang_from_path:布林值,設為 ‘true’ 時,即使在文章 Markdown 檔案內的 YAML front matter 中沒有明確指定 ‘lang’ 屬性,只要該 Markdown 檔案的路徑字串包含語言代碼,就會自動識別並使用

Sitemap 協議官方文件中明確指出:

“The location of a Sitemap file determines the set of URLs that can be included in that Sitemap. A Sitemap file located at http://example.com/catalog/sitemap.xml can include any URLs starting with http://example.com/catalog/ but can not include URLs starting with http://example.com/images/.”

“It is strongly recommended that you place your Sitemap at the root directory of your web server.”

為了遵守這一規定,應該將 ‘sitemap.xml’ 添加到 ‘exclude_from_localization’ 列表中,確保相同內容的 sitemap.xml 檔案不會為每種語言生成,而只在根目錄中存在一個,避免出現以下錯誤示例:

錯誤示例(每個檔案的內容都相同,沒有語言差異):

  • /sitemap.xml
  • /ko/sitemap.xml
  • /es/sitemap.xml
  • /pt-BR/sitemap.xml
  • /ja/sitemap.xml
  • /fr/sitemap.xml
  • /de/sitemap.xml

(2025.01.14. 更新)提交的包含上述內容的 README 補充的 Pull Request 已被接受,現在可以在 Polyglot 官方文件中看到相同的說明。

將 ‘parallel_localization’ 設為 ‘true’ 可以大幅縮短建置時間,但截至 2024 年 7 月,當對本部落格啟用該功能時,頁面右側側邊欄的 ‘Recently Updated’ 和 ‘Trending Tags’ 部分的連結標題無法正常處理,出現與其他語言混雜的錯誤。這似乎還不太穩定,如果要應用到網站上,需要事先測試是否正常運作。此外,如果使用 Windows,該功能也不支援,因此需要停用

此外,在 Jekyll 4.0 中,需要如下停用 CSS sourcemaps 生成

1
2
sass:
  sourcemap: never # In Jekyll 4.0 , SCSS source maps will generate improperly due to how Polyglot operates

撰寫文章時的注意事項

撰寫多語言文章時需要注意以下幾點:

  • 指定適當的語言代碼:必須使用檔案路徑(例如 /_posts/ko/example-post.md)或 YAML front matter 中的 ‘lang’ 屬性(例如 lang: ko)來指定適當的 ISO 語言代碼。可以參考 Chrome 開發者文件中的示例。

但是,雖然 Chrome 開發者文件中將地區代碼表示為 ‘pt_BR’ 這樣的格式,實際上應該使用 ‘pt-BR’ 這樣的格式,即用 - 代替 _,這樣在之後添加 html 標頭的 hreflang 替代標籤時才能正常運作。

  • 檔案路徑和名稱應保持一致。

詳細資訊請參考 GitHub untra/polyglot 儲存庫的 README

修改 html 標頭和 sitemap

現在,為了 SEO,我們需要在部落格內每個頁面的 html 標頭中插入 Content-Language 元標籤和 hreflang 替代標籤。

html 標頭

截至 2024.11. 的最新版本 1.8.1 發布,Polyglot 在頁面標頭部分調用 {% I18n_Headers %} Liquid 標籤時,會自動執行上述操作。 然而,這假設該頁面已明確指定了 ‘permalink’ 屬性標籤,如果沒有指定,則無法正常運作。

因此,我從 Chirpy 主題的 head.html 中獲取內容後,直接添加了以下內容: 參考了 Polyglot 官方部落格的 SEO Recipes 頁面,但修改了在 page.permalink 不存在時使用 page.url 屬性代替。

1
2
3
4
5
6
  <meta http-equiv="Content-Language" content="{{site.active_lang}}">

  {% if site.default_lang %}<link rel="alternate" hreflang="{{site.default_lang}}" href="{{site.url}}{{page.url}}" />{% endif %}
  {% for lang in site.languages %}{% if lang == site.default_lang %}{% continue %}{% endif %}
  <link rel="alternate" hreflang="{{lang}}" href="{{site.url}}/{{lang}}{{page.url}}" />
  {% endfor %}

sitemap

由於 Jekyll 在建置時自動生成的 sitemap 不能正確支援多語言頁面,因此需要在根目錄創建 sitemap.xml 檔案,並輸入以下內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
---
layout: content
---
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
{% for lang in site.languages %}

    {% for node in site.pages %}
        {% comment %}<!-- very lazy check to see if page is in the exclude list - this means excluded pages are not gonna be in the sitemap at all, write exceptions as necessary -->{% endcomment %}
        {% unless site.exclude_from_localization contains node.path %}
            {% comment %}<!-- assuming if there's not layout assigned, then not include the page in the sitemap, you may want to change this -->{% endcomment %}
            {% if node.layout %}
                <url>
                    <loc>{% if lang == site.default_lang %}{{ node.url | absolute_url }}{% else %}{{ node.url | prepend: lang | prepend: '/' | absolute_url }}{% endif %}</loc>
                    {% if node.last_modified_at and node.last_modified_at != node.date %}<lastmod>{{ node.last_modified_at | date: '%Y-%m-%dT%H:%M:%S%:z' }}</lastmod>{% elsif node.date %}<lastmod>{{ node.date | date: '%Y-%m-%dT%H:%M:%S%:z' }}</lastmod>{% endif %}
                </url>
            {% endif %}
        {% endunless %}
    {% endfor %}

    {% comment %}<!-- This loops through all site collections including posts -->{% endcomment %}
    {% for collection in site.collections %}
        {% for node in site[collection.label] %}
            <url>
                <loc>{% if lang == site.default_lang %}{{ node.url | absolute_url }}{% else %}{{ node.url | prepend: lang | prepend: '/' | absolute_url }}{% endif %}</loc>
                {% if node.last_modified_at and node.last_modified_at != node.date %}<lastmod>{{ node.last_modified_at | date: '%Y-%m-%dT%H:%M:%S%:z' }}</lastmod>{% elsif node.date %}<lastmod>{{ node.date | date: '%Y-%m-%dT%H:%M:%S%:z' }}</lastmod>{% endif %}
            </url>
        {% endfor %}
    {% endfor %}

{% endfor %}
</urlset>

在側邊欄添加語言選擇按鈕

(2025.02.05. 更新)將語言選擇按鈕改進為下拉列表形式。
創建 _includes/lang-selector.html 檔案,並輸入以下內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<link rel="stylesheet" href="{{ '/assets/css/lang-selector.css' | relative_url }}">

<div class="lang-dropdown">
    <select class="lang-select" onchange="changeLang(this.value)" aria-label="Select Language">
    {%- for lang in site.languages -%}
        <option value="{% if lang == site.default_lang %}{{ page.url }}{% else %}/{{ lang }}{{ page.url }}{% endif %}"
                {% if lang == site.active_lang %}selected{% endif %}>
            {% case lang %}
            {% when 'ko' %}🇰🇷 한국어
            {% when 'en' %}🇺🇸 English
            {% when 'ja' %}🇯🇵 日本語
            {% when 'zh-TW' %}🇹🇼 正體中文
            {% when 'es' %}🇪🇸 Español
            {% when 'pt-BR' %}🇧🇷 Português
            {% when 'fr' %}🇫🇷 Français
            {% when 'de' %}🇩🇪 Deutsch
            {% else %}{{ lang }}
            {% endcase %}
        </option>
    {%- endfor -%}
    </select>
</div>

<script>
function changeLang(url) {
    window.location.href = url;
}
</script>

此外,創建 assets/css/lang-selector.css 檔案,並輸入以下內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/**
 * 語言選擇器樣式
 * 
 * 定義側邊欄中語言選擇下拉列表的樣式。
 * 支援主題的深色模式,並針對移動環境進行了優化。
 */

/* 語言選擇器容器 */
.lang-selector-wrapper {
    padding: 0.35rem;
    margin: 0.15rem 0;
    text-align: center;
}

/* 下拉列表容器 */
.lang-dropdown {
    position: relative;
    display: inline-block;
    width: auto;
    min-width: 120px;
    max-width: 80%;
}

/* 選擇輸入元素 */
.lang-select {
    /* 基本樣式 */
    appearance: none;
    -webkit-appearance: none;
    -moz-appearance: none;
    width: 100%;
    padding: 0.5rem 2rem 0.5rem 1rem;
    
    /* 字體和顏色 */
    font-family: Lato, "Pretendard JP Variable", "Pretendard Variable", sans-serif;
    font-size: 0.95rem;
    color: var(--sidebar-muted);
    background-color: var(--sidebar-bg);
    
    /* 形狀和互動 */
    border-radius: var(--bs-border-radius, 0.375rem);
    cursor: pointer;
    transition: all 0.2s ease;
    
    /* 添加箭頭圖標 */
    background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
    background-repeat: no-repeat;
    background-position: right 0.75rem center;
    background-size: 1rem;
}

/* 國旗表情符號樣式 */
.lang-select option {
    font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
    padding: 0.35rem;
    font-size: 1rem;
}

.lang-flag {
    display: inline-block;
    margin-right: 0.5rem;
    font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
}

/* 懸停狀態 */
.lang-select:hover {
    color: var(--sidebar-active);
    background-color: var(--sidebar-hover);
}

/* 聚焦狀態 */
.lang-select:focus {
    outline: 2px solid var(--sidebar-active);
    outline-offset: 2px;
    color: var(--sidebar-active);
}

/* Firefox 瀏覽器適配 */
.lang-select:-moz-focusring {
    color: transparent;
    text-shadow: 0 0 0 var(--sidebar-muted);
}

/* IE 瀏覽器適配 */
.lang-select::-ms-expand {
    display: none;
}

/* 深色模式適配 */
[data-mode="dark"] .lang-select {
    background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
}

/* 移動環境優化 */
@media (max-width: 768px) {
    .lang-select {
        padding: 0.75rem 2rem 0.75rem 1rem;  /* 更大的觸摸區域 */
    }
    
    .lang-dropdown {
        min-width: 140px;  /* 移動端更寬的選擇區域 */
    }
}

接下來,在 Chirpy 主題的 _includes/sidebar.html 中,在 “sidebar-bottom” 類別前面添加以下三行,以便 Jekyll 在頁面建置時載入先前創建的 _includes/lang-selector.html 的內容:

1
2
3
4
5
6
7
  (前略)...
  <div class="lang-selector-wrapper w-100">
    {%- include lang-selector.html -%}
  </div>

  <div class="sidebar-bottom d-flex flex-wrap align-items-center w-100">
    ...(後略)

延伸閱讀

繼續閱讀 第二部分

This post is licensed under CC BY-NC 4.0 by the author.