Den statischen Hugo-basierten Blog mit Pagefind durchsuchen

Im Januar 2017 bin ich mit diesem Blog von Wordpress auf Hugo umgestiegen und habe damit auch die Suchfunktion verloren. Behelfsmäßig habe ich auf der 404-Fehlerseite ein DuckDuckGo Widget eingebaut, um verirrten Lesern zumindest irgendeine Art Suchfunktion an die Hand geben zu können. Besonders gefallen hat mir das aber nicht. Klar - irgendwie hat es schon funktioniert, aber so musste ich mich auch auf eine saubere Indizierung durch die DuckDuckGo Suchmaschine verlassen.

Vor zwei Wochen habe ich allerdings über die Hugo-Dokumentation eine sehr interessante Lösung für das Suchmaschinenproblem gefunden: Die Javascript-basierte Suchmaschine “Pagefind”.

Screenshot von Pagefind

Das Konzept ist ganz einfach: Nachdem eine Hugo-Site statisch generiert wurde, lässt man den Pagefind Indexer über die HTML-Dateien laufen. Dieser zieht sich alle möglichen Informationen aus dem Sourcecode und füttert damit einen eigenen Suchindex. Der Index kann dann vom Webbrowser der Leser bei Bedarf heruntergeladen werden und von einem kleinen Suchfeld-Javascript für eine Suche verwendet werden. Die Suche läuft also - wie man es von einer Static Site erwarten würde - ebenfalls clientseitig ab.

Index-Chunks für kurze Ladezeiten

Der Clou ist bei Pagefind allerdings, dass vom Suchscript nicht der gesamte Index heruntergeladen werden muss, sondern nur ein kleiner Teil / “Chunk”. Der Index ist auf mehrere Chunkdateien aufgeteilt und wird nur bruchstückhaft heruntergeladen. Vielleicht ist der Benutzer ja schon mit einem der ersten Suchtreffer zufrieden und benötigt keine weiteren Ergebnisse? So kann Pagefind sehr schnell und Datensparsam funktionieren.

Ein Beispiel: Der Gesamte Index (Deutsch und Englisch) für thomas-leister.de ist aktuell 3,4 MB groß und auf 87 Chunkdateien verteilt. Suche ich nach “Minix”, lädt mein Browser nur zwei kleine 41 kB Chunkdateien herunter, ehe die vorläufigen Suchergebnisse präsentiert werden. Fordere ich mehr Suchergebnisse an, werden weitere Indexchunks heruntergeladen. Selbstverständlich werden auch die Indexdateien vom Browser gecached. Einmal angeforderte Chunks müssen also nicht nochmal heruntergeladen werden.

Pagefind downloaden

Pagefind gibt es als NPX package oder auch als Binary zum Download. Die NPX Option war für mich wenig attraktiv, schließlich wollte ich nicht noch einen weiteren Paketmanager nutzen und schon gar keine gammelige und überkomplexe Javascript Buildumgebung. Die Entscheidung fiel also schnell für das Binary, welches übrigens Rust Quellcode entstammt. Das Binary habe ich einfach in mein Hugo Stammverzeichnis gelegt.

cd hugo/
wget https://github.com/CloudCannon/pagefind/releases/download/v1.1.0/pagefind-v1.1.0-x86_64-unknown-linux-musl.tar.gz -O pagefind.tar.gz
tar xf pagefind
rm pagefind.tar.gz

Suchseite anlegen

Als nächstes musste in meiner Hugo-Umgebung eine neue Seite für die Suche angelegt werden. Bei mir sollen es content/search.html bzw content/search.en.html sein. Der Inhalt sieht in etwa wie folgt aus:

+++
title = "Suche"
description = "Einen Beitrag auf thomas-leister.de suchen"
+++

<link href="/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
	window.addEventListener('DOMContentLoaded', (event) => {
	    new PagefindUI({ element: "#search", showSubResults: true });
	});
</script>

Die beiden im Code erwähnten Dateien pagefind-ui.css und pagefind-ui.js werden beim Indizieren der Seite übrigens automatisch generiert. Darum müssen wir uns nicht selbst kümmern.

Danach wird der Hugo-Blog ein erstes Mal gebaut und indiziert (und die CSS- bzw. Javascript-Dateien generiert):

hugo --minify
pagefind --site public/

Die Seite kann hochgeladen werden und die Suchseite funktioniert bereits grundsätzlich. Nun noch ein paar Korrekturen und Optimierungen …

Einstellungen für den Dark Mode

Mein Blog schaltet automatisch zwischen einem normalen, hellen Modus und einem Dark Mode um. Damit der Dark Mode korrekt unterstützt wird, waren noch ein paar Korrekturen am CSS nötig. Glücklickerweise lassen sich mittels CSS Variablen sehr einfach Anpassungen an der Pagefind Eingabemaske und der Ergebnisliste durchführen:

Style Anpassung in meinem Theme main.css:

body {
	[...]

	/* Pagefind search box */
	--pagefind-ui-scale: 0.9;
	--pagefind-ui-font: "Geist";
}

Und für Dark Mode:

