Mehrsprachige Websites mit Jekyll

Nach etlichen Jahren wollte ich vor kurzem endlich das Projekt Website von der TODO-Liste holen. Da ich nicht mehr bei Imperia{:target=_blank} arbeite, musste ich mich nach einer leichtgewichtigen Alternative umschauen. PHP wollte ich mir nicht antune, was die Auswahl stark einschränkte. Ein Kollege brachte mich schließlich auf Jekyll{:target=_blank}, das mich mit seinem simplen semi-statischen Ansatz an Imperia erinnerte, und ich beschloss, es auszuprobieren.

Das Thema Mehrsprachigkeit ist eins der Dinge, bei denen sich im CMS-Markt schnell die Spreu vom Weizen trennt, und von Imperia war ich in dieser Hinsicht natürlich sehr verwöhnt, weil Mehrsprachigkeit bei Imperia im ganzen System fest eingebaut ist, und deshalb mehr oder weniger einfach so funktioniert.

Mehrsprachigkeits-Optionen bei Jekyll

Für Jekyll gibt es verschiedene Plug-Ins, die dem System Mehrsprachigkeit beibringen sollen. Nach einiger Zeit stieß ich jedoch auf den ausgezeichneten Artikel Making Jekyll multilingual{:target=_blank} von Sylvain Durand{:target=_blank}, der einen Ansatz ohne Plug-Ins beschreibt.

Ich stelle hier nur die Lösungen dar, bei denen ich von Sylvains Empfehlungen abgewichen bin. Wer alles nachvollziehen will, sollte also zuerst seinen Artikel lesen.

Grundsatzfragen

Bevor die Struktur der mehrsprachigen Site entschieden werden kann, musste das Thema auch noch webserverseitig angegangen werden. Best Practice ist hier Content-Negotiation, bei der die Auswahl der Sprachversion einer Seite vom Webserver mit dem Browser der Besucherin gleichsam ausgehandelt wird. Das ist auch das Grundprinzip für Mehrsprachigkeit in Imperia.

Ich wollte aber als Webserver Nginx{:target=_blank} statt Apache{:target=_blank} einsetzen. Content-Negotiation ist für Nginx noch immer nur als Patch für den Sourcecode verfügbar. Das wollte ich mir nicht antun, und beschloss das Thema mit einem eigenen Handler in Perl anzugehen.

Der Perl-Handler handelt nur für die Startseite / die Sprache aus, und triggert dann einen Redirect auf die sprachspezifische Startseite /en/, /de/ und so weiter. That is described in the post Einfache Content-Negotiation für Nginx.

Sprachumschalter

Sylvain{:target=_blank} schlägt vor, im Vorspann die Variable name auf einen eindeutigen, sprachunabhängigen Identifier für jedes Dokument zu setzen, um zu einem Dokument die anderen Sprachvarianten finden zu können. Das Menü zur Sprachumschaltung sieht bei ihm so aus:

{% assign posts=site.posts | where:"name", page.name | sort: 'path' %}
<ul>
{% for post in posts %}
    <li class="lang">
        <a href="{{ post.url }}" class="{{ post.lang }}">{{ post.lang }}</a>
    </li>
{% endfor %}
</ul>

In Zeile 1 sucht er aus allen Posts die Posts heraus, die das gleiche Property name haben. Für jedes dieser Posts erzeugt er in der Schleife von Zeile 3 bis 7 einen Link.

Das heißt aber, dass für Sprachen, für die der jeweilige Artikel nicht übersetzt ist, kein Eintrag im Sprachmenü angezeigt wird. Ich bevorzuge in solchen Fällen, einen Link auf eine übergeordnete Seite, im Zweifel die Startseite zu setzen:

{% for lang in site.languages %}
  {% if page.type == 'posts' %}
    {% assign other=site.posts | where: "name", page.name 
                               | where: "lang", lang | first %}
  {% else %}
    {% assign other=site.pages | where: "pageid", page.pageid 
                               | where: "lang", lang | first %}
  {% endif %}
  <li class="lang">
    <a href="{% if other.url %}{{ other.url }}{% else %}/{{ lang }}/{% endif %}" 
       class="{{ lang }}">{{ lang | upcase }}</a></li>
{% endfor %}

In Zeile 1 iteriere ich über die Sprachen der Site. Die hole ich mir aus der Variablen site.languages, die aus _config.yml kommt:

languages: [en, de]

