Block > v12.0.0

 58 min (12144 WΓΆrter, 83038 Zeichen)

Inhaltsverzeichnis

Hinweis

Dieser Beitrag wird stΓ€ndig aktualisiert, erkennbar an den Updates πŸ˜‰

Yeah, ich bin zurueck πŸŽ‰
Das waren lange 3 Jahre, aber ich glaube es hat sich gelohnt 😊


Angefangen hat alles Anfang 2019 mit Treat Your Blog as Code bei noqqe .

Ich war direkt Feuer und Flamme.

Tooling / Gedanken #

Allerdings alles der Reihe nach, denn Tooling ist ebenfalls wichtig, und Hugo kannte ich damals noch nicht. CI/CD nutzten wir auf Arbeit schon mit GitLab , markdownlint war mir ebenfalls neu.

Statische Generatoren #

Bisher hatte ich meine Website immer nur mit selbst geschriebenen Bashscripts in einem selbst gehosteten Gitrepo verwaltet. Das Bash nicht die beste Loesung ist, war mir bewusst, denn Bilder einpflegen erforderte immer etwas Aufwand und die Laufzeit zum Bauen der Website mit allen Komponenten war auch eher unschoen. Zudem nicht wirklich crossplattform und teilweise buggy …

Klar war mir, dass ich immer noch eine statische Website haben wollte, nur eben mit einem besseren/schnelleren/sauberen “Backend”.

Mein erster Schritt war daher die Suche nach einem geeigneten Generator und ein bisschen auch die Ueberlegung, was ich ueberhaupt noch veraendern oder neu einbinden wollte. Laut diesen Listen gibt es ja so einige Generatoren …

Ins Auge gefasst hatte ich damals konkret:

Leider weiss ich nicht mehr die konkreten Gruende (evtl. weil Hugo schon einen Webserver mitbringt, was fuer die lokale Entwicklung natuerlich hervorragend ist), aber vermutlich war es die Anzahl an GitHub Sternen , die vielen verschiedenen Themes (darunter auch viele kostenpflichtige ), die angepriesene Schnelligkeit, Flexibilitaet sowie dass Hugo Open Source und Crossplattform ist, dass die Wahl auf Hugo fiel πŸŽ‰

Auszeichnungssprachen #

Auszeichnungssprachen gibt es einige (die bekannteste duerfte HTML sein), Hugo unterstuetzt davon auch ein paar und bisher hatte ich meine Beitraege immer in Markdown geschrieben.

Da nur die Endung (md vs adoc) letztendlich den Unterschied ausmacht (Front Matter bleibt gleich), bleibe ich erstmal bei Markdown.
Umwandeln kann ich ja Markdown in AsciiDoc mit z.B. Pandoc recht schnell.

Front Matter / Config #

Mein Front Matter , also ein paar Zeilen, die z.B. den Titel und die Tags angeben, war bisher “eigen” (1. Zeile = Titel, 2. Zeile = kommaseparierte Liste mit Tags). Bei Hugo gibt es hauptsaechlich 3 Moeglichkeiten (die Unterschiede sind in diesem Vergleich schoen zu sehen):

Ausser JSON ist mir die Nutzung recht egal, und so habe ich mich fuer YAML im Front Matter entschieden (weil --- schoener aussieht und leichter zu tippen ist als +++) und fuer TOML in meinen Hugo Konfigurationsdateien .

Stylesheet languages #

Mit “Cascading Style Sheets” (CSS ) wird eine Website erst so richtig bunt und attraktiv. Allerdings gibt es nicht nur das reine CSS, sondern auch Varianten, die u.a. Variablen und Funktionen nutzen und dann in CSS umgewandelt werden koennen.

Wikipedia listet einige auf, angeschaut habe ich mir:

Die Entscheidung zur Nutzung von SCSS fiel mir nicht schwer, da Hugo schon Support fuer Sass/SCSS mitbringt, was bei Less/Stylus (noch?) nicht der Fall ist .
Zudem finde ich die Nutzung von geschweiften Klammern wie bei CSS ueblich schoener als Einrueckungen πŸ˜‰

Hosting und Konfiguration #

Irgendwo muss die Website ja auch laufen. Bisher teilte ich mir einen Server bei Hetzner mit Flo . Allerdings wollte ich mir mal einen eigenen Server (VPS reicht) goennen und auch dort moeglichst viel nach dem Prinzip von Infrastructure as code (IaC) umsetzen.

Zudem sollte nicht nur die Website dort laufen, sondern u.a. auch Git, Seafile , newsboat , weechat , neomutt , …
Und natuerlich alles unter Arch Linux mit root-Zugriff. Daher fielen direkt schonmal einige Hostingideen raus, die ich hier aber trotzdem mal aufliste:

Kurz: ich habe mich fuer Netcup entschieden, einfach weil ich mit denen schon gute Erfahrungen beruflich (und auch vor Jahren privat) gesammelt habe und sie Arch Linux schon als ISO anbieten πŸ˜‰
Zudem ist die Verwaltung der Server (inkl. Umzuege) sehr einfach.

Mit der Klaerung des Hostings stellte sich mir die Frage, wie ich auch das System dahinter moeglichst gut via IaC abbilden kann.

Bisher (berufliche) Erfahrung gesammelt habe ich mit:

Das ist allerdings wesentlich komplexer, fuer mich alleine und mein kleines VPS. Im Zusammenhang mit Arch Linux bin ich dann noch auf folgende Moeglichkeiten gestossen:

Letztendlich entschieden habe ich mich fuer meta packages, da sie einfach zu erstellen und warten, und auch mit CI/CD ausspielbar sind.

Ein Beitrag ueber die Suche und die Technik dahinter sind in Arbeit.

CI/CD #

Die Grundlage von CI /CD ist Versionsverwaltung (bei mir Git ), klar. Aber wie bekomme ich den Code jetzt gebaut und auf den Server? Hierbei habe ich mir folgende Loesungen angeschaut:

Mein Code fuer die Website liegt aktuell bei GitHub, daher nutze ich auch die GitHub Actions fuer das Testing und Deployment der Website. GitLab CI waere aber auch eine gute Alternative.

Anforderungen / Wuensche #

Was wollte ich ueberhaupt mit dem ganzen Umbau? Warum nicht alles so wie bisher weiterbetreiben nach dem Motto never change a running system ?

Ich war schon sehr lange nicht mehr zufrieden mit meiner Loesung, aber es hat erstmal funktioniert. Doch mit dem Anreiz, mal alles neu zu machen, habe ich mir eine Liste zusammengestellt bzw. mal einfach drauf los rumgewerkelt.

Hier also eine Liste mit Dingen, die ich mit dem neuen statischen Generator (also Hugo) umsetzen wollte bzw. will. Einige Dinge habe ich noch nicht umgesetzt oder bin noch dabei, daher die Checkboxen.

Migration / Umsetzung #

Nach der sehr einfachen Installation von Hugo ging es daran, meine bestehende Website zu migrieren.

Wo und Wie anfangen waren meine ersten Fragen πŸ˜‰

Im Prinzip habe ich mich an einigen Blogposts und dem Quick Start entlang gehangelt.

hugo new site uxgch
hugo new theme uxgch

Wobei der letzte Befehl nicht ganz richtig ist, erst habe ich eins der vielen Themes kopiert (XMin ) und nach und nach durch eigenen Code ersetzt, wobei ich mich erstmal an dem Stil der alten Version orientiert habe.

Die alte 'Über mich'-Seite in Version 11.7
Die alte 'Über mich'-Seite in Version 11.7

Im Prinzip habe ich nach und nach jede Datei angepasst, sei es wegen Markdown-Anpassungen (Codebloecke z.B.), Emojis, Aktualisierungen/Ergaenzungen/Streichungen; das Hinzufuegen/Anpassen von Templates oder den Einbau von Shortcodes (Bilder z.B.).

Inspirationen gibt es dabei genug: im Forum , dank neuer Versionen , Code auf GitHub oder Blogbeitraegen .

Breaking Changes #

Beim Wechsel zu Hugo wollte ich so wenig wie moeglich an der alten Struktur veraendern. Und wenn doch, moeglichst alles umleiten, damit alte Links immer noch ans Ziel kommen.

Moeglich machen das hauptsaechlich Aliases im Frontmatter :

title: "Sinn des Lebens"
date: 2007-05-27T13:10:31+02:00
draft: false
author: "yannic"
tags: [ "tv", "sinn", "leben" ]
aliases: [ "/1180264231.htm" ] # magic

Beim Bauen von Hugo wird nun ein Ordner 1180264231.htm mit der Datei index.html angelegt, die ein meta http-equiv="refresh" zu der neuen Location beinhaltet. Das ist auch schon die ganze Magie dahinter. Laut Hugo habe ich so ca. 288 Aliase in meinen Dateien … πŸ™ˆ

Fuer den Rest habe ich folgendes in meiner nginx Config:

location / {
  rewrite ^/(tagcloud|holidays|recipes)\.fcgi$ /$1 permanent;
  rewrite ^/tagcloud_(.*)\.htm$ /tags/$1 permanent;
  rewrite ^/linkdump\.xml$ /linkdump/rss.xml permanent;
  rewrite ^/(linux|vegan)\.xml$ /tags/$1/rss.xml permanent;
  rewrite ^/yh\.xml$ /block/rss.xml permanent;
  rewrite ^/all\.xml$ /rss.xml permanent;
}

Diese sorgen fuer die notwendigen Weiterleitungen, gerade fuer die Feeds in den Planeten.

Das Einzige, was ich leider nicht weiterleiten konnte, sind die einzelnen Linkdumps, das waren naemlich einfach nur Anker zu einer jaehrlichen Uebersichtsseite …

Eine Uebersicht der Linkdumps in 2012 mit Ankern in Version 11.7
Eine Uebersicht der Linkdumps in 2012 mit Ankern in Version 11.7

Zumindest wird linkdump_2012.htm weitergeleitet zur jetzigen Uebersichtsseite … Naja, es gibt schlimmeres πŸ˜‰

Minify everything! #

Es sollte eigtl. Standard sein, dass alle JavaScript- und CSS-Dateien gebuendelt und “minifiziert ” werden, bevor sie ausgespielt werden. Zudem sollte auch HTML “gecrunched” werden. All das ist moeglich mit Hugo Pipes πŸŽ‰

Hier als Beispiel mein Code im <head> von layouts/_default/baseof.html, in dem CSS gebuendelt und minifiziert wird, unter Verwendung von Pipes :

{{ $style := resources.Get "css/style.scss" | toCSS }}
{{ $lightbox := resources.Get "css/lightbox2/lightbox.min.css" }}
{{ $fontawesome := resources.Get "fontawesome.scss" | toCSS }}
{{ $opensans := resources.Get "open-sans.scss" | toCSS }}
{{ $css := slice $fontawesome $opensans $lightbox $style | resources.Concat "css/bundle.css" | minify }}
{{ if ne hugo.Environment "development" -}}
  {{ $css = $css | fingerprint -}}
{{ end -}}
<link rel="stylesheet" href="{{ $css.RelPermalink }}">

Aehnlich verhaelt es sich mit JavaScript im <footer>. Im Letzten Schritt kann hier noch die Data Integrity dazukommen:

{{ $js_scratch := newScratch }}
{{ $js_scratch.Set "js" slice -}}
{{ $libs := slice "js/jquery/jquery.min.js" "js/lightbox2/lightbox.min.js" -}}
{{ range $libs -}}
  {{ $js_scratch.Add "js" (resources.Get . ) -}}
{{ end -}}
{{ $js := $js_scratch.Get "js" | resources.Concat "js/bundle.js" | resources.Minify | resources.Fingerprint -}}
{{ if .Site.IsServer -}}
  <script async defer src="{{ $js.RelPermalink }}"></script>
{{ else -}}
  <script async defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
{{ end -}}

