Post

Polyglotを使用してJekyllブログで多言語サポートを実装する方法(2)- Chirpyテーマのビルド失敗と検索機能エラーのトラブルシューティング

'jekyll-theme-chirpy'ベースのJekyllブログにPolyglotプラグインを適用して多言語サポートを実装した過程を紹介します。この投稿はシリーズの2番目の記事で、Chirpyテーマに Polyglot適用時に発生したエラーの原因を特定し解決する部分を扱います。

Polyglotを使用してJekyllブログで多言語サポートを実装する方法(2)- Chirpyテーマのビルド失敗と検索機能エラーのトラブルシューティング

概要

約4ヶ月前の人類紀元12024年7月初め、Jekyll基盤でGithub Pagesを通じてホスティングしているこのブログにPolyglotプラグインを適用して多言語サポートを実装しました。 このシリーズでは、Chirpyテーマにポリグロットプラグインを適用する過程で発生したバグとその解決過程、そしてSEOを考慮したHTMLヘッダーとsitemap.xmlの作成方法を共有します。 シリーズは2つの記事で構成されており、現在読んでいるこの記事はそのシリーズの2番目の記事です。

要件

  • ビルドした結果(ウェブページ)を言語別のパス(例:/posts/ko//posts/ja/)で区分して提供できること。
  • 多言語サポートに追加的に必要な時間と労力を可能な限り最小化するために、作成したオリジナルのマークダウンファイルの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を利用したビルド過程でエラーが発生しないこと
    • ブログ右上の投稿検索機能の正常動作

始める前に

この記事は第1回からの続きですので、まだ読んでいない場合は、まず前の記事から読むことをお勧めします。

トラブルシューティング(’relative_url_regex’: target of repeat operator is not specified)

前のステップまで進めた後、bundle exec jekyll serveコマンドを実行してビルドテストをしたところ、'relative_url_regex': target of repeat operator is not specifiedというエラーが発生し、ビルドに失敗しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...(前略)
                    ------------------------------------------------
      Jekyll 4.3.4   Please append `--trace` to the `serve` command 
                     for any additional information or backtrace. 
                    ------------------------------------------------
/Users/yunseo/.gem/ruby/3.2.2/gems/jekyll-polyglot-1.8.1/lib/jekyll/polyglot/
patches/jekyll/site.rb:234:in `relative_url_regex': target of repeat operator 
is not specified: /href="?\/((?:(?!*.gem)(?!*.gemspec)(?!tools)(?!README.md)(
?!LICENSE)(?!*.config.js)(?!rollup.config.js)(?!package*.json)(?!.sass-cache)
(?!.jekyll-cache)(?!gemfiles)(?!Gemfile)(?!Gemfile.lock)(?!node_modules)(?!ve
ndor\/bundle\/)(?!vendor\/cache\/)(?!vendor\/gems\/)(?!vendor\/ruby\/)(?!en\/
)(?!ko\/)(?!es\/)(?!pt-BR\/)(?!ja\/)(?!fr\/)(?!de\/)[^,'"\s\/?.]+\.?)*(?:\/[^
\]\[)("'\s]*)?)"/ (RegexpError)

...(後略)

同様の問題が報告されているか検索した結果、Polyglotリポジトリに全く同じ問題が既に登録されており、解決策も存在していました。

このブログに適用しているChirpyテーマの_config.ymlファイルには、次のような構文が存在します。

1
2
3
4
5
6
7
8
9
exclude:
  - "*.gem"
  - "*.gemspec"
  - docs
  - tools
  - README.md
  - LICENSE
  - "*.config.js"
  - package*.json

問題の原因はPolyglotのsite.rbファイルに含まれる次の二つの関数の正規表現構文が、上記の"*.gem""*.gemspec""*.config.js"のようなワイルドカードを含むグロビング(globbing)パターンを正常に処理できないことにあります。

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
    # a regex that matches relative urls in a html document
    # matches href="baseurl/foo/bar-baz" href="/ja/foo/bar-baz" and others like it
    # avoids matching excluded files.  prepare makes sure
    # that all @exclude dirs have a trailing slash.
    def relative_url_regex(disabled = false)
      regex = ''
      unless disabled
        @exclude.each do |x|
          regex += "(?!#{x})"
        end
        @languages.each do |x|
          regex += "(?!#{x}\/)"
        end
      end
      start = disabled ? 'ferh' : 'href'
      %r{#{start}="?#{@baseurl}/((?:#{regex}[^,'"\s/?.]+\.?)*(?:/[^\]\[)("'\s]*)?)"}
    end

    # a regex that matches absolute urls in a html document
    # matches href="http://baseurl/foo/bar-baz" and others like it
    # avoids matching excluded files.  prepare makes sure
    # that all @exclude dirs have a trailing slash.
    def absolute_url_regex(url, disabled = false)
      regex = ''
      unless disabled
        @exclude.each do |x|
          regex += "(?!#{x})"
        end
        @languages.each do |x|
          regex += "(?!#{x}\/)"
        end
      end
      start = disabled ? 'ferh' : 'href'
      %r{(?<!hreflang="#{@default_lang}" )#{start}="?#{url}#{@baseurl}/((?:#{regex}[^,'"\s/?.]+\.?)*(?:/[^\]\[)("'\s]*)?)"}
    end

この問題を解決する方法は二つあります。

1. Polyglotをフォーク(fork)して問題のある部分を修正して使用する

この記事を書いている時点(12024.11.)では、Jekyll公式ドキュメントexclude設定がグロビング(globbing)パターンの活用をサポートすると明記されています。

“This configuration option supports Ruby’s File.fnmatch filename globbing patterns to match multiple entries to exclude.”

つまり、問題の原因はChirpyテーマではなく、Polyglotのrelative_url_regex()absolute_url_regex()という二つの関数にあるため、これらを問題が発生しないように修正することが根本的な解決策です。

Polyglotではこのバグはまだ解決されていない状態なので、このブログ投稿前述のGitHubイシューに付いた回答を参考にして、Polyglotリポジトリをフォーク(fork)した後、問題のある部分を次のように修正して、オリジナルのPolyglotの代わりに使用すれば良いです。

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
    def relative_url_regex(disabled = false)
      regex = ''
      unless disabled
        @exclude.each do |x|
          escaped_x = Regexp.escape(x)
          regex += "(?!#{escaped_x})"
        end
        @languages.each do |x|
          escaped_x = Regexp.escape(x)
          regex += "(?!#{escaped_x}\/)"
        end
      end
      start = disabled ? 'ferh' : 'href'
      %r{#{start}="?#{@baseurl}/((?:#{regex}[^,'"\s/?.]+\.?)*(?:/[^\]\[)("'\s]*)?)"}
    end

    def absolute_url_regex(url, disabled = false)
      regex = ''
      unless disabled
        @exclude.each do |x|
          escaped_x = Regexp.escape(x)
          regex += "(?!#{escaped_x})"
        end
        @languages.each do |x|
          escaped_x = Regexp.escape(x)
          regex += "(?!#{escaped_x}\/)"
        end
      end
      start = disabled ? 'ferh' : 'href'
      %r{(?<!hreflang="#{@default_lang}" )#{start}="?#{url}#{@baseurl}/((?:#{regex}[^,'"\s/?.]+\.?)*(?:/[^\]\[)("'\s]*)?)"}
    end

2. Chirpyテーマの’_config.yml’設定ファイルでグロビング(globbing)パターンを正確なファイル名に置き換える

実際、正統的で理想的な方法は上記のパッチがPolyglotのメインストリームに反映されることです。しかし、それまではフォークしたバージョンを代わりに使用する必要がありますが、この場合、Polyglotのアップストリームがバージョンアップするたびにそのアップデートを見逃さずに反映しながら追いかけるのが面倒なため、私は別の方法を使用しました。

Chirpyテーマリポジトリでプロジェクトのルートパスに位置するファイルのうち、"*.gem""*.gemspec""*.config.js"パターンに対応するファイルを確認すると、以下の3つしかありません。

  • jekyll-theme-chirpy.gemspec
  • purgecss.config.js
  • rollup.config.js

したがって、_config.ymlファイルのexclude構文からグロビング(globbing)パターンを削除し、以下のように書き換えれば、Polyglotが問題なく処理できるようになります。

1
2
3
4
5
6
7
8
9
exclude: # https://github.com/untra/polyglot/issues/204 イシューを参考に修正。
  # - "*.gem"
  - jekyll-theme-chirpy.gemspec # - "*.gemspec"
  - tools
  - README.md
  - LICENSE
  - purgecss.config.js # - "*.config.js"
  - rollup.config.js
  - package*.json

検索機能の修正

前のステップまで進めた時点で、ほとんどのサイト機能が意図した通りに満足に動作していました。しかし、Chirpyテーマを適用したページの右上に位置する検索バーがsite.default_lang(このブログの場合は英語)以外の言語で書かれたページをインデックスできず、英語以外の他の言語で検索した場合にも検索結果として英語ページを出力するという問題があることを後で発見しました。

原因を把握するために、検索機能に関わるファイルが何であり、その中でどこに問題が発生しているのかを見てみましょう。

‘_layouts/default.html’

ブログ内のすべてのページの枠組みを構成する_layouts/default.htmlファイルを確認すると、<body>エレメント内にsearch-results.htmlsearch-loader.htmlの内容を読み込んでいることが確認できます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  <body>
    {% include sidebar.html lang=lang %}

    <div id="main-wrapper" class="d-flex justify-content-center">
      <div class="container d-flex flex-column px-xxl-5">
        
        (...中略...)

        {% include_cached search-results.html lang=lang %}
      </div>

      <aside aria-label="Scroll to Top">
        <button id="back-to-top" type="button" class="btn btn-lg btn-box-shadow">
          <i class="fas fa-angle-up"></i>
        </button>
      </aside>
    </div>

    (...中略...)

    {% include_cached search-loader.html lang=lang %}
  </body>

‘_includes/search-result.html’

_includes/search-result.htmlは検索窓に検索語入力時、そのキーワードに対する検索結果を保存するためのsearch-resultsコンテナを構成します。

1
2
3
4
5
6
7
8
9
10
<!-- The Search results -->

<div id="search-result-wrapper" class="d-flex justify-content-center d-none">
  <div class="col-11 content">
    <div id="search-hints">
      {% include_cached trending-tags.html %}
    </div>
    <div id="search-results" class="d-flex flex-wrap justify-content-center text-muted mt-3"></div>
  </div>
</div>

‘_includes/search-loader.html’

_includes/search-loader.htmlがまさにSimple-Jekyll-Searchライブラリベースの検索を実装した核心的な部分で、これはsearch.jsonインデックスファイルの内容のうち、入力キーワードと一致する部分を見つけて該当する投稿リンクを<article>エレメントとして返すJavaScriptを訪問者のブラウザ上で実行することによって、クライアントサイドで動作することがわかります。

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
{% capture result_elem %}
  <article class="px-1 px-sm-2 px-lg-4 px-xl-0">
    <header>
      <h2><a href="{url}">{title}</a></h2>
      <div class="post-meta d-flex flex-column flex-sm-row text-muted mt-1 mb-1">
        {categories}
        {tags}
      </div>
    </header>
    <p>{snippet}</p>
  </article>
{% endcapture %}

{% capture not_found %}<p class="mt-5">{{ site.data.locales[include.lang].search.no_results }}</p>{% endcapture %}

<script>
  {% comment %} Note: dependent library will be loaded in `js-selector.html` {% endcomment %}
  document.addEventListener('DOMContentLoaded', () => {
    SimpleJekyllSearch({
      searchInput: document.getElementById('search-input'),
      resultsContainer: document.getElementById('search-results'),
      json: '{{ '/assets/js/data/search.json' | relative_url }}',
      searchResultTemplate: '{{ result_elem | strip_newlines }}',
      noResultsText: '{{ not_found }}',
      templateMiddleware: function(prop, value, template) {
        if (prop === 'categories') {
          if (value === '') {
            return `${value}`;
          } else {
            return `<div class="me-sm-4"><i class="far fa-folder fa-fw"></i>${value}</div>`;
          }
        }

        if (prop === 'tags') {
          if (value === '') {
            return `${value}`;
          } else {
            return `<div><i class="fa fa-tag fa-fw"></i>${value}</div>`;
          }
        }
      }
    });
  });
</script>

‘/assets/js/data/search.json’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---
layout: compress
swcache: true
---

[
  {% for post in site.posts %}
  {
    "title": {{ post.title | jsonify }},
    "url": {{ post.url | relative_url | jsonify }},
    "categories": {{ post.categories | join: ', ' | jsonify }},
    "tags": {{ post.tags | join: ', ' | jsonify }},
    "date": "{{ post.date }}",
    {% include no-linenos.html content=post.content %}
    {% assign _content = content | strip_html | strip_newlines %}
    "snippet": {{ _content | truncate: 200 | jsonify }},
    "content": {{ _content | jsonify }}
  }{% unless forloop.last %},{% endunless %}
  {% endfor %}
]

JekyllのLiquid構文を利用して、サイト内のすべての投稿のタイトル、URL、カテゴリーおよびタグ情報、作成日、本文の最初の200文字のスニペット、そして全文内容を含むJSONファイルを定義しています。

検索機能の動作構造と問題発生部分の把握

つまり整理すると、GitHub Pages上でChirpyテーマをホスティングする場合、検索機能は次のようなプロセスで動作します。

stateDiagram
  state "Changes" as CH
  state "Build start" as BLD
  state "Create search.json" as IDX
  state "Static Website" as DEP
  state "In Test" as TST
  state "Search Loader" as SCH
  state "Results" as R
    
  [*] --> CH: Make Changes
  CH --> BLD: Commit & Push origin
  BLD --> IDX: jekyll build
  IDX --> TST: Build Complete
  TST --> CH: Error Detected
  TST --> DEP: Deploy
  DEP --> SCH: Search Input
  SCH --> R: Return Results
  R --> [*]

ここでsearch.jsonはPolyglotによって次のように各言語別に生成されることを確認しました。

  • /assets/js/data/search.json
  • /ko/assets/js/data/search.json
  • /es/assets/js/data/search.json
  • /pt-BR/assets/js/data/search.json
  • /ja/assets/js/data/search.json
  • /fr/assets/js/data/search.json
  • /de/assets/js/data/search.json

したがって、問題の原因となる部分は「Search Loader」です。英語以外の他の言語バージョンのページが検索されない問題は、_includes/search-loader.htmlで現在訪問中のページの言語に関係なく、英語インデックスファイル(/assets/js/data/search.json)のみを静的に読み込むために発生します。

したがって、インデックスファイル内のtitlesnippetcontentなどの値は言語別に異なって生成されますが、url値は言語を考慮しない基本パスを返すため、これに対する適切な処理を「Search Loader」部分に追加する必要があります。

問題解決

これを解決するには、_includes/search-loader.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
{% capture result_elem %}
  <article class="px-1 px-sm-2 px-lg-4 px-xl-0">
    <header>
      {% if site.active_lang != site.default_lang %}
      <h2><a {% static_href %}href="/{{ site.active_lang }}{url}"{% endstatic_href %}>{title}</a></h2>
      {% else %}
      <h2><a href="{url}">{title}</a></h2>
      {% endif %}

(...中略...)

<script>
  {% comment %} Note: dependent library will be loaded in `js-selector.html` {% endcomment %}
  document.addEventListener('DOMContentLoaded', () => {
    {% assign search_path = '/assets/js/data/search.json' %}
    {% if site.active_lang != site.default_lang %}
      {% assign search_path = '/' | append: site.active_lang | append: search_path %}
    {% endif %}
    
    SimpleJekyllSearch({
      searchInput: document.getElementById('search-input'),
      resultsContainer: document.getElementById('search-results'),
      json: '{{ search_path | relative_url }}',
      searchResultTemplate: '{{ result_elem | strip_newlines }}',

(...後略)
  • site.active_lang(現在のページの言語)とsite.default_lang(サイトのデフォルト言語)が同じでない場合、JSONファイルから読み込んだ投稿URLの前に"/{{ site.active_lang }}"プレフィックスを追加するように{% capture result_elem %}部分のliquid構文を修正しました。
  • 同じ方法で、ビルド過程で現在のページの言語とサイトのデフォルト言語を比較し、同じであればデフォルトパス(/assets/js/data/search.json)を、異なる場合はその言語に合ったパス(例:/ko/assets/js/data/search.json)をsearch_pathとして指定するように<script>部分を修正しました。

上記のように修正した後、ウェブサイトを再ビルドすると、各言語に合わせて検索結果が正常に表示されることを確認しました。

{url}は後でJSONファイルから読み込んだURL値が入る場所であり、それ自体がURLではないため、Polyglotではlocalizationの対象として認識しないため、直接言語に応じて処理する必要があります。問題は、そのように処理した"/{{ site.active_lang }}{url}"はURLとして認識され、すでにlocalizationが完了していますが、Polyglotはそこまで知らないため、重複してlocalizationを実行しようとすることです(例:"/ko/ko/posts/example-post")。これを防ぐために{% static_href %}タグを明示しました。

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