EN RU

@demondehellis

Рассказываю о технологиях и программировании.

Мультиязычный блог на Jekyll

Jekyll сам по себе не особо заточен под мультиязычность, а плагины либо заброшены, либо работают не так, как хотелось бы. Я решил сделать все сам.

Обзор изменений

Задача по сути делится на несколько частей:

Самое сложное во всей задаче оказалось не столько само добавление языков, сколько сохранение всей той инфраструктуры, которая уже работала: RSS, категории, рекомендации связанных постов и так далее.

Структура файлов: до и после

До изменений все выглядело достаточно просто:

_content/
  _notes/
    blog/
      2025-03-25-first-post.md
      2025-04-01-second-post.md
  _categories/
    dev.md
    travel.md
index.html
feed.xml

После добавления языков структура значительно усложнилась:

_content/
  _notes/
    en/
      blog/
        2025-03-25-first-post.md
    ru/
      blog/
        2025-03-25-first-post.md
  _categories/
    en/
      dev.md
      travel.md
    ru/
      dev.md
      travel.md
_data/
  translations.yml
en/
  index.html
  feed.xml
ru/
  index.html
  feed.xml
index.html (перенаправление)

Теперь для каждого языка у нас отдельные директории для контента, категорий и служебных файлов. Главная страница превратилась в своеобразный роутер, который определяет предпочтительный язык пользователя и делает редирект (подробнее об этом дальше).

Почему не сделать просто копию блога на другом языке на поддомене? Чтош, ЖПТ и Гугл убедили меня в том, что это будет хуже с точки зрения SEO. Кроме того, будет несколько сложнее управлять блогом, т.к. будет два разных репозитория или две разные ветки, их нужно будет синхронизировать при изменениях шаблонов и прочего. В общем, мне способ с поддоменами сначала показался проще, но, кажется, что в перспективе он требует больше мороки, чем с подпапками.

Настройки в _config.yml

Первым делом обновил конфиг. Вместо одного языка теперь массив:

# Было
lang: "ru"

# Стало
languages: ["en", "ru"]

Далее переделал структуру URL и дефолтные настройки для каждого языка:

# Дефолтные значения для контента по языкам
defaults:
  - scope:
      path: "*"
    values:
      lang: "en"  # по умолчанию язык английский

  # Общие настройки для всего контента
  - scope:
      path: "_content/_*/*"
    values:
      layout: "content"

  # Настройки для английского
  - scope:
      path: "_content/_*/en/*"
    values:
      lang: "en"
      permalink: "/en/:title/"

  # Настройки для русского
  - scope:
      path: "_content/_*/ru/*"
    values:
        lang: "ru"
        permalink: "/ru/:title/"

Здесь самое главное — структура permalink. Я решил идти через явное указание языка в URL, так что английские статьи доступны по /en/title/, а русские — по /ru/title/. Это самый простой и очевидный способ, хотя после публикации новой структуры нужно сделать еще и редиректы со старых версий.

Переводы интерфейса

Чтобы не хардкодить тексты в шаблонах, создал файл с переводами _data/translations.yml:

en:
  tagline: "travel, technology, and various things"
  description: "Interesting stuff about life abroad, technology and nerdy things 
  read_more: "Read more"
  previous: "Previous post"
  next: "Next post"
  more: "MORE"

ru:
  tagline: "путешествия, технологии и всякие штуки"
  description: "Интересное о жизни за границей, технологиях и задротских штуках 
  read_more: "Читать далее"
  previous: "Предыдущий пост"
  next: "Следующий пост"
  more: "ЕЩЕ"

Теперь в любом шаблоне достаточно обращаться к переводам через переменную текущего языка:

{{ site.data.translations[page.lang].read_more }}

page.lang - это как раз настройка из конфига, которая меняется в зависимости от урла, т.е. ru для /ru/... и en для /en/.... Очень удобно и гибко получилось, можно добавлять и другие языки.

СЕО и языковые метаданные (hreflang)

Это одна из самых важных частей всей работы. Когда у тебя мультиязычный сайт, поисковые системы должны понимать, что у статей есть разные версии на разных языках. Иначе переводы будут считаться дубликатами. Для этого используется мета-тег hreflang.

Я создал отдельный подшаблон _includes/meta/language.html:

{% assign all_pages = site.pages %}
{% for collection in site.collections %}
{% assign all_pages = all_pages | concat: collection.docs %}
{% endfor %}