Schoen waere nun noch eine Anpassung der Content Security Policy in nginx , wenn die Data Integrity bekannt ist. Das steht noch auf meiner TODO-Liste 😊
Die CSP kann uebrigens mit einem Evaluator getestet werden.

HTML kann schliesslich mit Hugo selbst minifiziert werden (letzter Satz):

hugo --minify

Ansonsten ist es sicher sinnvoll, auch die statischen Dateien moeglichst klein zu halten, z.B. durch Nutzung von WebP (siehe Bilder) oder WebM ).

Design/Layout #

Mein Ziel war es, ein simples, aber ansprechendes Layout hinzubekommen, und nicht so weit entfernt vom alten Design. Inspirationen gibt es ja alleine mit den Hugo Themes genug, aber ganz cool fand ich die Iterationen der Motherfucking Website (Suche , Reddit ):

Zusaetzlich zur Farbpalette (was eine Wisschenschaft fuer sich ist) kommen dann noch “Feinheiten” wie “neue” HTML-Tags , typographische Elemente wie Smart Quotes , und sonstige schoene Elemente. Davon hat die Designwelt ja recht viel πŸ€“

Gute Anlaufstellen bieten die Front-End Checklist , das SELFHTML-Wiki , Google Style Guides und generell diverse Standards .

Am Ende habe ich einfach das Standard-CSS in etwas abgewandelter Form verwendet. Die Farben sind bei mir jetzt also Weiss(-isch) als Hintergrundfarbe (#FCFCFC), Schwarz(-isch) als Schriftfarbe (#242424), Gruen(-isch) als Linkfarbe (#0A802D). Ich habe nur kein Lila als “Visited” verwendet, aber Underline als “Hover” .

Wichtig waren mir dabei Farben, die die folgenden Kontrasttests bestehen (und ich hab mir mal eben schnell noch neue Shortcodes gebaut πŸ˜‰):

CSS #

Das schwierigste (zumindest fuer mich) ist ein sauberes (S)CSS. Zudem habe ich ja mehrere Fremdsysteme mit aufgenommen (z.B. Lightbox und Font Awesome), die ja auch noch (S)CSS mitbringen. Das alles unter einen Hut zu bringen ist nicht immer ganz einfach, aber nur so lerne ich auch dazu πŸ˜‰

Etwas aufgehalten hat mich auch der selbst gebastelte, datenschutzfreundliche Gist-Shortcode, denn der passt nicht mit meinen anderen Styles (besonders pre und code) zusammen. Allerdings koennen Dinge ja ueberschrieben werden πŸ˜‰

Zur Vorbereitung auf den Dark Mode habe ich noch etwas anpassen bzw. hinzufuegen muessen. Und zwar habe ich erst in config/_default/markup.toml die Parameter zu Highlight wie folgt gesetzt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[highlight]
  anchorLineNos = false
  codeFences = true
  guessSyntax = false
  hl_Lines = ''
  linkAnchors = ''
  lineNoStart = 1
  lineNos = false
  lineNumbersInTable = true
  noClasses = false # wichtig ist diese Anpassung auf false
  style = 'solarized-light'
  tabWidth = 4

Mit dieser Einstellung muss ich die entsprechenden Klassen nun selbst hinzufuegen (damit kann ich sie aber z.B. spaeter fuer den Dark Mode einfach ueberschreiben). Ich habe mich fuer den Style solarized-light entschieden, eine Uebersicht gibt es in der Chroma Style Gallery . Die entsprechenden Klassen kann ich mit dem folgenden Befehl an meine assets/css/style.scss anhaengen:

hugo gen chromastyles --style=solarized-light >>assets/css/style.scss

Dark Mode #

Eine dunkle Variante hatte ich sogar schon eingebaut, allerdings bin ich mit dem (S)CSS noch nicht zufrieden gewesen. Das kommt also noch πŸ˜‰

Fonts #

Fuer die kleinen Icons nutze ich Font Awesome , Alternativen sind z.B. Feather , Fork Awesome , Simple Icons , oder Health Icons .

Ich habe lange herumprobiert und mich dann letztendlich dazu entschieden, nur die Woff2-Dateien durch etwas CSS einzubinden. Also kein JavaScript und auch keine SVG Sprites.
Zwar kann IE damit nicht umgehen , aber … who cares? πŸ˜‰

Fuer das Einbinden nutze ich npm und Hugo Mounts , es geht aber auch direkt mit einem Hugo Module . Alternativ geht das auch via Shortcode .

Meine Loesung wie folgt (von hier ). Erstmal das Paket installieren:

npm i @fortawesome/fontawesome-free

Danach folgendes in die config/_default/module.toml mit aufnehmen:

[[mounts]]
  source = "node_modules/@fortawesome/fontawesome-free/scss"
  target = "assets/css/fontawesome"
[[mounts]]
  source = "node_modules/@fortawesome/fontawesome-free/webfonts"
  target = "static/fonts/fontawesome"

Dann eine kleine Datei assets/fontawesome.scss erstellen:

$fa-font-path: "../fonts/fontawesome";
@import "css/fontawesome/fontawesome.scss";
@import "css/fontawesome/solid.scss";
@import "css/fontawesome/brands.scss";

Und diese dann in die Minifizierungspipeline mit aufnehmen:

{{ $fontawesome := resources.Get "fontawesome.scss" | toCSS }}
{{ $css := slice $fontawesome $opensans $lightbox $style | resources.Concat "css/bundle.css" | minify }}

Die Nutzung erfolgt entweder direkt (also mit HTML Code) oder via Shortcode . Ich habe dazu einfach selbst einen gebastelt (layouts/shortcodes/icon.html):

  icon.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{{- /* https://fontawesome.com */ -}}
{{- /* Set (fa-solid (solid), fa-regular (regular), fa-brands (brands)) */ -}}
{{- $set := .Get "set" | default "fa-solid" -}}
{{- /* Icon (https://fontawesome.com/icons) */ -}}
{{- $icon := .Get "icon" | default "fa-seedling" -}}
{{- /* Size (fa-(xs|sm|lg|2x|3x|5x|7x|10x) */ -}}
{{- $size := .Get "size" | default "" -}}
{{- /* Rotate (fa-(rotate-90|rotate-180|rotate-270|flip-horizontal|flip-vertical|flip-both)) */ -}}
{{- $rotate := .Get "rot" | default "" -}}
{{- /* Animate (https://fontawesome.com/docs/web/style/animate) */ -}}
{{- $animate := .Get "anim" | default "" -}}
{{- /* Color */ -}}
{{- $color := .Get "col" | default "" -}}
{{- /* Accessibility (d, s) */ -}}
{{- /* https://fontawesome.com/v6/docs/web/dig-deeper/accessibility */ -}}
{{- $access := .Get "access" | default "d" -}}
{{- $title := .Get "title" | default "" -}}
{{- $text := .Get "text" | default "" -}}
<i class='{{ $set }} {{ $icon }} {{ $size }} {{ $rotate }} {{ $animate }}' {{ with $color }}style='color:{{ . }}'{{ end }} aria-hidden='true' {{ with $title }}{{ . }}{{ end }}></i>
{{- if eq $access "s" -}}
<span class="sr-only">{{ $text }}</span>
{{- end -}}

Genutzt werden kann es nun z.B. auf einer Seite content/test.md wie folgt:

{{< icon size="fa-lg" anim="fa-spin" col="#abcdef" >}}

Info

Der obige Code musste auskommentiert werden, damit er nicht ausgefuehrt wird. Das geht, indem nach dem < ein /* und vor dem > ein */ hinzugefuegt wird.

Dieser Code erzeugt dann das hier: .

Wichtig ist hierbei, dass die Datei keine neue Zeile am Ende enthaelt, denn sonst wird das Icon mit einem Leerzeichen dahinter dargestellt.

Sofern eine .editorconfig genutzt wird, kann folgendes fuer .html-Dateien hinzugefuegt werden:

[*.html]
insert_final_newline = false

Alternativ kann das Leerzeichen laut Stackexchange mit perl -pi -e 'chomp if eof' /path/to/file entfernt werden.

Noch auf meiner TODO-Liste ist die Unterscheidung zwischen dekorativen und semantischen Icons und die entsprechende Umsetzung.

Ansonsten nutze ich als Schriftart Open Sans , was auch via npm und Hugo Mounts eingebunden und ausgeliefert wird.

Bilder #

Die Bilder wollte ich wie bisher auch mit einem Polaroid-Effekt versehen (zudem Lightbox zum schoener darstellen und navigieren), diesmal aber via CSS, statt vorgeneriert mit convert .

Polaroid Bilder in der alten Version 11.7
Polaroid Bilder in der alten Version 11.7

Inspirationen fuer die Umsetzung habe ich ein paar gefunden und damit folgenden SCSS-Code in assets/css/style.scss gebastelt:

figure {
  img {
    display: block;
    margin-left: auto;
    margin-right: auto;
  }
  &.lb {
    margin: 2em auto;
    background: white;
    background: linear-gradient(110deg, white, oldlace);
    box-shadow: 4px 4px 15px gray;
    max-width: 280px;
    vertical-align: top;
    position: relative;
    transform: rotate(4deg);
    transition: all ease 0.6s;
    text-align: center;
    a {
      text-decoration: none;
    }
    img {
      display: inline;
      max-width: 100%;
      height: auto;
      margin: 5% 5% 0 5%;
    }
    figcaption {
      width: 90%;
      min-height: 50px;
      margin: 0 5% 5% 5%;
      text-align: center;
    }
  }
}

Bisher habe ich vorwiegend Bilder in den Formaten JPEG und PNG verwendet. Allerdings gibt es seit Version v.83.0 in Hugo Support fuer WebP , was laut Can I use… in den meisten derzeit genutzten Browsern verwendet werden kann.

Ein paar der bisher genutzten Bilder habe ich nachtraeglich mit cwebp (alternativ convert ) umgewandelt. Dank Hugo kann ich aber zumindest die Thumbnails automatisch mit einem Shortcode umwandeln lassen πŸŽ‰

[Hugo kann noch viel mehr, zum Beispiel koennen auch automatisch Wasserzeichen hinzugefuegt werden oder Exif-Informationen ausgelesen und dargestellt werden. Sehr cool z.B. fuer Bildergalerien.]

Ich glaube, der Shortcode fuer Bilder ist eins der am haeufigsten angepassten Shortcodes, ein Beispiel gibt es im Forum . Mein Shortcode ist auch angepasst und hat Lightbox-Code schon integriert, zudem noch ein paar andere Dinge wie Unterschriften, Qualitaet, Linkziele, Format, …

Auf meiner TODO-Liste steht noch lazy loading , was aber etwas komplexer ist.

Favicon #

Ein Favicon hatte ich ja vorher schon, nur die Einbindung war noch nicht ganz perfekt. Ueber die Website RealFaviconGenerator habe fuer ca. alle Plattformen das richtige Bild erstellen und in layouts/_default/baseof.html einbinden koennen:

<!-- realfavicongenerator.net -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ "apple-touch-icon.png" | absURL }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ "favicon-32x32.png" | absURL }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ "android-chrome-192x192.png" | absURL }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ "favicon-16x16.png" | absURL }}">
<link rel="manifest" crossorigin="use-credentials" href="{{ "manifest.webmanifest" | absURL }}">
<link rel="mask-icon" href="{{ "safari-pinned-tab.svg" | absURL }}" color="#5bbad5">
<link rel="shortcut icon" href="{{ "favicon.ico" | absURL }}">
<meta name="apple-mobile-web-app-title" content="{{ site.Title }}">
<meta name="application-name" content="{{ site.Title }}">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-config" content="{{ "browserconfig.xml" | absURL }}">
<meta name="theme-color" content="#000000">

Die Dateien von der Website habe ich alle im Ordner static abgelegt.

Codebloecke / Syntaxhighlighting #

