Update: Durch einen eigenen Index konnte ich die Suchergebnisse noch weiter verbessern.
Der Umstieg meiner Website von WordPress nach 11ty hatte viele Vorteile. U.a. laden Seiten viel schneller, es ist unwahrscheinlicher, dass die Website gehackt wird und ich habe auch deutlich mehr Kontrolle über das Design und den Aufbau. Was bisher aber fehlte, war eine runde Lösung für eine Suche.
Vorher
Das bisherige Suchformular führte nach DuckDuckGo weiter. Damit sich die Ergebnisse nur auf Seiten von mir begrenzen, wird zur Suchanfrage noch " site:.lgk.io" angehängt. Sehr praktisch, ich brauche keine eigene Suchmaschine bauen. Etwas unglücklich ist aber, dass man meine Seite verlassen muss und sich dann das Design ändert. Viel besser wäre es, wenn die Ergebnisse direkt auf meiner Seite angezeigt werden.
Nachher
Nach dem Klicken des Suchen-Symbols öffnet sich jetzt ein Modal, also ein Fenster im Fenster mit einer Suchleiste. Die Ergebnisse werden direkt darunter angezeigt. Entweder nachdem man als User Enter drückt, oder nachdem man aufgehört hat zu tippen.
Die serverseitige Lösung habe ich mit Serverless Functions von Vercel umgesetzt. Am Frontend habe ich für das Modal Bootstrap und für die "Reactivity" HTMX eingesetzt.
Server-Funktionen für eine statische Website
Mein Ziel war, Backend-Code zu entwickeln, das sich die Ergebnisseite von DuckDuckGo holt und das HTML leicht angepasst für meine Seite zurückgibt. Das Problem: meine statische Website hat kein Backend, also hatte ich keine Möglichkeit serverseitigen Code auszuführen.
Mit meinem Webspace bei meinem Hosting-Anbieter habe ich zwar noch eine klassische Apache-Umgebung, wo ich mit PHP diesen Code hosten lassen könnte.
Ich wollte aber lieber nach anderen Möglichkeiten schauen. Ich hoste diese 11ty-Website bei Vercel. Und Vercel bietet Serverless Functions an. Genau das was ich brauche! In einem api
-Ordner kann ich JavaScript-Dateien verwalten, diese werden als Routen verfügbar sobald sie hochgeladen wurden.
Hier ein Beispiel einer solchen Datei, hier mit TypeScript geschrieben:
import type { VercelRequest, VercelResponse } from "@vercel/node"
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.json({
helloFrom: "my Vercel serverless function!"
})
}
Funktioniert sehr gut. Ein Vorteil ist auch, dass ich diesen Code in der selben Repository verwalten kann. Das erleichtert auch das Debuggen auf meinem Rechner. Hätte ich diesen Code in einer separaten PHP-Anwendung, wäre das etwas komplizierter. Auch über CORS-Probleme muss ich mir so keine Gedanken machen, weil das Frontend immer über die gleiche Domain läuft wie die "serverless" API.
Damit ich die API lokal starten und debuggen kann, starte ich meine Website nicht mehr mit npx @11ty/eleventy
, sondern mit der Vercel CLI mit dem Befehl vercel dev
.
Effiziente Reactivity mit HTMX
Ich bin vor kurzem auf HTMX gestoßen. Die Library ist ideal ohne viel Aufwand, um Teile einer eigentlich statischen Seite dynamisch zu machen. Um die Suchergebnisse zu sehen, soll nicht die aktuelle Seite verlassen werden, sie sollen einfach angezeigt werden. Mit den Beispielen von HTMX konnte ich folgendes umsetzen:
- Beim Klick auf Such-Button ein Modal anzeigen.
- Nach Eingabe des Suchbegriffs Ergebnisse im Bereich darunter einbinden.
Und das im Kern ohne selbst JavaScript zu schreiben.
Mit Elasticlunr einen eigenen Suchindex
Für genauere Suchergebnisse wollte ich lieber eine eigene Funktionalität einbringen, statt DuckDuckGo zu benutzen. Nach Vorgabe dieses Artikels konnte ich das nun mit der Library Elasticlunr umsetzen.
Wie im Artikel beschrieben, lasse ich mit einem Eleventy-Filter den Index als JSON statisch unter /search-index.json entstehen. Die eigentliche Suchanfrage wird dann allerdings nicht im Browser, sondern am Server ausgeführt. Ich habe dafür die Serverless Function angepasst, die zuvor die Anfragen an DuckDuckGo ausgeführt hat:
import type { VercelRequest, VercelResponse } from "@vercel/node"
import elasticlunr from "elasticlunr"
export default async function handler(req: VercelRequest, res: VercelResponse) {
const q = req.query.q.toString() || ""
const url = `${req.headers["x-forwarded-proto"] || "https"}://${
req.headers["x-forwarded-host"] || req.headers.host || "site.lgk.io"
}`
const rawIndex = await fetch(`${url}/search-index.json`).then((r) =>
r.json()
)
const index = elasticlunr.Index.load(rawIndex)
const results = index.search(q, {
bool: "OR",
expand: true
})
const html = results
.map((r) => {
const docInfo = index.documentStore.getDoc(r.ref) as {
id: string
title: string
tags: string[]
excerpt: string
}
return /*html*/ `
<a href="${r.ref}" class="list-group-item list-group-item-action">
<h3 class="h5 mb-1">${docInfo.title}</h3>
<p class="mb-1">${docInfo.excerpt}</p>
</a>`
})
.join("")
res.send(html)
}
Die Serverless Function gibt weiterhin, wie zuvor die Ergebnisse als HTML zurück, mit ungefähr dem gleichen Aufbau, wie zuvor mit DuckDuckGo. Am Frontend musste ich wenig ändern, das Suchmodal lädt die Ergebnisse weiterhin auf die gleiche Weise über HTMX an die Oberfläche.