Statische Websites mit Pandoc
»Source Code« CC BY-NC-SA Marc Oliver Orth

Statische Websites mit Pandoc

Für den Umzug weg von GitHub Pages wollte ich Jekyll durch ein bereits von mir verwendetes Werkzeug ersetzen.1 Als Markdown-Nerd habe ich mich entschieden, Pandoc als sogenannten Static Website Generator zweckzuentfremden.

Pandoc ist ein universeller Dokumentumwandler, der eine Vielzahl von Textformaten ineinander überführen kann. Mithilfe von Templates lässt sich die Umwandlung anpassen. Damit aus einer Sammlung von Markdown-Dokumenten mit YAML-Headern überhaupt eine vollständige Website erzeugt werden kann, sind einige Vorarbeiten nötig.

Inhalt

Der Rahmen

Die Umsetzung dieser statischen Website benötigt lediglich Standard-Befehle der Konsole, wie sie z. B. in macOS verfügbar sind, und eben Pandoc. Der Rest sind Struktur und Inhalt.

Die Feinheiten bei der Verarbeitung der Markdown-Dateien mit Pandoc ergeben sich dann aus Daten in den jeweiligen YAML-Headern sowie den Ablageorten der Dateien selbst.

Ein Bash-Skript kopiert, konvertiert und erstellt die für jeden Prozessschritt benötigten Daten, Dateien und Ordner in der richtigen Reihenfolge im build-Verzeichnis. Dort entsteht so die fertige statische Website.

Die Quelle mit Beispielprojekt ist unter dem Artikel verlinkt.

Ze List

Pandoc lebt von einem Dokument zum nächsten. Zwar können mehrere Dokumente zur Erstellung eines Zieldokumentes herangezogen werden, aber Pandoc behält keinen Überblick über Verzeichnisse oder Dokumentensammlungen. Daher wird als Erstes eine Liste aller zu konvertierender Markdown-Dateien benötigt. »Ze List« ist also das Inhaltsverzeichnis, an dem sich der Rest des Skripts orientiert.

Ein Bash-Einzeiler leistet die Basisarbeit:

find . -name "*.md" -type f -print0 | sort -rz | tr '\0' '\n' > ze_list.txt

Die vollständigen Pfade aller Markdown-Dateien werden mit find aufgelistet und mit der Option -print0 durch einen \0-Charakter aneinander gereiht. Danach sortiert sort die Pfade alphabetisch in umgekehrter Reihenfolge.2 Am Ende ersetzt tr die \0-en durch Zeilenumbrüche und alles wird in ze_list.txt gespeichert.

Aus dieser Liste wird dann das Dokument posts.md erzeugt, ein einfacher YAML-Header, in dem nur die Blog-Beiträge im Verzeichnis _posts enthalten sind. Aus dieser Information werden später der RSS-Feed und die Anzeige der neuesten Beiträge abgeleitet.

Die Verwandlung

Im nächsten Schritt werden alle Markdown-Dateien in HTML-Dokumente verwandelt. In einer Schleife wird Ze List ausgelesen und für jede Datei write_file mit dem kompletten Pfad der .md-Datei und dem relativen Ziel-Verzeichnis aufgerufen.

while IFS= read -r line; do
DESTINATION_DIR=""
    if [[ $line == *"_posts"* ]]; then
        DESTINATION_DIR="blog/"
    fi
    write_file "$line" "${DESTINATION_DIR}"
done < ze_list.txt

Die Funktion write_file gibt Pandoc je nach Datei-Art die entsprechenden Argumente mit, die zur Konvertierung benötigt werden. Für Index-Dateien werden die entsprechenden Daten der kürzlich veröffentlichten Beiträge aus posts.md mitgegeben. Dafür bekommen Blog-Beiträge dann den Lua-Filter zur Berechnung der Lesezeit.

# $1 = source file path
# $2 = relative destination folder
function write_file {
    echo -e "\x1b[1;37m- /$2 \x1b[0m $(basename $1 .md)"

    BASENAME="$(basename ${1} .md)"
    FILTER=""
    
    if [[ $1 == *"_posts"* ]] && [[ $1 != *"index"* ]]; then
        BASENAME=${BASENAME:11}
        FILTER="--lua-filter ./_filters/filter-reading-time.lua"
    fi

    if [[ $1 == *"index"* ]]; then
        FILTER="--lua-filter ./_filters/filter-latest-posts.lua"
    fi
    
    pandoc "./_data/posts.md" "${1}" -f markdown -t html \
        -o "build/${2}${BASENAME}.html" \
        --defaults "./_data/config.md" \
        --template="./_templates/default.template.html" \
        --metadata filename="${BASENAME}.html" \
        ${FILTER}
}

Ist »Ze List« abgearbeitet, sind alle Beiträge, Index- und normale Seiten umgewandelt.

Der Feed

Zu einer klassischen Website gehört ein RSS-Feed. Für die Erstellung kommt die posts.md zum Einsatz, gepaart mit einem XML-Template. Allerdings kann Pandoc kein XML, zumindest nicht richtig. Die erzeugte Datei muss also nachbearbeitet werden.

Zuerst schneidet sed überfüssige HTML-Tags heraus.3 Danach wird das XML mit xmllint »aufgehübscht« und valide gemacht.

mkdir -p build/feed
pandoc -i "./_data/posts.md" -o "./build/feed/rss.xml" \
        -f markdown -t html \
    --template="./_templates/rss.template.xml"
