Wpis

Jak dodać obsługę wielu języków na blogu Jekyll z Polyglot (1) — wdrożenie wtyczki Polyglot oraz modyfikacja nagłówka HTML i sitemap

Opis wdrożenia wtyczki Polyglot w blogu Jekyll opartym na „jekyll-theme-chirpy”, aby uruchomić wielojęzyczność. Część 1 serii: instalacja Polyglot oraz modyfikacje nagłówka HTML i pliku sitemap pod SEO.

Jak dodać obsługę wielu języków na blogu Jekyll z Polyglot (1) — wdrożenie wtyczki Polyglot oraz modyfikacja nagłówka HTML i sitemap

Przegląd

Na początku lipca 12024 roku dodałem do tego bloga (opartego na Jekyll i hostowanego przez GitHub Pages) obsługę wielu języków, wdrażając wtyczkę Polyglot. W tej serii dzielę się błędami, które pojawiły się podczas integracji Polyglot z motywem Chirpy, sposobem ich rozwiązania, a także metodą przygotowania nagłówków HTML i pliku sitemap.xml z uwzględnieniem SEO. Seria składa się z 3 wpisów, a czytany teraz tekst to część pierwsza.

Początkowo planowałem całość jako 2 części, jednak później kilkukrotnie uzupełniałem treść, przez co znacząco wzrosła objętość i ostatecznie przebudowałem serię do 3 części.

Wymagania

  • Wynik builda (strony WWW) musi dać się serwować z rozróżnieniem na ścieżki per język (np. /posts/ko/, /posts/ja/).
  • Aby możliwie zminimalizować dodatkowy czas i nakład pracy związany z wielojęzycznością, podczas builda język ma być wykrywany automatycznie na podstawie lokalnej ścieżki pliku (np. /_posts/ko/, /_posts/ja/) — bez konieczności ręcznego dodawania tagów lang i permalink w YAML front matter w każdym pliku źródłowym Markdown.
  • Nagłówek każdej strony w serwisie musi spełniać wytyczne Google SEO dla wyszukiwania wielojęzycznego: odpowiedni meta tag Content-Language, tagi alternatywne hreflang oraz link canonical.
  • Linki do stron dla każdej wersji językowej muszą być kompletne w sitemap.xml, a sam sitemap.xml nie może się duplikować — ma istnieć wyłącznie jeden, w katalogu głównym (root).
  • Wszystkie funkcje dostarczane przez motyw Chirpy muszą działać poprawnie na stronach w każdym języku; jeśli nie — trzeba je zmodyfikować, aby działały.
    • Poprawne działanie „Recently Updated” i „Trending Tags”
    • Brak błędów w procesie builda z użyciem GitHub Actions
    • Poprawne działanie wyszukiwania postów w prawym górnym rogu bloga

Wdrożenie wtyczki Polyglot

Jekyll nie wspiera wielojęzycznych blogów natywnie, więc aby zrealizować blog wielojęzyczny spełniający powyższe wymagania, trzeba użyć zewnętrznej wtyczki. Po rozeznaniu okazało się, że Polyglot jest często używany do budowy wielojęzycznych stron i może spełnić większość z powyższych wymagań, więc wybrałem tę wtyczkę.

Instalacja wtyczki

Ponieważ używam Bundlera, dodałem do Gemfile poniższą treść.

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

Następnie wystarczy uruchomić w terminalu bundle update, a instalacja zakończy się automatycznie.

Jeśli nie używasz Bundlera, możesz zainstalować gema bezpośrednio poleceniem gem install jekyll-polyglot, a potem dodać wtyczkę w _config.yml w następujący sposób:

1
2
plugins:
  - jekyll-polyglot

Konfiguracja

Następnie otwórz plik _config.yml i dodaj poniższe:

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: lista języków, które mają być wspierane
  • default_lang: domyślny język fallback
  • exclude_from_localization: wyrażenia (regex) dla ścieżek plików/katalogów w root, które mają być wyłączone z lokalizacji językowej
  • parallel_localization: wartość boolean określająca, czy przetwarzanie wielojęzyczne ma być równoległe podczas builda
  • lang_from_path: wartość boolean; po ustawieniu na true Polyglot rozpozna i użyje kodu języka z ciągu ścieżki pliku Markdown (o ile ścieżka zawiera kod języka), nawet jeśli w YAML front matter nie podasz osobno właściwości lang

Oficjalna dokumentacja protokołu Sitemap stwierdza:

“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.”

Aby się do tego dostosować, trzeba dopilnować, żeby plik sitemap.xml nie był generowany osobno dla każdego języka, tylko istniał dokładnie jeden w katalogu głównym. W tym celu dodaj sitemap.xml do listy exclude_from_localization, aby nie doszło do sytuacji jak w poniższym błędnym przykładzie.

Błędny przykład (zawartość każdego pliku nie różni się między językami — jest identyczna):

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

