投稿

PolyglotでJekyllブログの多言語対応を実現する方法 (1) - Polyglotプラグインの適用 & hreflang altタグ及びsitemap、言語選択ボタンの実装

'jekyll-theme-chirpy'ベースのJekyllブログにPolyglotプラグインを適用して多言語対応を実装した過程を紹介する。この投稿は該当シリーズの最初の記事として、Polyglotプラグインを適用し、htmlヘッダーとsitemapを修正する部分を扱う。

PolyglotでJekyllブログの多言語対応を実現する方法 (1) - Polyglotプラグインの適用 & hreflang altタグ及びsitemap、言語選択ボタンの実装

概要

約4ヶ月前の12024年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代替タグ、canonical linkを含んで多言語検索のための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.xml"]
parallel_localization: false
lang_from_path: true
  • languages:サポートしたい言語リスト
  • default_lang:基本fallback言語
  • exclude_from_localization:言語ローカライゼーション対象から除外するルートファイル/フォルダパス文字列正規表現指定
  • parallel_localization:ビルド過程で多言語処理を並列化するかどうかを指定するboolean値
  • lang_from_path:boolean値で、’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ファイルが言語別に作られず、ルートディレクトリに一つだけ存在するよう’exclude_from_localization’リストに追加して、下記の間違った例のようにならないようにしなければならない。

間違った例(各ファイルの内容は言語別に異ならず、すべて同一):

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

(12025.01.14. アップデート)上述した内容をREADMEに補強して提出したPull Requestが受け入れられたことにより、今ではPolyglot公式文書でも同じ案内を確認できる。

‘parallel_localization’を’true’に指定するとビルド時間が相当短縮される利点があるが、12024年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代替タグを挿入し、標準URL(canonical URL)を適切に指定しなければならない。

htmlヘッダー

12024.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
7
8
9
10
11
  <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 %}

(12025.07.29. 追加)またChirpyテーマはJekyll SEO Tagプラグインを基本内蔵しているが、Jekyll SEO Tagが自動生成するog:localeog:url Open Graphメタデータ属性及び標準URL(canonical URL)rel="canonical" link要素)がサイト基本言語(site.langsite.default_lang)基準なので追加的な処理が必要であることを確認した。
したがって{{ seo_tags }}の前に次の構文を追加した。

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

  {% capture seo_tags -%}
    {% seo title=false %}
  {%- endcapture %}

  ...(中略)...

  {%- capture old_og_locale -%}
    <meta property="og:locale" content="{{site.lang}}" />
  {%- endcapture -%}
  {%- capture new_og_locale -%}
    <meta property="og:locale" content="{{site.active_lang}}" />
    {% for lang in site.languages -%}
      {%- if lang == site.active_lang -%}
        {%- continue -%}
      {%- endif %}
    <meta property="og:locale:alternate" content="{{lang}}" />
    {%- endfor %}
  {%- endcapture -%}
  {% assign seo_tags = seo_tags | replace: old_og_locale, new_og_locale %}
  
  {% unless site.active_lang == site.default_lang -%}
    {%- capture old_canonical_link -%}
      <link rel="canonical" href="{{site.url}}{{page.url}}" />
    {%- endcapture -%}
    {%- capture old_og_url -%}
      <meta property="og:url" content="{{site.url}}{{page.url}}" />
    {%- endcapture -%}
    {%- capture new_canonical_link -%}
      <link rel="canonical" href="{{site.url}}/{{site.active_lang}}{{page.url}}" />
    {%- endcapture -%}
    {%- capture new_og_url -%}
      <meta property="og:url" content="{{site.url}}/{{site.active_lang}}{{page.url}}" />
    {%- endcapture -%}
    {% assign seo_tags = seo_tags | replace: old_canonical_link, new_canonical_link %}
    {% assign seo_tags = seo_tags | replace: old_og_url, new_og_url %}
  {%- endunless %}

  {{ seo_tags }}

  ...(後略)

Google開発者文書によると、一つのページに複数の言語バージョンがある時は主要コンテンツの言語が同じ場合、すなわちヘッダー、フッター、その他重要でないテキストのみ翻訳されており本文が同一の場合にのみ重複と見なす。したがって今このブログのように本文テキストを複数言語で提供する場合には、各言語バージョンすべて重複ではない独立的なページとして見なすので、言語によって異なる標準URLを指定しなければならない。
例えば今このページの日本語バージョンの場合、標準URLは”https://www.yunseo.kim/posts/how-to-support-multi-language-on-jekyll-blog-with-polyglot-1/”ではなく”https://www.yunseo.kim/ja/posts/how-to-support-multi-language-on-jekyll-blog-with-polyglot-1/”である。

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
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
---
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 -%}
    {%- comment -%}<!-- Exclude redirects from sitemap -->{%- endcomment -%}
    {%- if node.redirect.to -%}
      {%- continue -%}
    {%- endif -%}
    {%- 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 -%}
          {% if site.default_lang -%}
          <xhtml:link rel="alternate" hreflang="{{site.default_lang}}" href="{{site.url}}{{node.url}}" />
          {%- endif -%}
          {% for lang in site.languages -%}
            {% if lang == site.default_lang -%}
              {%- continue -%}
            {%- endif %}
          <xhtml:link rel="alternate" hreflang="{{lang}}" href="{{site.url}}/{{lang}}{{node.url}}" />
          {%- endfor %}
        </url>
      {% endif %}
    {%- elsif site.default_lang -%}
        <url>
          <loc>{{ node.url | absolute_url }}</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>
    {%- 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 %}
        {% if site.default_lang -%}
        <xhtml:link rel="alternate" hreflang="{{site.default_lang}}" href="{{site.url}}{{node.url}}" />
        {%- endif -%}
        {% for lang in site.languages -%}
          {% if lang == site.default_lang -%}
            {%- continue -%}
          {%- endif %}
        <xhtml:link rel="alternate" hreflang="{{lang}}" href="{{site.url}}/{{lang}}{{node.url}}" />
        {%- endfor %}
      </url>
    {% endfor %}
  {% endfor %}

{%- endfor %}
</urlset>

サイドバーに言語選択ボタンを追加

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

Further Reading

Part 2に続く

この投稿は投稿者によって CC BY-NC 4.0 の下でライセンスされています。