sed -i '' -e 's/<\/\{0,1\}p>//g' "build/feed/rss.xml"
xmllint --format "build/feed/rss.xml" --output "build/feed/rss.xml"

Die Sitemap

Die einfachste Sitemap ist laut Google eine Textdatei, die zeilenweise die URLs aller HTML-Seiten und sonstiger indizierbarer Dokumente enthält. Eine solche ist mit find und sed schnell erstellt.

BASEURL="https\:\/\/www\.foo\.bar\/"
cd build
find . -name "*.html" > sitemap.txt
sed -i '' -e "s/\.\//$BASEURL/g" sitemap.txt
cd ..

Znächst findet find alle .html-Dateien finden. Dann wird der lokale Verzeichnispfad durch die Basis-URL ersetzt.

Pandoc-Superkraft

Pandoc-Templates fehlt die Möglichkeit, Teile von Listen zu verwenden, z. B. Elemente 3 bis 8 oder die ersten fünf. Genau das braucht es allerdings für die neuesten Beiträge.

Das lässt sich bei Pandoc mit einem Lua-Filter umsetzen. Im YAML-Header der jeweiligen Markdown-Datei wird ein Wert für showInLatest: angegeben, z. B. 5 oder auch all, wenn alle Beiträge aus posts.md aufgelistet werden sollen.

Die Datei posts.md muss Pandoc dann zusätzlich zum Filter mitgegeben werden. Der Filter erzeugt daraus die benötigten Metadaten bestehends aus Titel, Artikelbild, Beschreibung und Link, die dann wiederum für Pandoc in der Variable latest zur Verfügung stehen.

Hier ein Auszug aus dem Skript des Lua-Filters.

function Pandoc(doc)

  -- ...
  
  local latest = {}
  local n = tonumber(pandoc.utils.stringify(doc.meta.showInLatest)) or 3
  
  if pandoc.utils.type(doc.meta.item) == "List" then

    -- ...
    
    for i = 1, n do
      latest[i] = {}
      latest[i].title = pandoc.utils.stringify(doc.meta.item[i].title)
      latest[i].link = pandoc.utils.stringify(doc.meta.item[i].link)
      latest[i].cover = pandoc.utils.stringify(doc.meta.item[i].cover)
      latest[i].description = pandoc.utils.stringify(doc.meta.item[i].description)
    end
  end
  
  doc.meta.latest = latest
  return doc
end

Eine weitere Superkraft ist die Berechnung der ungefähren Lesezeit eines Artikels. Auch das ist nur mit einem Lua-Filter nötig, da Pandoc selbst keine Berechnungen zum Umfang des Inhalts anstellt.

Hier ein Auszug aus meinem Lua-Filter, der grob alles zählt, was nach einem Wort aussieht, und Ende diese Anzahl durch die durchschnittliche Lesegeschwindigkeit von 250 Wörtern pro Minute teilt.

local wordsPerMinute = 250

function Pandoc(doc)
  local wordCount = 0
  local text = pandoc.utils.stringify(doc.blocks)
  
  for words in text:gmatch('[^.,?!\n\t()–%-]+') do
    if type(words) == "string" then
      for word in words:gmatch("%w+") do
        wordCount = wordCount + 1
      end
    end
  end
  
  local rt = math.ceil(wordCount / wordsPerMinute)

  -- ...

  doc.meta.readingTime = rt
  
  return doc
end

Lokal testen

Um die generierte Website richtig ausprobieren zu können, ist ein Webserver nötig. Ansonsten funktionieren die URLs nicht und das Stylesheet sowie Bilder werden nicht geladen, wenn HTML-Dokumente direkt im Browser geöffnet werden.

Wie der Zufall es will, verfügt Python im Standard über einen einfachen Webserver. Simpel, aber für statische Seiten ausreichend. Dieser lässt sich für jedes beliebige Verzeichnis starten.

In macOS funktioniert das wie folgt:

cd build
open "http://localhost:8080"
python -m http.server 8080

Wechsel in das Verzeichnis mit der fertig generierten Website, Öffnen der Local-Host-URL im Standard-Browser und Starten des Webservers mit Python. Soll ein anderer Browser verwendet werden, geht das natürlich auch.

open -a /Applications/Firefox.app --url "http://localhost:8080"

Informationsquellen

Basis-Beispiel als Quelltext-Projekt:

Diese drei Quellen enthalten weitere Informationen. Neben der Pandoc-Dokumentation ist es noch spannend zu sehen, dass auch andere Menschen versuchen, die ausgetretenen Pfade gängiger Website-Lösungen zu verlassen.


  1. Jekyll wird von GitHub Pages als Website-Generator verwendet, sodass Quelltexte in Form von Markdown, YAML etc. per Git zur Verfügung gestellt werden können. Auf Neocities müssen jedoch die fertigen, statischen Seiten selbst bereitgestellt werden.↩︎

  2. Das ist notwendig, wenn die Beiträge nach absteigender Aktualität angezeigt werden sollen. Dazu beginnt jeder Beitrags-Dateiname mit dem Erstellungsdatum in der Form yyyy-mm-dd-.↩︎

  3. Bei der Verwendung von sed auf macOS ist zu beachten, dass nach der Option -i, --in-place ein Leerzeichen notwendig ist. Dann mittels '' kennzeichnen, dass die Änderungen in die selbe Datei geschrieben werden sollen. Unter Linux reicht sed -i 's/..//g' file.↩︎

Codeberg
Pages