Ich verwende viele Codebloecke, daher sollen die natuerlich auch schoen aussehen. Hugo bringt sowas schon mit und funktioniert mit Code Fences und Shortcodes . Im Hintergrund verwendet Hugo Chroma , was sehr viele Sprachen unterstuetzt . Abweichend von den Standardeinstellungen habe ich mich fuer solarized-light als Style (siehe CSS) entschieden.

Die Inspiration, vor einen Codeblock die Sprache “anzupinnen” habe ich mir hier geholt .

Beispiele fuer den Einsatz gibt es weiter unten bei Gist oder GitHub.

Blockquotes #

Zitate verwende ich immer mal wieder und wollte die auch entsprechend aufhuebschen. Fuer das CSS habe ich mich ein bisschen an den “BQ Patterns” und “Classy Blockquotes” (ohne Bilder) orientiert und mich auch noch ein bisschen inspirieren lassen von einer anderen Website .

Mein Shortcode in layouts/shortcodes/blockquote.html ist recht simpel gestaltet:

 layouts/shortcodes/blockquote.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{{ $src := .Get "src" }}
{{ $dst := .Get "dst" }}

<blockquote {{ if .Get "dst" }}cite="http{{ $dst }}"{{ end }}>
{{ with .Inner }}{{ $.Page.RenderString . }}{{ end }}
{{ if .Get "src" }}
<footer>
  {{ if .Get "dst" }}{{ with print "[" $src "](http" $dst ")" }}{{ $.Page.RenderString . }}{{ end }}{{ else}}{{ $src }}{{ end }}
</footer>
{{ end }}
</blockquote>

Verwendet werden kann es wie folgt:

{{< blockquote src="Wikipedia" dst="s://de.wikipedia.org/wiki/Blockzitat" >}}
Ein **Blockzitat** ist eine typografische Darstellung eines Zitats, bei dem eine lΓ€ngere zitierte Passage als eigener Absatz herausgestellt wird.
{{< /blockquote >}}

Mein SCSS in assets/css/style.scss im Folgenden enthaelt Mixins (quasi Funktionen) und Variablen, da ich es z.B. auch fuer Twitter in aehnlicher Form verwendet habe:

$color: #575757;
$color_blockquote_before: #e6e6e6;
$color_blockquote_footer: #d3d3cf;
$font_serif: serif;

@mixin blockquote_before($color: $color_blockquote_before) {
  position: absolute;
  color: $color;
  z-index: -1;
}

blockquote {
  position: relative;

  &:before {
    font-family: $font_serif;
    content: '\201C';
    top: -50px;
    left: -15px;
    font-size: 5em;
    @include blockquote_before();
  }

  footer {
    color: $color_blockquote_footer;

    &:before {
      content: '\2015';
    }
  }
}

Das Resultat mit Quelle (bzw. Footer) sieht nun so aus:

Ein Blockzitat ist eine typografische Darstellung eines Zitats, bei dem eine lΓ€ngere zitierte Passage als eigener Absatz herausgestellt wird.

Render Hooks #

Das Feature Render Hooks gibt es seit Version v0.62.0 und erlaubt es, bei Headern (also z.B. <h2>) den Permalink daneben hinzuzufuegen (bei mir durch ein # gekennzeichnet) oder Links zu anderen Websites mit einem Icon (bei mir ) kenntlich zu machen.

Der Code in layouts/_default/_markup/render-heading.html fuer die Header sieht so aus:

<h{{ .Level }} id="{{ .Anchor | safeURL }}">{{ .Text | safeHTML }} <a href="#{{ .Anchor | safeURL }}">#</a></h{{ .Level }}>

Fuer die Icons neben den Links sieht der Code in layouts/_default/_markup/render-link.html wie folgt aus:

<a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}>{{ .Text | safeHTML }}{{ if strings.HasPrefix .Destination "http" }} <i class='fa-solid fa-up-right-from-square fa-sm'></i>{{ end }}</a>

Durch die Abfrage mit dem Prefix http werden nur externe Links mit dem Icon versehen, interne Links sind relativ und werden daher nicht mit gekennzeichnet.

CSS Icons #

Eine Alternative (?) ist die Nutzung von CSS , z.B. um Links zu einem RSS-Feed mit dem RSS-Icon () zu kennzeichnen.

Dazu reicht folgendes im CSS (mit der Nutzung von Fonts):

a[href$="rss.xml"]::before {
  content: '\f143';
  color: #f26522;
  font-family: "Font Awesome 5 Free";
  margin-right: 5px;
}

Verlinke ich jetzt einen RSS-Feed, erscheint das Icon: Link zu einem RSS-Feed. Das funktioniert auch mit externen Feeds, dann wird zusaetzlich noch der render-link.html angehaengt: Link zu externem “RSS-Feed” .

Back to top #

Gerade bei langen Texten und/oder auf dem Mobilgeraet ist ein Button, mit dem zum Anfang “gescrollt” werden kann recht sinnvoll.

Daher nun jetzt auch auf dieser Website, dank vanilla-back-to-top . Installiert habe ich es ganz einfach ueber npm :

npm i vanilla-back-to-top

Danach hab ich es in die config/_default/module.toml mit aufgenommen:

[[mounts]]
  source = "node_modules/vanilla-back-to-top/dist/vanilla-back-to-top.min.js"
  target = "assets/js/b2t/b2t.min.js"

Zum Schluss noch eine kleine Datei layouts/assets/js/b2t.js erstellt (was dem 2. Beispiel entspricht ):

 assets/js/b2t.js

1
2
3
4
5
addBackToTop({
  diameter: 56,
  backgroundColor: 'rgb(255, 82, 82)',
  textColor: '#fff'
})

Und diese dann mit der gemounteten layouts/assets/js/b2t/b2t.min.js in die Minifizierungspipeline mit aufgenommen:

{{ $libs := slice "js/jquery/jquery.min.js" "js/lightbox2/lightbox.min.js" "js/b2t/b2t.min.js" "js/b2t.js" -}}

Table Of Contents / Inhaltsverzeichnis #

Der Code in der Doku reicht leider nicht fuer meine Beduerfnisse, denn ich will eigentlich immer ein Inhaltsverzeichnis haben, wenn der Text 400 oder mehr Woerter enthaelt und nicht der Flag toc: false gesetzt ist.
Ansonsten soll das Inhaltsverzeichnis nur angezeigt werden, wenn es 2 oder mehr Header gibt (egal welche).

Mein Code in partials/block/toc.html sieht daher wie folgt aus (mit Nutzung von <details> ):

 layouts/partials/block/toc.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{{- /*
  https://gohugo.io/content-management/toc/#template-example-toc-partial
  https://github.com/gohugoio/hugo/issues/1778#issuecomment-522222658
  https://gist.github.com/percygrunwald/043e577beb90db72e09727a3ed3053c3
  https://gist.github.com/pyrrho/1d77cdb98ba58c7547f2cdb3fb325c62
*/ -}}
{{- if and (gt .WordCount 400 ) (ne .Params.toc false) -}}
  {{- $headers := findRE "<h[2-6].*?>(.|\n])+?</h[2-6]>" .Content -}}
  {{- $has_headers := ge (len $headers) 2 -}}
  {{- if $has_headers -}}
    <details>
      <summary>Inhaltsverzeichnis</summary>
      {{- .TableOfContents -}}
    </details>
  {{- end -}}
{{- end -}}

Ein Problem bestand noch darin, dass das erstellte Inhaltsverzeichnis auch den ersten Header (<h1>) mit dargestellt hat. Dank Goldmark kann das nun via startLevel = 2 eingestellt werden (das ging vorher mit Blackfriday naemlich nicht ).

Sitemap #

Hugo liefert schon von Haus aus eine Sitemap mit, der Code dafuer ist auf GitHub . Die Einbindung in den Header ist in diesem Thread beschrieben .

Soll eine Seite/Beitrag oder was auch immer nicht in der Sitemap erscheinen, kann die Loesung von Fryboyter verwendet werden. Eine Alternative Loesung bei Mert BakΔ±r . Meine Loesung in layouts/_default/sitemap.xml sieht jetzt einfach so aus:

