Tags auf mehrsprachigen Jekyll-Sites

Jekyll speichert Tags gesammelt für die ganze Site. In einem früheren Beitrag <a href=/multilang-mit-jekyll/>Mehrsprachige Websites mit Jekyll hatte ich bereits beschrieben, wie sich mit Jekyll eine mehrsprachige Website realisieren lässt. Eine Sache, die noch fehlte, war die Unterstützung von Tags, also Schlag- bzw. Schlüsselwörtern.

Anforderungen

Rubrikenseiten - also Seiten, die alle Beiträge für eine bestimmte Rubrik auflisten - pflege ich manuell, weil ohnehin eine Beschreibung zugefügt werden muss. Bei Tag-Seiten ziehe ich es vor, dass sie automatisch gepflegt werden.

Das Plug-in jekyll-tagging

De-facto-Standard für die Erzeugung von Tag-Seiten scheint das Plug-in jekyll-tagging zu sein. Meine Konfiguration dafür in _config.yml sieht so aus:

tag_page_layout: tag_page
tag_page_dir: tags
tag_feed_layout: tag_feed
tag_feed_dir: tags
tag_permalink_style: pretty

Bemerkung: Tatsächlich muss das Plug-In von Jekyll auch noch geladen werden, was weitere Konfiguration erfordert. Dies fehlt hier, weil die hier gezeigte Lösung ohnehin auf andere Art funktioniert.

Für die Generierung der Tag-Seiten wird das Layout-Template _layout/tag_page.html verwendet (Zeile 1). Geschrieben werden sollen sie in das Verzeichnis /tags/ (Zeile 2). In Zeile 3 und 4 werden RSS-Feeds pro Tag auf die gleiche Weise aktiviert und konfiguriert.

Schließlich wird noch in Zeile 5 der Link-Stil der Site gesetzt.

Das Template _layout/tag_page.html sieht folgendermaßen aus:

---
layout: default
---
{% assign posts=site.tags[page.tag] | where: "lang", page.lang | where: "type", "posts" %}
<div class="col-md-8">
  <span>{{ site.t[page.lang].tag }}: <i class="fa fa-tag"></i>{{ page.tag }}</span>
  {% for post in posts %}
  <article class="blog-post">
    {% if post.image %}
    <div class="blog-post-image">
      <a href="{{post.url | prepend: site.baseurl}}">
        <img src="{{post.image}}" alt="{{post.image_alt}}">
      </a>
    </div>
    {% endif %}
    <div class="blog-post-body">
      <h2>
        <a href="{{post.url | prepend: site.baseurl}}">{{ post.title }}</a>
      </h2>
      <div class="post-meta">
        <span><i class="fa fa-clock-o"></i>{% include {{ page.lang }}/long_date.html param=post.date %}</span> {% if post.comments %} / <span><i
          class="fa fa-comment-o"></i> <a href="#">{{ post.comments }}</a></span>
        {% endif %}
      </div>
      <p>{{post.excerpt}}</p>
      <div class="read-more">
        <a href="{{post.url}}">Continue Reading</a>
      </div>
    </div>
  </article>
  {% endfor %}
</div>

Die einzig interessante Zeile ist Zeile 4. Der Hash site.tags wird von Jekyll gefüllt. Das Attribut tag wird als Schlüssel für den Zugriff auf den Hash verwendet, und die so ermittelten Dokumente werden danach noch nach Sprache und Dokumententyp gefiltert. Leider funktioniert das aber nicht.

Das erste Problem ist, dass jekyll-tagging nichts über ein Dokumentenattribut lang weiß, und es dementsprechend auch nicht setzen kann. Außerdem erzeugt das Plug-in nur eine Seite pro Tag, aber wir wollen eine Seite pro Sprache und Tag.

Wrapper Um jekyll-tagging

Einzige Lösung war die Entwicklung eines Wrappers um das Plug-in. Leider hatte ich bislang noch nie eine Zeile Ruby-Code verfasst. Aber die Aufgabe sah so simpel aus, dass ich es unbelastet von jeglichem Ruby-Know-How dennoch versuchte.