@media (prefers-color-scheme: dark) {
	body {
	    [...]

	    /* Pagefind search box */
	    --pagefind-ui-text: #ffffff;
	    --pagefind-ui-background: #2d2d2d;
	    --pagefind-ui-border: #6a6a6a;
	    --pagefind-ui-primary: #dfdfdf;
	}
	[...]
}

Suchergebnisse verbessern

Mit den Suchergebnissen war ich anfangs noch nicht ganz zufrieden, wenn Pagefind indizierte nicht nur Artikel, sondern auch Hashtag-Indexseiten und ähnliches. Um die zu durchsuchenden Inhalte noch weiter einzugrenzen und die Suchmaschine nur auf Artikel loszulassen, habe ich noch das data-pagefind-body Attribut in mein Theme HTML eingebaut. Das Attribut wird in dem Tag ergänzt, welches den Artikeltext enthält (und möglichst keine anderen Elemente, wie Menüs, Footer etc):

zum Beispiel in der in der themes/<theme>/layouts/partials/article.html im main Tag:

<main data-pagefind-body>  <!-- Main section -->
    <article itemscope itemtype="http://schema.org/TechArticle">
        <header>
            <h1 class="article" itemprop="headline">Title</h1>
                
            <div class="translations"></div>
        </header>
        
        {{ .Content }}

    </article>
</main>

Ebenso in der themes/<theme>/layouts/partials/page.html

<main data-pagefind-body>  <!-- Main section -->
    <header>
        [...]
    </header>

    {{ .Content }}
</main>

Außerdem kann man Pagefind helfen, das Veröffentlichungsdatum eines Artikels mithilfe des data-pagefind-sort="date" Attributs korrekt zu erkennen, z.B.:

<time itemprop="datePublished" datetime="{{ .Date.Format "2006-01-02" }}" data-pagefind-sort="date">{{ i18n "postdate" . }}</time>

Über das <html> Tag und das dahin enthaltene lang Attribut kann Pagefind die Sprache ermitteln. Das ist wichtig, wenn die Suchmaschine auch “Word-Stemming” beherrschen soll. Damit ist gemeint, dass eine Suche nach “laufen” auch “läuft” und “gelaufen” - also andere Formen von Wörtern - findet.

Die header.html des Themes sollte dazu etwa wie folgt aussehen:

<!doctype html>
    <html lang="{{ .Lang }}">
    <head>

Es gibt noch viele weitere Möglichkeiten, den Index zu verfeinern, Elemente auszublenden etc: https://pagefind.app/docs/indexing/ Für’s erste soll es das gewesen sein. Evtl. werde ich zu einem späteren Zeitpunkt noch einmal genauer mit Pagefind auseinandersetzen.

Probleme

… denn es gibt auch noch ein paar Baustellen und Punkte, die mir noch nicht so gut gefallen. Für einige wird sich vielleicht eine Lösung finden lassen:

  • Live-Modus von Hugo wird nicht unterstützt (hugo serve)
  • Artikelbilder / Thumbnails werden manchmal nicht angezeigt, obwohl vorhanden. Siehe z.B. “Minix Z100-0dB” Artikel. Ursache: Grafik-URLs werden von Pagefind teilweise nicht korrekt ermittelt.
  • Suche ist sprachsensitiv und nutzt für die Ergebnisse nur die aktuelle Seitensprache. Ich würde gerne deutschsprachige und englischsprachige Artikel gleichzeitig durchsuchen
  • Artikel-Tags sollen bei der Indizierung explizit berücksichtigt werden

Update bzgl. Pagefind Thumbnails

2024-07-06:

Das Problem lag daran, dass die in den betreffenden Artikeln eingebundenen Grafiken nicht mit einer absoluten URL verlinkt waren, sondern mit einer relativen. Daher hat Pagefind nur die relative URL in seinen Index aufgenommen, welche auf der Suchseite nicht mehr gültig war. Die Grafik konnte dann nicht mehr geladen werden.

Für einen Teil der Bilder habe ich in Hugo den figure Shortcode verwendet. Um die damit ausgegeben Bild-URLs absolut zu machen, habe ich eine Kopie der Datei figure.html (siehe hier) in mein Theme-Verzeichnis unter [theme]/shortcodes/figure.html gelegt und sie wie folgt angepasst:

[...]

  {{- $u := urls.Parse (.Get "src") -}}
  {{- $src := $u.String -}}
  {{- if not $u.IsAbs -}}
    {{- with or (.Page.Resources.Get $u.Path) (resources.Get $u.Path) -}}
      {{- $src = .RelPermalink -}}
    {{- end -}}
  {{- end -}}

[...]

In Zeile 10 wird nun also ein .Permalink statt .RelPermalink verwendet. Thumbnails in der Pagefind-Suche, die diesem Shortcode entspringen, werden nun korrekt geladen.

Für alle weiterern Grafiken, die z.B. nur über Markdown

![Alt Text](url/zum/bild)

eingebunden sind, muss ich noch eine Lösung entwickeln.

Hier gibt es übrigens einen Thread mit einem anderen Workaround, den ich noch nicht ausprobiert habe: Hugo Page Bundles and images with no path #529