{{ printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
  xmlns:xhtml="http://www.w3.org/1999/xhtml">
  {{ range where .Data.Pages ".Params.exclude" "!=" true }} // statt {{ range .Data.Pages }}
    {{- if .Permalink -}}
  <url>
    <loc>{{ .Permalink }}</loc>{{ if not .Lastmod.IsZero }}
    <lastmod>{{ safeHTML ( .Lastmod.Format "2006-01-02T15:04:05-07:00" ) }}</lastmod>{{ end }}{{ with .Sitemap.ChangeFreq }}
    <changefreq>{{ . }}</changefreq>{{ end }}{{ if ge .Sitemap.Priority 0.0 }}
    <priority>{{ .Sitemap.Priority }}</priority>{{ end }}{{ if .IsTranslated }}{{ range .Translations }}
    <xhtml:link
                rel="alternate"
                hreflang="{{ .Language.Lang }}"
                href="{{ .Permalink }}"
                />{{ end }}
    <xhtml:link
                rel="alternate"
                hreflang="{{ .Language.Lang }}"
                href="{{ .Permalink }}"
                />{{ end }}
  </url>
    {{- end -}}
  {{ end }}
</urlset>

Nun kann im Frontmatter eine Seite via exclude: true in der Seite ausgeschlossen werden.

Konfigurieren lassen sich ansonsten ein paar Einstellungen via Config:

 config/_default/sitemap.toml

1
2
3
4
# https://gohugo.io/templates/sitemap-template/
changefreq = "monthly"
priority = 0.5
filename = "sitemap.xml"

Das XML-Schema einer Sitemap ist hier spezifiziert und kann mit einem Validator ueberprueft werden.

Warnung

Dieser Fehler hat mich mein halbes Leben gekostet … πŸ™„

Wird in config/_default/outputs.toml bei home die Sitemap inkludiert, wird einfach das Default-Template genutzt:

home     = [ "html", "rss", "manifest", "sitemap" ]

Somit greifen jegliche Aenderungen in layouts/_default/sitemap.xml nicht.

robots.txt / humans.txt #

Eine robots.txt ist ja ueblich, aber auf der anderen Seite gibt es auch humans.txt (mehr dazu ). Ein lustiges Projekt, fuer mich aber nicht notwendig, da ich hier ja alles selbst mache, daher leite ich es einfach auf meine Kontakt-Seite weiter.

Natuerlich laesst sich die robots.txt auch anpassen . Ein Forumsbeitrag enthaelt z.B. die Moeglichkeit, einzelne Seiten/Beitraege nicht zu erlauben (aehnlich zur Sitemap). Meine sieht z.B. so aus (layouts/robots.txt), indem ich einach den gleichen Parameter wie bei der Sitemap nutze:

 layouts/robots.txt

1
2
3
4
5
6
7
# Have phun ...
User-agent: *

{{ range where .Data.Pages "Params.exclude" true -}}
Disallow: {{ .RelPermalink }}
{{ end }}
Sitemap: {{ "sitemap.xml" | absLangURL }}

Wichtig bei einer eigenen robots.txt ist folgende Einstellung in config/_default/config.toml:

enableRobotsTXT = true

Werden bestimmte Seiten ausgeschlossen , sollten sie auch im <head> ausgeschlossen werden. Daher sieht meine layouts/_default/baseof.html so aus:

<head>
  <!-- [..] -->
  <meta name="robots" content="{{ if .Params.exclude }}noindex,nofollow{{ else }}index,follow{{ end }}">
</head>

Errorcodes #

Normalerweise sehen die Errorcodes-Seiten ziemlich langweilig aus. Mit Hugo ist es moeglich, diese auch wie eine normale Webseite auszugeben und mit z.B. nginx zu nutzen (content/403/index.md):

 content/403/index.md

1
2
3
4
5
6
7
8
---
title: "403 - Forbidden"
draft: false
description: "403 - Forbidden"
url: 403.html
exclude: true
---
{{< youtube RfiQYRn7fBg >}}

Wichtig ist hier die Zeile mit url: 403.html, denn ansonsten wuerde Hugo dies als /403/ ausgeben, womit nginx Schwierigkeiten hat, daher reicht nun einfach:

error_page 403 /403.html;

Ebenso wichtig ist die Zeile mit exclude: true, denn diese Fehlerseiten sollen weder in der Sitemap auftauchen, noch in der robots.txt bzw. <head> “erlaubt” sein .

Und auch wichtig ist, dass CSS-, JavaScript und sonstige Dateien absolut eingebunden werden. Bisher hatte ich in meinem <head> alles auf relURL und relPermalink stehen (siehe Minifizierung), doch dann koennen Resourcen nicht geladen werden, wenn z.B. https://uxg.ch/bewusst/falscher/pfad aufgerufen wird. Daher habe ich nun alles einiges auf absURL und Permalink umgestellt.

Meine Fehlerseiten: 403 und 404. Weitere schoene gibt es bei http.cat 😸

RSS #

Da ich in mehreren Planeten gelistet bin, brauche ich natuerlich auch einen ordentlichen RSS-Feed , wenn nicht sogar mehrere πŸ˜‰

Allerdings ist das Default-Template nicht ausreichend genug, denn ich will im Feed nicht nur ein Summary eines Beitrags drin haben, sondern eben den ganzen Beitrag . Zudem sollen nicht alle Beitraege im Feed sein, sondern nur die neuesten 10. Das soll eigtl. durch das Setzen von rssLimit funktionieren, tut es bei mir aber nicht.

Dazu habe ich das Template in layouts/_default/rss.xml etwas modifiziert (mit Einhaltung der Standards ):

// erste Zeilen entfernt
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>{{ if eq  .Title  .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} on {{ end }}{{ .Site.Title }}{{ end }}</title>
    <link>{{ .Permalink }}</link>
    <description>Recent content {{ if ne  .Title  .Site.Title }}{{ with .Title }}in {{.}} {{ end }}{{ end }}on {{ .Site.Title }}</description>
    <generator>Hugo {{ hugo.Version }} -- gohugo.io</generator>{{ with .Site.LanguageCode }} // hugo.Version hinzugefuegt
    <language>{{.}}</language>{{end}}{{ with .Site.Author.email }}
    <managingEditor>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</managingEditor>{{end}}{{ with .Site.Author.email }}
    <webMaster>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</webMaster>{{end}}{{ with .Site.Copyright }}
    <copyright>{{.}}</copyright>{{end}}{{ if not .Date.IsZero }}
    <lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>{{ end }}
    {{- with .OutputFormats.Get "RSS" -}}
    {{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
    {{- end -}}
    {{ range first 10 .Pages }} // nur die ersten 10 ausgeben
    <item>
      <title>{{ .Title }}</title>
      <link>{{ .Permalink }}</link>
      <pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
      {{ with .Site.Author.email }}<author>{{.}}{{ with $.Site.Author.name }} ({{.}}){{end}}</author>{{end}}
      <guid>{{ .Permalink }}</guid>
      <description>{{ .Content | html }}</description> // .Content statt .Summary
    </item>
    {{ end }}
  </channel>
</rss>

Das funktioniert schonmal prima fuer die Tags πŸŽ‰

Allerdings soll die Tags-Seite kein RSS ausspucken (wozu auch?). Dazu ist eine Anpassung der Outputs in config/_default/outputs.toml notwendig:

page     = [ "html" ]
home     = [ "html", "rss", "manifest" ]
section  = [ "html", "rss" ]
# taxonomy soll kein rss beinhalten
taxonomy = [ "html" ]
term     = [ "html", "rss" ]

Hugo wirft jetzt erstmal einen Fehler bei dieser Aenderung aus:

ERROR 2021/12/13 17:12:50 You have configured output formats for 'taxonomy' in your site configuration. In Hugo 0.73.0 we fixed these to be what most people expect (taxonomy and term).
But this also means that your site configuration may not do what you expect. If it is correct, you can suppress this message by following the instructions below.
If you feel that this should not be logged as an ERROR, you can ignore it by adding this to your site config:
ignoreErrors = ["error-output-taxonomy"]

Da ich mir sicher bin, dass ich kein RSS-Feed fuer meine Tags-Seite haben will, kann ich den Fehler in der config/_default/config.toml ignorieren:

ignoreErrors = ["error-output-taxonomy"]

Falls ihr nicht sicher seid, was alles noch einen RSS-Feed ausspuckt, und ob das wirklich notwendig ist, koennt ihr eure Seite erstmal bauen und dann nach rss.xml suchen. Bei mir hatten sich ein paar Dateien eingeschlichen, die durch Sektionen generiert wurden, obwohl das gar keine Section sein muss. Also mal schnell ein git mv _index.md index.md gemacht in den entsprechenden Ordnern und bis auf eine Sektion war alles gut (steht noch auf meiner TODO-Liste das zu fixen) πŸ˜‰

Bei der Gelegenheit ist mir aufgefallen, dass meine /rss.xml auch ganz normale Seiten wie Kontakt enthaelt. Das ist natuerlich Quatsch. Eine Loesung habe ich dann schliesslich bei Mert BakΔ±r gefunden .

Daher erstellen wir noch eine zusaetzliche Kopie von layouts/_default/rss.xml names layouts/_default/index.rss.xml mit folgenden Aenderungen:

{{- $pages := where $.Site.RegularPages ".Type" "!=" "ordinary" -}} // vor die erste Zeile
{{ range first 10 $pages }} // statt {{ range first 10 .Pages }}

Das klappt dank der Einlese-Reihenfolge in Hugo . Interessanterweise habe ich nirgendwo im Frontmatter type: ordinary angegeben, es scheint aber trotzdem zu funktionieren … πŸ€”

Eingebunden werden koennen die Feeds (und ggf. andere Formate) in den <head> in layouts/_default/baseof.html so:

{{ range .AlternativeOutputFormats -}}
    {{ printf `<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
{{ end -}}

Uebrigens gibt es auch Validatoren fuer RSS-Feeds .

Alternativen: Atom und JSON Feed #

Zusaetzlich zu RSS gibt es auch noch Atom und JSON Feed , was wir natuerlich auch in Hugo realisieren koennen.

Darauf habe ich aber (erst einmal) verzichtet, liste aber hier ein paar Quellen auf, wie das realisiert werden kann.

Wie in jeder Standardwordpressinstallation gibt es auch bei Hugo die Moeglichkeit, aehnliche Beitraege z.B. nach einem Blockpost anzuzeigen.

Meine Config (config/_default/related.toml) sieht so aus:

 config/_default/related.toml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# https://gohugo.io/content-management/related/

threshold = 1
includeNewer = false
toLower = false

[[indices]]
name = "tags"
weight = 100

[[indices]]
name = "keywords"
weight = 50

[[indices]]
name = "date"
weight = 1
pattern = "2006"

Und mein Partial (layouts/partials/block/related.html), welcher in layouts/block/single.html eingebunden wird, so:

 layouts/partials/block/related.html

1
2
3
4
5
6
7
8
9
{{ $related := .Site.RegularPages.Related . | first 5 }}
{{ with $related }}
<h3>Γ„hnliche BeitrΓ€ge</h3>
<ul>
	{{ range . }}
	<li><a href="{{ .Permalink }}">{{ .Title }}</a></li>
	{{ end }}
</ul>
{{ end }}

Naechste / Vorherige Seite #

Auch das wollte ich schon immer mal haben, eine einfache Navigation zum vorherigen bzw. naechsten Beitrag oder Rezept. Und auch das ist mit Hugo recht einfach moeglich:

Hierzu nutzen wir einfach die Variablen , die Hugo uns fuer eine Seite zur Verfuegung stellt. Meine Datei layouts/partials/page/prev_next.html, die z.B. in layouts/block/single.html eingebunden wird, sieht so aus (unter Nutzung von Icons):

 layouts/partials/page/prev_next.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<hr>
<div class="blockprenext">
  <div>
    {{ with .PrevInSection }}<i class="fa-solid fa-arrow-left"></i>&nbsp;<a href="{{ .Permalink }}">{{ .Title }}</a>{{ end }}
  </div>
  <div class="right">
    {{ with .NextInSection }}<a href="{{ .Permalink }}">{{ .Title }}</a>&nbsp;<i class="fa-solid fa-arrow-right"></i>{{ end }}
  </div>
</div>
<hr>

Tagcloud #

Wie in der alten Version, generiert durch ein paar Bashskripte, wollte ich wieder eine Tagcloud . Das ist natuerlich auch mit Hugo moeglich (Taxonomy als Stichwort ).

Diesmal allerdings nicht direkt nativ, d.h. ich habe mir den Code von verschiedenen Quellen angeguckt und schliesslich von Artem Sidorenko uebernommen . Danke!

Die Seite an sich ist die Uebersichtsseite der Tags, die via layouts/_default/terms.html modifiziert werden kann. Dort habe ich einfach ein Partial namens layouts/partials/term_cloud.html angelegt mit folgendem Inhalt:

 layouts/partials/term_cloud.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{{ if not (eq (len $.Site.Taxonomies.tags) 0) }}
    {{ $fontUnit := "em" }}
    {{ $largestFontSize := 2.0 }}
    {{ $largestFontSize := 2.5 }}
    {{ $smallestFontSize := 1.0 }}
    {{ $fontSpread := sub $largestFontSize $smallestFontSize }}
    {{ $max := add (len (index $.Site.Taxonomies.tags.ByCount 0).Pages) 1 }}
    {{ $min := len (index $.Site.Taxonomies.tags.ByCount.Reverse 0).Pages }}
    {{ $spread := sub $max $min }}
    {{ $fontStep := div $fontSpread $spread }}

    <div id="tag-cloud" style="padding: 5px 15px">
        {{ range $name, $taxonomy := $.Site.Taxonomies.tags }}
            {{ $currentTagCount := len $taxonomy.Pages }}
            {{ $currentFontSize := (add $smallestFontSize (mul (sub $currentTagCount $min) $fontStep) ) }}
            {{ $count := len $taxonomy.Pages }}
            {{ $weigth := div (sub (math.Log $count) (math.Log $min)) (sub (math.Log $max) (math.Log $min)) }}
            {{ $currentFontSize := (add $smallestFontSize (mul (sub $largestFontSize $smallestFontSize) $weigth) ) }}
            <!--Current font size: {{$currentFontSize}}-->
            <a href="{{ "/tags/" | relLangURL }}{{ $name | urlize }}" style="font-size:{{$currentFontSize}}{{$fontUnit}}">{{ $name }}<sup>{{ $count }}</sup></a>
        {{ end }}
    </div>
{{ end }}

Mit ausgelagertem CSS waere das natuerlich ebenso moeglich gewesen …

Weitere Inspirationen fuer Tagclouds .

URL-Schema #

Dank Hugo ist das sehr einfach moeglich. Generell nutze ich nun keine Endung (genannt Ugly URLs ) mehr bei den Eintraegen/Seiten (also z.B. kein $timestamp.htm bei den Blockeintraegen), was in Hugo der Default ist. Allerdings sollten die Blockposts anders dargestellt werden. Das geht in der Config so :

[permalinks]
  block = "/block/:year/:month/:day/:slug/"

Suche #

Update (2022-08-06): Seit Jahren habe ich mich auf externe Suchmaschinen verlassen, wollte aber schon immer eine “eigene” Suche haben. Ich hatte auch schon des oefteren angefangen zu recherchieren, was ich denn nun eigentlich einsetzen will, habe mich aber nicht zu einer Loesung durchringen koennen. Im Raum standen z.B. eine Suche mit Lunr oder FlexSearch , die mit einem JSON Feed arbeiten.

Der Nachteil dabei wurde in den letzten Tagen auf Brain Baking beschrieben (dieser Feed oder Index kann je nach Umfang der Website dann natuerlich auch selbst sehr gross sein und muss komplett client-seitig heruntergeladen werden).

Und daher setze ich nun, wie auch viele andere in den letzten Wochen auf das noch recht junge Projekt Pagefind , was die Suche auf allen Seiten vereinfacht, da hier mit Fragmenten gearbeitet wird und der Index erstellt wird, nachdem die Website gebaut wurde 😊

Der Einbau war sehr einfach, ich habe einfach ein Partial layouts/partials/search-pagefind.html erstellt und es auf einer Seite eingebunden:

 layouts/partials/search-pagefind.html

1
2
3
4
5
6
7
8
<link href="/_pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/_pagefind/pagefind-ui.js" type="text/javascript"></script>
<div id="search"></div>
<script>
    window.addEventListener('DOMContentLoaded', (event) => {
        new PagefindUI({ element: "#search", showImages: false, resetStyles: true });
    });
</script>

Zusaetzlich habe ich in meinem GitHub Workflow die folgenden Zeilen ergaenzt (der gesamte Workflow steht weiter unten):

  - name: Run Pagefind
    run: npx -y pagefind --source "public"

Und das wars auch schon, die Suche war nach wenigen Stunden live πŸŽ‰

Statistik #

Bisher wurde die Seite Statistik mit einem Bashscript gebaut (via Cronjob).

alte Statistikseite
alte Statistikseite

Dabei wurden nicht nur Eintraege, Linkdumps, Rezepte usw. gezaehlt, sondern auch die Haeufigkeit von Suchmaschineneingaben (ausgelesen ueber die Webserverlogs). Das funktioniert mit der Nutzung von GitHub Actions jetzt nicht mehr so einfach (auch die vorherigen Zaehlungen koennen mit Hugo nur schwierig abgebildet werden). Daher halt eben nur eine kleinere Statistik. 😊

Allerdings wuerde ich gerne noch ein paar kleine Dinge mit einbauen:

Ersteres laesst sich generieren ueber einen normalen Durchlauf von Hugo, wobei der Output in eine Datei geschrieben wird.

Zweiteres funktioniert mit einem find-Befehl , der ebenfalls in eine Datei geschrieben wird.

Beide Dateien werden anschliessend bei einem zweiten Durchlauf mit Hilfe von readfile eingebunden.

# Hugo Statistik
$ hugo | grep -v "^INFO" | sed '1d'
hugo v0.90.1+extended darwin/amd64 BuildDate=unknown
INFO 2021/12/11 20:14:31 syncing static files to /

                   |  DE
-------------------+-------
  Pages            | 1099
  Paginator pages  |    0
  Non-page files   |  907
  Static files     |  125
  Processed images |  805
  Aliases          |  287
  Sitemaps         |    1
  Cleaned          |    0

Built in 1370 ms
# Auflistung aller Dateiendungen sortiert nach der Haeufigkeit (erstmal nur die letzten 10)
$ find . -type f | grep -oE '\.(\w+)$' | sort | uniq -c | sort | tail
  26 .woff
  26 .woff2
 195 .png
 272 .xml
 278 .js
 512 .md
1057 .html
1663 .svg
1786 .webp
2215 .jpg

Beides habe ich noch nicht umgesetzt, das steht noch auf meiner TODO-Liste.

Changelog / Version #

Bisher habe ich Aenderungen in einer Datei namens changelog.txt festgehalten, diese wurde nun durch eine regulaere Seite ersetzt. Zusaetzlich gab es noch eine Datei namens version.txt, die ist nun aber auch im Changelog aufgegangen.

Lange Zeit hatte ich nur Major- und Minor-Versionen festgehalten, nun kehre ich wieder zu Semantic Versioning zurueck, also zusaetzlich mit Patch-Version.

Schoen waere noch ein automatischer Changelog, zusammengebaut via Git Log und/oder Tags. Aber mal gucken, steht als Low Prio auf meiner TODO-Liste.

DSGVO #

Es gibt zwar ein paar Moeglichkeiten, die Standard-Shortcodes etwas datenschutzfreundlicher zu gestalten (Bountysource ), allerdings gehen diese mir nicht weit genug. So wird bei einem Seitenaufruf immer noch Zeugs von anderen Websites nachgeladen, was ich natuerlich nicht will (und ihr wohl auch nicht).

Daher habe ich mir die Muehe gemacht, selber was zu basteln. gist und twitter kommen ohne sonstige Skripte aus, bei youtube und vimeo ist noch ein Skript notwendig, was das Thumbnail runterlaedt und auf die passende Groesse von 560x560 (siehe geometry bei imagemagick ) umwandelt. instagram habe ich bisher nicht gebraucht und github (als Alternative zu gist) sowie osm habe ich neu gebaut. spotify wollte ich auch noch bauen, aber die API ist doof πŸ˜‰

Ansonsten kann noch durch interne Templates Google Analytics sowie Disqus verwendet werden. Ersteres ist bei mir nicht im Einsatz und selbst wenn sollte lieber Matomo , umami , Plausible Analytics oder GoatCounter verwendet werden. Auf Zweiteres gehe ich unter Kommentare naeher drauf ein.

Gist #

Die Einbindung von Gists ist mir nicht datensparsam genug (es werden Sachen von GitHub nachgeladen). Zum Glueck gibt es die Moeglichkeit, per API u.a. den Code abzufragen. Im Gegensatz zu dem offiziellen Hugo Shortcode benoetigt mein Shortcode (layouts/shortcodes/gist.html) mit “Positional Parameters” nicht den Namen, sondern nur die Gist ID und ggf. einen Dateinamen, wenn der Gist mehrere Dateien enthaelt, aber nur eine angezeigt werden soll:

 layouts/shortcodes/gist.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{{- /*
  https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates/shortcodes/gist.html
  https://docs.github.com/en/rest/gists#get-a-single-gist
*/ -}}
{{- $id := .Get 0 -}}
{{- $file := .Get 1 -}}
{{- $json := getJSON "https://api.github.com/gists/" $id -}}
{{- range $json.files -}}
{{- if or (eq $file "") (eq .filename $file) -}}
<div class="gist">
<p><i class="fa-brands fa-github"></i>&nbsp;
{{ with print "[" .filename "](" $json.html_url ")" }}{{ $.Page.RenderString . }}{{ end }}
</p>
{{ highlight .content .language "linenos=table" }}
</div>
{{- end -}}
{{- end -}}

Die Magie passiert via getJSON , das Styling passiert via CSS und ist in Codebloecke naeher beschrieben.

Ein Beispiel Gist mit mehreren Dateien , wovon nur eine angezeigt werden soll, sieht dann so aus:

{{< gist a900acbf7140c18217dc7a1679c52114 "example-bower.json" >}}

  example-bower.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "name": "gist-embed",
  "authors": [
    "Blair Vanderhoof"
  ],
  "description": "Ultra powered gist embedding for your website http://blairvanderhoof.com/gist-embed/",
  "main": "gist-embed.js",
  "keywords": [
    "gist",
    "embed",
    "github",
    "ajax"
  ],
  "license": "BSD-2-Clause",
  "homepage": "https://github.com/blairvanderhoof/gist-embed",
  "repository": {
    "type": "git",
    "url": "https://github.com/blairvanderhoof/gist-embed.git"
  }
}

GitHub #

Ganz aehnlich zu dem Shortcode fuer ein Gist ist layouts/shortcodes/github.html mit “Named Parameters” aufgebaut:

 layouts/shortcodes/github.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{{- /*
  https://github.com/haideralipunjabi/hugo-shortcodes/tree/master/github
  https://docs.github.com/en/rest/repos/contents
*/ -}}
{{- $repo := .Get "repo" -}}
{{- $file := .Get "file" -}}
{{- $lang := .Get "lang" | default "txt" -}}
{{- $opts := .Get "opts" | default (printf "%s%s" "linenos=table,anchorlinenos=true,lineanchors=" $file) -}}
{{- $json := getJSON "https://api.github.com/repos/" $repo "/contents/" $file -}}
{{- $data := base64Decode $json.content -}}
<div class="gist">
<p><i class="fa-brands fa-github"></i>&nbsp;
{{ with print "[" $json.name "](" $json.html_url ")" }}{{ $.Page.RenderString . }}{{ end }}
</p>
{{ highlight $data $lang $opts }}
</div>

In diesem Shortcode muss die Sprache mit angegeben werden, da die API diese leider nicht mit ausgibt. Zusaetzlich gibt es noch die Moeglichkeit, dem Shortcode highlight Optionen mitzugeben:

{{< github repo="tohn/aa3d-tools" file="dm2txt.py" lang="py3" >}}

  dm2txt.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#!/usr/bin/env python
"""convert a depth map image to a 3d txt map to be used by `aa3d`"""

# inspiration:
# https://github.com/RameshAditya/asciify/blob/master/asciify.py

import sys
try:
    from PIL import Image
except ImportError:
    print("Unable to import pillow")
    sys.exit(1)

# main
#   - takes as parameters the image path [width and intensity]
#   - converts an image to a txt map
#   - prints result to console
if __name__ == '__main__':
    # try to load image
    try:
        image = Image.open(sys.argv[1])
    except IndexError:
        print("No image given")
        sys.exit(1)
    except IOError:
        print("Unable to load image")
        sys.exit(1)
    # set width (default: 80), in the range of 1-500
    try:
        WIDTH = max(1, min(abs(int(sys.argv[2])), 500))
    except (IndexError, ValueError):
        WIDTH = 80
    # set layers (default: 9), in the range of 1-9
    try:
        LAYERS = max(1, min(abs(int(sys.argv[3])), 9))
    except (IndexError, ValueError):
        LAYERS = 9
    # resize image
    image.thumbnail((WIDTH, WIDTH))
    # greyscale (8-bit pixels, black and white) since the input should
    # be like this anyways (this will also provide just one integer in
    # the range of 0-255 instead of a tuple)
    image = image.convert('L')
    # convert every pixel to a value 0-9 corresponding to their intensity
    pixels = [list(map(str, range(0, 9)))[p//(256//LAYERS)] for p in list(image.getdata())]
    # and join the result
    PIXELS_RES = ''.join(pixels)
    # construct the image from the character list
    new_image = [PIXELS_RES[i:i+WIDTH] for i in range(0, len(PIXELS_RES), WIDTH)]
    # and print the resulting lines
    print('\n'.join(new_image))

Instagram #

Der Standard-Shortcode von Instagram will ich nicht einsetzen, da er direkt Daten von Instagram nachlaedt. Allerdings braucht die API viel zu viele Daten von mir und daher ganz ehrlich: war mir zuviel Arbeit, kommt vielleicht irgendwannβ„’.

Kommentare #

Vor Jahren habe ich mich ja eigentlich gegen Kommentare entschieden. Allerdings bringt Hugo schon Support fuer Disqus mit , was die Einbindung natuerlich recht einfach macht.

Wobei Disqus aus Datenschutzgruenden natuerlich nicht die beste Wahl ist. Auf der Seite im Wiki werden aber Alternativen aufgelistet .

Isso kenne ich schon sehr lange und hatte es probeweise auch schon im Einsatz, allerdings habe ich es noch nicht mit Docker am Laufen. Und die Python-Dependency-Hell will ich mir nicht nativ antun.

tl;dr Kommentare will ich haben, allerdings habe ich mich noch nicht fuer eine Loesung entschieden, daher ist das noch ein TODO auf meiner Liste πŸ˜‰

OpenStreetMaps #

Statt Google Maps sollte OpenStreetMaps verwendet werden. Doch wie datenschutzfreundlich einbinden? Denn der Standard Embed-Code laedt $Dinge direkt von OpenStreetMaps …

<iframe width="425" height="350" frameborder="0" scrolling="no" marginheight="0" marginwidth="0" src="https://www.openstreetmap.org/export/embed.html?bbox=-10.315990447998049%2C51.53373644524688%2C-10.150337219238283%2C51.62846065474101&amp;layer=mapnik&amp;marker=51.5810798959352%2C-10.233279168605804" style="border: 1px solid black"></iframe><br/><small><a href="https://www.openstreetmap.org/?mlat=51.5811&amp;mlon=-10.2333#map=13/51.5811/-10.2332">View Larger Map</a></small>

Daher habe ich folgenden Shortcode layout/shortcodes/osm.html gebaut:

 layouts/shortcodes/osm.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{{- $bbox := index .Params 0 -}}
{{- $marker := index .Params 1 | default "" -}}
{{- $mlat := index .Params 2 | default "" -}}
{{- $mlon := index .Params 3 | default "" -}}
{{- $z := index .Params 4 | default "" -}}
{{- $code_safejs := safeJS (md5 (printf "%s_%s" $bbox $marker)) -}}
<div id="wrapper-{{ $code_safejs }}" class="osm-wrapper">
  <div id="container-{{ $code_safejs }}" class="osm-container">
    <script>
    function loadIframe_{{ $code_safejs }}() {
      var e1 = document.getElementById('info-{{ $code_safejs }}');
      e1.parentNode.removeChild(e1);
      var e2 = document.getElementById('image-{{ $code_safejs }}');
      e2.parentNode.removeChild(e2);
      document.getElementById('container-{{ $code_safejs }}').innerHTML = '<iframe style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;" src="https://www.openstreetmap.org/export/embed.html?bbox={{ $bbox }}&amp;layer=mapnik&amp;marker={{ $marker }}" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen"></iframe>';
    }
    </script>
    <div id="info-{{ $code_safejs }}" class="osm-info">
      <p class="osm-button-p"><i class="fa-solid fa-map osm-button" onclick="loadIframe_{{ $code_safejs }}();"></i></p>
      <p class="osm-info-p">
        {{ with print "Die Karte wird von [OpenStreetMap](https://www.openstreetmap.org) eingebettet." }}{{ $.Page.RenderString . }}{{ end }}
        <br>
        {{ with print "Es gelten die [Datenschutzerklaerungen von OpenStreetMap](https://wiki.osmfoundation.org/wiki/Privacy_Policy)." }}{{ $.Page.RenderString . }}{{ end }}
      </p>
    </div>
    <div id="image-{{ $code_safejs }}" class="osm-image" style="background:url('{{ .Site.BaseURL | absURL }}/iframe/osm/osm.png');">
    </div>
  </div>
</div>
<div id="small-{{ $code_safejs }}" class="osm-small"><i class="fa-solid fa-magnifying-glass-location"></i>
  {{ with print "[Karte auf OpenStreetMap ansehen](https://www.openstreetmap.org/?mlat=" $mlat "&amp;mlon=" $mlon "#map=" $z "/" $mlat "/" $mlon ")." }}{{ $.Page.RenderString . }}{{ end }}
</div>

Hierbei wird erstmal nur ein Bild mit Text im Vordergrund angezeigt. Erst bei Klick auf das Icon () in der Mitte wird die Karte in einem Iframe nachgeladen. Alternativ kann der Link unter dem <div> genutzt werden, der direkt auf die OpenStreetMap-Website verlinkt.

Den Code dafuer habe ich fuer youtube uebernommen aus einem Beitrag von Florian Meier (Code auf GitLab ) mit einigen Anpassungen und ihn dann fuer OpenStreetMap nochmals angepasst:

Der notwendige SCSS-Code layouts/assets/css/style.scss zum Stylen ist der hier:

.osm-wrapper {
        position: relative;
        width: 560px;
        height: 315px;
        margin: 10px auto;
        overflow: hidden;
}

.osm-container {
        height: 100%;
}

.osm-info {
        text-align: center;
        position: absolute;
        height: 100%;
        width: 100%;
        z-index: 10;
        background: rgba(255, 255, 255, 0.75);
        overflow: auto;
}

.osm-info-p {
        padding: 3px;
}

.osm-image {
        filter: blur(5px);
        height: 100%;
}

.osm-button-p {
        padding-top: 20%;
}

.osm-button {
        cursor: pointer;
        font-size: 60px;
        color: #000;
        opacity: 0.6;
}

.osm-small {
        font-size: 14px;
        margin-left: 75px;
        margin-top: -10px;
        padding-bottom: 10px;
}

.osm-button:hover {
        color: #89C261;
        opacity: 1;
}

Und hier noch das Bild, was im Hintergrund angezeigt wird (abgewandelt vom Original ):

OpenStreetMap Logo
OpenStreetMap Logo

Abgeleitet vom Embed-Code sieht mein Shortcode dann wie folgt aus:

{{< osm "-10.315990447998049%2C51.53373644524688%2C-10.150337219238283%2C51.62846065474101" "51.5810798959352%2C-10.233279168605804" "51.5811" "-10.2333" "13" >}}

Die Karte wird von OpenStreetMap eingebettet.
Es gelten die Datenschutzerklaerungen von OpenStreetMap .

Spotify #

Aehnlich zu osm, vimeo und youtube habe ich mir auch das einfach selbst gebastelt bzw. es erstmal nur versucht.

Denn das Problem war: Hugo konnte noch nicht mit POST-Requests umgehen. Zuerst hatte ich mir daher mit curl einen Access Token besorgt und den dann mit Hugo genutzt. Allerdings ist all das jetzt mit resources.GetRemote (seit Version 0.91.0 ) moeglich πŸŽ‰

Dank der kurzen Haltbarkeit des access_tokens von nur einer Stunde ist die lokale Entwicklung aber sehr nervig. Daher im Folgenden nur ein Proof of Concept .

Nach etwas Hilfe aus der Community hatte ich dann auch meinen Shortcode layouts/shortcodes/spotify.html zusammengebaut:

 layouts/shortcodes/spotify.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
{{/* spotify.html
  https://developer.spotify.com/documentation/web-api/reference/
  https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
  https://github.com/Flowm/spotify-api-bash/blob/master/create_playlist_from_artists_list.sh

  spotify album 1KAg47NePhjsKC4Y8ZC9z3
  spotify show 1O9vyJwNvUcdq1d9vFblQw 0
  spotify playlist 37i9dQZF1DXc51TI5dx7RC
  spotify track 2TpxZ7JUBn3uw46aR7qd6V
*/}}
{{/* - $code_safejs := "" - */}}
{{- $_time := "" -}}
{{- $market := "DE" -}}
{{- $type := index .Params 0 -}}
{{- $code := index .Params 1 -}}
{{- $time := index .Params 2 | default "" -}}
{{/* - if eq $type "show" - */}}
{{/* - $code_safejs := safeJS (md5 (printf "%s_%s_%s" $type $code $time)) - */}}
{{/* - $_time := printf "%s%s" "&t=" $time - */}}
{{/* - else - */}}
{{- $code_safejs := safeJS (md5 (printf "%s_%s" $type $code)) -}}
{{/* - end - */}}
{{- $base64 := base64Encode (printf "%s:%s" (getenv "HUGO_SECRET_SPOTIFY_ID") (getenv "HUGO_SECRET_SPOTIFY_SECRET")) -}}
{{- $base64_2 := printf "%s %s" "Basic" $base64 -}}
{{- $opts := dict
  "method" "post"
  "headers" (dict
    "Authorization" $base64_2
    "Content-Type" "application/x-www-form-urlencoded"
  )
  "body" "grant_type=client_credentials"
-}}
{{- $postResponse := resources.GetRemote "https://accounts.spotify.com/api/token" $opts | transform.Unmarshal -}}
{{- $headers := dict "Authorization" (printf "Bearer %s" $postResponse.access_token) -}}
{{- $json := getJSON "https://api.spotify.com/v1/" $type "s/" $code "?market=" $market $headers -}}
<div id="wrapper-{{ $code_safejs }}" class="spotify-wrapper">
  <div id="container-{{ $code_safejs }}" class="spotify-container">
    <script>
    function loadIframe_{{ $code_safejs }}() {
      var e1 = document.getElementById('info-{{ $code_safejs }}');
      e1.parentNode.removeChild(e1);
      var e2 = document.getElementById('image-{{ $code_safejs }}');
      e2.parentNode.removeChild(e2);
      document.getElementById('container-{{ $code_safejs }}').innerHTML = '<iframe src="https://open.spotify.com/embed/{{ $type }}/{{ $code }}{{ $_time }}" width="100%" height="380" frameBorder="0" allowfullscreen="" allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>';
    }
    </script>
    <div id="info-{{ $code_safejs }}" class="spotify-info">
      <p class="spotify-button-p"><i class="fa-brands fa-spotify spotify-button" onclick="loadIframe_{{ $code_safejs }}();"></i></p>
      <p class="spotify-info-p">
        {{ with print "[" $json.name "](" $json.external_urls.spotify ") wird von [Spotify](https://www.spotify.com) eingebettet." }}{{ $.Page.RenderString . }}{{ end }}
        <br>
        {{ with print "Es gelten die [Datenschutzerklaerungen von Spotify](https://www.spotify.com/de/legal/privacy-policy/)." }}{{ $.Page.RenderString . }}{{ end }}
      </p>
    </div>
    <div id="image-{{ $code_safejs }}" class="spotify-image" style="background:url('{{ .Site.BaseURL | absURL }}/iframe/spotify/{{ $code }}.webp');">
    </div>
  </div>
</div>

Um mit der Spotify-API zu reden, brauchen wir eine ID und ein Secret . Die koennen wir in Hugo mit Environment Variablen einbinden. Ich habe dafuer eine gute Loesung gefunden, diese Variablen abhaengig vom Ordner einzubinden, naemlich mit direnv πŸŽ‰

Nach der Installation und der Einbindung in die Shell , erstellen wir im aktuellen Hugo-Verzeichnis eine Datei .envrc:

export HUGO_SECRET_SPOTIFY_ID="012"
export HUGO_SECRET_SPOTIFY_SECRET="789"

Diese Datei erlauben wir jetzt mit direnv allow . und koennen danach Hugo starten:

hugo server -v -D

Nun werden die Variablen genutzt und die API kann angesprochen werden πŸŽ‰

Um jetzt noch die Hintergrundbilder runterladen zu koennen, habe ich folgendes Script in bin/sm.sh gebaut, was rg (ripgrep, einfach viel schneller als grep), youtube-dl (nur fuer Vimeo und YouTube) und convert (imagemagick ) benoetigt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/bin/bash

set -e -o pipefail

# Script to download thumbnails from spotify, vimeo or youtube

for exe in rg youtube-dl convert ; do
  command -v "$exe" >/dev/null 2>&1 || { echo >&2 "I require \"$exe\" but it's not installed. Aborting."; exit 1; }
done

function spotify() {
  # get access-token
  _id=$(env | grep HUGO_SECRET_SPOTIFY_ID | cut -d= -f2)
  _secret=$(env | grep HUGO_SECRET_SPOTIFY_SECRET | cut -d= -f2)
  _base64=$(echo -n "$_id:$_secret" | base64)
  _at=$(curl -s -X "POST" -H "Authorization: Basic $_base64" -d grant_type=client_credentials https://accounts.spotify.com/api/token | jq .access_token | tr -d '"')

  rg -I "\{\{< spotify" .. | sort | uniq | \
    while read -r _ _ type code _ ; do
      _code=$(echo "$code" | tr -d '"')
      _site="../static/iframe/spotify"
      if [[ ! -d $_site ]] ; then mkdir -p "$_site" ; fi
      dst="$_site/$_code"
      if [[ ! -e "$dst.webp" && ! -e "$dst.jpg" ]] ; then
        echo "spotify: $code"
        _img=$(curl -s -X "GET" "https://api.spotify.com/v1/${type}s/$code?market=DE" -H "Accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer $_at" | jq .images[].url | sed -n '1p' | tr -d '"')
        curl -s -o "../static/iframe/spotify/$code.jpg" "$_img"
        convert "$dst.*" -resize "560x560" "$dst.webp"
        rm "$dst.jpg"
      fi
    done
}

spotify

Ausserdem noch notwendig: Alle Vorkommnisse von osm- in spotify- im CSS in layouts/assets/css/style.scss umbenennen bzw. kopieren und die color im CSS in .spotify-button:hover durch #1db954 ersetzen/kopieren (um die genaue Farbe von Logos/Brands herauszufinden, kann z.B. BrandColors genutzt werden). Da die Hoehe sich von den anderen Einbettungen unterscheidet, habe ich noch folgendes hinzugefuegt:

.spotify-wrapper {
  height: 380px;
}

Insgesamt sieht der Shortcode dann wie folgt aus:

{{< spotify album 25r7pEf31viAbsoVHC6bQ4 >}}

Da wie oben beschrieben der access_token nur fuer eine Stunde gueltig ist, muesste ich lokal entweder jede Stunde Hugo neu starten oder den Parameter --ignoreCache=true anhaengen:

hugo server -v -D --ignoreCache=true

Da beides mega nervig ist, gibt es den Spotify Shortcode nur als Bilder πŸ˜‰

Spotify Einbindung mit Bild im Hintergrund
Spotify Einbindung mit Bild im Hintergrund
Spotify Einbindung nach Klick auf das Logo
Spotify Einbindung nach Klick auf das Logo

Twitter #

Ich glaube Twitter ist mit der Privacyeinstellung schon datenschutzfreundlich. Daher habe ich den Code fuer layouts/shortcodes/twitter auch groestenteils uebernommen aus dem Repo (API-Doku dazu):

 layouts/shortcodes/twitter.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{{- /*
  https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates/shortcodes/twitter_simple.html
  https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/get-statuses-oembed
*/ -}}
{{- $pc := .Page.Site.Config.Privacy.Twitter -}}
{{- $sc := .Page.Site.Config.Services.Twitter -}}
{{- if not $pc.Disable -}}
  {{- $msg1 := "The %q shortcode requires two named parameters: user and id. See %s" -}}
  {{- $msg2 := "The %q shortcode will soon require two named parameters: user and id. See %s" -}}
  {{- if .IsNamedParams -}}
    {{- $id := .Get "id" -}}
    {{- $user := .Get "user" -}}
    {{- if and $id $user -}}
      {{- template "render-simple-tweet" (dict "id" $id "user" $user "dnt" $pc.EnableDNT "disableInlineCSS" $sc.DisableInlineCSS "ctx" .) -}}
    {{- else -}}
      {{- errorf $msg1 .Name .Position -}}
    {{- end -}}
  {{- else -}}
    {{- $id := .Get 1 -}}
    {{- $user := .Get 0 -}}
    {{- if eq 1 (len .Params) -}}
      {{- $id = .Get 0 -}}
      {{- $user = "x" -}} {{/* This triggers a redirect. It works, but may not work forever. */}}
      {{- warnf $msg2 .Name .Position -}}
    {{- end -}}
    {{- template "render-simple-tweet" (dict "id" $id "user" $user "dnt" $pc.EnableDNT "disableInlineCSS" $sc.DisableInlineCSS "ctx" .) -}}
  {{- end -}}
{{- end -}}

{{- define "render-simple-tweet" -}}
  {{- $url := printf "https://twitter.com/%v/status/%v" .user .id -}}
  {{- $query := querify "url" $url "dnt" .dnt "omit_script" true -}}
  {{- $request := printf "https://publish.twitter.com/oembed?%s" $query -}}
  {{- $json := getJSON $request -}}
  {{ $json.html | safeHTML -}}
{{- end -}}

Im Prinzip habe ich lediglich das CSS ausgelagert und angepasst (siehe auch Fonts und CSS):

$code_top: 30px;

@mixin blockquote_before($color: #e6e6e6) {
    position: absolute;
    color: $color;
    z-index: -1;
}

blockquote {
    position: relative;
    // https://developer.twitter.com/en/docs/twitter-for-websites/embedded-tweets/guides/css-for-embedded-tweets
    &.twitter-tweet {
        &:before {
            font-family: "Font Awesome 5 Brands";
            content: '\f099';
            top: $code_top;
            left: $code_top*-1;
            font-size: 2.5em;
            @include blockquote_before(#1DA1F2);
            z-index: 0;
        }
        border-color: #eee #ddd #bbb;
        border-radius: 5px;
        border-style: solid;
        border-width: 1px;
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
        margin: 1em;
        padding: 0 16px 16px 16px;
    }
}

Die Nutzung sieht dann wie folgt aus:

{{< twitter user="yhaupenthal" id="733918560528007168" >}}

Vimeo #

Wie bei osm schon beschrieben, habe ich den Code fuer youtube auch fuer Vimeo verwendet, da mir auch hier der Standard-Shortcode nicht weit genug geht. Angepasst fuer Vimeo sieht layouts/shortcodes/vimeo.html so aus:

 layouts/shortcodes/vimeo.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{{- /*
  https://developer.vimeo.com/api/guides/start
  https://developer.vimeo.com/api/reference/videos
  https://github.com/gohugoio/hugo/blob/master/tpl/tplimpl/embedded/templates/shortcodes/vimeo.html
*/ -}}
{{- $code := index .Params 0 -}}
{{- $time := index .Params 1 | default "" -}}
{{- $code_safejs := safeJS (md5 (printf "%s_%s" $code $time)) -}}
{{- $json := getJSON "https://api.vimeo.com/videos/" $code "?access_token=" (getenv "HUGO_SECRET_VIMEO") -}}
<div id="wrapper-{{ $code_safejs }}" class="vimeo-wrapper">
  <div id="container-{{ $code_safejs }}" class="vimeo-container">
    <script>
    function loadIframe_{{ $code_safejs }}() {
      var e1 = document.getElementById('info-{{ $code_safejs }}');
      e1.parentNode.removeChild(e1);
      var e2 = document.getElementById('image-{{ $code_safejs }}');
      e2.parentNode.removeChild(e2);
      document.getElementById('container-{{ $code_safejs }}').innerHTML = '<iframe style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;" src="https://player.vimeo.com/video/{{ $code }}?autoplay=1&dnt=1#t={{ $time }}" width="560" height="315" frameborder="0" allow="autoplay; fullscreen" allowfullscreen title="{{ $json.name }}"></iframe>';
    }
    </script>
    <div id="info-{{ $code_safejs }}" class="vimeo-info">
      <p class="vimeo-button-p"><i class="fa-brands fa-vimeo vimeo-button" onclick="loadIframe_{{ $code_safejs }}();"></i></p>
      <p class="vimeo-info-p">
        {{ with print "Das Video [" $json.name "](" $json.link "#t=" $time ") wird von [Vimeo](https://vimeo.com) eingebettet." }}{{ $.Page.RenderString . }}{{ end }}
        <br>
        {{ with print "Es gelten die [Datenschutzerklaerungen von Vimeo](https://vimeo.com/de/features/video-privacy)." }}{{ $.Page.RenderString . }}{{ end }}
      </p>
    </div>
    <div id="image-{{ $code_safejs }}" class="vimeo-image" style="background:url('{{ .Site.BaseURL | absURL }}/iframe/vimeo/{{ $code }}.webp');">
    </div>
  </div>
</div>

Zudem noch notwendig: Alle Vorkommnisse von osm- in vimeo- im CSS in layouts/assets/css/style.scss umbenennen bzw. kopieren und die color im CSS in .vimeo-button:hover durch #1ab7ea ersetzen/kopieren.

Um mit der Vimeo-API zu reden, brauchen wir ein Secret . Das koennen wir in Hugo mit Environment Variablen einbinden. Hierzu kann die Loesung, die ich bei Spotify beschrieben habe ebenfalls verwendet werden. Die .envrc habe ich einfach mit der folgenden Zeile erweitert:

export HUGO_SECRET_VIMEO="abc"

Um jetzt noch die Hintergrundbilder runterladen zu koennen, habe ich das Script bin/sm.sh von Spotify erweitert:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# siehe bei Spotify

function sm() {
  site="$1"
  url="$2"
  rg -I "\{\{< $site" .. | sort | uniq | \
    while read -r _ _ code _ ; do
      _code=$(echo "$code" | tr -d '"')
      _site="../static/iframe/$site"
      if [[ ! -d $_site ]] ; then mkdir -p "$_site" ; fi
      dst="$_site/$_code"
      if [[ ! -e "$dst.webp" && ! -e "$dst.jpg" ]] ; then
        echo "$site: $code"
        youtube-dl --abort-on-error -w --no-warnings \
          --skip-download -o "../static/iframe/$site/%(id)s" \
          --write-thumbnail --playlist-items 1 \
          "$url$code"
        convert "$dst.*" -resize "560x560" "$dst.webp"
      fi
    done
}

sm "vimeo" "https://vimeo.com/"
sm "youtube" "https://www.youtube.com/watch?v="

Insgesamt sieht der Shortcode dann wie folgt aus:

{{< vimeo 22439234 >}}

Das Video The Mountain wird von Vimeo eingebettet.
Es gelten die Datenschutzerklaerungen von Vimeo .

Optional kann noch die Startzeit in Sekunden mit angegeben werden:

{{< vimeo 22439234 83 >}}

Das Video The Mountain wird von Vimeo eingebettet.
Es gelten die Datenschutzerklaerungen von Vimeo .

YouTube #

Im Prinzip wurde ja alles schon weiter oben beschrieben. Der Standard-Shortcode gefaellt mir nicht , daher habe ich selbst was gebastelt in layouts/shortcodes/youtube.html, mit den hier folgenden Influenzen , wobei ich mich mehr an dem Beitrag von Florian Meier (siehe osm) orientiert habe:

 layouts/shortcodes/youtube.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{{- /*
  https://www.flomei.de/blog/2019/01/04/youtube-datenschutzkonform-einbinden/
  https://developers.google.com/youtube/v3/docs/videos
  https://console.developers.google.com/apis/credentials
*/ -}}
{{- $code := index .Params 0 -}}
{{- $time := index .Params 1 | default "" -}}
{{- $code_safejs := safeJS (md5 (printf "%s_%s" $code $time)) -}}
{{- $json := getJSON "https://www.googleapis.com/youtube/v3/videos?part=snippet&id=" $code "&key=" (getenv "HUGO_SECRET_YOUTUBE") -}}
{{- range $json.items -}}
<div id="wrapper-{{ $code_safejs }}" class="youtube-wrapper">
  <div id="container-{{ $code_safejs }}" class="youtube-container">
    <script>
    function loadIframe_{{ $code_safejs }}() {
      var e1 = document.getElementById('info-{{ $code_safejs }}');
      e1.parentNode.removeChild(e1);
      var e2 = document.getElementById('image-{{ $code_safejs }}');
      e2.parentNode.removeChild(e2);
      document.getElementById('container-{{ $code_safejs }}').innerHTML = '<iframe style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;" src="https://www.youtube-nocookie.com/embed/{{ $code }}?autoplay=1&start={{ $time }}" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen title="{{ .snippet.title }}"></iframe>';
    }
    </script>
    <div id="info-{{ $code_safejs }}" class="youtube-info">
      <p class="youtube-button-p"><i class="fa-brands fa-youtube youtube-button" onclick="loadIframe_{{ $code_safejs }}();"></i></p>
      <p class="youtube-info-p">
        {{ with print "Das Video [" .snippet.title "](https://www.youtube.com/watch?v=" .id "&t=" $time ") wird von [YouTube](https://www.youtube.com) eingebettet." }}{{ $.Page.RenderString . }}{{ end }}
        <br>
        {{ with print "Es gelten die [Datenschutzerklaerungen von Google](https://www.google.de/intl/de/policies/privacy/)." }}{{ $.Page.RenderString . }}{{ end }}
      </p>
    </div>
    <div id="image-{{ $code_safejs }}" class="youtube-image" style="background:url('{{ .Site.BaseURL | absURL }}/iframe/youtube/{{ $code }}.webp');">
    </div>
  </div>
</div>
{{- end -}}

Zudem noch notwendig: Alle Vorkommnisse von osm- in youtube- im CSS in layouts/assets/css/style.scss umbenennen bzw. kopieren und die color im CSS in .youtube-button:hover durch #ff0000 ersetzen/kopieren.

Um mit der YouTube-API zu reden, brauchen wir ein Secret . Auch dieses Mal koennen wir mit Environment Variablen und direnv (siehe vimeo) das Secret mit Hugo nutzen und erweitern die .envrc um folgende Zeile:

export HUGO_SECRET_YOUTUBE="xyz"

Und ebenfalls wie bei vimeo kann auch das Shellscript zum Runterladen der Hintergruende genutzt werden. Das waere noch was fuer meine TODO-Liste, dass das via Pipeline passieren kann (der Durchlauf wuerde dann aber laenger dauern).

Und so sieht dann der Shortcode aus:

{{< youtube beTqiiV5zhI >}}

Das Video Kinicles wird von YouTube eingebettet.
Es gelten die Datenschutzerklaerungen von Google .

Optional kann noch die Startzeit in Sekunden mit angegeben werden:

{{< youtube beTqiiV5zhI 83 >}}

Das Video Kinicles wird von YouTube eingebettet.
Es gelten die Datenschutzerklaerungen von Google .

Mehrsprachigkeit / i18n #

Mehrsprachigkeit ist mit Hugo recht leicht moeglich . Eine Loesung fuer einzelne Artikel/Seiten etc. wird bei Fryboyter beschrieben . Noch bin ich unsicher, ob ich das ueberhaupt will, daher lasse ich das erstmal offen.
Zu ueberlegen waere dann noch, wie die URL aufgebaut sein soll, z.B. /en/artikel/ oder /artikel-en/ oder /artikel/en/ oder …

Structured Data / Schema.org #

Weil ich meine Rezepte bei Tandoor importieren wollte, ist mir aufgefallen, dass es dafuer eine Loesung namens structured data gibt, damit das auch einfach gelingt und schoen aussieht.

Also habe ich mal mit ein wenig Hilfe meine Rezepte umgebaut, sodass sie auch valides structured data ausspucken. Die Kategorien usw. habe ich mir ein bisschen bei der Rezepteingabe bei Chefkoch abgeguckt. Weitere Quellen:

Testen laesst sich das dann mit dem Test fuer Rich-Suchergebnisse .
Bei der weiteren Beschaeftigung damit, habe ich es mal zumindest noch fuer die Startseite eingebaut. Weitere Seiten werden vermutlich folgen.

Die Einbindung ist recht simpel und habe ich mir in diesem hervorragenden Repository etwas abgeguckt. Ich habe einfach in meiner _default/baseof.html folgendes vor dem schliessenden </head> hinzugefuegt:

  {{ partial "schema/schema.html" . }}
</head>

Das Partial layouts/partials/schema/schema.html sieht dann so aus:

 layouts/partials/schema/schema.html

1
2
3
4
5
6
7
{{ if .IsHome -}}
  {{ partial "schema/schema_website.html" . }}
{{- else if .IsPage -}}
  {{ if eq .Section "recipes" }}
    {{ partial "schema/schema_recipe.html" . }}
  {{ end }}
{{ end }}

Das Schema fuer Website sieht dann z.B. so aus:

{{ $author :=  or (.Params.author) (.Site.Author.name) }}
{{ $description := .Site.Params.description }}
<script type="application/ld+json">
{
    "@context": "http://schema.org",
    "@type": "WebSite",
    "name": "{{ .Site.Title }}",
    "url": {{ .Site.BaseURL }},
    "description": "{{ $description }}",
    "thumbnailUrl": {{ .Site.Params.logo | absURL }},
    "license": "{{ .Site.Params.copyright }}"
}
</script>

Hier noch weitere Quellen:

Neue Domain #

Im Laufe der Jahre fielen mir immer wieder neue und vor allem kuerzere Domains ein. Schliesslich habe ich mich fuer uxg.ch entschieden.

Warum? Auf vielen Geraeten lautet mein Username seit langem benjo, was (abgeleitet von der Caesar-Verschluesselung ) ein ROT1 von admin ist. Da es keine TLD .jo gibt, habe ich einfach mal durchrotiert und bin bei “ROT20” haengen geblieben. Die Domain war verfuegbar, ist kurz (gerade im Vergleich zu yhaupenthal.org), unterstuetzt DNSSEC und war nicht mega teuer, also habe ich sie gekauft πŸŽ‰

Workflow #

So, und wie funktioniert nun das alles im Zusammenspiel?

GitHub Action #

Zuerst legen wir einen GitHub Workflow an (im Repo in .github/workflows/test_build_deploy.yml):

 .github/workflows/test_build_deploy.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
---
name: test_build_deploy
on: push  # yamllint disable-line rule:truthy
jobs:
  markdownlint:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v3
      - name: Run markdownlint-cli
        uses: nosborn/github-action-markdown-cli@v3.1.0
        with:
          config_file: .markdownlintrc
          files: .
  shellcheck:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v3
      - name: Run shellcheck
        uses: azohra/shell-linter@v0.6.0
  build_and_deploy:
    if: github.ref == 'refs/heads/main'
    needs: [markdownlint, shellcheck]
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v3
        with:
          # Fetch all history for .GitInfo and .Lastmod
          fetch-depth: 0
      - name: Install node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      - name: Install hugo
        uses: peaceiris/actions-hugo@v2.5.0
        with:
          hugo-version: 'latest'
          extended: true
      - name: Install npm dependencies
        run: npm i
      - name: Build
        env:
          HUGO_SECRET_YOUTUBE: ${{ secrets.HUGO_SECRET_YOUTUBE }}
          HUGO_SECRET_VIMEO: ${{ secrets.HUGO_SECRET_VIMEO }}
        run: hugo -v --minify --panicOnWarning
      - name: Run Pagefind
        run: npx -y pagefind --verbose --source ./public
      - name: Deploy
        uses: AEnterprise/rsync-deploy@1.0.1
        env:
          DEPLOY_KEY: ${{ secrets.SERVER_SSH_KEY }}
          ARGS: "-at --quiet --delete --delete-delay --delay-updates --exclude=_"
          SERVER_PORT: ${{ secrets.SERVER_PORT }}
          FOLDER: "public/"
          SERVER_IP: ${{ secrets.SERVER_IP }}
          USERNAME: ${{ secrets.SERVER_USERNAME }}
          SERVER_DESTINATION: ${{ secrets.SERVER_DESTINATION }}

Im Repo selbst muessen wir nun noch Secrets anlegen , die die entsprechenden Daten enthalten. Also z.B. das Secret fuer die YouTube API oder die SSH-Daten zum Server.

Bei jedem Push werden erst 2 Testjobs ausgefuehrt und bei erfolgreichem Status der 3. Job:

  1. markdownlint

    Fortlaufend den Code mit Lintern etc. zu ueberpruefen sollte eigtl. Standard sein. Ich nutze in neovim dazu ALE , was Support fuer Markdown via markdownlint mitbringt.

    Meine .markdownlintrc sieht so aus:

    {
      "comment": "https://github.com/DavidAnson/markdownlint / https://github.com/noqqe/noqqe.de/blob/master/.markdownlintrc",
    
      "MD013": false,
      "MD026": true,
      "MD033": true
    }
    
  2. shellcheck

    Auch meine Shellscripts will ich fortlaufend ueberpruefen und nutze hierzu wieder ALE mit dem Tool shellcheck .

  3. Build & Deploy

    Waren beide vorherigen Jobs erfolgreich, folgt der dritte. Dieser checkt erstmal das Repo aus (mit History fuer Git Info Variablen ), installiert dann node.js , Hugo und die npm-Pakete , die in der packages.json angegeben sind. Schliesslich wird unsere Website mit Minifizierung von Hugo gebaut und in einem weiteren Schritt auf unseren Webserver hochgeladen. Auf entsprechende Vorkehrungen auf dem Server gehe ich jetzt nicht ein, aber ein Artikel dazu (siehe Hosting) ist in Arbeit. Ebenso das Ersetzen von npm durch Hugo Modules .

  4. Profit πŸŽ‰

GitLab CI #

Als Alternative hier die Vorgehensweise mit der GitLab CI (trotz Repo bei GitHub ). Wie auch bei GitHub muessen wir unsere Secrets im Repo hinterlegen .

Ansonsten funktioniert die ganze Magie ueber die .gitlab-ci.yml, was im Prinzip die gleichen Steps durchlaeuft wie die Action bei GitHub (nur mit evtl. alten Paketen, weil ich die Docker Images schon laenger nicht mehr aktualisiert habe):

 .gitlab-ci.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
---
stages:
  - test
  - build
  - deploy

test:markdownlint:
  image: benjo2342/markdownlint-cli
  stage: test
  script:
    - "find . -name '*.md' | grep -v '^\\.\\/themes' | xargs markdownlint"
  variables:
    GIT_LFS_SKIP_SMUDGE: "1"

test:shellcheck:
  image: koalaman/shellcheck-alpine
  stage: test
  script:
    - "find . -name '*.sh' -print0 | xargs -0 shellcheck"
  variables:
    GIT_LFS_SKIP_SMUDGE: "1"

build:hugo:
  image: benjo2342/hugo
  stage: build
  script:
    - "apk --no-cache add npm~=12.16"
    - "npm i"
    - "hugo --minify"
  variables:
    GIT_SUBMODULE_STRATEGY: normal
  artifacts:
    paths:
      - public
  only:
    - master

deploy:live:
  variables:
    GIT_STRATEGY: none
  image: benjo2342/deploy
  stage: deploy
  script:
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - >/dev/null
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" >~/.ssh/config
    - rsync -at --quiet --delete --delete-delay --delay-updates --exclude=_ -e "ssh -p $SSH_LIVE_PORT" public/ "${SSH_LIVE_USER_HOST_LOCATION}"
  only:
    - main
  dependencies:
    - build:hugo

Lokal #

Dank Archetypes und Frontmatter legen wir anschliessend eine Datei namens archetypes/block.md an:

 archetypes/block.md

1
2
3
4
5
6
7
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags:
- default
---

Es reicht dann folgender Befehl, um eine Datei anzulegen:

$ hugo new block/beispiel.md
/path/to/website/content/block/beispiel.md created

Diese Datei koennen wir nun oeffnen und beliebig aendern. Die Datei wird anschliessend zum Git Index hinzugefuegt (git add content/block/beispiel.md), commited (git commit -va) und die Aenderungen gepusht (git push origin main).

Der Push loest dann auf GitHub die oben beschriebene Action aus und wir sollten nach kurzer Zeit eine neue Version auf der Website sehen πŸŽ‰

Die ganzen technischen Dinge im Hintergrund (also Server an sich, ssh, nginx usw.) versuche ich noch in einem anderen Beitrag zu beschreiben.

Und … das wars! Ueber 3 Jahre Arbeit, um meine Website auf einen neuen technischen Stand zu heben πŸ™ˆ πŸ˜… πŸŽ‰

Influenzen / Nuetzliches #


  1. Laeuft πŸŽ‰ ↩︎


 Buecher 2018

Beitraginfos

 2022-02-18, 17:30:55
 2022-08-06, 19:28:13
 Yannic
 dsgvo, hugo, linux, static, update, website
 Permalink

Γ„hnliche BeitrΓ€ge