Diese Website

Wie diese Website gebaut ist und was sich im Repository befindet, das sie erzeugt.

Repository

762 Dateien in 284 Verzeichnissen
apps
web
src
content.config.ts
.gitignore
AGENTS.md
astro.config.mjs
eslint.config.js
package.json
README.md
tsconfig.json
turbo.json
vitest.config.ts
.gitignore
.mcp.json
.npmrc
.nvmrc
.prettierignore
AGENTS.md
CLAUDE.md
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
prettier.config.js
README.md
renovate.json
turbo.json

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-config und packages/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 aus packages/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.

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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