Auch die Fallunterscheidung in Zeile 2 ist neu. Das Property name soll die verschiedenen Sprachversionen verlinken. Das funktioniert bei mir nur für Seiten vom Typ posts, nicht aber zum Beispiel für page. In Seiten, die keine Posts sind, definiere ich deshalb eine analoge Variable pageid mit dem gleichen Zweck. Es wäre wahrscheinlich schlauer, durchgehend pageid statt name zu verwenden, und dann die Fallunterscheidungen zu sparen.

Nachtrag: Statt name benutzt Sylvain{:target=_blank} mittlerweile ref, was den gleichen Effekt hat, wie durchgehend pageid zu verwenden.

Effekt ist jedenfalls, dass die Variable other jeweils das Seitenobjekt in der jeweiligen Sprache enthält, sofern dieses vorhanden ist. In Zeile 10 wird der Link für die Sprache entweder auf die Version der Seite in dieser Sprache oder hilfsweise auf die Startseite gesetzt, genau so, wie wir es haben wollten.

Verlinkung der Sprachvarianten

Der Code für die sitemap.xml muss analog angepasst werden:

---
layout:
permalink: /sitemap.xml
---
<?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 page in site.pages %}
  <url>
    <loc>{{ site.url }}{{ page.url }}</loc>
    {% assign versions=site.pages | where:"pageid", page.pageid %}
    {% for version in versions %}
      <xhtml:link rel="alternate" hreflang="{{ version.lang }}" 
                  href="{{ site.url }}{{ version.url }}" />
    {% endfor %}
    {% if page.date %}
    <lastmod>{{ page.date | date_to_xmlschema }}</lastmod>
    {% endif %}
    <changefreq>monthly</changefreq>
  </url>
  {% endfor %}
  {% for post in site.posts %}
  <url>
    <loc>{{ site.url }}{{ post.url }}</loc>
    {% assign versions=site.posts | where:"name", post.name %}
    {% for version in versions %}
      <xhtml:link rel="alternate" hreflang="{{ version.lang }}" 
                  href="{{ site.url }}{{ version.url }}" />
    {% endfor %}
    <lastmod>{{ post.date | date_to_xmlschema }}</lastmod>
    <changefreq>weekly</changefreq>
  </url>
  {% endfor %}
</urlset>

Auch hier wird wieder zwischen Seiten vom Typ page und post unterschieden. Bei Posts wird über die Eigenschaft name verlinkt, bei Pages via pageid. Die Verlinkung im <head> des HTMLs verläuft analog:

{% for lang in site.languages %}
  {% if page.type == 'posts' %}
    {% assign other=site.posts | where: "name", page.name
                               | where: "lang", lang | first %}
  {% else %}
    {% assign other=site.pages | where: "pageid", page.pageid
                               | where: "lang", lang | first %}
  {% endif %}
  {% if other and page.lang != other.lang %}
  <link rel="alternate" hreflang="{{other.lang}}" href="{{other.url}}" />
  {% endif %}
{% endfor %}

Diesmal fallen wir natürlich nicht auf den Link zur Startseite zurück, weil wir ja wirklich nur auf Ressourcen mit dem gleichen Inhalt in einer anderen Sprache verweisen wollen.

Übersetzung von Template-Texten

Nicht nur die eigentlichen Inhalte müssen übersetzt werden, sondern auch die Templates enthalten Strings, die übersetzt werden müssen. Das mache ich noch immer mit dem Ansatz, den auch Sylvain Durand vorschlägt. Die Übersetzungen werden in _config.yml eingepflegt:

# Boilerplate translations.
t:
  en:
    home: Home
    toggle_navigation: "Toggle navigation
    categories: Categories
    featured_posts: "Featured Posts"
    ads: Ads
  de:
    home: Start
    toggle_navigation: "Navigation ein-/ausklappen"
    categories: Rubriken
    featured_posts: "Mehr zu lesen"
    ads: Werbung
In den Templates wird dann so auf die Variablen zugegriffen:
<span class="sr-only">{{site.t[page.lang].toggle_navigation}}</span>

Das ist potthässlich. Platzhalter für übersetzbare Strings zu verwenden ist ein Rezept für Probleme. Ich würde eine Markierung der Strings in der Primärspache bevorzugen:

<span class="sr-only">{% gettext "Toggle navigation" %}</span>

Im Moment habe ich nur sehr wenige solche Strings in meiner Konfigurationsdatei und deshalb habe ich mich damit abgefunden. Werden es mehr Strings, werde ich mir eine bessere Lösung überlegen.


blog comments powered by Disqus