Diese Website
Wie diese Website gebaut ist und was sich im Repository befindet, das sie erzeugt.
Repository
Diese Seite ist ein statisches, viersprachiges Portfolio, gebaut mit Astro,
dazu ein Strapi-CMS, das sie mit Inhalten versorgt. Alles, was du siehst,
entsteht aus einem einzigen Repository, und der Baum links ist dieses
Repository — erzeugt aus git ls-files, er listet also genau das, was
eingecheckt ist, und nichts anderes.
Zwei Apps, ein Workspace
Es ist ein pnpm-und-Turbo-Monorepo mit zwei Anwendungen, drei gemeinsamen Konfigurationspaketen und einem Asset-Generator:
apps/web— die Astro-7-/React-19-/Tailwind-v4-Seite, die du gerade liest.apps/admin— ein Strapi-5-CMS (auf 5.52.3 gepinnt, auf Postgres).packages/typescript-config,packages/eslint-configundpackages/tailwind-config— gemeinsame Grundlagen für TypeScript, Linting und Design-Tokens.packages/logo-assets— zeichnet die Bildmarke der Seite als SVG und PNG. Es liest seine Farben auspackages/tailwind-config/theme.css, statt eigene zu halten, und nichts im Build hängt davon ab.
Die beiden Apps teilen bewusst nichts außer Daten. apps/admin betreibt sein
eigenes React-18-Admin-Panel, seine eigene tsconfig.json und seine eigene
Prettier-Konfiguration; es importiert weder Komponenten noch Typen aus
apps/web und nutzt auch nicht dessen $/*-Pfad-Alias. Der einzige Vertrag
zwischen beiden ist ein generierter: pnpm --filter admin generate liest das
Schema von einem laufenden Strapi und schreibt einen typisierten Client nach
apps/web/src/.generated/strapi-client/, den web über den $strapi-Alias
importiert. Ändere einen Content-Type, generiere neu, und die Typfehler sagen
dir, was zu korrigieren ist. Nichts sonst überschreitet die Grenze.
Turbos Aufgabe ist absichtlich klein. turbo.json deklariert sechs Tasks —
build, lint, lint:fix, typecheck, test, dev — die sich im
Wesentlichen nur in der Reihenfolge ihrer Workspace-Abhängigkeiten einordnen
(dependsOn: ["^build"] und Verwandte). dev ist als cache: false, persistent: true markiert. Was jeder Task hasht, ist der Teil mit dem
Nachdenken darin, und der Anteil der Web-App daran liegt in
apps/web/turbo.json: die Umgebungsvariablen, die ihre Ausgabe verändern, und
das "cache": false an astro check. Turbo weiß weiterhin nichts über Astro
oder Strapi; es plant und cacht, und jede App besitzt ihre eigenen Skripte.
Statisch von Grund auf
apps/web/astro.config.mjs konfiguriert keinen Adapter. Diese eine Auslassung
ist die Entscheidung, die alles andere formt: die Ausgabe ist vorgerendertes
HTML, nach S3 synchronisiert und über CloudFront ausgeliefert. Zur
Request-Zeit gibt es keinen Server, also findet jeder Datenzugriff zur
Build-Zeit statt — und zwar über eine Sync-Phase, die einmal läuft, bevor
irgendeine Seite rendert: Content-Collection-Loader rufen den generierten
Client auf, der Client ruft Strapi für alle vier Sprachen auf, und die
Ergebnisse landen in Astros Content Store. Seiten holen nichts mehr selbst;
sie lesen aus diesem Store, und die Antwort landet am Ende trotzdem im HTML.
Die Folgen sind der interessante Teil. Inhaltsänderungen sind nicht sofort
sichtbar — sie brauchen einen neuen Build, weshalb das CMS einen Deploy-Button
bekommen hat. Secrets erreichen niemals einen Browser, weil der einzige
Prozess, der sie hält, der Build ist. Und die Konfiguration ist streng an
dieser Grenze: CDN_URL und IMAGE_DOMAINS werden über Vites loadEnv
gelesen und beide sind optional, während STRAPI_BASE_URL,
STRAPI_API_TOKEN, SITE_URL in Astros env.schema unter context: 'server' deklariert sind, das Token zusätzlich als access: 'secret'. Ein
Schlupfloch wurde geschlossen und ein anderes bewusst offen gelassen: die
Konfiguration wirft ohne CDN_URL nicht mehr, sodass die Testsuite
überhaupt keine Zugangsdaten braucht, aber sowohl astro check als auch ein
echter Build brauchen weiter ein erreichbares CMS, und CI liefert echte
Zugangsdaten aus einem Secret, statt auf einen leeren Content Store
auszuweichen.
Zwei Dinge sprechen doch aus einem Browser mit dem CMS, und sie sind die
Ausnahme, die die Regel schärft. POST /api/contact trägt das Kontaktformular
der Startseite, und — seit die Anmeldung gelandet ist — antworten die
Session-Endpunkte unter /api/auth/* sowie /api/users/me den
/auth/*-Seiten und dem Header. Keines von beiden kann ein Geheimnis halten,
denn eine statisch gebaute Seite hat keinen Ort dafür: die Kontaktroute ist
öffentlich und wird von einem Honeypot und einem Rate Limiter verteidigt statt
von einem Token, und das Credential der Session ist ein httpOnly-Cookie auf
der CMS-Origin, das kein Skript lesen kann, eingetauscht gegen ein
Zehn-Minuten-Access-Token, das in einer modulweiten Variablen lebt und in
keinen Speicher geschrieben wird. Die Seite trägt also jetzt eine Session, die
kein Build je gesehen hat. Die Header-Insel fragt nach dem Laden der Seite,
wer du bist, und rendert überhaupt nichts, wenn die Antwort niemand ist — und
das ist die Antwort für so gut wie jeden Besucher.
Das CMS tut außerdem etwas, wovon weder ein Build noch ein Browser etwas
mitbekommt: es verschickt Mail. Eine Einsendung des Kontaktformulars lässt
jetzt zwei Nachrichten zurück — die Benachrichtigung des Betreibers und eine
Empfangsbestätigung an den Besucher, in derjenigen der vier Sprachen, in der
er geschrieben hat. Diese, ein Newsletter und die zwei Auth-Mails sind
zusammen zehn Vorlagen, und jede einzelne ist von Hand geschrieben. Ein
Generator in apps/admin/scripts/email-templates/ baute früher alle zehn aus
derselben Hülle, sodass ein Passwort-Reset und eine automatische Antwort nach
derselben Seite aussahen; er ist weg, mitsamt seinen eingecheckten Artefakten,
denn eine generierte Kopie von etwas, das das CMS schon hält, ist eine zweite
Quelle der Wahrheit und driftet von ihr ab. Sie erreichen Strapi auf zwei
verschiedenen Wegen, weil sie zwei verschiedene Dinge sind: die acht
lokalisierten sind Zeilen im Email-Designer-Plugin, geschrieben in dessen
Custom-Tab, während die zwei Auth-Mails überhaupt keine Zeilen sind, sondern
von Hand über Strapis eigenen Einstellungen-Screen in den Plugin-Store
eingefügt werden — der Editor des Plugins kann handgeschriebenes Markup nicht
bewahren, weil das Speichern dort immer das Unlayer-Dokument darüber
reexportiert. apps/admin/src/lib/email-templates.ts ist, was übrig blieb,
und die eine Datei, die du jetzt von Hand mit dem CMS im Gleichstand halten
musst: sie hält je Locale eine Reference-ID, und eine im Plugin angelegte
Vorlage ohne die passende templateReferenceId scheitert stumm — eine
unbekannte Reference-ID schreibt eine Logzeile und kehrt zurück, und der
Besucher hört einfach nie etwas. Das abzusichern hat apps/admin schließlich
auch einen Test-Runner verschafft: eine Vitest-Suite unter
apps/admin/tests/, wo es vorher gar keinen gab.
Vom Push zum CDN
flowchart TB renovate["Renovate, montags"] --> pr["Pull Request"] pr --> checks["Format, Lint, Typecheck, Test, Web-Build"] checks --> push["Push nach master"] push --> detect["Geänderte Pfade erkennen"] detect --> cms["Strapi bauen, synchronisieren, neu starten"] cms --> web["Astro bauen, mit S3 synchronisieren"] web --> cdn["CDN invalidieren"] widget["Strapi-Veröffentlichungs-Widget"] --> web
Prüfungen und Deploys sind getrennte Workflows, und das ist die eine
Entscheidung, auf der die Einfachheit alles Übrigen beruht.
.github/workflows/ci.yml läuft auf Pull Requests und hat einen einzigen Job,
Checks: eine Frischeprüfung des Dateibaums, format:check, turbo run lint typecheck test --continue, dann der Web-Build. .github/workflows/deploy.yml
läuft auf Pushes nach master, löst die Deploy-Ziele auf und ruft
cd-cms.yml und dann cd-web.yml auf. Keiner verrichtet die Arbeit des
anderen, jeder hat also genau einen Auslöser, und das Aggregations-Gate, die
bereichsweisen wiederverwendbaren Workflows und die meisten
Skip-Fortpflanzungsbedingungen, die hier lebten, sind verschwunden.
ci.yml hat absichtlich keinen push-Auslöser. pull_request prüft
refs/pull/N/merge — den Head in die Basis gemergt. Wenn sich die Basis also
nicht bewegt hat, ist der Baum, der auf master landet, der bereits geprüfte.
Ihn dort erneut zu prüfen beweist nichts, und die Garantie, dass sich die
Basis nicht unter einem veralteten Pull Request bewegt, ist eine Einstellung
des Branch-Schutzes (aktuelle Branches verlangen oder eine Merge Queue),
nichts, was das YAML ausdrücken könnte. deploy.yml führt daher überhaupt
keine Prüfungen aus.
Nur das CMS-Deploy wird über dorny/paths-filter nach Pfaden gefiltert, weil
es ein Upload von ~1 GB und ein Strapi-Neustart ist. Das Web-Deploy läuft bei
jedem Push, und das ist keine Faulheit: /about/this-website veröffentlicht
einen Baum, der aus git ls-files an der Wurzel des Repositorys gebaut wird,
sodass ein Commit, der nur docs/ berührt, eine veröffentlichte Seite ändert.
Es auf apps/web/** zu filtern lieferte stillschweigend einen veralteten Baum
aus. Der Filter hat ein Loch, das man kennen sollte — workflow_dispatch hat
keine Diff-Basis, würde also den Branch mit sich selbst vergleichen und alles
überspringen, was ein manuelles Redeploy zu einer stillen Nulloperation macht.
Der Filterschritt wird beim Dispatch daher übersprungen, und ein
Auflösungsschritt schaltet das CMS-Ziel scharf — genau das macht „Deploy auf
master dispatchen“ zum alles-neu-deployen-Knopf.
Das CMS kommt vor dem Web, weil vorgerenderte Seiten Strapi-Antworten in die
statische Ausgabe einbacken: eine Inhaltsänderung ohne Web-Rebuild lässt die
Live-Seite veralten. Der Web-Job wartet auf den CMS-Job bei Erfolg oder
Übersprungen, und das ist die einzige Skip-Fortpflanzungsbedingung, die in der
Pipeline übrig bleibt — !cancelled() ist es, was einem Job erlaubt, einen
übersprungenen Vorgänger zu beobachten, statt mit ihm übersprungen zu werden,
zum Preis, die dadurch verworfenen Gates erneut zu nennen. Das Web-Deploy lädt
dann in drei Durchläufen hoch — unveränderliche gehashte Assets, dann Seiten
mit --delete, dann ein Prune veralteter Assets —, invalidiert CloudFront und
fordert zwei Routen über die Distribution an, denn Bytes in einen Bucket zu
legen ist nicht dasselbe, wie diesen Bucket auszuliefern.
Der letzte Pfeil in das Web-Deploy kommt von Strapi selbst. Ein lokales Plugin
unter apps/admin/src/plugins/web-deploy/ ergänzt ein Widget auf der
Startseite und eine Seite in der Seitenleiste, die cd-web.yml über die
GitHub-REST-API mit einer zufälligen dispatch-id auslösen. cd-web.yml gibt
diese ID in seinem run-name wieder aus, und das Plugin findet den Lauf über
dieses Token — deterministische Zuordnung statt Raten innerhalb eines
Zeitfensters.
Ein fünfter Workflow gehört zu keiner der beiden Hälften. renovate.yml läuft
selbst gehostet auf einem Montags-Cron und ist der einzige, der in das
Repository schreibt, statt daraus zu deployen — weshalb sein Token auf einer auf
master beschränkten renovate-Umgebung liegt statt bei den
Repository-Secrets: Er hat Schreibrechte auf Workflows und könnte damit genau
die Dateien umschreiben, die den Deploy-SSH-Schlüssel tragen. Er öffnet Pull
Requests, seine Ausgabe kommt also wie jede andere Änderung durch ci.yml
zurück. Eine Konvention, die er erzwingt, betrifft jeden Workflow hier: Eine
Drittanbieter-Action, die auf einen nackten SHA gepinnt ist, ist für ihn
unsichtbar, deshalb trägt jede Anheftung ihre Version als angehängten
# vX.Y.Z-Kommentar — und wer einen löscht, beendet die Überwachung dieser
Action, ohne es zu sagen.
Was ein Build wirklich tut
flowchart TD loader["Content-Loader"] --> client["Generierter Strapi-Client"] client --> strapi["Strapi"] loader --> store["Content-Store"] store --> data["Feature-Datenschicht"] page["Astro-Seite"] --> routes["Locale- und Routenauflösung"] page --> i18n["Übersetzungen"] page --> data page --> html["Vorgerendertes HTML"] html --> islands["Hydratisierte Islands"]
Eine Seite zu rendern ist vor allem Auflösung. Die Route bestimmt die Sprache,
die Sprache wählt ein Übersetzungspaket, und Feature-Module lesen aus Astros
Content Store, welchen Inhalt die Seite braucht — geholt wurde er schon
vorher, einmal pro Build, in der oben beschriebenen Sync-Phase. Wo ein Feature
unter src/features/ Inhalte zu lesen hat, hält es diese Trennung explizit,
und die Dateinamen sind die Rollen: repository.ts ist die Fetch-Schicht, die
ein Content-Loader aufruft, das Gültigkeitsprädikat reist mit der Collection
in content.config.ts mit, und service.ts liest das Ergebnis über
getCollection oder getEntry wieder heraus, sodass eine Seite nie selbst mit
Strapi spricht. contact überspringt die Schicht ganz; search behält eine
service.ts, braucht aber kein eigenes Repository, weil es durch vier andere
Features liest.
Diese Seite gehört zu beiden Hälften. Die Baumstruktur kommt aus einem
generierten JSON-Artefakt, während die Pfadnotizen und diese Übersicht aus dem
lokalisierten Content-Type File Annotation in Strapi stammen. Auch der
Beitragsgraph darunter fragt Strapi ab, aber nicht mehr direkt zur
Render-Zeit: about/contributions/repository.ts — das einzige Repository der
Codebasis, das ein nacktes fetch statt des generierten Clients nutzt, weil
ihr Endpunkt eine eigene Strapi-Route und kein Content-Type ist — läuft
innerhalb eines Loaders während der Sync-Phase, und die service.ts des
Graphen liest das Ergebnis aus dem Content Store zurück. Dieser Loader ist tolerant:
ein unerreichbarer Endpunkt oder eine Payload, die an seinem Schema scheitert,
schreibt gar keinen Eintrag, sodass der Graph verschwindet, statt den Build
scheitern zu lassen. Was am anderen Ende herausfällt, ist in beiden Fällen
HTML.
Interaktivität ist eine bewusste Entscheidung. Die Regel, der die Codebasis
folgt, ist: Astro-Komponenten besitzen Layout und alles, was zur Build-Zeit
entscheidbar ist, und React-.tsx-Dateien erscheinen nur dort, wo die
Oberfläche wirklich interaktiv ist — die Suchpalette, die Menüs, die
Karussells. Alles andere geht als HTML ohne jedes angehängte JavaScript raus. Verhalten,
das das DOM braucht, aber kein Framework — die Karussells, die beiden
Seitenfilter, das Physikfeld unter den Skills —, ist ein Controller: eine
Klasse, die genau ein Wurzelelement besitzt, ihre Listener über einen Helfer
registriert, der sich merkt, wie man sie wieder löst, und die der
Lifecycle-Manager montiert, sodass eine View Transition sie abbaut und neu
aufbaut, statt sie lecken zu lassen.
Die Suchpalette hydriert mit client:idle und holt ihren Index nicht einmal,
bis du sie öffnest.
Das Timing war früher der einzige Teil, der keine reine Auflösung war:
getSearchIndex memoisierte ein Promise pro Sprache, damit vier Renderings
— eines je Sprache — nicht jedes Mal jede Collection erneut von Strapi holen.
Jetzt, wo die Suche aus dem Content Store liest statt selbst zu holen, gibt es
nichts mehr erneut zu holen — die Sync-Phase lief bereits einmal für alle vier
Sprachen, bevor irgendeine Seite gerendert wurde —, sodass die
Pro-Sprache-Memoisierung ganz gestrichen wurde.
Vier Schichten unter src/
apps/web/src/ besteht aus vier Schichten, und die Richtung zwischen ihnen ist
der Punkt. core/ ist die Maschinerie, die nichts rendert: der
Lifecycle-Manager, die Basisklasse Controller, der Strapi-Client mit seinem
paginierenden Repository und die Loader und Leser der Content-Collections.
design-system/ ist Darstellung ohne jedes Domänenwissen — primitives/ für
die Atome und ihre Style-Rezepte, patterns/ für die zusammengesetzten Teile,
die immer noch nicht wissen, was ein Projekt ist. shell/ ist das Gewand, das
jede Seite trägt: Header, Footer, Section-Layout, Ladeanzeige, SEO und
Analytics. features/ ist alles, was die Domäne kennt.
Ein Feature darf in jede der drei anderen Schichten greifen; keine von ihnen
darf in ein Feature zurückgreifen, und ein Feature erreicht ein anderes nur
über dessen service.ts. Innerhalb eines Features sind die Dateinamen die
Rollen, und genau das lässt die Platzierung aufhören, eine Frage zu sein: ein
Modul, das von Strapi holt, hat genau einen Ort zum Leben, und eine Datei, die
sich schwer benennen lässt, tut meist zwei Dinge. Der Baum trug früher
components/-, utils/-, constants/- und common/-Verzeichnisse, die
weder das eine noch das andere beantworten konnten. Er hat keines davon mehr.
Vier Sprachen, ein Seitenverzeichnis
Das lokalisierte Routing ist bewusst geteilt. Das Manifest in
src/i18n/route-manifest.ts erzeugt Paraglides übersetzte URL-Muster und
speist die repo-eigene Astro-Integration localized-static-routes. Während
astro:config:setup durchläuft sie src/pages/, validiert das Manifest und
ruft injectRoute für jede Nicht-Standardsprache auf. Englisch bleibt ohne
Präfix; die anderen Sprachen erhalten /{locale} und, wo deklariert, einen
übersetzten Slug. Darum existiert diese Seite unter /about/this-website und
unter /de/ueber-mich/diese-website.
Paraglide übernimmt URL-Umkehrung und Message-Kompilierung. Die typisierten
Helfer in src/i18n/routes.ts delegieren localeFromUrl, localizedHref und
alternateHrefs dorthin. .astro-Dateien und die .ts-Module, die nur sie
erreichen, lesen Nachrichten über useMessages(locale) — ein Proxy pro
Sprache über das generierte Barrel in src/i18n/messages.ts, indiziert über
die eigenen Schlüssel des Katalogs. Alles, was Astro an den Browser ausliefern
kann — React-Islands, Module, die nur über einen Astro-<script>-Block
erreicht werden, und alles, was diese importieren — importiert weiterhin genau
die benötigten generierten Message-Funktionen und übergibt die Sprache
explizit, damit der Katalog Tree-Shaking-fähig bleibt; eine eigene Testsuite
lässt den Build fehlschlagen, wenn ein clientseitig erreichbares Modul
stattdessen das Barrel importiert. Es gibt keine Middleware, keinen Provider,
keinen globalen Build-Zustand und keinen Client-Katalog-Payload.
Hier gibt es eine Stolperfalle, die man klar benennen muss, weil sie leise
scheitert. localizedHref stellt Nicht-Standardsprachen immer ein Präfix
voran, Manifest-Eintrag oder nicht — eine nicht gemappte Route fällt direkt
auf /{locale}{route} durch. Nicht der Eintrag verschafft dir das Präfix; er
verschafft dir einen übersetzten Slug. Also liefert localizedHref('/about', { locale: 'ar' }) ohne Eintrag /ar/about: korrekt mit Präfix, ein völlig
funktionierender Link, englischer Slug. Einen Eintrag zu vergessen erzeugt
keinen kaputten Link, es erzeugt einen subtil falschen, und das ist im Review
weit leichter zu übersehen.
Die 532 ms Licht
Der Dark Mode war früher klassenbasiert — ein @custom-variant dark, das auf
eine .dark-Klasse passte, die JavaScript bei DOMContentLoaded setzte. Weil
das CSS keinen prefers-color-scheme-Rückfall trug, bekam ein Besucher,
dessen System auf dunkel stand, die helle Palette ausgeliefert und sah sie,
für gemessene 532 ms, bevor die Klasse ankam.
Die Lösung bestand darin, Code zu löschen. das gemeinsame Theme in packages/tailwind-config/theme.css definiert
die helle Palette nun auf :root und überschreibt sie innerhalb einer
prefers-color-scheme: dark-Media-Query — was genau das ist, wozu Tailwind
v4s eingebaute dark:-Variante auflöst, sodass es überhaupt keine
@custom-variant-Überschreibung mehr gibt und jede dark:-Utility
unverändert weiterfunktioniert. Eine Media-Query löst vor dem ersten Paint
auf, das Aufblitzen kann also nicht passieren. :root deklariert außerdem
color-scheme: light dark, was auch native Scrollbars, Formularelemente und
die Canvas-Farbe vor dem Paint auf die Systemeinstellung legt; die
klassenbasierte Fassung ließ all das unabhängig davon auf hell stehen.
Eine verwandte Entscheidung sitzt ein paar Zeilen darüber, als Kommentar an
der Stelle, wo einmal ein Token stand. Eine
--animate-fade-in-Einstiegsanimation nutzte den Fill-Mode both, sodass
jeder Wrapper, den sie berührte, bei opacity: 0 verharrte, bis seine
Animation begann — einschließlich der Hero-Überschrift und beider
Profilspalten. Das machte das LCP-Element instabil: es landete je nach Lauf
auf dem Nav-Logo, einem Absatz oder dem Profilbild und kostete rund 100 ms
LCP. Das Token ist weg und der Kommentar erklärt, warum, damit es niemand
wieder einfügt. Inhalt sollte einfach da sein.
Ein Import für Icons
Jede Komponente importiert Icon aus
$/design-system/primitives/Icon.astro, niemals
direkt aus astro-icon/components, und dieser Wrapper tut genau eines: er
erzwingt is:inline.
Ohne ihn dedupliziert astro-icon Sprites, indem es dem ersten Rendern eines
Icon-Namens die <symbol>-Definition gibt und jedem späteren Rendern nur ein
nacktes <use href="#...">. Die Dokumentreihenfolge entscheidet, wer der
Erste ist — und in dieser App kann das Markup innerhalb eines
astro-island-Slots sein, das Astro bis zur Hydration in einem inerten
<template data-astro-template> ausliefert. Das Symbol gelangt dann nie ins
lebende DOM, und jedes <use> dieses Namens rendert nichts. Als es passierte,
riss es alle 28 Pfeil-nach-rechts-Icons sowie die GitHub- und Link-Icons im
Footer auf einem Desktop-Viewport mit. Gemischte Nutzung kann es auch nicht
retten, denn Inline-Renderings zählen astro-icons Zähler pro Name trotzdem
weiter, weshalb überall inline die einzig konsistente Richtlinie ist. Ein
begleitender Test, tests/repo-guards.test.ts, prüft, dass jeder verwendete
Icon-Name im installierten Icon-Set tatsächlich existiert.
Der Endpunkt, der sich vor dem Router versteckt
Die Suche ist eine statische JSON-Datei pro Sprache. src/features/search/
setzt zur Build-Zeit ein SearchDoc[] aus Projekten, Blogs,
Zeitleisteneinträgen, Tags und handgeschriebenen Seiten-Dokumenten zusammen,
und der Endpunkt unter src/pages/search-index/[locale].json.ts gibt
/search-index/{locale}.json aus.
Diese Dateiendung ist tragend. Der Loader der statischen Routen durchläuft nur
.astro-Dateien, ein .ts-Endpunkt ist für ihn also unsichtbar: die
Integration injiziert nie sprachpräfigierte Varianten, und die Sprachpfade,
die der Endpunkt erzeugt, bleiben die wörtlichen Zeichenketten, die in der
Datei stehen. Eine .astro-Route daraus zu machen hätte der Integration einen
Endpunkt zum Lokalisieren gegeben, und das ist nicht, was ein sprachweiser
JSON-Index will.
Der eigene Baum dieser Seite
Der Baum links wird nicht von der Platte gelesen, wenn du die Seite lädst — es
gibt nichts zur Request-Zeit. apps/web/scripts/generate-repo-tree.mjs ruft
git ls-files auf und faltet das Ergebnis zu verschachteltem JSON in
src/.generated/repo-tree.json. Diese Datei ist eingecheckt, und nicht,
weil ihr Inhalt das verlangte: sie ist eine reine Funktion der getrackten
Dateiliste, der Arbeitsbaum legt sie also vollständig fest. Sie ist eingecheckt,
damit Turborepo sie hasht — der Baum kommt aus git ls-files an der
Repo-Wurzel, während turbo seine Eingaben paketweise hasht, sodass eine Datei
unter apps/admin/ diese Seite änderte, ohne den Hash des Web-Builds zu ändern,
und ein Cache-Treffer dann den veralteten Baum auslieferte. Der
gen:tree-Schritt läuft vor dev, build, test und typecheck, sie ist
damit immer aktuell, sobald irgendetwas sie liest, und die CI lässt den Pull
Request scheitern, sobald die eingecheckte Kopie abgedriftet ist.
Die Arbeit an git zu delegieren statt das Dateisystem zu durchlaufen bedeutet,
dass .gitignore allein entscheidet, was öffentlich ist, ohne eine zweite
Ignore-Matching-Implementierung, die man synchron halten müsste. Nicht
getrackte Notizen unter draft/ und temp/ erscheinen einfach nie. Die
Sortierung läuft über einen fest gesetzten Intl.Collator('en'), damit die
Ausgabe nicht je Maschine variieren kann, und das Artefakt trägt nichts als
eine Dateizahl, eine Verzeichniszahl und die verschachtelten Kinder. Eine
frühere Fassung stempelte zusätzlich den HEAD-SHA hinein — genau das Feld,
das ein generiertes und eingechecktes Artefakt nicht halten kann: das
Schreiben der Datei rückt HEAD weiter, der Stempel ist also in dem Moment
veraltet, in dem er landet. Den Stempel zu entfernen und die Datei aus dem
Tracking zu nehmen war derselbe Fix; die Datei ist seither wieder unter
Tracking, der Stempel nicht, denn eine Dateiliste konvergiert dort, wo ein
HEAD-Stempel es nicht kann.
Die Notizen an bestimmten Pfaden sind lokalisierte File
Annotation-Einträge in Strapi. Jeder normale Pfad wird nach dem Abruf gegen
den generierten Baum geprüft; ein veralteter Pfad wird mit einer Build-Warnung
übersprungen, damit ein CMS-Tippfehler keine anderen Deployments blockiert.
Der reservierte Eintrag $$ROOT_ANNOTATION$$ liefert diese Übersicht, statt
auf einen Baumknoten zu zeigen. Er ist auf Englisch verpflichtend, andere
Sprachen fallen auf Englisch zurück, und die beiden Diagramm-Marker oben
werden erst ersetzt, nachdem das CMS-Markdown aufgelöst wurde.
.github/workflows
Die Workflows und zusammengesetzten Actions sind so getrennt, dass jeder genau
einen Auslöser und eine Job-Form hat. ci.yml läuft auf Pull Requests und hat
einen einzigen Job, Checks: eine Frischeprüfung des Dateibaums,
format:check, turbo run lint typecheck test --continue, dann der
Web-Build. Er ist die einzige Prüfung, die der Branch-Schutz verlangt. Früher
waren es drei Jobs hinter einem Aggregations-Gate namens CI complete, und
jeder führte seine eigene vollständige Workspace-Installation aus — der
Web-Job installierte das gesamte Strapi, der Admin-Job das gesamte Astro —
sodass die Installation dreimal bezahlt wurde, um prettier, tsc und Vitest
je einmal auszuführen. Turbo führt die Tasks weiterhin parallel innerhalb des
einen Jobs aus, und --continue lässt jeden Task-Fehler in einem einzigen
Lauf sichtbar werden.
Ein push-Auslöser fehlt absichtlich. pull_request prüft nicht den Branch,
sondern refs/pull/N/merge — den Head in die Basis gemergt. Solange sich die
Basis also nicht bewegt hat, ist der Baum, der auf master landet, genau der
bereits geprüfte, und ihn erneut zu prüfen beweist nichts. Dieses Argument
hält nur, wenn sich die Basis nicht unter einem veralteten Pull Request
bewegen kann, und das ist eine Einstellung des Branch-Schutzes, nichts im
YAML: aktuelle Branches verlangen oder eine Merge Queue verwenden.
deploy.yml läuft auf Pushes nach master und trägt selbst keine Prüfungen.
Es löst die Deploy-Ziele auf und ruft dann cd-cms.yml und cd-web.yml in
dieser Reihenfolge auf, weil vorgerenderte Seiten Strapi-Antworten in die
statische Ausgabe einbacken. Nur das CMS wird nach Pfaden gefiltert: es ist
ein Upload von ~1 GB und ein Strapi-Neustart, während das Web-Deploy ein Build
und ein S3-Sync ist. Das Web-Deploy zu filtern war tatsächlich falsch —
/about/this-website veröffentlicht einen Baum, der aus git ls-files an der
Wurzel des Repositorys gebaut wird, sodass ein Commit, der nur docs/
berührt, eine veröffentlichte Seite ändert — und es lieferte stillschweigend
einen veralteten Baum aus. Eine Bedingung zur Skip-Fortpflanzung bleibt, auf
dem Web-Job: cms wird zu Recht übersprungen, und !cancelled() ist es, was
einem Job erlaubt, einen übersprungenen Vorgänger zu beobachten, statt mit ihm
übersprungen zu werden — zum Preis, die dadurch verworfenen Gates erneut zu
nennen.
WEB_ENV ist ein Repository-Secret, während die vier Deploy-Secrets in der
Umgebung Production liegen. Der Check-Job braucht es, weil astro check
einen Content-Sync durch jeden Loader gegen ein lebendes Strapi ausführt, und
der einzige Weg, ein Umgebungs-Secret zu lesen, ist, die Umgebung zu
deklarieren — was auf einem Pull-Request-Job für jeden Pull Request ein
Deployment auf Production verzeichnen und seinen Inhalt jedem Branch im
Repository aushändigen würde. Tests brauchen jetzt überhaupt keine Secrets:
astro.config.mjs wirft ohne CDN_URL keinen Fehler mehr. Pull Requests aus
einem Fork können den Typecheck weiterhin nicht bestehen, da GitHub ihnen
keine Secrets sendet.
WEB_ENV erreicht den Check-Job und das Web-Deployment auf dieselbe Weise —
nach apps/web/.env geschrieben und exportiert — und diese Symmetrie trägt,
statt bloß ordentlich zu sein. Turborepo hasht die Datei über inputs und die
exportierten Werte über env: ein Job, der nur eines von beidem tut,
errechnet einen anderen Build-Hash und baut neu, was der andere bereits gebaut
hat. Beides zu tun ist es, was dem Deployment erlaubt, das Artefakt der Pull
Request wiederherzustellen. Artefakte im Remote-Cache werden mit
TURBO_REMOTE_CACHE_SIGNATURE_KEY signiert; ist er nicht gesetzt, warnt turbo
einmal und baut vollständig.
Vier Härtungsentscheidungen, die man nicht zurücknehmen sollte. Fremd-Actions
sind auf Commit-SHAs gepinnt, First-Party-Actions bleiben auf Major-Tags:
burnett01/rsync-deployments und appleboy/ssh-action erhalten beide
SERVER_SSH_KEY, was auf dem VPS root bedeutet, und ein veränderlicher Tag
ist zu viel, um es dort auf einen einzelnen Maintainer zu setzen. Das
CMS-Deploy vergleicht den Node-Major des Servers mit dem des Runners und
bricht vor cp -al ab, wenn sie sich unterscheiden, weil das Bundle
native Module mitbringt, die auf dem Runner kompiliert wurden. Ein
fehlgeschlagener Health Check rollt auf previous zurück und prüft erneut,
sodass die Produktion die letzte als gut bekannte Version ausliefert, auch
wenn der Job rot wird. Und der Web-Upload erfolgt in drei Durchläufen —
unveränderliche Assets, dann Seiten mit --delete, dann ein Prune — damit es
kein Fenster gibt, in dem live-HTML auf fehlende oder bereits gelöschte Assets
zeigt.
renovate.yml ist der fünfte und der einzige, der in das Repository schreibt,
statt daraus zu deployen. Er läuft selbst gehostet auf einem Montags-Cron —
Renovate handelt nur, solange der Job läuft, dieser Cron ist also der gesamte
Zeitplan — und sein Token liegt auf einer renovate-Umgebung statt bei den
Repository-Secrets, denn Schreibrechte auf Workflows hießen, dass er genau die
Dateien umschreiben könnte, die SERVER_SSH_KEY tragen.
Der größte Teil der Prosa liegt in docs/CI-CD.md, aber nicht alles:
renovate.yml behält seine Begründung an Ort und Stelle, in zwanzig
Kommentarzeilen, weil das, was sein Environment-Secret und seinen
Trockenlauf-Standard rechtfertigt, diese Datei betrifft und nicht die
Pipeline. Ein Kommentar ist tragend statt erklärend — jede SHA-Anheftung einer
Drittanbieter-Action endet auf ein # vX.Y.Z, und Renovate deaktiviert jeden
nackten SHA, den es keinem Tag zuordnen kann, sodass das Löschen eines
Kommentars die Überwachung dieser Action beendet und nichts dazu sagt.
AGENTS.md
Diese Datei hatte 528 Zeilen. Jetzt hat sie 69, und die Begründung für den Schnitt ist nützlicher als der Diff.
Die alte Fassung hielt fest, was das Repo ohnehin schon festhielt — Abhängigkeitsversionen, Inventare sämtlicher Kommandos — sie veraltete also schon dadurch, dass sie stillstand, und die wirklich tragenden Beschränkungen lagen unter dieser Füllung begraben. Was übrig ist, leitet nur weiter: was das Repo ist, die sechs Gate-Kommandos, die überall geltenden Regeln und eine Tabelle „wo als Nächstes lesen".
Das Detail ist nach unten gewandert, nicht verschwunden. apps/web/AGENTS.md
(121 Zeilen) und apps/admin/AGENTS.md (103) tragen die Beschränkungen, die
keine Quelldatei gesteht — der Dev-Server, der sich abkoppelt und statt eines
Stacktrace einen 70-Byte-Stub antwortet, das git stash, das Strapis Tabellen
fallen lässt, während es zusieht — jede auf ein bis drei Zeilen verdichtet: die
Beschränkung und ihr Grund. CLAUDE.md ist auf acht Zeilen geschrumpft und
behält nur die eine Falle, die zuschnappt, bevor diese Datei gelesen ist.
Der Rest wurde zu fünf Skills unter .claude/skills/: client-controllers,
feature-modules, design-system, output-neutral-refactor (das seine
Gate-Skripte neben der Prosa mitbringt) und cms-annotations. Ein Skill lädt
bei Bedarf — die Beschreibung im Front-Matter nennt die Situationen, in denen
es greift —, sodass der stets geladene Teil des Agentenkontexts klein bleibt,
die Tiefe aber einen Lesevorgang entfernt.
Eine Regel formuliert das Prinzip, das die ganze Aufteilung kodiert: Versionen
aus der jeweiligen package.json lesen, nie aus Prosa. Was hier steht, ist,
was keine Datei gesteht; was eine Datei schon sagt, bleibt ihr überlassen.
apps/admin/src/api
Fünfzehn Content-Types in Strapis üblicher Vier-Verzeichnis-Form, alle
lokalisiert außer den beiden Submission-Typen, die speichern, was ein Besucher
getippt hat, und keine Übersetzung haben. about, hero und profile sind
Single Types; der Rest sind Collections, und diese Trennung setzt sich im
generierten Web-Client als zwei verschiedene Klassen fort — ein
SingleTypeAPI, das nur find und update freigibt, und ein CollectionAPI
mit dem vollen Satz —, sodass der Griff nach einer Collection-Methode auf einem
Single Type ein Typfehler ist statt einer Laufzeitüberraschung.
Drei der achtzehn Verzeichnisse enthalten überhaupt keinen Content-Type,
und genau so sieht ein rein eigener Endpoint in Strapi aus: ein Router, ein
Controller, manchmal ein Service, kein content-types/. contribution und
contribution-repository bedienen den Contribution-Graph und die
Repository-Timeline dieser Seite aus GitHubs GraphQL-API. github-auth ist das
GitHub-Anmeldebein und die eine Stelle, an der dieses CMS einen Plugin-Flow
ersetzt statt ihn zu erweitern — Strapis eigenes /connect/* schreibt am Ende
den access_token des Providers in den Query-String einer Weiterleitung, wo er
im Browserverlauf landet, und es gibt keinen Hook, das zu ändern. Uns gehören
nur das State-Cookie, der Code-gegen-Token-Tausch und das Session-Cookie.
Neununddreißig der zweiundfünfzig Controller-, Route- und Service-Dateien sind
siebenzeilige factories.createCoreX(...)-Aufrufe: die Web-App liest über die
REST-API mit einem Token und braucht keine maßgeschneiderten Endpoints. Die
dreizehn übrigen gehören zu den fünf eigenen APIs — den beiden
Contribution-Lesern, den zwei der Anmeldung und den beiden öffentlichen
Schreibzugriffen.
Diese Schreibzugriffe sind der Grund, warum testimonial-submission ein
eigener Content-Type ist und keine Route auf testimonial. Beide
Submission-Typen bekommen einen eigenen Router statt createCoreRouter, weil
der voreingestellte CRUD-Satz create, update und delete eine
Admin-Checkbox von öffentlich entfernt platzieren würde. Den Testimonial-Write
ganz abzutrennen geht weiter: die veröffentlichte testimonial-Collection, die
die Seite liest, behält rein lesende öffentliche Rechte, und daneben wartet
kein öffentliches create darauf, versehentlich eingeschaltet zu werden.
POST /api/testimonials/submit ist auth: false — eine statische Seite hält
kein Geheimnis bereit —, also treten ein Honeypot, der 200 antwortet und
nichts speichert, plus zwei IP-gebundene Rate-Limit-Stufen an die Stelle der
Authentifizierung.
Die Lifecycles enthalten den interessanten Code. tag normalisiert sein
aliases-JSON beim Schreiben und übersetzt den AliasError eines
framework-freien Moduls in Strapis ValidationError, damit der Admin ihn am
richtigen Feld anzeigt; Aliase sind es, die Tag-Synonyme auffindbar machen.
Sechs Typen exportieren createTagMirror(...), das die Tag-Liste vom englischen
Dokument in jede andere Locale kopiert, weil eine Relationszeile eine
Zeilen-Id speichert und jede Locale eine eigene Zeile ist — ein Tag am
englischen Projekt zu verknüpfen lässt die französische Zeile ohne. Weder
manyToMany noch pluginOptions.i18n.localized: false ändern daran etwas;
beides wurde versucht.
Nichts hier schreibt beim Start ins CMS — Seeder gibt es keine. Was liest, ist
scripts/cms-snapshot/pull.ts: es schreibt jeden api::-Typ in das
git-ignorierte .cms-snapshot/, alle vier Locales nebeneinander, damit eine
abgedriftete Übersetzung mit bloßem Auge sichtbar wird; nur veröffentlichte
Dokumente, contact-submission ausgelassen. Der Snapshot ist abgeleitet und
nur lesbar. Die Formulartexte des Admin-Panels und die beiden
Auth-E-Mail-Rümpfe liegen im core_store, nicht in einem Content-Type — womit
pnpm --filter admin cms:export die einzige Kopie davon und das einzige Backup
der Inhalte ist. Es muss auch tatsächlich laufen: dieses Repository hat schon
einmal File-Annotation-Zeilen verloren.
apps/web/astro.config.mjs
Das Folgenreichste in dieser Datei fehlt: es gibt keinen adapter. Das ist es,
was die Seite vollständig statisch macht — vorgerendertes HTML, kein Server zur
Request-Zeit, jeder Strapi-Zugriff während des Builds aufgelöst.
Die Behandlung der Umgebungsvariablen ist bewusst zweigeteilt. CDN_URL und
IMAGE_DOMAINS werden zur Auswertungszeit der Konfiguration über Vites
loadEnv gezogen, weil sie die image.domains-Erlaubnisliste aufbauen, bevor
Astros eigene Umgebungsvalidierung laufen würde. Beide sind optional und
additiv: jede trägt Hosts bei, ein Eintrag darf ein nackter Host oder eine
vollständige URL sein, und keine ist erforderlich. Die Konfiguration warf
ohne CDN_URL, was jeden Vitest-Lauf auf Produktions-Zugangsdaten angewiesen
machte — vitest.config.ts baut seine Konfiguration über getViteConfig — und
damit einen pull_request-Job zwang, die Umgebung Production allein zum Lesen
zu deklarieren. Alles andere geht über env.schema:
STRAPI_BASE_URL und SITE_URL als öffentliche Serverwerte mit Standardwerten
und URL-Validierung, STRAPI_API_TOKEN als access: 'secret' — nur im
Server-Kontext, es kann also nicht einmal versehentlich in ein Client-Bundle
gelangen.
Eine einzige Zeile entscheidet, wo der Content-Layer-Store liegt, und es lohnt
sich zu wissen, warum sie vom Standard abweicht. Astro löst diesen Store unter
astro dev nach .astro/ auf und bei jedem anderen Befehl nach cacheDir.
Vitest läuft über getViteConfig, also den Dev-Zweig, und liest damit
.astro/data-store.json, während astro sync und astro check die
cacheDir-Kopie schreiben — unter dem voreingestellten node_modules/.astro
zwei verschiedene Dateien. Die inhaltsgestützten Tests liefen dann nur auf einem
Rechner durch, auf dem astro dev zufällig einen Store hinterlassen hatte, und
nie in einem frischen CI-Checkout. cacheDir: '.astro' legt alle drei auf eine
Datei zusammen.
Vier Integrationen, in dieser Reihenfolge: sondaPost() (eine zweite
repo-eigene Integration, die bei astro:build:done einen Bundle-Report
erzeugt), localizedStaticRoutes(...), react(), icon(). Die
Static-Route-Integration bezieht translatedRoutes aus
src/i18n/route-manifest.ts; dasselbe Manifest erzeugt Paraglides
urlPatterns für das Vite-Plugin. So bleiben injizierte Seiten,
Sprachlinks und umgekehrte Lokalisierung synchron. /about/this-website
existiert auch als /حول/هذا-الموقع, /a-propos/ce-site und
/ueber-mich/diese-website.
Der Rest ist Feinabstimmung: prefetch für jeden Link an, mit hover als
Standard, Build-Concurrency auf 6, die Dev-Toolbar aus, Vite-Sourcemaps an, und
drei Google-Fonts über Astros Font-Provider registriert — Quicksand für
lateinischen Text, Rubik für Arabisch, Pixelify Sans für die
Pixel-Display-Schrift — jede als CSS-Variable bereitgestellt, die das gemeinsame
Theme zu einer font-*-Utility macht.
apps/web/integrations/localized-static-routes
Eine eigene, rein statische Astro-Integration für dieses Repository.
Bei astro:config:setup durchsucht sie src/pages/ nach .astro-Dateien,
macht aus jedem Dateipfad eine Route und ruft injectRoute einmal je
Nicht-Standardsprache auf — drei zusätzliche Routen pro Seite, für ar, fr
und de. Englisch wird ohne Präfix ausgeliefert. Das gemeinsame Manifest
src/i18n/route-manifest.ts liefert übersetzte Slugs an diesen Injector und
Paraglides URL-Compiler, sodass /about/this-website auch unter
/fr/a-propos/ce-site erreichbar ist.
Die Integration hat absichtlich kein virtuelles Laufzeitmodul. Vor der
Injection prüft sie eindeutige Sprachen, die Standardsprache, vorhandene
gemappte und ausgeschlossene Routen, führende Schrägstriche, gleiche dynamische
Parameter und Kollisionen — letztere auch gegen die Quellseiten selbst, nicht
nur gegeneinander, denn Astro sieht eine einzige Routentabelle. Eine Seite unter
src/pages/ar/about.astro wird deshalb abgelehnt: sie liegt bereits auf der
URL, unter der die arabische Variante von /about injiziert wird.
Laufzeit-Lokalisierung und Umkehrung liegen in src/i18n/routes.ts und
delegieren mit expliziter Sprache an Paraglide. Eine nicht gemappte Seite behält
ihren kanonischen Slug unter dem Sprachpräfix, etwa /ar/auth/sign-in; eine nur
für einige Sprachen übersetzte Route warnt hingegen, da sie still zurückfällt.
Die Injection läuft einmal zur Konfigurationszeit, und Astro wiederholt diesen
Hook nur für eine Datei, die es beobachten soll. Das Routen-Manifest wird daher
über addWatchFile registriert, und astro:server:setup — das hinzugefügte und
entfernte .astro-Dateien unter src/pages/ beobachtet — berührt dieses
Manifest nach 100 ms Debounce, anstatt den Server selbst neu zu starten. Der
Umweg ist Absicht: Vites eigenes restart() baut aus der Konfiguration neu auf,
die Astro schon aufgelöst hatte, käme also mit der veralteten Routentabelle
zurück — und gäbe eine beruhigende Logzeile aus, während die neue Seite nur auf
Englisch existierte.
Sie liefert kein Laufzeitmodul, wohl aber einen Typ: astro:config:done
schreibt eine ambiente Union LocalizedRoutes.SourceRoute — ein Mitglied pro
gefundener Seite —, auf die localizedHref eingeschränkt ist. Ein Tippfehler
oder eine inzwischen umbenannte Seite lässt sich damit nicht mehr kompilieren,
statt in Produktion einen 404 zu liefern. Dass sie aus demselben Durchlauf
entsteht, der injectRoute speist, verhindert ein Auseinanderdriften von
verlinkbaren und tatsächlich existierenden Routen.
apps/web/src/.generated
Zwei Build-Eingaben, die von Werkzeugen erzeugt und nicht von Hand geschrieben werden. Beide sind eingecheckt, und das Repo checkt sie nicht aus demselben Grund ein.
strapi-client/ ist ein typisierter Client für die Strapi-API — client.ts,
types.ts, index.ts — geschrieben von pnpm --filter admin generate, das
das Schema von einem laufenden Strapi auf Port 3333 holt. Starte das CMS,
bevor du neu generierst. Die Ausgabe trägt im Kopf @ts-nocheck und „nicht
manuell bearbeiten“ und führt den Schema-Hash mit, aus dem sie erzeugt wurde,
sodass eine Content-Type-Änderung, die niemand neu generiert hat, in einem
Diff auftaucht und nicht erst zur Laufzeit. Er ist eingecheckt, und genau das
erlaubt es einem frischen Checkout, Typecheck und Build ohne irgendwo
laufendes CMS zu bestehen.
Dreizehn Dateien greifen über den $strapi-Alias darauf zu, aber nur eine
instanziiert etwas: src/core/cms/client.ts konstruiert den einen
StrapiClient mit Basis-URL und Token aus astro:env/server. Ein einziges
Feature-Repository holt sich den isStrapiErrorOf-Guard, die übrigen
importieren nur Typen — BlogGetPayload, TagGetPayload, HeroGetPayload und Verwandte,
die die jeweils eigene types.ts eines Features auf die Form verengt, die
seine Komponenten wollen. Der Client ist also ein Singleton, während seine
Typfläche frei genutzt wird, und das ist genau die gewünschte Aufteilung.
repo-tree.json ist der Dateibaum, den diese Seite rendert, erzeugt von
scripts/generate-repo-tree.mjs aus git ls-files. Sie trägt eine Dateizahl,
eine Verzeichniszahl und die verschachtelten Kinder — und sonst nichts. Sie
ist eingecheckt, obwohl nichts an ihrem Inhalt das verlangt: sie ist eine
reine Funktion der getrackten Dateiliste, und dev, build, test und
typecheck erzeugen sie jeweils neu, bevor sie loslaufen. Was es verlangt, ist
Turborepo. Der Baum wird aus git ls-files an der Repo-Wurzel gebaut, während
turbo seine Eingaben paketweise hasht: eine Datei unter apps/admin/ änderte
damit diese Seite, ohne den Hash des Web-Builds zu ändern — und ein
Cache-Treffer lieferte dann den alten Baum aus. Das Einchecken bringt den Baum
in diesen Hash, und pnpm --filter web gen:tree:check in der CI lässt den Pull
Request scheitern, sobald die eingecheckte Kopie abgedriftet ist. Früher
stempelte sie zusätzlich den HEAD-SHA hinein — das ist tatsächlich das eine,
was eine generierte und eingecheckte Datei nicht halten kann, denn das Schreiben
der Datei rückt HEAD weiter — und dieser Stempel blieb gelöscht, als die Datei
wieder unter Tracking kam.
Das ganze Verzeichnis steht in .prettierignore, mit der Begründung, dass
eine Neuerzeugung jede daran vorgenommene Formatierung zunichtemachen würde
und die Formatierungsprüfung in der CI dann Dateien anmahnen würde, die
niemand geschrieben hat.
apps/web/src/core/controller.ts
Jedes Stück Client-Verhalten auf dieser Seite ist eine Controller-Subklasse.
Es gibt kein loses <script>, das DOM-Arbeit leistet: ein Skriptblock
importiert seine Klasse und ruft Controller.mount(id, resolve) auf, und diese
eine Zeile ist die gesamte Registrierungsfläche.
Der Grund sind View Transitions. Astro tauscht das Dokument aus, statt es neu zu
laden; ein auf astro:page-load gebundener und nie entfernter Listener sammelt
also pro Navigation eine lebende Kopie an, gebunden an Knoten, die das Dokument
nicht mehr enthält. Aufräumen ist hier der Normalfall statt ein Nachgedanke:
listen() und own() hinterlegen den Disposer im Moment der Registrierung, und
disconnect() wickelt sie in umgekehrter Reihenfolge ab. Der Lebenszyklus war
früher ein eigener AstroLifecycle-Singleton, doch Controller war sein
einziger Aufrufer — beide wurden in diese eine Datei gefaltet.
Ein Modulskript läuft einmal pro Session, nicht einmal pro Seite; resolve
ist deshalb das Tor, das entscheidet, ob die aktuelle Seite überhaupt etwas zu
mounten hat — byId, bySelector und whenPresent sind die üblichen drei, und
nichts zurückzugeben ist gewöhnlich statt ein Fehler, weshalb dafür nichts
protokolliert wird. Die Schlüsselung über id sorgt dafür, dass eine erneute
Registrierung ersetzt statt verdoppelt. Mounts und Teardowns werden über eine
einzige Promise-Queue serialisiert, was verhindert, dass ein asynchroner Mount
den Teardown der Seite überlappt, die er ablöst.
Die Aufrufstellen übergeben keine Konfiguration als Argument. Jeder Hook ist ein
data-*-Attribut, das der Controller beim Connect einliest, sodass das Markup
in der .astro-Datei bleibt, in der der Rest der Seite ohnehin lebt. Drei Basen
bauen auf dieser Konvention auf: core/filter-controller.ts nimmt prefix und
keys, hält Suchtext und Facetten in der URL und überlässt present,
resultMessages und onCriteriaChange seinen Subklassen;
core/form-controller.ts liest seine Texte aus data-strings und besitzt die
Statuszeile, die Submit-Sperre und das Mapping der 400er-Feldfehler; und
design-system/patterns/carousel/EmblaController.ts liest data-embla-*,
bezieht seine Richtung aus dir — arabische Seiten brauchen also keinerlei
Verdrahtung — und besteht darauf, dass der Dots-Container auch leer existiert,
weil sein Click-Handler an ihn delegiert ist.
apps/web/src/design-system
Präsentation ohne Domänenwissen. Dieses Verzeichnis darf nicht aus features/,
shell/ oder i18n/ importieren; die einzige Abhängigkeit, die es eingeht, ist
core/controller, weil drei seiner Patterns Verhalten sind statt Markup.
Die Tokens liegen überhaupt nicht hier. Tailwind v4 hat keine
JavaScript-Konfigurationsdatei, also leben sie ein Package höher in
packages/tailwind-config/theme.css. Ein schlichter @theme-Block hält die
Rohwerte — --leading-display: 0.92, --leading-heading: 0.95,
--tracking-eyebrow: 0.2em und --text-eyebrow mit eigener
--text-eyebrow--line-height, damit die kleinste Schrift nicht auf dem
Standardverhältnis sitzen bleibt —, während ein @theme inline-Block die
semantischen Namen auf die Palettenvariablen abbildet, was --color-background
erst zur Utility bg-background macht.
Zwei Kommentare in jener Datei halten Entscheidungen fest statt Code. Dark Mode
ist eine schlichte prefers-color-scheme-Media-Query: die klassenbasierte
Variante setzte ihre Klasse per JavaScript auf DOMContentLoaded ohne
CSS-Fallback, sodass Dark-Mode-Besucher die helle Palette bekamen und sie
gemessene 532 ms lang sahen. Eine Media Query löst vor dem ersten Paint auf, das
Aufblitzen kann also gar nicht auftreten. Und es gibt kein
Entrance-Animation-Token: --animate-fade-in nutzte den Fill-Mode both, hielt
damit jeden betroffenen Wrapper bei opacity: 0, bis seine Animation begann,
machte das LCP-Element instabil und kostete rund 100 ms. Beide Kommentare
stehen dort, damit keine der beiden Entscheidungen still zurückkehrt.
Typography ist eine zusammengesetzte Komponente über einer einzigen
tv()-Recipe: vierzehn Varianten und eine Ton-Achse, statisch aus .astro
gerendert und nie hydriert. Wo eine Komponente unmöglich ist, ist die rohe
Funktion typography() der Notausgang: transition:name, set:html,
class:list, Slot-Weitergabe, ein interaktives Element und <time> brauchen
allesamt den Klassen-String statt des Wrappers.
Die Primitives sind bewusst wenige: Chip mit chip.ts, Surface über
tone/radius/padding, die Formen von Input, Button und Link auf einer
gemeinsamen interactive-Recipe, und Icon. Die Schwelle für eine neue liegt
bei zwei Konsumenten, nicht bei einem. patterns/ versammelt die
zusammengesetzten Teile, die immer noch nicht wissen, was ein Projekt ist —
EmptyState, FilterControls, FilterBottomSheet, AnimatedTooltip,
MarkdownBody, PixelSignal, card-transitions und das Embla-Karussell. Sogar
generierte Ausgabe bezieht ihre Farbe auf demselben Weg: das handgeschriebene
Shiki-Theme in lib/shiki.ts ist aus var(--color-foreground) und color-mix
über transparentem Grund gebaut, sodass ein einziges Theme in beiden Schemata
korrekt liest, statt ein Paar zu verlangen.
apps/web/src/design-system/primitives/Icon.astro
Dreizehn Zeilen, fünf davon der Kommentar, der erklärt, warum die Datei
existiert — das kleinste der Design-System-Primitives und das mit der längsten
Begründung. Sie re-exportiert astro-icons Icon mit einer erzwungenen Prop:
<AstroIconBase {...Astro.props} is:inline />
Jede Komponente der App importiert von hier und niemals von
astro-icon/components. Der Grund ist ein Fehlermodus, der keinerlei Fehler
erzeugt. Standardmäßig dedupliziert astro-icon Sprites: das erste Rendern
eines gegebenen Icon-Namens gibt die <symbol>-Definition aus, und jedes
spätere Rendern ist ein nacktes <use href="#...">. Die Dokumentreihenfolge
entscheidet, wer als Erster gilt — und in dieser App kann das Markup innerhalb
eines astro-island-Slots sein (das Nav- und das Seitenmenü), das Astro bis zur
Hydration in einem inerten <template data-astro-template> ausliefert. Das
Symbol gelangt nie ins lebende DOM, jedes <use>, das darauf zeigt, löst also
nichts auf und rendert nichts. Gemessen, auf einem Desktop-Viewport: alle 28
Pfeil-nach-rechts-Icons sowie die GitHub- und Link-Icons im Footer,
verschwunden.
Nur die Icons innerhalb von Islands inline zu setzen behebt es nicht, denn
Inline-Renderings zählen astro-icons Zähler pro Name trotzdem weiter — der
Sprite-Pfad und der Inline-Pfad teilen dieselbe Buchführung, eine gemischte
Richtlinie verschiebt also nur, welche Instanzen verschwinden. is:inline
überall zu erzwingen ist die einzige konsistente Einstellung, um den Preis, das
SVG-Markup pro Instanz zu wiederholen.
tests/repo-guards.test.ts bewacht die andere Hälfte des Problems und nutzt
dafür scripts/icons/scan.ts, um zu prüfen, dass jeder im Quellcode
referenzierte pixelarticons:*-Name im installierten Icon-Set tatsächlich
existiert, und lässt die Prüfung scheitern, wenn irgendetwas lucide-react
importiert. Das alte Skript, das es ersetzt, war nie an die CI angebunden —
diese beiden Prüfungen laufen hier zum ersten Mal automatisch.
apps/web/src/features
Zwölf Feature-Module — about, auth, blogs, contact, entries, hero,
og, profile, projects, search, tags, testimonials — eines pro
Domäne. Gemeinsames lebt hier nicht: core/ hält die Controller-Basisklasse,
den Strapi-Client und die Content-Collection-Loader, design-system/ die
Präsentation ohne Domänenwissen, und shell/ das Gewand, das jede Seite trägt.
Der Leitgedanke ist, dass eine Datei nach ihrer Rolle benannt wird und nicht
nach ihrer Art, und dass die Rollen eine Kette bilden. repository.ts ist die
einzige Stelle, die den generierten Strapi-Client berühren darf: sie besitzt die
populate-Formen, die Filter und die Sortierung und gibt rohe Strapi-Typen
zurück. Eine Seite ruft sie nie auf — content.config.ts verdrahtet sie in
einen Loader, der einmal pro Sync läuft, mit dem Gültigkeitsprädikat des
Features daneben deklariert, sodass ein fehlerhafter Datensatz seine Collection
und seine Entry-Id nennt, statt als geworfene Assertion mitten im Rendern
aufzutauchen. service.ts sitzt über dem Content Store und liest ihn über
getCollection oder getEntry wieder aus. Die Seite erwartet den Service und
reicht das Ergebnis nach unten; ui/ rendert, was es bekommt, und holt selbst
nichts. controller.ts — oder controllers/<name>.ts, wo ein Feature mehrere
hat — ist die Browser-Hälfte. types.ts trägt die Domänenformen, und mehrere
Features führen eine translation-keys.ts, die Domänen-Enum-Werte an einer
Stelle auf i18n-Schlüssel abbildet, statt String-Verkettung über die Komponenten
zu verstreuen.
Die Benennung nach Rolle ist es, die Platzierung aufhört, eine Frage zu sein:
ein Modul, das von Strapi liest, hat genau einen Ort, und eine Datei, die sich
schwer benennen lässt, tut meist zwei Dinge. Deshalb hat der Baum auch keine
components/-, utils/- oder constants/-Verzeichnisse mehr und überhaupt
keine Barrel-Dateien — nichts re-exportiert einen Nachbarn, also nennt jeder
Import das Modul, das er wirklich meint. Importe zwischen Features laufen über
den $/-Alias und erreichen nur die service.ts des anderen, was verhindert,
dass ein Verzeichnisumzug einen Diff voller ../../..-Korrekturen erzeugt.
auth ist die bewusste Ausnahme und sollte gelesen werden, bevor man die Form
von irgendetwas anderem hier kopiert: kein Repository und kein Service, weil es
eine Session besitzt — und eine Session existiert nur in einem Browser.
apps/web/src/features/search
Die Suche der Seite ist vollständig statisch. service.ts baut zur Build-Zeit
ein SearchDoc[] je Sprache — Projekte, Blogartikel, Timeline-Einträge und
Tags über je einen Mapper in mappers.ts, dazu handgeschriebene Routen- und
Startseiten-Ankerdokumente in pages.ts, sodass die Suche zugleich Navigation
ist. Das Ergebnis wird als einfache JSON-Datei von
src/pages/search-index/[locale].json.ts ausgeliefert, bewusst als
.ts-Endpunkt: der localized-static-routes-Loader läuft nur über
.astro-Dateien, injiziert also nie sprachpräfixierte Varianten, und die
Pfade darin bleiben wörtlich.
Eine Feinheit lebt weiterhin in service.ts: weil /projects/[slug] und
/blogs/[slug] ihre statischen Pfade nur aus den Daten der Standardsprache
erzeugen, liegt die Detailseite jeder Sprache unter dem Slug der
Standardsprache — Such-hrefs lösen Slugs deshalb über die kanonische englische
Entität auf, verschlüsselt per documentId, statt über die lokalisierte, die
sie beschreiben. Eine zweite Feinheit ist weg: der Index je Sprache wurde
früher als Promise memoisiert, nicht als Wert, weil der Endpunkt pro Build
viermal rendert und jedes Rendern sonst jede Collection erneut von Strapi
geholt hätte. Das kanonische Set zu lesen ist jetzt ein Store-Lookup, also
wurde diese Promise-Memoisierung gestrichen.
Das Matching lebt gar nicht in diesem Feature. $/lib/fuzzy ist das einzige
Modul der App, das Fuse importiert, und derselbe Index trägt auch die Filter
der Projekt- und Tag-Seiten, sodass alle drei Oberflächen identisch ranken.
engine.ts passt SearchDoc[] daran an und ergänzt den Gleichstand nach Art,
und grouping.ts besitzt die Obergrenzen je Art und die Gesamtobergrenze —
die ganze schlecht bewertete Gruppen verwirft, statt Zeilen aus einer Gruppe
zu schneiden, die bereits einen Überlauf meldet. Beide laufen im Browser und
sind bewusst pur: $/lib/text, ihre eigenen Typen, und sonst nichts.
Die Palette unter ui/ ist eine der wenigen echten React-Inseln der Seite und
inzwischen mehrere kleine Dateien statt einer großen. SearchPalette.tsx ist
die Hülle und hydriert mit client:idle; use-palette-open.ts und
use-palette-search.ts halten die beiden Zustandsstücke, die vorher darin
verheddert waren; PaletteInput.tsx, PaletteResults.tsx,
PaletteRowItem.tsx und PaletteStates.tsx rendern, mit ihren
Klassen-Rezepten in palette-styles.ts. Keine davon holt irgendetwas:
worker-client.ts startet search.worker.ts beim ersten Öffnen — oder bei
einem Pointer-Enter-Prefetch — und hält einen Client je Index-URL auf
Modulebene, sodass der Index eine View Transition überlebt, und fällt dort,
wo sich kein Modul-Worker konstruieren lässt, auf dieselbe Engine im
Haupt-Thread zurück.
apps/web/src/i18n
Paraglide kompiliert die Übersetzungen aus den vorhandenen, nach Namespaces
geordneten JSON-Katalogen. config.ts bleibt die Quelle der Wahrheit für
Locale, locales und defaultLocale; project.inlang/settings.json spiegelt
die vier Sprachen und pinnt das offizielle Katalog-Plugin. Das Vite-Plugin
schreibt typisierte, ignorierte message-modules nach
src/.generated/paraglide/.
messages.ts ist jetzt die gesamte Message-Zugriffsfläche der App, nicht mehr
der kleine Helfer von früher. .astro-Dateien und die .ts-Module, die nur sie
erreichen, lesen Nachrichten über useMessages(locale) — ein Proxy pro Sprache
über dieses generierte Barrel, indiziert über die eigenen Schlüssel des Katalogs
(t['header:language.label']()) statt über den Import einzelner
Message-Funktionen. Enum-Beschriftungstabellen speichern LabelRef-Daten —
einen Katalogschlüssel oder { key, context } — die über label(t, ref)
aufgelöst werden. Kein Prerender-Pfad ruft setLocale() auf, daher kann Astros
paralleler Build keinen Sprachzustand zwischen Seiten vermischen.
Alles, was Astro an den Browser ausliefern kann, ist die Ausnahme:
React-Islands, Module, die nur über einen Astro-<script>-Block erreicht
werden, und alles, was diese importieren — acht Dateien insgesamt — importieren
weiterhin genau die benötigten generierten Message-Funktionen und übergeben
{ locale } explizit, damit der Katalog in ihren Chunks Tree-Shaking-fähig
bleibt, statt vollständig über das Barrel gezogen zu werden.
tests/i18n/paraglide.test.ts berechnet diese clientseitig erreichbare Menge
und lässt den Build fehlschlagen, wenn eines ihrer Module stattdessen das Barrel
importiert.
Der Katalog-Validator lehnt weiterhin Abweichungen bei Sprachen, Namespaces und
Schlüsseln, ungültige Werte, alte Platzhalter, Interpolationsunterschiede und
fehlende Pluralkategorien ab. tests/i18n/paraglide.test.ts kompiliert außerdem
das Projekt und prüft Einstellungsparität, Kontextschlüssel, Zahlenformatierung
und alle sechs arabischen Pluralkategorien.
apps/web/src/styles/view-transitions.css
Jede animierte Navigation der Website wird hier konfiguriert, in einer
einzigen Stylesheet-Datei statt verstreut über Komponenten. Astros
<ClientRouter /> tauscht das Dokument aus, statt es neu zu laden, und die
View-Transition-API des Browsers macht aus diesem Austausch eine Animation —
diese Datei bestimmt, welche.
Bei einer Navigation geschehen zwei Dinge. Die Seite selbst blendet über, und
jedes als geteilt markierte Element wandelt sich von seiner alten in seine neue
Position: etwa das Titelbild einer Projektkarte, das zum Heldenbild der
Detailseite anwächst. Ein neues Paar hinzuzufügen kostet zwei Attribute je
Seite und kein JavaScript — transition:name zum Paaren, dazu
data-vt-image oder data-vt-text für die Voreinstellung.
Die Regeln stehen bewusst außerhalb jeder Layer. Astro gibt sein eigenes
Transition-CSS innerhalb von @layer astro aus, und ungelayerte Regeln
schlagen gelayerte — nichts hier braucht also !important.
Der größte Teil der Datei besteht aus Kommentaren, und diese halten Messwerte
fest, keine Vorlieben. Sowohl die Seitenblende als auch das Morphing geteilter
Elemente bekamen zunächst eine Kurve mit schnellem Start, und beide mussten
geändert werden: Bild für Bild aus der Browser-Animation abgetastet, lagen zwei
Drittel der Bewegung in den ersten drei Frames, was wie ein Sprung wirkt und
nicht wie ein Übergang. Die Zahlen stehen in der Datei, und
tests/projects/transitions.test.ts leitet sie aus diesen Variablen neu ab,
sodass ein Rückfall die Tests scheitern lässt, statt ausgeliefert zu werden.
Eine Regel ist als Abwesenheit geschrieben. Kopf- und Fußzeile erhalten
absichtlich keine eigenen Transition-Namen: Ein benanntes Element wird zur
Backdrop-Root, und die satinierte Pille der Kopfzeile lebt davon, dass
backdrop-filter die Seite dahinter abtastet — sie zu benennen ließ den
Glaseffekt auf jeder Seite still verschwinden. Die Seitenblende hält die
ausgehende Momentaufnahme darunter deckend, weshalb die Rahmenelemente nie
herausgelöst werden mussten: Sie wirken schlicht unverändert.
Ein Teil davon lässt sich nicht in CSS ausdrücken und lebt in
design-system/patterns/card-transitions.ts. Ein transition:name trägt genau
einen Wert, und ein Projekt-Cover trägt bewusst denselben Namen auf der
Startseite, auf dem Index und im Hero der Detailseite — genau dieses Teilen
lässt den Morph aus beiden Listen funktionieren, ohne Verdrahtung pro Seite.
Der Nebeneffekt: die beiden Listen teilen den Namen dann miteinander. Die
Navigation von der Startseite zum Index paarte alle vier hervorgehobenen Cover
und ließ sie aus 2752 px unterhalb des Falzes heranfliegen.
CardTransitions.install() hängt einen einzigen
astro:before-preparation-Listener ein, der sourceElement liest, zu dessen
[data-vt-card]-Vorfahren hochläuft und bei den Zielen jeder anderen Karte
viewTransitionName = 'none' inline setzt — inline, weil die Namen aus
Stylesheet-Regeln stammen und sich nicht einfach entfernen lassen, und nichts
stellt sie wieder her, weil der Router dieses Dokument ohnehin verwirft. Eine
History-Navigation trägt kein sourceElement, also bleibt jede Karte unbenannt
— und genau das verhindert, dass der Rückweg ebenfalls fliegt. Der Helfer
existiert nur, um den Morph von den falschen Karten fernzuhalten; er weiß
nichts von Projekten oder Blogs, weshalb er unter den Design-System-Patterns
sitzt statt in einem der beiden Features, die ihn aufrufen.
turbo.json
Sieben Tasks, und kein Wissen darüber, was die beiden Apps eigentlich sind.
build, lint, lint:fix, typecheck, sync, test und dev deklarieren
Reihenfolge und Caching und sonst nichts; wie ein Build abläuft, bleibt in den
package.json-Skripten der jeweiligen App. dev ist
cache: false, persistent: true, damit Turbo ihn angehängt lässt, statt zu
versuchen, einen Server zu cachen.
Der interessante Eintrag ist sync, das existiert, um ein Wettrennen
aufzulösen, und nicht, weil jemand einen Build-Schritt verlangt hätte.
apps/web/astro.config.mjs setzt cacheDir: '.astro' und legt damit bewusst
den Content-Layer-Store auf eine einzige Datei zusammen —
apps/web/.astro/data-store.json —, sodass Vitest, astro sync und
astro check denselben Inhalt lesen statt der zwei getrennten Kopien, die das
voreingestellte node_modules/.astro erzeugt. Der Preis dieser Zusammenlegung:
typecheck und test schreiben nun dieselbe Datei, und Turbo führt unabhängige
Tasks nebenläufig aus — in CI kollidierten sie und scheiterten mit einem
ENOENT beim Umbenennen von data-store.json.tmp. sync zu deklarieren und
beiden Tasks dependsOn: ["^…", "sync"] zu geben, lässt den Store einmal
entstehen, bevor einer von beiden ihn liest. sync ist die einzige Task mit
.astro/** als Output.
web#typecheck ist cache: false, und das ist Absicht und kein Versehen:
astro check synchronisiert lebende CMS-Inhalte durch jeden Loader, und kein
inputs-Hash kann eine Zeile sehen, die sich in Strapi geändert hat — ein
gecachter Durchlauf würde also über Inhalte berichten, die es nicht mehr gibt.
Die apps/web/turbo.json auf App-Ebene ergänzt nur die env-Listen: die fünf
Astro-Variablen, pro Task deklariert, weil Turbo im strikten Env-Modus läuft
und alles Undeklarierte stillschweigend entfernt.
Diese Seite und ihre Vorgänger
Fünf Fassungen in zweieinhalb Jahren. Jede hat das Problem der vorigen gelöst und ihr eigenes mitgebracht — meist, indem sie zu einem Framework griff, bevor das Problem groß genug dafür war.
v1Feb. 2024 – Aug. 2025
4 Commits
Von Hand geschriebene Seiten
- Rendering
- Statische Dateien
- Inhalt
- Fest im Markup
- Server
- Keiner
- Repositories
- Hadi-Hijazi · HadiHz88.github.io
Zwei Repositories, vier Commits, kein Build-Schritt: HTML und ein Stylesheet, auf GitHub Pages geschoben. Nichts zu kompilieren, nichts zu deployen, nichts zu bezahlen — und immer noch die schnellste Fassung, die diese Seite je hatte. Das Ende war absehbar: Inhalt und Layout lagen in derselben Datei, ein weiteres Projekt hinzuzufügen hieß also, einen Block Markup zu kopieren und sich an jede andere Stelle zu erinnern, die mitgeändert werden musste.
v2März–Apr. 2025
50 Commits
Laravel, und ein Frontend, das ich nicht geschrieben habe
- Rendering
- Client-gerendert
- Inhalt
- Datenbank hinter Laravel
- Server
- PHP und Node
- Repositories
- My-Platform-BackEnd · My-Platform-FrontEnd
Ich wollte ein echtes Backend — eine Datenbank, ein Admin-Panel, Authentifizierung — und Laravel lieferte alle drei an einem Nachmittag. Es zu lernen war der eigentliche Zweck, und das hat funktioniert. Das Frontend war generiert statt geschrieben, es hat mir also nie wirklich gehört, und nach drei Wochen war die Form des Problems klar: zwei Anwendungen, zwei Deployments und ein Server, der rund um die Uhr läuft, um eine Seite auszuliefern, die sich zweimal im Monat ändert.
v3Juli–Sept. 2025
144 Commits
Dieselbe Architektur, in einer Sprache
- Rendering
- Client-gerendert
- Inhalt
- Datenbank hinter NestJS
- Server
- Node
- Repositories
- my-website-backend · My-Website-Client
Die API in NestJS neu zu schreiben machte den Code wieder zu meinem: eine Sprache auf beiden Seiten, Typen durchgehend geteilt, und ein deutlich besserer Alltag. Was es nicht änderte, war die Architektur. Es blieb eine handgeschriebene CRUD-API für ein paar Dutzend Datensätze, eine Single-Page-App, die erst eine leere Fläche zeichnete und dann den Inhalt, und weiterhin zwei Dinge im Betrieb für eine Seite, bei der sich niemand anmeldet.
v4Nov. 2025 – Feb. 2026
92 Commits
Statisch, mit dem Inhalt im Repository
- Rendering
- Beim Build vorgerendert
- Inhalt
- JSON-Dateien im Repository
- Server
- Keiner
- Repositories
- HadiHz-Portfolio
Das Backend zu löschen hat das Performance-Problem sofort behoben. Next.js renderte jede Seite vor, der Inhalt lag als JSON neben dem Code, und es blieb keine Laufzeit übrig, die langsam werden oder ausfallen konnte. Der Aufwand verschwand nicht, er verschob sich: Jeder Tippfehler wurde zu einem Commit, JSON ist eine elende Oberfläche zum Schreiben, und eine zweite Sprache hätte bedeutet, parallele Dateien von Hand zu pflegen.
v5Apr. 2026 – heute
857 Commits
Astro und Strapi
- Rendering
- Vorgerendert, mit Islands
- Inhalt
- Strapi
- Server
- Keiner beim Lesen
- Repositories
- hadihz.me
Diese Fassung behält das Ergebnis der vorigen und gibt das Schreiben zurück. Strapi hält den Inhalt, Astro rendert jede Seite vor, und es wird kein JavaScript ausgeliefert, solange keine Komponente darum bittet — der Dateibaum oben und das Diagramm unten sind Islands, alles dazwischen ist reines HTML. Der verbleibende Kompromiss ist sichtbar statt versteckt: Veröffentlichen erfordert einen neuen Build, deshalb gibt es eine Deploy-Pipeline.
Commits pro Monat
Dieselben fünf Fassungen, gemessen. Eine Linie pro Fassung, gestrichelt, sobald sie die Seite nicht mehr ausgeliefert hat.
1.147 Commits in diesen Repositories
Daten vom 30.9.2026