Ich beschloss die Konfigurations-Option ignore_tags von jekyll-tagging für meine Zwecke zu missbrauchen. Statt das Plug-in nur einmal aufzurufen, sollte der Wrapper es einmal für jede Sprache aufrufen, und dabei jedesmal eine leicht angepasste Konfiguration zu übergeben. Insbesondere der Wert von ignore_tags sollte jedesmal auf die Liste von Tags, die für die aktuelle Sprache nicht existieren, gesetzt werden.

Grundgerüst für das Plug-in

Zunächste einmal muss eine Datei _plugins/ml_tagging.rb erzeugt werden:

require 'jekyll/tagging'

module Jekyll
    class MultiLangTagger < Tagger

        @types = [:page, :feed]
        
        def generate(site)
            # Generate some pages.
        end
    end
end

Mein Wrapper-Plug-in heißt MultiLangTagger und ist eine Unterklasse von Tagger (Zeile 4), also der Generatorklasse, die von jekyll-tagging definiert wird.

Zeile 6 sieht dubios aus. Sie ist eins-zu-eins aus der Elternklasse in jekyll-tagging kopiert. Offensichtlich wird diese Variable nicht von der Elternklasse geerbt. Jemand mit etwas mehr Ruby-Know-How kann das wahrscheinlich erklären, ich leider nicht.

Die einzige Methode, die Generator-Plug-ins für Jekyll implementieren müssen, ist generate, siehe Zeile 8. Ihr einziges Argument ist eine Instanz von Jekyll::Site, mit deren Hilfe die Konfiguration, Seiten, Posts, Tags und so weiter abgefragt werden können.

Gruppierung von Tags

Zunächst müssen die Tags nach Sprachen gruppiert werden. Dazu wurde die Methode generate wie folgt geändert:

def generate(site)
    # Iteriere über alle Posts, und gruppiere nach Sprache.
    for post in site.posts.docs do
        lang = post.data['lang']
        site.config['t'][lang]['tagnames'] = 
                {} unless site.config['t'][lang]['tagnames']
        tagnames = site.config['t'][lang]['tagnames']
        tags = post.data['tags']
        for tag in tags do
            slug = jekyll_tagging_slug(tag)
            if tagnames[slug]
                if tagnames[slug] && tagnames[slug] != tag
                    raise "Tag '%{tag1}' and tag '%{tag2}' will create the same filename.  Change one of them!" % { :tag1 => tagnames[slug], :tag2 => tag }
                end
            else
                tagnames[slug] =  tag
            end
        end
    end

end

site.posts.docs ist ein Hash, der alle Blog-Beiträge enthält. Für jeden Beitrag wird die Sprache über das Attribut lang ermittelt. Bei meiner Site ist dieses Attribut immer gesetzt, weshalb keine Überprüfung stattfindet, ob das Attribut existiert oder nicht.

In meiner Site-Konfiguration gibt es bereits einen Schlüssel t, der die Übersetzungen für Zeichenketten-Konstanten in allen unterstützten Sprachen enthält. Die Tags wurden in Analogie zum Schlüssel catnames für Rubriken in den Zeilen 5 bis 6 unter dem Schlüssel tagnames ebenfalls hier abgeleigt.

Ab Zeile 9 wird über alle Tags für das aktuelle Dokument iteriert. In der nächsten Zeile wird das Tag mit der Hilfsfunktion jekyll_taging_slug() in einen sicheren Dateinamen transformiert. Diese Funktion wird auch von jekyll-tagging verwendet, um den Namen der Ausgabedatei zu bestimmen.

Für meine Site ist es wichtig, dass es eine eineindeutige Beziehung zwischen Tags und der entsprechenden Tag-Seite gibt. Die Zeilen 11 bis 17 stellen dies sicher. Dieser Schritt ist nicht wirklich notwendig, sondern eher eine Qualitätssicherungsmaßnahme, um eine konsistente Schreibweise für Tags sicherzustellen.

Ergebnis ist eine Datenstruktur, die von Ruby nach YAML übersetzt ungefähr so in _config.yml aussähe:

