Für das Jahr 2025 habe ich mir vorgenommen in meinem privaten Blog mehr Inhalte zu teilen. Um mir das Leben etwas einfacher zu machen habe ich mich entschlossen von Hugo als statischem Seitengenerator wieder weg zu gehen. Es lag nicht daran, dass ich mit Hugo per se unzufrieden gewesen wäre. Es lag viel mehr daran, dass ich wieder etwas kürzere Wege beim publizieren haben wollte. Vor Hugo hatte ich eine Eigenentwicklung und danach WordPress im Einsatz.
Als Basis habe ich jetzt Statamic verwendet, über das ich hier jetzt ein wenig berichten möchte.
Warum habe ich Statamic ausgewählt?
Ein Grund war, dass ich sowieso vor hatte mich mit Statamic zu beschäftigen. Es ist eine interessante und kostengünstige Alternative zu Headless Content Management Systemen. Ein Headless CMS war eigentlich nicht in meinem Fokus. Die jetzt hier veröffentlichte Seite ist auch ein Monolith. Daher war Headless nicht der Grund. Allerdings könnte es sein, dass ich doch irgendwann Headless Funktionen brauche. Statamic bietet hier (in der kostenpflichtigen Version) eine GraphQL und eine REST Schnittstelle an.
Leider sind die offiziellen Schnittstellen nur "read only". Ich möchte aber gerade über andere Tools mit meiner Webseite per API Artikel als Draft automatisch anlegen können. Daher musste ich hier eine andere Lösung in Statamic suchen.
Statamic selbst basiert auf dem Laravel Framework. Laravel ist in der PHP Welt inzwischen unangefochten das beliebteste Framework. Daher überrascht es auch nicht, dass es hier schon fertige Lösungen gibt. In meinem Fall bin ich mit dem Private REST API Addon fündig geworden. Das Addon stellt private Routen mit CRUD Operationen bereit, über die die einzelnen Bestandteile von Statamic (Collections, Taxonomien, Navigation, ...) gepflegt werden können. Das ist eine tolle Sache.
Statamic ist "Flat First"
Statamic selbst versucht ein Mitbewerber zu WordPress zu sein. Daher wundert es nicht, dass man inzwischen auch eine Möglichkeit hat WordPress (Gutenberg) Inhalte direkt in Statamic zu importieren. Ich hatte allerdings bereits Markdown Inhalte mit einem wenig spezial Syntax für Hugo.
Statamic unterstützt hervorragend Markdown. Daher konnte der Inhalte sehr schnell übernommen werden.
Statamic setzt für die Datenhaltung auf einfache Dateien. Es gibt in der Standard-Installation keine klassische MySQL Datenbank. Daher ist das Hosting auch sehr einfach. Das klingt erstmal als ob es langsam wäre. Das ist es aber überhaupt nicht. Statamic kommt mit einem ausgefuchsten System, dem sogenannten Stache. Da auch Daten gefiltert werden müssen, unterstütz Stache auch Indizes für einzelne Felder.
Im Gegensatz zu WordPress, kommt Statamic nicht mit einem vordefinierten Konzept wie die Blog-Posts und Seiten strukturiert sein sollen. Man muss also erst selbst seine Datenstruktur definieren.
Über Blueprints definiert man seine Daten und kann aus 40 verschiedenen Feld-Typen, die auch über Fieldsets wiederverwendet werden können seine Collections (Liste von Einträgen).
Ein interessanter Feld-Typ ist Bard. Dieser Typ erlaubt das erstellen eine Page-Builder Elements, dass komplett selbst seine Inhaltselemente definieren kann. Bard ist dann ein Block-Editor, der sehr einfach zu konfigurieren ist. Dieser soll mit WordPress Gutenberg konkurrieren. Der Aufwand ein Element für Bard zu erstellen ist aus meiner Sich aber wesentlich geringer. Hier werden ich mich als nächstes mal dranwagen. Im ersten Schritt habe ich erstmal meine Markdown Inhalte kopiert. Kopier im wörtlichen Sinne. Einfach die .md Dateien von Hugo in das "content" Verzeichnis der angelegten Collection kopiert und per Suchen und Ersetzen Attribute im YAML Frontmatter ersetzt. Das war es. Danach waren alle Blog-Posts von mir migriert.
Die spezielle Hugo Syntax habe ich dann manuell ersetzt.
KI darf nicht fehlen
Weil heute überall KI drinsteckt...
Bisher habe ich wenig auf KI im Blog gesetzt. Was nicht viel Spaß macht, ist die pflege von ALT Attributen.
Hierzu habe ich das kleine AI Alt Text Addon installiert, dass diese Aufgabe an die OpenAI API delegiert.
Die "AI Addon Liste" wird aber auf Dauer sicherlich länger werden (Siehe Addon Marktplatz: AI Kategorie).
Technische Details der Umsetzung
Laravel Guard für API Authentifizierung
Leider ist die Authentifizierung zur privaten API nicht geregelt und man muss sich selbst um die Absicherung kümmern. Hier habe ich mich dann entschieden einen eigenen Guard für Laravel zu entwickeln. Der Guard liest ein API Token Attribut, was bei meinem einem Benutzer gepflegt werden kann. Der API Token kann dann als Bearer Token zur Authentifizierung gesendet werden. Problem gelöst.
Falls jemand auch so einen Guard braucht... Hier ist der Code:
<?php
declare(strict_types=1);
namespace App\Guards;
use Illuminate\Auth\TokenGuard;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Statamic\Auth\UserProvider;
use Statamic\Facades\User;
class SimpleBearerTokenGuard extends TokenGuard
{
const USER_ALL_CACHE_TTL = 60;
protected $request;
/**
* @var UserProvider
*/
protected $provider;
protected $user;
public function check()
{
return $this->user() !== null;
}
public function guest()
{
return !$this->check();
}
public function user()
{
if ($this->user) {
return $this->user;
}
$token = $this->request->header('Authorization');
if ($token && str_starts_with($token, 'Bearer ')) {
$token = substr($token, 7); // Remove "Bearer " prefix
$this->user = $this->findUserByAuthToken($token);
}
return $this->user;
}
public function validate(array $credentials = [])
{
// Validation logic can be added here if needed
return false;
}
private function findUserByAuthToken($token) {
// Cache users for a day to avoid loading files repeatedly.
$users = Cache::remember('statamic_users', self::USER_ALL_CACHE_TTL, function () {
return User::all();
});
foreach ($users as $user) {
$hashedToken = $user->get('api_auth_token');
if ($hashedToken && Hash::check($token, $hashedToken)) {
return $user; // Return the matched user
}
}
return null;
}
}
Das API Token Text-Feld des Benutzers habe ich über das Statamic Backend angelegt und als Option "Passwort" gewählt damit dieses nicht direkt angezeigt wird. Statamic hashed das Passwort allerdings nicht direkt. Hier habe ich dann einen Listener erstellt, der auf das UserSaving
Event lauscht.
<?php
namespace App\Listeners;
use Illuminate\Support\Facades\Hash;
use Statamic\Events\UserSaving;
class HashAuthToken
{
protected static $processing = false;
/**
* Handle the event.
*/
public function handle(UserSaving $event): void
{
$user = $event->user;
if ($user->api_auth_token && Hash::needsRehash($user->api_auth_token)) {
$user->set('api_auth_token', Hash::make($user->api_auth_token));
}
}
}
Die Einbindung erfolgt dann über einen EventServiceProvider.
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Listeners\HashAuthToken;
use Illuminate\Support\ServiceProvider;
use Statamic\Events\UserSaving;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
UserSaving::class => [
HashAuthToken::class,
],
];
}
Markdown Rendering
Meine Blog-Posts (wie auch der aktuelle) enthalten eine Menge Code Blöcke. Hier möchte ich, dass der Leser den Code mit Syntax Highlighting leichter verstehen kann.
Um Syntax Highlighting in Statamic Markdown Code-Blöcken zu bekommen, muss das Rendering angepasst werden. Eine Lösung ist es, dass man im \App\Providers\AppServiceProvider
der Applikation eine Markdown Extension registriert.
use use App\Markdown\SyntaxHighlightExtension;
// ...
public function boot(UrlGenerator $url): void
{
Markdown::addExtension(function() {
return [
new SyntaxHighlightExtension(),
];
});
}
Statamic nutzt zum Render von Markdown die Library League\CommonMark, die es erlaubt in das Rendering einzugreifen. hier kann man dann eine eigene Extension registrieren, die dann die Code-Blöcke (FencedCode) und eingerückten Code (IndentedCode) in einem Highlighter gibt.
Zum Highlighten selbst habe ich dann Spatie\CommonMarkHighlighter installiert. Die Installation erfolgt einfach per Composer.
<?php
declare(strict_types=1);
namespace App\Markdown;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
use League\CommonMark\Extension\CommonMark\Node\Block\IndentedCode;
use League\CommonMark\Extension\ExtensionInterface;
use Spatie\CommonMarkHighlighter\FencedCodeRenderer;
use Spatie\CommonMarkHighlighter\IndentedCodeRenderer;
class SyntaxHighlightExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addRenderer(FencedCode::class, new FencedCodeRenderer(), 1000);
$environment->addRenderer(IndentedCode::class, new IndentedCodeRenderer(), 1000);
}
}
Datenschutzkonforme Anzeige von externen Inhalten
Gelegentlich nutzt ich externe Tools um Inhalte einzubetten. Das sind in der Regel Inhalte wie Posts aus Social Media (X, Bluesky, Mastodon), ein Video von Vimeo oder Youtube, oder auch Slides von Slideshare oder Speaker Deck sein. Auch Terminal-Demos mit asciinema habe ich gelegentlich eingebunden.
Meist werden die Inhalte über ein iframe
Tag eingebunden. Das ist einfach, allerdings verliert man hier schnell die Kontrolle über die Inhalte und es kann sein, dass die eine oder andere Integration dann z.B. einen Cookie nutzt.
Ich habe micht dazu entschieden bei jedem externen Inhalt, der angezeigt werden soll, den Benutzer entscheiden zu lassen. Eine Lösung hierfür bietet das kostenpflichtige Addon Cookie Byte.
Es erlaubt Content Cover zu pflegen, die man dann in seinem Template um die Einbettung legen kann.
Zuerst muss man aber die Einbettung von Inhalten in Statamic lösen. Hierzu habe ich eine weitere Markdown Extension über App\Providers\AppServiceProvider
hinzugefügt.
use League\CommonMark\Extension\Embed\EmbedExtension;
// ...
public function boot(UrlGenerator $url): void
{
Markdown::addExtension(function() {
return [
new SyntaxHighlightExtension(),
new EmbedExtension(), // <-- Dient der Einbettung von Inhalten
];
});
}
Die Extension nutzt im Standard das Composer Paket embed/embed. Das Paket ist sehr beliebt. Allerdings möchte der Entwickler das Paket auf Dauer nicht mehr pflegen. Die EmbedExtension erlaubt es den Adapter, der das Embedding regelt zu tauschen (über die Konfiguration in config/statamic/markdown.php
). Es muss nur ein einfaches Interface implementiert werden.
Ich habe mich dazu entschieden hier einen eigenen Adapter zu entwickeln, der dann das Embera\Embera
Paket nutzt. Embera bringt über 150 Embedding Provider mit, die man aber nicht alle benötigt. Es ist möglich nur die Provider zu registrieren, die man braucht. Das beschleunigt dann das Rendering der Seite.
Im meinem EmberaEmbedAdapter
habe ich dann die Content Cover über ein Antlers (Template Engine von Statamic) Tag, was vom Cookie Addon bereitgestellt wurde eingefügt.
Auszug aus dem Adapter Code:
// ...
$embeds[$i]->setEmbedCode(
(string) Antlers::parse(
'{{ cookie_cover handle="' . $cookieCoverHandle . '" }}'
. $embedCode
. '{{ /cookie_cover }}'
)
);
Die komplette Implementierung meines
EmberaEmbedAdapter
zu erklären, würde Blog-Post hier sprengen. Deswegen nur so viel bisher.
Das Cookie Plugin belendet dem Benutzt beim Aufruf der Webseite dann ein entsprechendes Consent Banner ein. Werden keine Drittanbieter Cookies erlaubt, dann zeigt das Tag cookie_cover
anstatt des iframe
oder des Javascript der Einbettung dann einen Container mit einem Hintergrund-Bild, Text und einem Button zum nachträglichen aktivieren der Cookies an. Wird der Button gedrückt, dann wird die Seite mit der Einbettung geleaden.
Auf andere externe Inhalte wie Fonts habe ich verzichtet.
Frontend UI mit Tailwind CSS, Alpine.js und Maria
Da ich keine Experte im Bereich Frontend Styling bin, aber die Seite schon für die mobile Nutzung optimiert sein soll, habe ich mir ein paar Tipps von meiner Kollegin Maria Kern eingeholt.
Für das Frontend setze ich auf Tailwind CSS und ein wenig Alpine.js. Das kennt der eine oder Leser aus der Magento Welt schon vom bekannten Hyvä.
Dank Tailwind UI und Maria konnte ich das Frontend einigermaßen hübsch und vor allem für die Leser möglichst barrierefrei umsetzen.
Die Integration von JS und CSS erfolgt in Statamic mit Vite, was den Standard für Laravel Anwedungen übernimmt.
Für Benutzer die nicht alles selbst machen wollen gibt es so etwas ähnliches wie einen Theme. In Statamic nett sich dies Starter Kit. Starter Kits stellen in der Regel die Blueprints für Collections und die passenden Templates und Konfigurationen bereit. Das Konzept geht also etwas weiter als man das von WordPress gewohnt ist.
Für mein Blog habe ich auf ein Starter Kit verzichtet, da ich alles von Grund auf kennenlernen wollte.
Es ist noch nicht alles optimal. So muss ich auf jeden Fall noch an die Optimierung der Bilder ran, die noch viel zu groß sind was der Pagespeed Test zeigt.
Entwickler Setup mit ddev
Für das Entwickler Setup nutze ich ddev. Da ddev Laravel Setups mit einem eigenen Projekt-Typ inzwischen direkt unterstützt, ist dies kein Hexenwerk und im Handumdrehen aufgesetzt.
Der in der ddev Welt gut bekannte Matthias Andrach hat in einem Blog Beitrag ein solches Setup gut erklärt.
Matthias hat zudem ein nettes ddev Add-On für please
(CLI Tool von Statamic) entwickelt, was ich bei mir im Setup installiert habe.
Da man lokal keine Datenbank benötigt, habe ich die Erstrellung des DB-Container über ddev config --omit-containers=db
deaktiviert.
Als Entwicklungsumgebung nutze ich IntelliJ Ultimate. Dort habe ich das Plugin Antlers Language Support installiert.
Betrieb von Statamic in Docker
Da ich auf meinem Hetzner Server schon lange auf Docker und Traefik (als Reverse Proxy) setze, habe ich hier einfach eine Projekt auf Basis von Docker Compose erstellt.
Hier seht ihr meine Konfiguration ohne Zensur :-)
Da es keine Datenbank gibt, brauche ich auch nichts zensieren.
services:
statamic:
image: shinsenter/statamic
container_name: statamic
restart: always
volumes:
- ./statamic:/var/www/html
labels:
- "traefik.enable=true"
- "traefik.http.routers.muench_dev_unsecure.rule=Host(`muench.dev`) || Host(`www.muench.dev`)"
- "traefik.http.routers.muench_dev_unsecure.entrypoints=http"
- "traefik.http.routers.muench_dev_unsecure.middlewares=redirect-to-https,redirect-to-non-www,secure-headers@file"
- "traefik.http.routers.muench_dev_secure.tls=true"
- "traefik.http.routers.muench_dev_secure.rule=Host(`muench.dev`) || Host(`www.muench.dev`)"
- "traefik.http.routers.muench_dev_secure.tls.certresolver=letsencrypt"
- "traefik.http.routers.muench_dev_secure.entrypoints=https"
- "traefik.http.routers.muench_dev_secure.middlewares=redirect-to-non-www,secure-headers@file"
- "traefik.http.services.muench_dev.loadbalancer.server.port=80"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"
- "traefik.http.middlewares.redirect-to-non-www.redirectregex.regex=^https?://www.muench.dev/(.*)"
- "traefik.http.middlewares.redirect-to-non-www.redirectregex.replacement=https://muench.dev/$${1}"
- "traefik.http.middlewares.redirect-to-non-www.redirectregex.permanent=true"
environment:
- HTTPS=on
- ENABLE_CRONTAB=1
- CRONTAB_SETTINGS=* * * * * php artisan schedule:run >> /dev/null 2>&1
depends_on:
- redis
networks:
- web
redis:
container_name: statamic_redis
image: redis:7-alpine
restart: unless-stopped
networks:
- web
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 10
volumes:
redis-data:
networks:
web:
external: true
Die Labels werden von Traefik ausgelesen. Der Traefik Server wird mit einer Middleware konfiguriert, die einen Redirect von HTTP zu HTTPS durchführen kann.
Der Traefik Server setzt auch ein paar Secure Header für den Browser. Zudem übernimmt der Reverse Proxy das aktualisieren von LetsEncrypt Zertifikaten.
Redis ist bei Statamic optional. Ich habe das Caching von Dateien auf Redis umgestellt.
Sollte ich irgendwann zu viele Daten haben und der Statamic Stache wird zu langsam (> 30.000 Blog Posts), dann kann man diesen auch auf eine Datenbank legen. Hier bietet Statamic einiges an Optionen.
Laravel bietet die Möglichkeit Jobs über eine Queue zu steuern. Auch das kann in Redis abglegt werden.
Zum Betrieb von Statamic habe ich das fertige Docker Image shinsenter/statamic verwendet, was für die Produktivbetrieb optimiert ist. Hier muss man nur beachten, dass man die Crontan und die Cronjobs korrekt aktiviert und registriert (Siehe ENV Variablen).
Wie geht es weiter?
Eine einfache Idee ist in viel Arbeit ausgeartet. So manche Nacht in den letzten Wochen habe ich mit der Portierung und in die Optimierung meiner Webseite gesteckt.
Die Inhalte selbst waren nicht der Zeitfresser. Es waren das Ausprobieren von Konzepten. Das Lernen einer neuen Template Syntax. Die SEO Optimierung. Das Coden von fehlenden Features.
Die Frontend-Umsetzung mit meinen veraltetem CSS Verständnis.
Das Deployment auf das Zielsystem. Und und und...
Ich hoffe euch gefällt es auch in Zukunft, wenn ich weitere Details meiner privaten Systemlandschaft teile.
Update 26.01.2025
Die Cover Images sind nun auf responsiv umgestellt und sie werden auch im webp Format ausgeliefert.
Der Pagespeed Score ist nun deutlich nach oben gesprungen.
Das ging in Statamic recht einfach über das Responsive Images Addon.
Zum konvertieren des Inhalts (nach der Umstellung auf das Responsive Image Field) habe ich folgendes Kommando ausgeführt.
Link zum Github Gist: https://gist.github.com/cmuench/31dd3b15ff2b9ac15c3a16012b09e2ab