1 # Multilingual Experiment in Jekyll
3 The [basic *GitHub Pages* site](https://ranbureand.github.io/multilingual-experiment/) hosted in this repository and this README illustrate my approach to create a multilingual site in *[Jekyll](https://jekyllrb.com/ "Jekyll")*.
8 + [Foreword](#foreword)
9 + [Directory Structure](#directory-structure)
11 + [Exceptions](#exceptions)
13 + [Configuration](#configuration)
14 + [Front Matter](#front-matter)
17 + [Data Files](#data-files)
18 + [Snippets](#snippets)
19 + [Includes](#includes)
20 + [header.html](#headerhtml)
21 + [navigation.html](#navigationhtml)
22 + [language-switch.html](#language-switchhtml)
23 + [if page.layout == 'page'](#if-pagelayout--page)
24 + [elsif page.layout == 'post'](#elsif-pagelayout--post)
26 + [Fallback Page](#fallback-page)
27 + [title.html](#titlehtml)
28 + [localizations.html](#localizationshtml)
29 + [Multilingual Sitemaps](#multilingual-sitemaps)
30 + [Sitemap Index File](#sitemap-index-file)
31 + [Sitemap Files](#sitemap-files)
32 + [RSS Feed](#rss-feed)
33 + [404 Page Not Found](#404-page-not-found)
34 + [Resources](#resources)
35 + [Afterword](#afterword)
39 When I found myself coding a multilingual site in [Jekyll](https://jekyllrb.com/ "Jekyll"), I stumbled on [a lot of useful resources](#resources) while surfing the Web, but I struggled not a little while trying to digest and replicate their approaches because of the lack of a concrete, working example to look at.
41 At first, I tried to replicate their approaches directly in the site I was working on, but this quickly backfired because it proved to be too big of a bite to chew for a designer who codes.
43 Not giving up, I then opted for creating a basic site from scratch, so that I could just focus on experimenting with multiple languages in Jekyll without any extra complexity in the picture.
45 That very same basic site is hosted in this repository, which I gladly share with the world as an example project, hoping to be of a help for anybody who is into coding a multilingual site using Jekyll.
49 A few words before starting. Sites built with the approach illustrated in this README:
51 + can support [as many languages as needed](#directory-structure)
52 + can serve pages or posts that do not necessarily need to be translated in all the supported languages
53 + have a [language switch](#language-switchhtml) that can either direct web surfers to view the current page or post in the selected language, if available, or can direct them to an alternative [fallback page](#fallback-page)
54 + do not need you to install custom plugins
55 + leverage the basics of Jekyll and thus should be relatively future-proof (last famous words)
56 + can be published as *GitHub Pages* sites
58 Specifically, [the basic site](https://ranbureand.github.io/multilingual-experiment/) hosted in this repository and used as an example:
60 + is visually quite crude, since the focus is on illustrating a structural (not visual) approach to building multilingual sites
61 + supports English and Italian as example languages
63 ## Directory Structure
65 The directory structure of this basic site looks like this:
74 │ ├── localizations.html
83 │ │ ├── YYYY-MM-DD-title.markdown
85 │ │ └── YYYY-MM-DD-title.markdown
87 │ ├── YYYY-MM-DD-titolo.markdown
89 │ └── YYYY-MM-DD-titolo.markdown
103 │ ├── prefazione.html
111 We organize the pages into as many subdirectory as the languages that we plan to support, and name them using [ISO language codes](https://www.w3schools.com/tags/ref_language_codes.asp "HTML Language Code Reference in W3Schools"). This basic site has two subdirectories, one named `en` for grouping the English pages, and one named `it` for grouping the Italian pages.
126 │ ├── prefazione.html
132 After Jekyll has built the site, we can reach, for example, the English page `stories.html` and the Italian page `storie.html` at the URLs `www.site.ext/en/stories.html` and `www.site.ext/it/storie.html`, respectively.
136 But, of course, there are exceptions. We place the pages `404.html`, `index.html`, and `sitemap.html` in the root directory of the site. Why?
138 `404.html` and `index.html` are *unique* pages because Jekyll builds and serves automatically one and only one of them at a time.
140 `sitemap.xml` instead is none other than a [Sitemap index](https://www.sitemaps.org/protocol.html#index "Sitemaps XML Format, Sitemap index") which points to the other localized sitemaps in the respective language subfolders (read the section [Multilingual Sitemap](#multilingual-sitemap) for more details).
144 We organize the posts following a similar logic. This basic site has two subdirectories in the folder named `_posts`, one named `en` for grouping the English posts, and one named `it` for grouping the Italian posts.
150 │ │ ├── YYYY-MM-DD-title.markdown
152 │ │ └── YYYY-MM-DD-title.markdown
154 │ ├── YYYY-MM-DD-titolo.markdown
156 │ └── YYYY-MM-DD-titolo.markdown
162 We then add the following configuration options in the `_config.yml` file placed in the site’s root directory:
171 permalink: 'en/story/:title'
178 permalink: 'it/storia/:title'
182 By setting global permalinks for posts, we can reach, for example, the English post named `2021-01-01-hello-world.markdown` and the Italian post named `2021-01-01-ciao-mondo.markdown` at the URLs `www.site.ext/en/hello-world.html` and `www.site.ext/it/ciao-mondo.html`, respectively.
188 Here is how the front matter of a page looks like:
195 description: Stories.
198 language_reference: stories
204 But for the usual variables, we set two new ones, `language` to define the language of the page, and `language_reference` to relate different translations of the same page. The logic is based on the principle articulated in Sylvain Durand’s *[Making Jekyll Multilingual](https://sylvaindurand.org/making-jekyll-multilingual/#principle "Making Jekyll
207 For example, here is the front matter of the English page *Stories*:
214 description: Stories.
217 language_reference: stories
223 and here is the front matter of its Italian counterpart:
233 language_reference: stories
239 Both pages have the variable `language_reference` set to `stories` so that they can be easily related.
241 We can use `language` to retrieve only the pages that have the same language, and `language_reference` to retrieve only the pages that return the same content translated in different languages.
245 Here is how the front matter of a post looks like:
252 description: Hello world.
253 date: 2021-01-01 00:00:00
256 language_reference: world
262 Again, but for the usual variables, we set two new ones, `language` to define the language of the post, and `language_reference` to relate different translations of the same post.
264 For example, here is the front matter of the English post *Hello World*:
271 description: Hello world.
272 date: 2021-01-01 00:00:00
275 language_reference: world
281 and here is the front matter of its Italian counterpart:
288 description: Ciao Mondo.
289 date: 2021-01-01 00:00:00
292 language_reference: world
298 Both posts have the variable `language_reference` set to `world` so that they can be easily related.
300 Again, we can use `language` to retrieve only the posts that have the same language, and `language_reference` to retrieve only the posts that return the same content translated in different languages.
306 We create a YAML [Data File](https://jekyllrb.com/docs/datafiles/ "Data Files") named `snippets.yml` to store the different translations of the user interface copy as additional data in the `_data` subdirectory.
308 We then create a new variable named `snippets` in the `base.html` layout to shorten the code that we need to write to access the data contained in the `snippets.yml` file:
311 {%- assign snippets = site.data.snippets %}
314 Since the `base.html` layout works as the base for all the other layouts, if we place the variable `snippets` there, we can then call it from any page.
316 Through this variable, we can write just `snippets.name_of_the_data_item` when accessing a data item rather than the full, longer `site.data.snippets.name_of_the_data_item`.
318 For example, the piece of code that generates *Back to the Top* link at the bottom of the page:
321 <a href="#{{ snippets.top[page.language] | slugify: 'latin' }}">{{ snippets.back[page.language] }}</a>
324 uses the following variable:
327 {{ snippets.back[page.language] }}
330 to retrieve the name of the link in the current selected language from the following lines in the `snippets.yml` data file:
344 The purpose of most of the includes in this basic site is building the navigation.
348 The include `header.html` generates the header in the HTML page. It, in turn, has three more includes:
352 + `language-switch.html`
356 {% include site-title.html %}
358 {% include navigation.html %}
360 {% include language-switch.html %}
367 The include `navigation.html` generates an unordered list containing all the published pages having the same `language` variable as the current page.
371 {%- assign navigation_pages = site.pages
372 | where: 'layout', 'page'
373 | where: 'language', page.language
374 | where: 'published', true
376 {%- for navigation_page in navigation_pages %}
377 <li{%- if navigation_page.title == page.title %} class="current"{%- endif %}>
378 <a href="{{ site.baseurl }}{{ navigation_page.url }}">{{ navigation_page.title }}</a>
384 In the code above, we create a new variable named `navigation_pages` which returns a list of the pages that, [in their front matter](#pages-1), have:
386 + the `layout` variable set to `page`
387 + the `language` variable set to the language of the current page (`page.language`)
388 + the `published` variable set to `true`
390 and we order the list according to the `order` variable. We then loop trough the array of pages and generate the list items of the unordered list.
392 Whenever the title of the current page in the array (`navigation_page.title`) matches the title of the current page (`page.title`), we add a class named `current` to the corresponding `<li/>` tag.
394 #### language-switch.html
396 The include `language-switch.html` generates an unordered list containing all the languages supported in the site. You can use the list to switch to one of the other language translations of the current page/post, if available.
400 {%- for language in snippets.languages %}
402 {%- if page.layout == 'page' %}
403 {%- assign navigation_pages = site.pages
404 | where: 'language_reference', page.language_reference
405 | where: 'language', language[1].slug %}
406 {%- if navigation_pages.size == 1 %}
407 {%- for navigation_page in navigation_pages %}
408 {%- assign url = site.baseurl | append: navigation_page.url %}
411 {%- assign navigation_pages = site.pages
412 | where: 'language_reference', site.fallback_page
413 | where: 'language', language[1].slug %}
414 {%- for navigation_page in navigation_pages %}
415 {%- assign url = site.baseurl | append: navigation_page.url %}
419 {%- elsif page.layout == 'post' %}
420 {%- assign navigation_posts = site.posts
421 | where: 'language_reference', page.language_reference
422 | where: 'language', language[1].slug %}
423 {%- if navigation_posts.size == 1 %}
424 {%- for navigation_post in navigation_posts %}
425 {%- assign url = site.baseurl | append: navigation_post.url %}
428 {%- assign navigation_pages = site.pages
429 | where: 'language_reference', site.fallback_page
430 | where: 'language', language[1].slug %}
431 {%- for navigation_page in navigation_pages %}
432 {%- assign url = site.baseurl | append: navigation_page.url %}
437 {%- assign navigation_pages = site.pages
438 | where: 'language_reference', site.fallback_page
439 | where: 'language', language[1].slug %}
440 {%- for navigation_page in navigation_pages %}
441 {%- assign url = site.baseurl | append: navigation_page.url %}
445 <li{%- if language[1].slug == page.language %} class="current"{%- endif %}>
446 <a href="{{ url }}">{{ language[1].value }}</a>
452 In the code above, we loop through the languages defined in the `snippets.html` file (read the section [Snippets](#snippets) for more details).
464 The *for* loop contains three different code blocks that are run only if specific conditions are met. If we were to look only at its high-level structure:
468 {%- for language in snippets.languages %}
470 {%- if page.layout == 'page' %}
471 <!-- first code block -->
473 {%- elsif page.layout == 'post' %}
474 <!-- second code block -->
477 <!-- third code block -->
480 <li {%- if language[1].slug == page.language %} class="current"{%- endif %}>
481 <a href="{{ url }}">{{ language[1].value }}</a>
487 We run the first block of code only if the `layout` variable of the current page is set to `page`, else, if it is set to `post`, we run the second block of code, else, if it is set to anything else (or to nothing at all), we run the third block of code.
489 After at least one of the code blocks has been run, we generate the list items of the unordered list.
491 Whenever the slug of the current language item of the array `snippets.languages` (`language[1].slug`) matches the language of the current page (`page.language`), we add a class named `current` to the corresponding `<li/>` tag.
493 ##### if page.layout == 'page'
496 {%- if page.layout == 'page' %}
497 {%- assign navigation_pages = site.pages
498 | where: 'language_reference', page.language_reference
499 | where: 'language', language[1].slug %}
500 {%- if navigation_pages.size == 1 %}
501 {%- for navigation_page in navigation_pages %}
502 {%- assign url = site.baseurl | append: navigation_page.url %}
505 {%- assign navigation_pages = site.pages
506 | where: 'language_reference', site.fallback_page
507 | where: 'language', language[1].slug %}
508 {%- for navigation_page in navigation_pages %}
509 {%- assign url = site.baseurl | append: navigation_page.url %}
514 What does the first block of code do?
517 {%- assign navigation_pages = site.pages
518 | where: 'language_reference', page.language_reference
519 | where: 'language', language[1].slug %}
522 We create a new variable named `navigation_pages` which returns a list of the pages that, [in their front matter](#pages-1), have:
524 + the `language_reference` variable equal to the current page’s `language_reference` variable (`page.language_reference`)
525 + the `language` variable equal to the slug of the current language item (`language[1].slug`) in the array `snippets.languages`
527 If we set the front matter of the pages correctly, the size of the array `navigation_pages` should be:
529 + either equal to one if the current page <u>has</u> a corresponding page translated in the current language item of the array `snippets.languages`
530 + or equal to zero if the current page <u>does not have</u> a corresponding page translated in the current language item of the array `snippets.languages`
533 {%- if navigation_pages.size == 1 %}
534 {%- for navigation_page in navigation_pages %}
535 {%- assign url = site.baseurl | append: navigation_page.url %}
539 If the size of the array `navigation_pages` is equal to one, we loop through the array `navigation_pages` and create a new variable named `url` by combining the `site.baseurl` (defined in the `_config.yml` file) and the url of the one page (`navigation_page.url`) contained in the array `navigation_pages`.
543 {%- assign navigation_pages = site.pages
544 | where: 'language_reference', site.fallback_page
545 | where: 'language', language[1].slug %}
546 {%- for navigation_page in navigation_pages %}
547 {%- assign url = site.baseurl | append: navigation_page.url %}
552 If instead, the size of the array `navigation_pages` is equal to zero (or more than one, which is trouble), we do not have a corresponding page in the current language item of the array `snippets.languages` to switch to.
554 Thus, we provide a fallback page (`site.fallback_page`) so that web surfers who interact with the language switch and press on a language that does not support the current page are at least redirected to a meaningful page in the language they selected.
556 We set the `fallback_page` in the `_config.yml` file placed in the site’s root directory:
559 fallback_page: 'stories'
562 The fallback pages of this basic site are those whose `language_reference` variable is set to `stories`.
564 Why `stories`? Because the pages whose `language_reference` variable is set to `stories` work as *home* pages, since they:
566 + return a list of all the published posts (they have exactly the same structure as the `index.html` page)
567 + have a translated counterpart in all the languages supported on the site
569 ##### elsif page.layout == 'post'
572 {%- elsif page.layout == 'post' %}
573 {%- assign navigation_posts = site.posts
574 | where: 'language_reference', page.language_reference
575 | where: 'language', language[1].slug %}
576 {%- if navigation_posts.size == 1 %}
577 {%- for navigation_post in navigation_posts %}
578 {%- assign url = site.baseurl | append: navigation_post.url %}
581 {%- assign navigation_pages = site.pages
582 | where: 'language_reference', site.fallback_page
583 | where: 'language', language[1].slug %}
584 {%- for navigation_page in navigation_pages %}
585 {%- assign url = site.baseurl | append: navigation_page.url %}
590 The second block of code behaves akin to the first, with the only difference that we manipulate an array of posts (`navigation_posts`) rather than one of pages (`navigation_pages`).
596 {%- assign navigation_pages = site.pages
597 | where: 'language_reference', site.fallback_page
598 | where: 'language', language[1].slug %}
599 {%- for navigation_page in navigation_pages %}
600 {%- assign url = site.baseurl | append: navigation_page.url %}
604 The third block of code runs in the remote eventuality in which both the first and second blocks of code are not run, so that we make sure, again, to serve a fallback page to our web surfers.
608 How can we be sure that the fallback page truly works?
610 In this basic site, not all the pages and posts are translated into all the supported languages—on purpose.
614 | English | Italian |
616 | preface.html | prefazione.html |
617 | stories.html | storie.html |
620 If you go to [the English page *Postface*](https://ranbureand.github.io/multilingual-experiment/en/postface.html) and press on *Italian* in the language switch, you can see that you are indeed redirected to the Italian page *Storie*.
624 | English | Italian |
626 | hello-world.markdown | ciao-mondo.markdown |
627 | hello-mars.markdown | ciao-marte.markdown |
628 | — | ciao-giove.markdown |
630 Similarly, if you go to [the Italian post *Ciao Giove*](https://ranbureand.github.io/multilingual-experiment/it/storia/ciao-giove) and press on *English* in the language switch, you can see that you are indeed redirected to the English page *Stories*.
634 The include `title.html` generates the title of this basic site.
637 {%- if page.language == site.default_language %}
638 {%- assign url = site.baseurl | append: '/'%}
640 {%- assign navigation_pages = site.pages
641 | where: 'language_reference', site.fallback_page
642 | where: 'language', page.language %}
643 {%- for navigation_page in navigation_pages %}
644 {%- assign url = site.baseurl | append: navigation_page.url %}
648 <a href="{{ url }}" {%- if page.url == '/' %} class="current"{%- endif %}>{{ site.title }}</a>
652 Again, we have two different code blocks that are run only if specific conditions are met.
654 We run the first code block when the language of the current page (`page.language`) is equal to the default language (`site.default_language`) defined in the `_config.yml` file. Through it we create a new variable named `url` by combining the `site.baseurl` (defined in the `_config.yml` file) and `/`, that is, the domain name of the site. Web surfers who browse the site in the default language are directed to the main page when they press on the title.
656 Else, we run the second code block to provide the usual fallback page already discussed above (read the section [language-switch.html](#language-switchhtml) for more details). Web surfers who browse the site in a language different than the default one are directed to the fallback page in their current language when they press on the title.
658 ### localizations.html
660 The include `localizations.html` adds `<link rel="alternate" … />` tags in the `<head/>` tag of a page [to tell search engines](https://developers.google.com/search/docs/advanced/crawling/localized-versions "Tell Google about localized versions of your page") if there are multiple versions of the page for different languages or regions.
663 {%- if page.layout == 'page' %}
664 {%- assign localized_pages = site.pages
665 | where: 'language_reference', page.language_reference
666 | sort: 'language' %}
667 {%- for localized_page in localized_pages %}
668 <link rel="alternate" hreflang="{{ localized_page.language }}" href="{{ site.baseurl }}{{ localized_page.url }}" />
671 {%- elsif page.layout == 'post' %}
672 {%- assign localized_posts = site.posts
673 | where: 'language_reference', page.language_reference
674 | sort: 'language' %}
675 {%- for localized_post in localized_posts %}
676 <link rel="alternate" hreflang="{{ localized_post.language }}" href="{{ site.baseurl }}{{ localized_post.url }}" />
679 {%- elsif page.layout == 'index' %}
680 {%- assign localized_pages = site.pages
681 | where: 'language_reference', site.fallback_page
682 | sort: 'language' %}
683 {%- for localized_page in localized_pages %}
684 <link rel="alternate" hreflang="{{ localized_page.language }}" href="{{ site.baseurl }}{{ localized_page.url }}" />
689 Again, we have three different code blocks that are run only if specific conditions are met (read the section [language-switch.html](#language-switchhtml) for more details).
691 ## Multilingual Sitemaps
693 To serve a multilingual sitemap, we need to create a [Sitemap index](https://www.sitemaps.org/protocol.html#index "Sitemaps XML Format, Sitemap index") file and list a Sitemap file for each language we support.
695 ### Sitemap Index File
697 We place the page named `sitemap.html` in the root directory of the site. It points to the other localized sitemaps in the respective language subfolders.
707 <?xml version="1.0" encoding="UTF-8"?>
708 <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
710 {%- assign pages = site.pages | where: 'language_reference', 'sitemap' %}
712 {%- for page in pages %}
714 <loc>{{ site.absoluteurl }}{{ page.url | remove: 'index.html' }}</loc>
716 {%- if page.sitemap.lastmod %}
717 {%- assign lastmod = page.sitemap.lastmod | date: '%Y-%m-%d' %}
718 {%- elsif page.date %}
719 {%- assign lastmod = page.date | date_to_xmlschema %}
721 {%- assign lastmod = site.time | date_to_xmlschema %}
723 <lastmod>{{ lastmod }}</lastmod>
730 By setting the following variables in the front matter of the Sitemap index file:
737 we make sure to exclude it from the list of pages returned in the other Sitemap files.
745 title: English Sitemap
748 language_reference: sitemap
754 <?xml version="1.0" encoding="UTF-8"?>
755 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
757 {%- assign posts = site.posts | sort: 'date' | where: 'language', page.language | where: 'published', true %}
759 {%- for post in posts reversed %}
760 {%- unless post.sitemap.excluded == true %}
762 <loc>{{ site.absoluteurl }}{{ post.url }}</loc>
764 {%- if post.sitemap.lastmod %}
765 {%- assign lastmod = post.sitemap.lastmod | date: '%Y-%m-%d' %}
766 {%- elsif post.date %}
767 {%- assign lastmod = post.date | date_to_xmlschema %}
769 {%- assign lastmod = site.time | date_to_xmlschema %}
771 <lastmod>{{ lastmod }}</lastmod>
773 {%- if post.sitemap.changefreq %}
774 {%- assign changefreq = post.sitemap.changefreq %}
776 {%- assign changefreq = 'monthly' %}
778 <changefreq>{{ changefreq }}</changefreq>
780 {%- if post.sitemap.priority %}
781 {%- assign priority = post.sitemap.priority %}
783 {%- assign priority = 0.5 %}
785 <priority>{{ priority }}</priority>
790 {%- assign pages = site.pages | where: 'language', page.language %}
792 {%- for page in pages %}
793 {%- unless page.sitemap.excluded == true %}
795 <loc>{{ site.absoluteurl }}{{ page.url | remove: 'index.html' }}</loc>
797 {%- if post.sitemap.lastmod %}
798 {%- assign lastmod = page.sitemap.lastmod | date: '%Y-%m-%d' %}
799 {%- elsif post.date %}
800 {%- assign lastmod = page.date | date_to_xmlschema %}
802 {%- assign lastmod = site.time | date_to_xmlschema %}
804 <lastmod>{{ lastmod }}</lastmod>
806 {%- if page.sitemap.changefreq %}
807 {%- assign changefreq = page.sitemap.changefreq %}
809 {%- assign changefreq = 'monthly' %}
811 <changefreq>{{ changefreq }}</changefreq>
813 {%- if page.sitemap.priority %}
814 {%- assign priority = page.sitemap.priority %}
816 {%- assign priority = 0.3 %}
818 <priority>{{ priority }}</priority>
832 changefreq: 'monthly'
841 ### 404 Page Not Found
847 + [Making Jekyll multilingual](https://sylvaindurand.org/making-jekyll-multilingual/ "Making Jekyll multilingual")
848 + [Making a multilingual website with Jekyll collections](https://www.kooslooijesteijn.net/blog/multilingual-website-with-jekyll-collections "Making a multilingual website with Jekyll collections")
852 If you feel like adding something to the subject and/or you have spotted something worth fixing, please feel free to either [drop me a line](andreaburan.com/ "Andrea Buran’s Sitefolio") or [create an issue on GitHub](https://github.com/ranbureand/multilingual-experiment/issues): thoughts, critiques, suggestions are all more than welcomed.