t:
  en:
    dns: DNS
    system-administration: "System Administration"
    jekyll: Jekyll
    development: Development
  de:
    dns: DNS
    systemadministration: "Systemadministration"
    jekyll: Jekyll
    entwicklung: Entwicklung
Aufruf des Elternklassengenerators ---------------------------------- Nachdem die Tags gruppiert sind, muss die Methode `generate` für jede Sprache aufgerufen werdern, und zwar jedesmal mit einer leicht modifizierten Konfiguration. Dazu wird die Methode `generate` des Wrapper-Plug-ins weiter erweitert:
saved_tag_page_dir = site.config['tag_page_dir']
saved_tag_feed_dir = site.config['tag_feed_dir']

for lang in site.config['t'].keys
    site.config['tag_page_dir'] = '/' + lang + '/' + saved_tag_page_dir
    site.config['tag_feed_dir'] = '/' + lang + '/' + saved_tag_feed_dir
    site.config['ignored_tags'] = site.tags.keys - site.config['t'][lang]['tagnames'].values

    super
end

Die Ausgabeverzeichnisse müssen sich für alle Sprachen unterscheiden. Andernfalls würden Tag-Seiten für Tags, die in mehreren Sprachen verwendet werden, sich jeweils überschreiben. In den Zeilen 1 und 2 werden zunächst die Originalwerte für die Ausgabeverzeichnisse aus der Konfiguration abgefragt und in Variablen abgelegt.

Danach wird über alle verfügbaren Sprachen iteriert, also die Schlüssel für den Hash-Wert t. Für jede Sprache werden die Konfigurationsvariablen tag_page_dir und tag_feed_dir mit dem sprachspezifischen Wert überschreiben. Um die Dinge einfach zu halten, wird hier einfach der Sprachbezeichner dem ursprünglichen Wert vorangestellt.

Zeile 7 ist wichtig. jekyll-tagging verwendet die Konfigurationsvariable ignored_tags, um einzelne Tags zu ignorieren. Hier wird dieses Feature missbraucht, indem die Variable temporär mit einem Array befüllt wird, das die Differenzmenge zwischen allen auf der Site verwendeten Tags und den für die aktuelle Sprache verwendeten Tags enthält.

In Zeile 9 schließlich wird die Methode generate der Basisklasse aus jekyll-tagging aufgerufen, die das Erzeugen der Tag-Seiten implementiert.

An dieser Stelle vermasselte ein Showstopper das Konzeipt. jekyll-tagging Version 1.0.1 hat einen Bug, aufgrunddessen der Mechanismus zum Ignorieren von Tags nicht funktioniert, siehe diesen Bug-Report auf GitHub für Einzelheiten.

Leider ist es mir nicht gelungen, den Bug durch Monkey-Patching aus jekyll-tagging zu entfernen, und ich habe schließlich die Quelldatei von Hand geändert. In der Datei tagging.rb in jekyll-tagging muss die Methode active_tags folgendermaßen aussehen:

def active_tags
  return site.tags unless site.config["ignored_tags"]
  site.tags.reject { |t| site.config["ignored_tags"].include? t }
end

Alternativ muss man warten, bis der Bug upstream gefixt wurde.

Ein Teil der Lösung fehlt allerdings noch immer. Auch die Tag-Seiten müssen ein Attribut lang mit dem Code der jeweiligen Sprache aufweisen. jekyll-tagging erlaubt jedoch leider keine Injizierung zusätzlicher Daten, weshalb wir das selber erledigen müssen:

for page in site.pages
    if page.data['tag']
        dir = Pathname(page.url).each_filename.to_a
        lang = page.data['lang'] = dir[0]
        description = site.config['t'][lang]['taglist']['description']
        page.data['description'] = description % { :tag => page.data['tag'] }
    end
end

Noch immer in der Methode generate, iterieren wir jetzt über alle Dokumente vom Typ page. In Ermangelung einer besseren Methode, um Tag-Seiten zu identifizieren, wird überprüft, ob das Dokument ein Attribut tag besitzt. Falls ja, wird das erste Element des Dokumenten-URLs extrahiert und als Sprachkürzel ins Dokument injiziert. Wichtig: Um Pathname() verwenden zu können muss am Anfang der Datei ein require 'pathname' eingefügt werden.

