Was diese Seite antreibt
Diese Seite ist komplett selbstgebaut: der Blog, das Portfolio und die Admin-Oberfläche, in der ich das hier gerade tippe. Ich habe kein fertiges CMS installiert und ein Theme angepasst, sondern selbst ein kleines CMS geschrieben. Für eine private Website ist das ein absurder Aufwand, und ob er sich gelohnt hat, mache ich im nächsten Beitrag mit mir selbst aus. Hier geht es erst mal nur um die Führung durchs Haus. Ein paar Sachen sind richtig gut geworden. Ein, zwei andere hätten mir fast das Wochenende geraubt.
Das Fundament
Symfony, modernes PHP, Twig und LiveComponents. Die Assets laufen über AssetMapper, dadurch brauche ich beim Deployment unter anderem keinen Node-Build-Schritt. Das Projekt ist bewusst „langweilig“ gehalten. Langweilig ist billig im Unterhalt und spart meine Nerven für die Teile, die wirklich meine eigenen sind und erlaubt es mir neue Ideen oder Features einfacher auszuprobieren.
Ein Beitrag besteht aus… Blöcken?
Ich wollte nicht, dass ein Beitrag ein einziger großer HTML-Klumpen ist. Das geht so lange gut, bis man Dinge umsortieren will, denselben Inhalt an zwei Stellen ausgeben möchte oder verhindern muss, dass ein einzelnes vergessenes Tag die halbe Seite mitreißt. Deshalb ist ein Beitrag hier eine Liste aus kleinen, typisierten Teilen, die entsprechenden Blöcke. Dieser Absatz ist ein Text-Block, die Überschrift darüber ein Heading-Block. Dazu kommen Code-Blöcke, Callouts, Zitate, Bilder und ein Divider, dessen einzige Aufgabe es ist, Beiträge etwas visuell aufzuteilen.
Jeder Block registriert sich selbst. Ich pflege keine Liste aller Block-Typen, weil es diese Liste schlicht nicht gibt. Das Interface trägt ein Tag, und Symfony sammelt automatisch alles ein, was dieses Tag hat:
#[AutoconfigureTag('app.content_block_type')]
interface BlockTypeInterface
{
public function createDefaults(): array;
public function normalize(array $data): array;
// …
}
Jeder Block legt seinen eigenen Namen über ein Attribut fest. Dieser String wird gespeichert und ist gleichzeitig der Name des Twig-Templates:
#[AsTaggedItem(index: 'callout', priority: 60)]
final class CalloutBlockType implements BlockTypeInterface
{
// 'callout' ist ab jetzt überall der Name dieses Blocks: im
// gespeicherten Dokument, im Einfügemenü und als Template
// blocks/callout.html.twig
}
So aufgeräumt war das nicht von Anfang an. Vorher hatte jeder Block eine getName()-Methode, die die Registry aufrief, um den Index aufzubauen. Dann hat Symfony 8.1 genau diesen Weg zugunsten des Attributs für veraltet erklärt und mich mit einer Deprecation-Warnung belohnt: Mein getName() machte, was das Attribut ohnehin schon erledigte. Also habe ich alle rausgeworfen. Es ist die seltene Deprecation, über die ich mich gefreut habe, denn am Ende hatte ich weniger Code als vorher.
Bisher gibt es neun Block-Typen: Text, Heading, Callout, Code, Zitat, Liste, Bild, Tabelle und Divider.
Mit diesem Setup ist ein weiterer Block lediglich eine Klasse und ein Template und würde von allein im Einfügemenü des Editors auftauchen.
Alle Sprachen in einer Spalte
Die Seite muss in mehreren Sprachen funktionieren. Der naheliegende Weg wäre eine Spalte oder gleich eine ganze Tabelle pro Sprache oder auch eine Tabelle in der die Übersetzungen gemischt sind, aber das wird mit jeder zusätzlichen Sprache ein Stück unübersichtlicher. Ich habe es anders gelöst. Übersetzbarer Text ist ein einziges Value Object, ein TranslatedString, der alle Sprachfassungen zusammen in einer JSON-Spalte hält. Ein Beitrag bleibt eine einzige Zeile und trägt seine Übersetzungen einfach mit sich. Die Blöcke haben davon gratis profitiert, denn der Inhalt eines Blocks ist auch nichts anderes als ein TranslatedString.
Das Thema mit den Zeitzonen
Den hier habe ich beim Testen auf der Dev-Umgebung erwischt, bevor er es je in Produktion geschafft hat. Man plant einen Beitrag im Editor über ein ganz normales Datumsfeld, in der Uhrzeit, die man vor sich sieht. Genau diesen Wert habe ich gespeichert, unverändert. Nur vergleicht der Job, der über die Fälligkeit zum Veröffentlichen entscheidet, gegen UTC. Der Editor geht von mitteleuropäischer Zeit aus (Europe/Berlin), im Sommer also zwei Stunden vor UTC, und schon würde ein auf 9 Uhr geplanter Beitrag klammheimlich um 7 online gehen. Keine Fehlermeldung, keine Log-Zeile, nichts. Aufgefallen ist es nur, weil beim Durchtesten die Zeitstempel nicht mehr zu dem passten, was ich eigentlich eingestellt hatte.
Die Lösung ist ziemlich unspektakulär. Das Feld im Editor wird jetzt als Europe/Berlin Zeit interpretiert, beim Speichern sofort in UTC umgerechnet und erst für die Anzeige wieder zurück. UTC in der Datenbank, UTC im Vergleich, Ortszeit nur da, wo ein Mensch draufschaut. Doctrine macht den Roundtrip anstandslos mit, solange man ihm konsequent UTC gibt.
Es ist also möglich, dass dieser Beitrag veröffentlicht wird während ich schlafe oder anderweitig beschäftigt bin.
Der dümmste Bug kriegt seine eigene Überschrift
Dann war da noch der Featured-Slot. Die Übersichtsseite des Blogs hebt ganz oben einen Beitrag groß hervor, und zwar den jeweils neuesten. Neuester heißt: sortiert nach Veröffentlichungsdatum, absteigend. Klingt harmlos. Nur war einer meiner Testbeiträge als „veröffentlicht“ markiert, hatte aber gar kein Datum, und in Postgres landet ein NULL bei absteigender Sortierung ganz oben. Also hat sich dieses datumslose, halbfertige Ding an die Spitze der Seite geschoben und ist da nicht mehr weggegangen. Alle echten Beiträge standen darunter.
Die Lösung: überall dort, wo das „effektive Datum” berechnet wird, aufs Erstellungsdatum zurückfallen, wenn kein Veröffentlichungsdatum da ist. Also in der Sortierung, in den Vor- und Zurück-Links und in den Templates. Es gibt jetzt einen Test, der einen datumslosen Beitrag einwirft und prüft, dass er sich nicht vordrängelt. Diesen Test schreibe ich deutlich lieber, als mich mit den Eigenheiten von Postgres zu beschäftigen.
So weit der Blick unter die Haube. Zwei Sachen davon, die sich selbst registrierenden Blöcke und der Editor, in dem man sie zusammensetzt, verdienen eigene Beiträge, die schreibe ich noch. Und die größere Frage, warum ich das Ganze überhaupt selbst gebaut habe, statt eines der vielen guten Tools zu nehmen, die es längst gibt, kommt als Nächstes. Danke, dass du bis hierher gelesen hast.