(Aktualizacja 12025.01.14.) Ponieważ przyjęto mój Pull Request, w którym uzupełniłem README o powyższe informacje, teraz takie samo zalecenie można znaleźć także w oficjalnej dokumentacji Polyglot.

Ustawienie parallel_localization na true ma tę zaletę, że znacząco skraca czas builda, jednak według stanu na lipiec 12024, gdy włączyłem tę funkcję na tym blogu, występował błąd: tytuły linków w sekcjach „Recently Updated” i „Trending Tags” na prawym pasku bocznym nie były przetwarzane poprawnie i mieszały się między językami. Wygląda na to, że funkcja nie była jeszcze w pełni stabilna — przed użyciem na swojej stronie warto ją przetestować. Dodatkowo jeśli używasz Windows, ta funkcja nie jest wspierana, więc trzeba ją wyłączyć.

(Aktualizacja 12025.09) Latem 12025 ponownie przetestowałem parallel_localization na tym blogu i wtedy działało poprawnie. W związku z tym obecnie mam tę funkcję włączoną, co istotnie skróciło czas builda.

Dodatkowo, w Jekyll 4.0 należy wyłączyć generowanie CSS sourcemaps w następujący sposób.

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

Uwagi przy pisaniu postów

Poniżej rzeczy, na które trzeba uważać, pisząc posty wielojęzyczne:

  • Poprawne wskazanie kodu języka: należy podać właściwy kod ISO, korzystając ze ścieżki pliku (np. /_posts/ko/example-post.md) albo z właściwości lang w YAML front matter (np. lang: ko). Warto oprzeć się na przykładach z dokumentacji dla programistów Chrome.

Uwaga: w dokumentacji deweloperskiej Chrome kod regionu bywa zapisywany jako pt_BR, ale w praktyce trzeba użyć pt-BR (myślnik zamiast podkreślenia), aby później tagi alternatywne hreflang w nagłówku HTML działały poprawnie.

  • Ścieżki i nazwy plików powinny być spójne.

Szczegóły znajdziesz w README repozytorium GitHub: untra/polyglot.

Modyfikacja nagłówka HTML i sitemap

Teraz, na potrzeby SEO, trzeba wstawić do nagłówka HTML każdej strony meta tag Content-Language, alternatywne tagi hreflang oraz poprawnie ustawić adres kanoniczny (canonical URL).

Nagłówek HTML

Według stanu na 12024.11, w wersji 1.8.1 (wówczas najnowszej), Polyglot ma funkcję, która automatycznie wykonuje powyższe działania, gdy w nagłówku strony wywołasz tag Liquid {% I18n_Headers %}. Jednak funkcja ta zakłada, że dla danej strony jawnie ustawiono atrybut permalink; w przeciwnym razie nie działa poprawnie.

Dlatego skopiowałem head.html z motywu Chirpy i samodzielnie dodałem poniższą treść. Kierowałem się stroną SEO Recipes na oficjalnym blogu Polyglot, ale dopasowałem implementację do mojego środowiska i wymagań — zamiast page.permalink użyłem właściwości 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 %}

(Dodano 12025.07.29.) Motyw Chirpy domyślnie zawiera również wtyczkę Jekyll SEO Tag. Potwierdziłem jednak, że automatycznie generowane przez nią metadane Open Graph og:locale i og:url, a także adres kanoniczny (canonical URL) (element link z rel="canonical") są generowane względem podstawowego języka strony (site.lang, site.default_lang), więc potrzebna jest dodatkowa obróbka.
W związku z tym dodałem następujący fragment przed {{ 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 }}

  ...(후략)

Zgodnie z dokumentacją dla deweloperów Google, gdy jedna strona ma wiele wersji językowych, Google uznaje je za duplikaty tylko wtedy, gdy język głównej treści jest taki sam — tzn. przetłumaczone są wyłącznie nagłówek, stopka i inne mało istotne fragmenty, a zasadnicza treść pozostaje identyczna. Dlatego w przypadku takiego bloga jak ten, gdzie treść wpisów jest dostępna w wielu językach, każdą wersję językową należy traktować jako osobną, nieduplikującą się stronę — i trzeba ustawić różne canonical URL zależnie od języka.
Na przykład dla koreańskiej wersji tej strony canonical URL to nie "https://www.yunseo.kim/posts/how-to-support-multi-language-on-jekyll-blog-with-polyglot-1/", lecz "https://www.yunseo.kim/ko/posts/how-to-support-multi-language-on-jekyll-blog-with-polyglot-1/".

Sitemap

Jeśli nie wskażesz osobnego szablonu, sitemap generowany automatycznie przez Jekyll podczas builda nie wspiera poprawnie stron wielojęzycznych. Dlatego utwórz w katalogu głównym plik sitemap.xml i wprowadź następującą treść:

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>

Dalsza lektura

Ciąg dalszy w Części 2

Ten wpis jest objęty licencją CC BY-NC 4.0 przez autora.