{% for lang in site.languages %}
{% assign alt_prefix = '/' | append: lang | append: '/' %}
{% assign current_prefix = '/' | append: page.lang | append: '/' %}
{% assign alt_url = page.url | replace_first: current_prefix, alt_prefix %}
{% assign alt_page = all_pages | where: "url", alt_url | first %}
{% if alt_page and page.url != '/' %}
<link rel="alternate" hreflang="{{ lang }}" href="{{ site.url }}{{ alt_page.url }}" />
{% endif %}
{% endfor %}

<!--remember language preference-->
<script>window.location.pathname === '/' || window.localStorage.setItem("lang", "{{ page.lang }}");</script>

Что здесь происходит? Самая хитрая часть — автоматическое определение альтернативных версий страницы. В первом блоке мы просто обираем список всех доступных страниц включая документы из всех коллекций. Затем для каждого языка мы делаем такие шаги:

  1. Берем текущий URL страницы
  2. Меняем в нем языковой префикс (например, с /ru/ на /en/)
  3. Ищем в списке всех страниц ту, которая соответствует этому новому URL
  4. Если нашли — добавляем тег <link rel="alternate" hreflang="...">

Этот тег — ключевой момент для SEO. Он говорит поисковикам: “Эй, у этой страницы есть альтернативная версия на другом языке, вот она”. Таким образом, поисковики могут показывать правильную языковую версию правильной аудитории. Немного громоздко, но зато расширяемо и все работает автоматически.

Еще один момент - мы добавляем не только альтернативные версии страницы, но и версию с текущим языком, это норм и не мешает СЕО.

Дополнительно я добавил скрипт, который запоминает текущий язык в localStorage. Это пригодится для автоматического редиректа с главной.

Главная страница и переключение языков

На главной странице теперь у нас редирект на основе языка пользователя:

<script>
  (function() {
    if (/bot|crawl|spider/i.test(navigator.userAgent)) return;
    const lang = localStorage.getItem('lang') || (navigator.language).split('-')[0];
    window.location.href = lang === 'ru' ? '/ru/' : '/en/';
  })();
</script>

<div class="prose prose-lg md:prose-xl prose-img:mx-auto mx-2 md:mx-auto text-center">
  <h1>Welcome</h1>
  👉 <a href="/en/">Read in English</a><br>
  👉 <a href="/ru/">Читать по-русски</a>
</div>

Скрипт делает следующее:

  1. Проверяет, не поисковый ли это бот (им редирект не нужен)
  2. Берет предпочтительный язык из localStorage, если он там есть
  3. Если нет — берет из настроек браузера пользователя
  4. Делает редирект на соответствующую языковую версию

Для поисковиков (и случаев, когда JS отключен) есть обычные ссылки. Если пользователь специально тыкнул на другой язык отличный от настроек браузера, то скрипт из предыдущего шага запомнит этот выбор и будет использовать в приоритете. Минималистично и гибко, мне нравится.

В шапке сайта добавил переключатель языков:

<div class="flex justify-center space-x-4 mb-2 text-sm">
    <a href="/en/" class="">EN</a>
    <a href="/ru/" class="font-bold">RU</a>
</div>

Здесь можно сделать цикл и вывести доступные языки из массива который мы забили в _config.yml, но я оставил пока так для простоты.

Фильтрация контента по языку

Важная часть — фильтровать контент по языку в списках и коллекциях. Если пользователь смотрит сайт с /en/... версии, то ему следует показать только англоязычные статьи. А если англоязычной версии нет, то не показывать вовсе. С этим как раз и была проблема у популярного плагина polyglot, который отображает статьи для языка по умолчанию, если альтернативные версии отсутствуют. Т.е. в моем случае англоязычные пользователи видели бы некоторые статьи на русском, если я не добавил перевод. Поэтому я и отказался от него.

В тех местах где я вывожу список статей я фильтрацию по языку:

{% assign notes = site.notes | where: "lang", page.lang %}

Это гарантирует, что на русскоязычной версии показываются только посты на русском, а на англоязычной версии — только на английском.

RSS-фиды

Для каждого языка создал отдельный RSS-фид:

en/feed.xml
ru/feed.xml

В шаблоне фида тоже добавил фильтрацию по языку:

{% assign notes = site.notes | where: "lang", page.lang %}

Готово

В итоге блог полностью готов к многоязычному контенту. Структура гибкая, поэтому если захочется добавить еще какой-то язык — это будет просто. Карта сайта остается общей для всех языков, это нормальная практика.

Что мне нравится в этом решении:

С точки зрения SEO все настроено по лучшим практикам:

Теперь осталось только перевести все статьи…


Еще всякое интересное