Lucide-Icons in Hugo: Inline-SVG statt Icon-Font
Vor kurzem habe ich meinen Blog neu gebaut. Endlich weg von WordPress und hin zu Hugo. Hugo nutze ich schon lange für Kundenprojekte und auch für Websites in meinem Hauptjob. Das passende Template habe ich auf Basis von Simple.css gebaut. Was noch fehlte: Icons.
Meine Wahl fiel auf Lucide, das ich dir schon in meiner Übersicht freier Icon-Sets vorgestellt habe. Ich liebe dieses Set, weil es die schlichte Handschrift von Feather fortführt, dabei aber deutlich mehr Auswahl bietet. Wie es in die Seiten kommt? Über ein Hugo-Modul und über zehn Zeilen Template-Code, die verhindern, dass Screenreader dekorative Symbole vorlesen.
Was mir wichtig war
Icons lassen sich auf viele Arten einbinden: als Font, als Sprite oder per JavaScript-Bibliothek – jeweils von einem CDN oder aus dem eigenen Projekt. Bevor ich mich entschieden habe, habe ich deshalb aufgeschrieben, was mir dabei überhaupt wichtig ist.
Keine zusätzliche Build-Chain
Für ein paar Symbole ein npm-Projekt, einen Bundler und ein node_modules/ ins Repo zu holen, steht in keinem Verhältnis.
Webentwicklung darf und soll unkompliziert bleiben.
Dieser (mein persönlicher) Blog kommt bis heute ohne package.json aus und das darf gern auch so bleiben.
Kein JavaScript
Es gibt reichlich Bibliotheken, die ihre Symbole erst im Browser ins HTML setzen. JavaScript als Zugabe ist eine feine Sache, aber um ein Icon neben einen Link zu stellen, brauche ich es nicht. Eine statische Seite baut ihr HTML einmal fertig und liefert es dann aus. Symbole, die erst im Browser in die Seite eingefügt werden, widersprechen genau diesem Ansatz.
Schnelle Ladezeiten
Jede Datei, die eine Seite zusätzlich anfordert, kostet Zeit. Eine Icon-Font zum Beispiel ist ein weiterer Download, und bis er da ist, stehen an den Stellen der Symbole gerne mal leere Kästchen. Kurze Ladezeiten belohnen die Besucher mit einer besseren Erfahrung und die Suchmaschinen mit einer besseren Bewertung.
Datenschutz
Diese Seite lädt keine Ressourcen von fremden Servern – auch die Schriftarten liegen deshalb selbst gehostet im Projekt. Ein CDN würde die IP-Adresse jedes Besuchers an einen Dritten weitergeben, ganz gleich, ob es eine Font, ein Skript oder ein einzelnes SVG ausliefert. Ein hoher Preis für ein paar Kilobyte SVG.
Alle vier Punkte führen zur selben Antwort: Das Icon steht als SVG direkt im HTML, hineingeschrieben von Hugo beim Bauen der Seite. Kein zusätzlicher Request, kein Skript und nichts, was nachlädt. Im ausgelieferten HTML landen wirklich nur die Symbole, die auf der Seite auch vorkommen.
Das Modul
Lucide gibt es glücklicherweise als fertiges Hugo-Modul.
Eingebunden wird es in config/_default/hugo.toml:
[module]
[[module.imports]]
path = 'github.com/hugomods/icons/vendors/lucide'
Danach muss man einmal das Modul laden:
hugo mod get github.com/hugomods/icons/vendors/lucide
Das legt go.mod und go.sum an.
Dafür muss die Go-Toolchain installiert sein, Node braucht es aber nicht.
Das Modul bringt die einzelnen SVG-Dateien des Sets mit.
Diese werden gemountet nach assets/icons/lucide/.
Ab jetzt stehen dir zwei Wege offen, ein Icon zu setzen.
Im Text, also in einer Markdown-Datei, nimmst du den Shortcode ico:
Ein Stern {{< ico lucide star >}}, der deinen Namen trägt…
Der erste Parameter ist das Set, der zweite der Name des Icons, so wie er in der Lucide-Suche steht.
Optional kommt als dritter Parameter eine Größe dazu, etwa 2rem.
Wer es genauer mag, nutzt die Variante mit benannten Parametern, die zusätzlich color, width, height und className kennt:
{{< icons/icon vendor="lucide" name="star" size="2rem" >}}
In den Templates rufst du stattdessen das Partial des Moduls auf:
{{ partial "icons/icon" (dict "vendor" "lucide" "name" "star") }}
Egal welchen Weg du nimmst – im fertigen HTML landet dasselbe eingebettete SVG:
<svg
aria-hidden="true"
class="hi-svg-inline"
fill="none"
height="1em"
width="1em"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 …" />
</svg>
Drei Details daran sind wichtig.
currentColor bedeutet, dass ein Icon automatisch die Farbe des umgebenden Textes annimmt.
Im Dark Mode dieser Seite muss dafür also nichts weiter angepasst werden.
1em bedeutet, dass es so hoch wie der Text ist und mit diesem mitwächst, wenn jemand die Schriftgröße hochdreht.
Und aria-hidden="true" setzt das Modul immer, was für die meisten Icons genau richtig ist, aber eben nicht für alle.
Dazu gleich mehr.
Aktualisiert wird das Set wie jede andere Abhängigkeit (siehe Docs):
hugo mod get -u ./...
Meine Zwischenschicht
Aufgerufen wird das Modul-Partial in meinem Template aber nie direkt.
Dazwischen liegt layouts/_partials/icon.html, und das ist der eigentlich interessante Teil:
{{ with .title }}
<span title="{{ . }}">
{{ end }}
{{- partial "icons/icon" (dict "vendor" "lucide" "name" (.name)) -}}
{{ with .title }}
</span>
<span class="sr-only">{{- . -}}:</span>
{{ end }}
Das hat zwei Gründe.
Der erste ist Bequemlichkeit: Der Vendor-Name steht genau einmal im Projekt. Denn ich will ja nicht verschiedene Icon-Sets in meinem Blog benutzen. Ein Aufruf sieht damit so aus:
{{ partial "icon" (dict "name" "rss") }}
Der zweite Grund ist der wichtigere: Barrierefreiheit, der ich bei meinem Blog besondere Aufmerksamkeit gewidmet habe. Icons treten hier nämlich in zwei völlig verschiedenen Rollen auf.
Dekorativ, wenn direkt daneben schon steht, was gemeint ist.
In der Fußzeile bekommt jede Kategorie ein Symbol: cpu für Informatik, shield für IT Security, globe für Web.
Der Linktext nennt die Kategorie jedoch ohnehin beim Namen.
Ein Screenreader soll hier gar nichts vorlesen.
Genau das passiert, wenn title fehlt: Übrig bleibt ein nacktes SVG mit aria-hidden="true".
Semantisch, wenn das Icon die Beschriftung ersetzt. In der Fußzeile einer Beitragsvorschau steht ein aufgeschlagenes Buch und daneben „5 min“. Wer das Symbol sieht, versteht sofort: Lesezeit. Wer es nicht sieht, weil ein Screenreader vorliest, hört nur „5 min“ und weiß nicht, worum es geht.
Für diesen Fall nimmt das Partial ein title entgegen und macht daraus beides: ein title-Attribut für den Tooltip mit der Maus und einen visuell versteckten Text davor.
Die Karte ist eine Definitionsliste, und das Ergebnis liest sich vorgelesen als „Lesezeit: 5 min“, während auf dem Bildschirm nur das Icon steht.
Ein alt-Text am SVG würde das nicht leisten, denn das aria-hidden des Moduls bliebe ja bestehen.
Eine Stolperfalle zum Schluss
Das Modul-Partial gibt seinen SVG-String mit einem abschließenden Zeilenumbruch zurück.
Steht das Icon direkt vor Text, rendert der Browser diesen Umbruch als Leerzeichen zusätzlich zu dem Abstand, den das Stylesheet ohnehin setzt.
Die Trim-Marker von Go-Templates ({{- und -}}) helfen hier nicht, denn sie greifen auf den Template-Text drumherum, jedoch nicht auf den Rückgabewert eines Partials.
Das habe ich neu gelernt:
Der Umweg dafür ist chomp.
{{ partial "icon" (dict "name" "arrow-left") | chomp }}
Fazit
Viel ist es am Ende nicht: ein Modul in der Konfiguration, ein Partial mit zehn Zeilen und ein chomp an der richtigen Stelle.
Dafür kommt der Blog ohne Node, ohne JavaScript und ohne Ressourcen von fremden Servern aus.
Und alle Icons sind da, sobald die Seite geladen hat.
Falls du auf den Geschmack gekommen bist: Das Hugo Icons Module bietet übrigens noch weitere Icon-Sets.
Kommentare
Es gibt noch keinen Kommentar zu diesem Beitrag.