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月初め、Jekyll基盤でGithub Pagesを通じてホスティング中の本ブログにPolyglotプラグインを適用して多言語サポートの実装を追加しました。 このシリーズは、ChirpyテーマにPolyglotプラグインを適用する過程で発生したバグとその解決過程、そしてSEOを考慮したhtmlヘッダーとsitemap.xmlの作成方法を共有します。 シリーズは2つの記事で構成されており、現在読んでいるこの記事はそのシリーズの最初の記事です。

要件

  • ビルドした結果物(ウェブページ)を言語別のパス(例:/posts/ko//posts/ja/)で区別して提供できること。
  • 多言語サポートに追加的に必要な時間と労力を可能な限り最小化するために、作成した原本のマークダウンファイルのYAML front matterに’lang’および’permalink’タグを一々指定しなくても、ビルド時に該当ファイルが位置するローカルパス(例:/_posts/ko//_posts/ja/)に応じて自動的に言語を認識できること。
  • サイト内の各ページのヘッダー部分は適切なContent-Languageメタタグとhreflang代替タグを含み、Googleの多言語検索のためのSEOガイドラインを満たすこと。
  • サイト内で各言語をサポートするすべてのページリンクを漏れなくsitemap.xmlで提供できること、またsitemap.xml自体は重複なくルートパスに1つだけ存在すること。
  • 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: デフォルトのフォールバック言語
  • exclude_from_localization: ローカライゼーション対象から除外するルートファイル/フォルダパス文字列正規表現指定
  • parallel_localization: ビルド過程で多言語処理を並列化するかどうかを指定するブール値
  • lang_from_path: ブール値で、’true’に設定すると投稿マークダウンファイル内にYAML front matterで’lang’属性を別途明示しなくても、該当マークダウンファイルのパス文字列が言語コードを含んでいれば、これを自動的に認識して使用します

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ファイルが言語別に作成されずにルートディレクトリに1つだけ存在するように’exclude_from_localization’リストに追加して、以下の誤った例のようにならないようにする必要があります。

誤った例(各ファイルの内容は言語別に異なるものではなく、すべて同じ):

  • /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ソースマップ生成を無効化する必要があります

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”クラスの直前に次のように3行を追加して、先ほど作成した_includes/lang-selector.htmlの内容をJekyllがページビルド時に読み込むようにしました。

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">
    ...(後略)

さらなる読み物

パート2に続きます

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