Bei dieser Gelegenheit wird der den Tag-Seiten auch eine Beschreibung zugefügt. Die Beschreibung stammt aus einem sprachspezifischen String in _config.yml. Diese Zeichenkette kann einen Platzhalter %{tag} für das jeweilige Tag enthalten, der in Zeile 6 ersetzt wird.

Schließlich müssen noch die Konfigurationsvariablen auf ihren Originalwert zurückgesetzt werden, weil sie beim nächsten Aufruf erneut verwendet werden:

site.config['tag_page_dir'] = saved_tag_page_dir
site.config['tag_feed_dir'] = saved_tag_feed_dir

An dieser Stelle funktioniert die Implementierung mehr oder weniger korrekt. Die Methode generate ruft generate in jekyll-tagging für jede Sprache separat und mit modifizierter Konfiguration auf. Dabei werden ebenfalls die Attribute lang und description sprachspezifisch in die generierten Seiten injiziert.

Weitere Optimierungen

Jekyll stellt offensichtlich fest, dass ein weiteres Generator-Plug-in geladen wurde, und ruft es nach unserem eigenen auf. Bei meiner Site steigt die Liquid-Template-Engine aber leider aus, wenn bei einer Seite oder einem Blog-Beitrag das Attribut lang fehlt. Es muss also verhindert werden, dass das Plug-in ohne von uns gepatchte Konfiguration Seiten generiert.

Ich konnte allerdings keinen Weg finden, um Jekyll davon abzuhalten den Generator der Basisklasse aufzurufen. Nachdem unser Wrapper-Plug-in die Basismethode für jede Sprache aufgerufen hat, setze ich ignore_tags einfach auf die Liste aller Tags:

site.config['ignored_tags'] = site.tags.keys

Die Basismethode wird jetzt zwar noch immer aufgerufen, generiert aber keine Seiten mehr, weil alle Tags ignoriert werden.

Ein weiteres Problem bestand darin, dass ich die Anzahl der Dokumente in einer bestimmten Sprache für jedes Tag bestimmen wollte. Dies habe ich mit Hilfe eines Hooks gelöst, der diese Zahlen nicht nur für Tags sondern auch für Rubriken berechnet und in der Site-Konfiguration ablegt.

Ein Download-Link für diese Datei `precompute.rb findet sich weiter unten.

Fragen

Wie erzeuge ich Links auf eine Tag-Seite?

So:

<a href="/{{ page.lang }}{{ tag | tag_url }}">{{% tag %}}</a>

Der Filter tag_url wird von jekyll-tagging bereitgestellt und liefert den URL der Tag-Seite. Aufgrund unserer Modifikationen muss aber das Sprachkürzel noch vorangestellt werden.

Wie lässt sich die Anzahl der Dokumente für ein Tag in einer Sprache ermitteln?

So:

{{ site.tagcounts[page.lang][tag] }}

Diese Zahl wird in precompute.rb berechnet.

Wie lässt sich die Anzahl der Dokumente für eine Rubrik in einer Sprache ermitteln?

Genauso:

{{ site.catcounts[page.lang][category] }}

Diese Zahl wird in precompute.rb berechnet.

Wie lassen sich sprachspezifische Tag-Clouds erzeugen?

Tag-Clouds? Hallo? Wir haben 2016!

Downloads

Links für alle relevanten Dateien:

_plugins/ml_tagging.rb
Wrapper-Plug-in für `jekyll-tagging`
_plugins/precompute.rb
Hook, mit dem die Anzahl der Dokumente pro Sprache errechnet wird
_layouts/tag_page.html
Template für Tag-Seiten
_layouts/tag_feed.xml
Template für Tag-Feeds
_includes/feed.xml
Include für alle Feeds
_includes/tag-feeds.xml
Include um alle Tag-Feeds für eine Sprache zu listen

blog comments powered by Disqus