Jak přidat vícejazyčnou podporu do Jekyll blogu pomocí Polyglot (3) – troubleshooting: selhání buildu tématu Chirpy a chyba vyhledávání
Popisuji, jak jsem na Jekyll blogu založeném na 'jekyll-theme-chirpy' nasadil plugin Polyglot pro vícejazyčný web. 3. díl: příčiny chyb při buildu Chirpy a problém vyhledávání a jejich řešení.
Přehled
Na začátku července 12024 jsem na tento blog (hostovaný přes Github Pages a postavený na Jekyllu) nasadil plugin Polyglot a doplnil podporu více jazyků.
Tato série sdílí bugy, které se objevily při aplikaci Polyglot na téma Chirpy, jejich řešení, a také postup tvorby HTML hlavičky a sitemap.xml s ohledem na SEO.
Série má 3 články a tento, který právě čtete, je třetí díl.
- díl: Troubleshooting selhání buildu tématu Chirpy a chyby vyhledávání (tento článek)
Původně to měly být jen 2 díly, ale později jsem obsah několikrát doplnil, rozsah výrazně narostl, a proto jsem sérii přepracoval na 3 díly.
Požadavky
- Musí být možné poskytovat build (webové stránky) odděleně podle jazyka pomocí cest (např.
/posts/ko/,/posts/ja/). - Aby se minimalizoval dodatečný čas a práce kvůli vícejazyčnosti, při buildu se musí jazyk automaticky rozpoznat podle lokální cesty souboru (např.
/_posts/ko/,/_posts/ja/) bez nutnosti ručně vyplňovat tagylangapermalinkve YAML front matter každého markdown souboru. - Hlavička každé stránky musí obsahovat vhodný meta tag Content-Language, alternativní tagy hreflang a canonical link tak, aby splnila Google SEO doporučení pro vícejazyčné vyhledávání.
- Pro každou jazykovou verzi musí být v
sitemap.xmlposkytnuty odkazy bez vynechání; zároveň samotnýsitemap.xmlnesmí být duplicitní a musí existovat pouze jednou v root cestě. - Všechny funkce poskytované tématem Chirpy musí fungovat korektně na stránkách všech jazyků; pokud ne, je potřeba je upravit tak, aby fungovaly.
- Funkce „Recently Updated“ a „Trending Tags“ fungují správně
- Během buildu přes GitHub Actions nevznikají chyby
- Vyhledávání příspěvků vpravo nahoře funguje správně
Než začnete
Tento článek navazuje na 1. díl a 2. díl. Pokud jste je ještě nečetli, doporučuji nejdřív přečíst předchozí části.
Troubleshooting („relative_url_regex“: target of repeat operator is not specified)
(+ aktualizace 12025.10.08.) Tento bug byl opraven ve verzi Polyglot 1.11.
Po dokončení předchozích kroků jsem spustil bundle exec jekyll serve pro otestování buildu, ale build selhal s chybou '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
...(vynecháno)
------------------------------------------------
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)
...(zkráceno)
Když jsem hledal, zda už někdo hlásil podobný problém, zjistil jsem, že v repozitáři Polyglot už existuje naprosto stejný issue a také řešení.
V souboru Chirpy tématu _config.yml použitým na tomto blogu je mimo jiné následující část:
1
2
3
4
5
6
7
8
9
exclude:
- "*.gem"
- "*.gemspec"
- docs
- tools
- README.md
- LICENSE
- "*.config.js"
- package*.json
Příčina problému je v tom, že regulární výrazy ve dvou funkcích níže v souboru Polyglot site.rb neumí správně zpracovat globbing patterny s wildcardy jako "*.gem", "*.gemspec", "*.config.js".
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="/cs/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
Jak to opravit? Jsou dvě možnosti.
1. Polyglot forknout a upravit problematickou část
K datu psaní tohoto článku (12024.11.) oficiální dokumentace Jekyll uvádí, že volba exclude podporuje globbing patterny Ruby File.fnmatch.
“This configuration option supports Ruby’s File.fnmatch filename globbing patterns to match multiple entries to exclude.”
Jinými slovy: problém není v tématu Chirpy, ale ve dvou funkcích Polyglotu relative_url_regex(), absolute_url_regex(). Fundamentální řešení je upravit je tak, aby nezpůsobovaly chybu.
V době, kdy jsem problém řešil, tento bug v Polyglotu ještě opraven nebyl, ale jak už bylo zmíněno výše, od verze Polyglot 1.11 je to opraveno. V době výskytu jsem se řídil tímto blog postem(web už neexistuje) a odpovědí v uvedeném GitHub issue: forknout Polyglot a upravit problémové místo takto, a používat upravenou verzi místo originálu.
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. V tématu Chirpy nahradit globbing patterny v \_config.yml konkrétními názvy souborů
Správné a ideální by bylo, aby se výše uvedený patch dostal do mainstream Polyglotu. Do té doby by ale bylo nutné používat fork, což je nepohodlné: při každém update upstream Polyglotu je otravné hlídat změny a přenášet je. Proto jsem zvolil jiný přístup.
Když se v repozitáři Chirpy podíváte na soubory v rootu projektu, které odpovídají patternům "*.gem", "*.gemspec", "*.config.js", jsou to stejně jen tyto tři:
jekyll-theme-chirpy.gemspecpurgecss.config.jsrollup.config.js
Proto stačí z exclude v _config.yml odstranit globbing patterny a nahradit je konkrétními názvy takto — a Polyglot to pak zvládne bez problémů:
1
2
3
4
5
6
7
8
9
exclude: # Upraveno s ohledem na issue 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
Oprava vyhledávání
Po provedení předchozích kroků fungovala téměř celá webová stránka přesně tak, jak jsem chtěl. Později jsem ale zjistil problém: vyhledávací lišta vpravo nahoře (v tématu Chirpy) nedokáže indexovat stránky v jiném jazyce než site.default_lang (v mém případě angličtina) a i při hledání na neanglických stránkách vrací odkazy na anglické verze.
Abychom našli příčinu, projděme si soubory, které se vyhledávání týkají, a kde přesně to selhává.
‘_layouts/default.html’
Když se podíváte na soubor _layouts/default.html, který definuje šablonu všech stránek, uvidíte, že do <body> elementu vkládá obsah search-results.html a 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
<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">
(...vynecháno...)
{% 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>
(...vynecháno...)
{% include_cached search-loader.html lang=lang %}
</body>
‘_includes/search-result.html’
_includes/search-result.html sestaví kontejner search-results, do kterého se ukládají výsledky vyhledávání po zadání dotazu.
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’
Soubor _includes/search-loader.html je klíčová část: implementuje vyhledávání založené na knihovně Simple-Jekyll-Search. V prohlížeči návštěvníka běží JavaScript, který v indexu search.json (viz níže) najde shody a vrátí odkazy na příspěvky jako <article> elementy — jde tedy o client-side vyhledávání.
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 %}
]
Pomocí Liquid syntaxe Jekyllu se zde definuje JSON soubor, který obsahuje u všech postů na webu: název, URL, kategorie a tagy, datum, 200-znakový snippet z obsahu a kompletní obsah.
Struktura vyhledávání a identifikace místa, kde vzniká problém
Shrnuto: když hostujete téma Chirpy na GitHub Pages, vyhledávání funguje zhruba tímto procesem.
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 --> [*]
Zde jsem ověřil, že search.json je Polyglotem generován pro každý jazyk zvlášť:
/assets/js/data/search.json/ko/assets/js/data/search.json/ja/assets/js/data/search.json/zh-TW/assets/js/data/search.json/es/assets/js/data/search.json/pt-BR/assets/js/data/search.json/fr/assets/js/data/search.json/de/assets/js/data/search.json
Proto je problém v části „Search Loader“. Neanglické stránky se neindexují, protože _includes/search-loader.html vždy staticky načítá pouze anglický index (/assets/js/data/search.json) bez ohledu na jazyk právě navštívené stránky.
- Nicméně se zdá, že pro JSON soubory (na rozdíl od markdown/HTML) funguje Polyglot wrapper pro Jekyll proměnné typu
post.title,post.contentapod., ale nefunguje funkce Relativized Local Urls.- Stejně tak jsem během testování ověřil, že v šabloně JSON souboru nelze (kromě základních Jekyll proměnných) přistupovat k Liquid tagům přidaným Polyglotem
{{ site.default_lang }},{{ site.active_lang }}.Proto se hodnoty jako
title,snippet,contentgenerují pro každý jazyk správně, aleurlvrací základní cestu bez ohledu na jazyk — a je potřeba to vhodně ošetřit v části „Search Loader“.
Řešení
Oprava spočívá v úpravě _includes/search-loader.html následovně.
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 %}
(...vynecháno...)
<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 }}',
(...zkráceno)
- Pokud se
site.active_lang(jazyk aktuální stránky) nerovnásite.default_lang(výchozí jazyk webu), upravil jsem Liquid v části{% capture result_elem %}tak, aby před URL příspěvku načtenou z JSON přidal prefix"/{{ site.active_lang }}". - Stejným způsobem jsem v
<script>části upravilsearch_path: při buildu se porovná jazyk aktuální stránky s výchozím jazykem webu; pokud jsou stejné, použije se základní cesta (/assets/js/data/search.json), jinak se použije jazyková cesta (např./ko/assets/js/data/search.json).
Po této úpravě a rebuildu webu jsem ověřil, že vyhledávání zobrazuje výsledky správně pro každý jazyk.
{url}je místo, kam se při samotném vyhledávání dosadí URL načtená z JSON souboru pomocí JS; v čase buildu to není platná URL. Polyglot ji tedy nebere jako lokalizovatelný cíl a je nutné ji ošetřit ručně dle jazyka. Problém je, že takto ošetřená šablona"/{{ site.active_lang }}{url}"je při buildu považována za relativní URL; a protože Polyglot neví, že už je lokalizace „vyřešená“, snaží se ji lokalizovat znovu (např."/ko/ko/posts/example-post"). Aby se tomu zabránilo, explicitně jsem použil tag{% static_href %}.
