Die Galaxie
ORBIT I · KONSTELLATION

Builders

Produkt ausliefern

Aus einer Idee etwas machen, das lädt, gut aussieht und deployt wird. Astro, Komponenten, Multi-Tenant, White-Label und i18n. Das Handwerk von Espejo und den Websites von XNLAB.

Versorgt EspejoXNLAB
6 Module · 6 Lektionen
MODULE DER KONSTELLATION Öffne jedes Modul für die ganze Lektion
BD-01

Astro & Vite, die schnell laden

Lektion

Eine Astro-Site aufsetzen und verstehen, die statisches HTML pro Route und Locale generiert, sowie ein Vite-SPA-Projekt – und dabei genau wissen, welches JS im Browser landet und warum die Site schnell lädt.

90% der Langsamkeit im modernen Web ist JavaScript, das gar nicht hätte ausgeliefert werden müssen. Astro und Vite geben dir Geschwindigkeit von Haus aus – aber nur, wenn du verstehst, wo jede Sache ausgeführt wird: build-time vs. runtime. Die beiden zu verwechseln ist die Grundursache von statischen Sites, die plötzlich 400KB JS wiegen.

DIE LEKTION

Fang mit der Unterscheidung an, die alles steuert: In Astro wird der Code einer .astro-Komponente im BUILD ausgeführt (auf deiner Maschine oder in CI), nicht im Browser. Das Ergebnis ist reines HTML. MOONKEY ist genau das: `astro.config.mjs` deklariert `i18n` mit `fallbackType: 'rewrite'` und nutzt KEIN SSR. Wenn du `npm run build` laufen lässt, durchläuft Astro `src/pages/`, führt jede .astro einmal pro Locale aus und schreibt HTML nach `dist/`. In Produktion gibt es keinen Server: Cloudflare Pages liefert Dateien aus. Genau das macht, dass `moonkeylab.pages.dev` in Millisekunden lädt.

Bau das Gerüst von Grund auf, um es ohne Magie zu sehen: `npm create astro@latest mi-sitio -- --template minimal --no-install`, dann `cd mi-sitio && npm install && npm run dev`. Öffne `http://localhost:4321`. Bearbeite `src/pages/index.astro` und schau, wie ein `const hoy = new Date()`, ausgegeben mit `{hoy}`, im Moment des Builds einfriert, sobald du `npm run build` machst. Das ist der Aha-Moment: Das JS im Frontmatter (zwischen den `---`) läuft einmal, nicht bei jedem Besuch.

Jetzt die Feinsteuerung: die Islands. Standardmässig wird eine Framework-Komponente (React/Svelte) in Astro zu statischem HTML gerendert OHNE ihr JS. Erst wenn du ihr eine `client:*`-Direktive gibst, wird sie hydratisiert. `client:load` hydratisiert beim Laden, `client:visible` wenn sie in den Viewport kommt, `client:idle` wenn der Thread frei ist. Handwerksregel: Fang OHNE Direktive an. Füge `client:visible` nur dann hinzu, wenn die Komponente echte Interaktivität BRAUCHT. In MOONKEY lebt der `IntersectionObserver` fürs Reveal in `Layout.astro` als globales `<script>`, nicht als Island pro Seite – das ist die richtige Entscheidung: ein 15-zeiliges Skript statt ein ganzes Framework zu hydratisieren.

Vite ist der Motor, der unter Astro steckt, und auch der von Espejo (das ein reines React-SPA ist: `vite.config.ts`, `@vitejs/plugin-react`). Der Unterschied: Espejo schickt React SEHR WOHL an den Browser, weil es live interaktiven State braucht (Kamera-Auslesungen mit MediaPipe, Navigation mit react-router). Das ist die echte Trennlinie des Handwerks: Ist der Inhalt überwiegend Lesen (Website von XNLAB, MOONKEY) oder ist es eine App mit dichtem State (Espejo)? Ersteres ruft nach Astro/SSG; Letzteres nach Vite+SPA. Misch die Religionen nicht: Stopf kein ganzes SPA in Astro hinein, wenn 95% Inhalt ist.

Miss, statt zu meinen. Lass `npm run build` laufen und schau dir die Grösse von `dist/` an. In MOONKEY sollte ein Build einer Content-Seite ~0KB Seiten-JS haben (nur das gemeinsame Reveal-Skript). Öffne DevTools → Network, lade mit leerem Cache neu und filtere nach JS: Wenn du auf einer Seite, die nur Text ist, ein Framework-Bundle siehst, hast du eine `client:*`-Direktive zu viel gesetzt oder einen Import, der eine schwere Library in den Client zieht. Der Befehl `npx astro build --verbose` listet dir auf, was vorgerendert wird.

Klassischer Failure Mode: eine Datums-/Markdown-/Icon-Library im Frontmatter importieren im Glauben, das sei build-only, sie aber innerhalb eines client-`<script>` verwenden oder an ein Island weiterreichen – und schon landet sie im Bundle. Ein anderer: vergessen, dass `import.meta.env` in Astro zwischen `PUBLIC_*` (geht in den Client) und dem Rest (bleibt im Build) unterscheidet. Wenn eine Variable, die du für privat hieltest, in `dist/` auftaucht, hast du sie falsch präfixiert. Und die Vite-Falle: Alles unter `public/` wird wortwörtlich ohne Verarbeitung kopiert – leg dort keine Geheimnisse ab und erwarte nicht, dass Vite diese Assets optimiert.

ÜBUNG

Erstelle eine minimale Astro-Site mit zwei Seiten: `/` (nur HTML, null JS) und `/contador` mit einer React-Komponente `<Counter client:visible />`. Lass `npm run build` laufen. Öffne `dist/` und weise mit DevTools Network nach, dass `/` 0KB JS herunterlädt und `/contador` erst hydratisiert, wenn du bis zum Button scrollst. Dokumentiere die exakte Grösse beider Bundles.

ERGEBNIS

Ordner `astro-islas/` mit dem Repo + einem `MEDICIONES.md`, das auflistet: Grösse von `dist/`, KB JS pro Seite (Network-Screenshot) und ein Satz, der erklärt, warum `/` nichts schickt und `/contador` schon.

KERNEINSICHT

Die richtige Frage ist nie 'Welches Framework nehme ich?', sondern 'Welches JS MUSS im Browser existieren?'. Astro kehrt den Default des Webs um: Es startet bei null JS und zwingt dich, jedes Byte Interaktivität mit einer expliziten Direktive zu rechtfertigen. An dem Tag, an dem du verinnerlichst, dass das Frontmatter im Build und nicht im Runtime läuft, hörst du auf, Müll zu verschicken.

ZU VERMEIDENDE FEHLER

  • ×Überall `client:load` setzen 'für alle Fälle' – du hydratisierst die ganze Site und verlierst Astros Vorteil; fang ohne Direktive an und füge `client:visible` nur dort ein, wo es echte Interaktion gibt.
  • ×Glauben, das JS im Frontmatter (zwischen `---`) laufe im Browser; es läuft im Build, ein einziges Mal, und seine Ausgabe friert im HTML ein.
  • ×Eine geheime Variable in `import.meta.env.PUBLIC_*` stecken – das Präfix PUBLIC legt sie in `dist/` offen; Nicht-Öffentliches bleibt im Build.
  • ×Verwechseln, wann man Astro vs. Vite-SPA nimmt: Lese-Inhalt → Astro/SSG; App mit dichtem, lebendigem State wie Espejo → Vite+React.
  • ×Annehmen, dass `public/` optimiert wird: Es wird wortwörtlich kopiert, ohne Hashing und Minifizierung – also nicht der Ort für Assets, die Vite verarbeiten soll.
BD-02

Komponenten und Design Tokens

Lektion

Ein kohärentes visuelles System mit Design Tokens (CSS Custom Properties) und wiederverwendbaren Komponenten bauen, statt mit verstreutem, wiederholtem CSS – so, dass ein Markenwechsel eine Zeile ist und keine Schnitzeljagd.

Verstreutes CSS skaliert nicht: An dem Tag, an dem der Kunde 'das Violett ein bisschen dunkler' will, suchst du am Ende `#7c3aed` in 40 Dateien und übersiehst drei. Tokens verwandeln diesen Wechsel in das Editieren einer einzigen Variable. Das ist der Unterschied zwischen einem wartbaren Produkt und visuellen Schulden.

DIE LEKTION

Ein Design Token ist eine Variable mit semantischem Namen, kein wiederholter Rohwert. MOONKEY definiert seine in `src/styles/global.css`: `--surface-base: #f8fafc`, `--text-primary: #0f172a`, dazu die Palette emerald/violet/amber. Die goldene Regel: Komponenten verwenden NIE den Hex direkt, sie verwenden das Token. So taucht `--surface-base` einmal auf; ändert sich die Palette, ändert sich eine Stelle. Das Antipattern ist, `color: #0f172a` in jede Card zu schreiben – du hast 30 Dateien an einen Wert gekoppelt, der einen Namen haben sollte.

Das lehrreichste Beispiel des Handwerks steckt in Espejo, denn dort sind die Tokens DYNAMISCH pro Tenant. Schau dir `src/brand/BrandProvider.tsx` an: Die Funktion `applyTheme(brand)` macht `root.style.setProperty('--brand', brand.accent)` und leitet `--brand-soft`, `--brand-line`, `--brand-glow` mit einer Funktion `withAlpha(hex, a)` ab, die den Marken-Hex in rgba mit Transparenz umwandelt. Die gesamte öffentliche UI von Espejo zeichnet mit `var(--brand)`. Ergebnis: Derselbe Code bedient Luna Rosa (Rosa `#e58fb0`) und jede beliebige Influencerin, indem EIN Feld `accent` geändert wird. Das ist ein Design System, das zugleich der Motor des White-Labels ist.

Komponente vs. lose Klasse: Eine Komponente kapselt Struktur + Tokens + Verhalten unter einem Namen. MOONKEY hat `PromptBlock`, `AnimalIcon`, `MoonkeyLogo`, `FlowDiagram` in `src/components/`. Das Signal, dass du eine Komponente brauchst: Du kopierst denselben Block aus Markup+Klassen ein drittes Mal. Bis zwei Mal: dupliziere; beim dritten Mal: extrahiere. Abstrahiere nicht vorher – die verfrühte Abstraktion einer Komponente mit 8 Props 'für alle Fälle' ist schlimmer als ehrliche Duplikation.

Mit Tailwind gibt es eine spezifische Falle, die MOONKEY ausdrücklich dokumentiert: Interpoliere NIE Klassen wie `bg-${theme}`. Tailwind macht Tree-Shaking, indem es den Code im Build nach VOLLSTÄNDIGEN Klassen-Strings durchsucht; eine zur Laufzeit konstruierte Klasse existiert im generierten CSS nicht und wird ohne Stil gerendert. Deshalb hat `galaxy.ts` ein Objekt `THEME`, das jede Konstellation auf ihre bereits ausgeschriebenen literalen Klassen abbildet (`violet`, `amber`, `emerald`…). Die Lektion: Dynamische Klassen löst man mit einer MAP literaler Strings auf, nicht mit Interpolation.

Typografie als System-Token: MOONKEY legt `font-display` = Space Grotesk für h1–h3 fest, Inter für Body, JetBrains Mono für Labels (`.label` = mono uppercase tracking-widest emerald-600). Das ist keine lose Dekoration: Es ist eine einmal codierte Entscheidung, die die ganze App erbt. Würde jede Überschrift ihre eigene Schrift wählen, hättest du keine Marke, sondern Lärm. Das System lebt in der Token-/Utility-Schicht, nicht in jedem einzelnen `<h1>`.

Wie man Kohärenz prüft: Mach ein grep nach Rohwerten, die ausserhalb der Token-Datei NICHT existieren dürften. `grep -rn '#[0-9a-fA-F]\{6\}' src/components/` sollte in MOONKEY fast nichts zurückgeben – wenn ein Hex in einer Komponente auftaucht, ist es ein ausgebrochenes Token, das man durch `var(--token)` ersetzen muss. Dieses grep ist dein selbstgebauter Linter für visuelle Kohärenz, und du solltest es vor jedem Merge laufen lassen.

ÜBUNG

Nimm eine Landingpage mit 3 Sektionen, die wiederholte Hex verwendet. Extrahiere ALLE Farben und Radien in Tokens in einem `:root` (`--surface`, `--text`, `--accent`, `--radius`). Repliziere dann das Espejo-Pattern: Füge eine Funktion `applyTheme(accent)` hinzu, die `--accent` zur Laufzeit ändert, und zeig, dass mit EINEM Aufruf die ganze Seite die Marke wechselt. Verifiziere mit `grep`, dass kein Hex ausserhalb des Token-Blocks überlebt.

ERGEBNIS

Ordner `tokens-tenant/` mit `styles/tokens.css` (alle Variablen), den Komponenten, die `var(--token)` verwenden, und einem Button, der `applyTheme()` mit 3 verschiedenen Farben aufruft. Dazu ein `grep`-Log, das 0 lose Hex in Komponenten nachweist.

KERNEINSICHT

Ein Design System ist keine Bibliothek hübscher Komponenten: Es ist die Indirektion, die 'welcher Wert' von 'wo wird er verwendet' trennt. Der ultimative Test ist der von Espejo – wenn du die gesamte Marke eines White-Label-Produkts mit einem einzigen `setProperty` ändern kannst, hast du ein System; wenn du mehrere Dateien anfassen musst, hast du verkleidetes CSS.

ZU VERMEIDENDE FEHLER

  • ×Tailwind-Klassen interpolieren (`bg-${theme}`) – der JIT sieht sie im Build nicht und sie werden ohne Stil gerendert; nimm eine Map literaler Strings wie das `THEME` aus galaxy.ts.
  • ×Rohe Hex in Komponenten schreiben statt `var(--token)` – du koppelst N Dateien an einen Wert, der einen einzigen Änderungspunkt haben sollte.
  • ×Eine Komponente mit 8 Props 'für alle Fälle' abstrahieren, bevor du 3 echte Verwendungen hast; ehrliche Duplikation ist besser als verfrühte Abstraktion.
  • ×Jede Überschrift ihre Schrift wählen lassen, statt die typografische Hierarchie einmal in der Token-Schicht festzulegen.
  • ×Vergessen, die Zustände der Markenfarbe abzuleiten (soft/line/glow): Ein einzelner Accent ohne seine Alpha-Varianten erzeugt inkohärente Schatten und Ränder – kopiere das `withAlpha`-Pattern von Espejo.
BD-03

Multi-Tenant & White-Label

Lektion

Ein echtes multi-tenant White-Label-Produkt entwerfen: eine einzige Codebasis, die viele Kunden mit eigener Marke, eigenen Daten und eigener Konfiguration bedient – mithilfe des Seam-Patterns von Store, das die UI vom Storage isoliert.

White-Label ist das Geschäftsmodell, das einen Build mit N Kunden multipliziert, ohne irgendetwas neu zu schreiben. Aber es funktioniert nur, wenn die Isolation zwischen Tenants echt ist: Ein Datenleck von einem Kunden zum anderen tötet das Produkt. Der Store-Seam von Espejo ist die Architektur, die das ermöglicht und zugleich die Migration zum Backend vorbereitet.

DIE LEKTION

Multi-tenant bedeutet: eine Instanz der Software, viele Kunden (Tenants), die sie als ihre eigene wahrnehmen. White-Label fügt hinzu: Jeder Tenant sieht sie mit SEINER Marke, ohne Spur von deiner. Espejo ist der kanonische Fall – `src/brand/types.ts` sagt es im Kommentar: 'jede Influencerin ist ein Tenant mit ihrer Marke'. Der Tenant wird über einen `slug` in der URL identifiziert (`/r/<slug>`) und alles – Name, Accent, Glyph, Stimme, welche Readings angeboten werden, der Upsell-CTA – kommt aus einem Objekt `Brand`. Die gesamte öffentliche App wird aus diesem Objekt gezeichnet.

Das architektonische Herzstück ist der SEAM der Daten. Schau dir `src/brand/store.ts` an: Es definiert ein `interface Store` mit async-Signaturen (`getBrand`, `listBrands`, `saveBrand`, `addSubscriber`, `listSubscribers`) und eine Implementierung `LocalStore` über localStorage. Der Kommentar macht es explizit: 'Heute: localStorage. Morgen: Supabase (gleiche Signaturen). Die UI fasst den Storage nie direkt an, nur dieses Interface.' Dieses Interface ist der Seam: Die UI hängt von der ABSTRAKTION ab, nicht von der Implementierung. Den Backend wechseln = eine neue Klasse schreiben, die `Store` implementiert, ohne eine einzige Komponente anzufassen.

Wie der Tenant in die UI gelangt: `BrandProvider.tsx` erhält den `slug`, ruft `store.getBrand(slug)` auf, und wenn er existiert, macht es `applyTheme(brand)` und legt das Brand in einen React Context (`useBrand()`). Wenn er nicht existiert, markiert es `notFound`. Sauberes Pattern: Ein Einstiegspunkt löst den Tenant auf, wendet sein Theme an und stellt es dem ganzen Baum bereit. Jede beliebige Komponente macht `const brand = useBrand()` und zeichnet ihr Eigenes. Niemand sonst fasst den Storage an oder entscheidet über das Theme.

Die Isolation zwischen Tenants ist die rote Linie des White-Labels, und hier liegt das echte Risiko: `listSubscribers(slug)` filtert nach `brandSlug`. In localStorage ist das trivial, aber FRAGIL – alles lebt im selben Browser, es gibt keine echte Sicherheitsgrenze. Der kritische Moment ist die Migration zu Supabase: Dort MUSS sich die Isolation zu Postgres RLS verlagern (eine Subscriber-Zeile ist nur für ihren Tenant sichtbar), genau die Disziplin, die MOONKEY mit `is_admin()` und self-or-admin-Policies anwendet. In echtem Multi-tenant ist der Filter `WHERE slug = ...` im Client KEINE Sicherheit; die Sicherheit ist RLS in der Datenbank.

Konfiguration pro Tenant ohne Code-Verzweigungen: Beachte, dass Espejo nirgends ein `if (tenant === 'lunarosa')` hat. Die Variation sind DATEN (`Brand.readings`, `Brand.voice`, `Brand.offer`), keine bedingte Logik. Das ist die Regel des gesunden White-Labels: Die Unterschiede zwischen Kunden leben in der Konfiguration (Daten), nie in Code-Verzweigungen. In dem Moment, in dem du das erste `if (Kunde X)` schreibst, hast du angefangen, das Produkt zu forken und die Ökonomie des Modells zu verlieren.

Es gibt in Espejo ein spezifisches ethisches Geländer, das nicht optional ist: Das Projektgedächtnis hält fest, dass KEIN verdecktes Abschöpfen von Leads und kein Verkauf persönlicher/biometrischer Daten gebaut wird – nur eingewilligte First-Party-Daten und anonymisierte Aggregate. In einem Multi-tenant-Produkt, das Daten der Endnutzer deiner Kunden verarbeitet, ist das Einwilligungsmodell (`types.ts` hat eine Version des akzeptierten Einwilligungstexts und `Brand.legal.responsible`) Teil der Architektur, kein nachgelagerter rechtlicher Zusatz. Der Verantwortliche der Verarbeitung ist der Influencer-Tenant, und das muss in den Daten modelliert sein.

ÜBUNG

Bau ein Mini-White-Label-Produkt: ein Interface `Store` mit `getTenant(slug)`/`listItems(slug)`, ein `LocalStore` über localStorage mit 2 Seed-Tenants (unterschiedlicher Accent, unterschiedlicher Name). Ein `TenantProvider`, der den Slug aus der URL auflöst, das Theme anwendet und es per Context bereitstellt. Zeig, dass die gesamte App allein durch das Ändern des Slugs in der URL die Marke wechselt, ohne ein einziges `if` pro Kunde.

ERGEBNIS

Ordner `white-label-seam/` mit `store.ts` (interface + LocalStore), `TenantProvider` und zwei Routen `/t/<slug>`, die zwei verschiedene Marken aus DEMSELBEN Code rendern. Eine `README`, die zeigt, wo der Seam ist und was man ändern würde, um zum Backend zu migrieren.

KERNEINSICHT

Der Store-Seam von Espejo ist das wertvollste Ingenieurstück des Produkts: Er trennt 'was die App tut' von 'wo die Daten leben' hinter einem Interface aus 5 Signaturen. Diese Indirektion ist es, die eine localStorage-Demo in ein multi-tenant SaaS verwandelt – und die dafür sorgt, dass der Slug-Filter des Clients nie mit echter Sicherheit verwechselt werden kann (die lebt in RLS).

ZU VERMEIDENDE FEHLER

  • ×Nach Tenant nur im Client filtern (`WHERE slug=...` in JS) und glauben, das isoliere Daten – im Backend MUSS die Isolation RLS in Postgres sein, kein Applikationsfilter.
  • ×Ein `if (Kunde === 'X')` in den Code stecken: Sobald du nach Kunde verzweigst, hast du das Produkt geforkt; die Variation gehört in Daten/Config, nie in Logik.
  • ×Die UI direkt an localStorage/Supabase koppeln statt an das Interface `Store` – du verlierst den Seam und die Migration wird zur Neufassung.
  • ×Das Einwilligungsmodell und den Verantwortlichen der Verarbeitung pro Tenant vergessen: In Multi-tenant mit Endnutzerdaten ist das Architektur, kein Formalismus (ethische Grenze von Espejo: kein verdecktes Abschöpfen von Daten).
  • ×Den Fall `Tenant nicht gefunden` nicht definieren: Ohne ein explizites `notFound` bricht ein ungültiger Slug die App, statt einen sauberen Zustand zu zeigen.
BD-04

i18n: eine Galaxie in 6 Sprachen

Lektion

Internationalisierung für 6 Sprachen architektonisch anlegen, ohne Seiten zu duplizieren – mit Verständnis für die Übersetzungs-Patterns, den Fallback und warum die Hrefs locale-aware sein müssen.

Mit Gewalt gemachtes i18n (ein Seitenordner pro Sprache) multipliziert die Wartung mit 6 und garantiert, dass die Übersetzungen auseinanderdriften. Die richtige Architektur hat EINE Vorlage, und die Sprachen sind Daten, keine Kopien. Das ist der Unterschied zwischen auf 6 Locales zu skalieren oder darin zu ertrinken.

DIE LEKTION

MOONKEY läuft mit 6 Locales – `['es','en','th','fr','it','ch']` (Config in `src/i18n/config.ts`). ES lebt im Root; der Rest unter Präfix (`/en/`, `/th/`, `/fr/`, `/it/`, `/ch/`). Das Stück, das das Duplizieren von Seiten vermeidet, ist Astro i18n mit `fallbackType: 'rewrite'` in `astro.config.mjs`: Während des Builds SCHREIBT Astro jede Seite mit dem Kontext jedes Locales um, sodass ein einziges `index.astro` 6 übersetzte HTML generiert. Es gibt kein `pages/en/index.astro`, `pages/fr/index.astro`… es gibt EINS. Das ist die zentrale Regel: eine Vorlage, N Ausgaben.

Der Fallback macht das System robust, ohne alles auf einen Schlag übersetzen zu müssen: `astro.config.mjs` deklariert `fallback: { en:'es', th:'es', fr:'es', it:'es', ch:'es' }`, und die Funktion `useTranslations` in `src/i18n/utils.ts` implementiert dieselbe Kette: `dict[key] ?? base[key] ?? key`. Heisst: Fehlt ein Key auf Französisch, fällt er auf Spanisch; ist er auch dort nicht, gibt er den Key selbst zurück (sichtbar, leicht aufzuspüren). Das erlaubt dir, mit Teilübersetzungen zu LANCIEREN, ohne etwas zu brechen – das Französische zeigt Spanisch dort, wo du noch nicht übersetzt hast, keine kaputte Seite.

MOONKEY nutzt DREI i18n-Patterns, und CLAUDE.md warnt vor der Inkonsistenz, was an sich eine Handwerkslektion ist: (1) Dictionary `src/i18n/ui.ts` mit `t('key')` für Chrome/Nav-Kurztexte (~240 Keys); (2) Inline-Content-Objekte mit `cLocale = (es|th) ? locale : 'en'`, das FR/IT/CH auf EN kollabiert, für schwere Inhalte wie das Curriculum; (3) Seiten `src/pages/en/*.astro`, die NUR auf Englisch überschreiben. Pattern 3 ist Schuld: Es gibt 5 Root-Seiten, die auf Spanisch hartcodiert sind, sodass `/th/fr/it/ch` dort ES zeigen. Lektion: Wähle EIN dominantes Pattern (das Dictionary mit Fallback) und behandle die anderen als abzuzahlende Schuld, nicht als gesunde Vielfalt.

Der teuerste Bug von i18n mit Präfixen ist der rohe Href. Wenn du `<a href='/cuenta'>` von einer Seite unter `/fr/` schreibst, schickst du den Nutzer aus seiner Sprache hinaus. Deshalb hat MOONKEY `localizedPath(pathname, target)` in `utils.ts`: Es entfernt das aktuelle Locale-Präfix, baut den Pfad neu auf und stellt das Target voran (`/fr/cuenta`), wobei ES im Root bleibt. CLAUDE.md markiert es als verpflichtend: 'nimm `localizedPath('/ruta', locale)`, nie rohes `/ruta`'. Verinnerliche das: Auf einer Site mit Sprachpräfixen geht JEDER Link durch die Lokalisierungsfunktion, ohne Ausnahme.

Das aktive Locale zu erkennen hat eine Feinheit, die `utils.ts` löst: `resolveLocale(currentLocale, pathname)` bevorzugt `Astro.currentLocale` gegenüber der URL-Erkennung, weil während eines Fallback-Rewrites die URL das Präfix verloren haben kann und nur der Astro-Kontext das echte Locale kennt. Wenn du das Locale nur über `pathname.split('/')` erkennst, irrst du dich genau auf den Seiten, die Fallback nutzen. Regel: Vertraue zuerst dem Framework-Kontext, der URL nur als Rückfall.

Der Sprachselektor braucht zwei getrennte Dictionaries: `LOCALE_LABELS` (ES, EN, TH…) für den kompakten Switcher und `LOCALE_NAMES` (Español, ไทย, Italiano…) für das vollständige Dropdown. Und Achtung bei den Sonderfällen: 'ch' ist hier Deutsch (CH), Schweizerdeutsch, nicht Chinesisch – ein Kommentar in `config.ts` stellt das klar. Genau diese Art von Mehrdeutigkeit (ein Code, der nach einer anderen Sprache aussieht) dokumentierst du im Code, damit nicht in sechs Monaten jemand in die falsche Sprache übersetzt.

ÜBUNG

Bau eine Astro-Site mit 3 Locales (es im Root, en/fr mit Präfix). Erstelle EIN `index.astro`, das `t('hero.title')` aus einem Dictionary `ui.ts` nutzt. Implementiere `useTranslations` mit Fallback `dict[key] ?? base[key] ?? key` und `localizedPath`. Lass einen Key in FR UNübersetzt und zeig, dass er auf ES fällt (nicht bricht). Setz einen Sprachselektor, der `localizedPath` nutzt, um den Nutzer nicht aus seinem Locale zu reissen.

ERGEBNIS

Ordner `i18n-3locales/` mit `i18n/config.ts`, `ui.ts`, `utils.ts` (useTranslations + localizedPath), einem einzigen `index.astro`, das 3 HTML generiert, und einem Screenshot, der FR beim unübersetzten Key auf ES fallen zeigt.

KERNEINSICHT

Gut gemachtes i18n macht die Sprache zu einem Datenparameter über EINER Vorlage, nicht zu Kopien von Seiten. Der verkettete Fallback (`dict ?? base ?? key`) ist es, der dir erlaubt, mit halben Übersetzungen zu lancieren, ohne kaputte Seiten – und `localizedPath` in jedem Href ist der unsichtbare Unterschied zwischen einem Nutzer, der in seiner Sprache bleibt, und einem, der beim ersten Klick auf den Root fällt.

ZU VERMEIDENDE FEHLER

  • ×Seiten pro Sprache duplizieren (`pages/fr/index.astro`, `pages/en/index.astro`…) statt einer Vorlage mit Rewrite – du multiplizierst die Wartung mit 6 und die Übersetzungen driften auseinander.
  • ×Rohe Hrefs schreiben (`/cuenta`) unter einem Locale-Präfix: Du reisst den Nutzer aus seiner Sprache; jeder Link geht durch `localizedPath`.
  • ×Das Locale nur über die URL erkennen: Während eines Fallback-Rewrites verliert die URL das Präfix; bevorzuge `Astro.currentLocale` (`resolveLocale`).
  • ×Drei i18n-Patterns ohne ein dominantes mischen – MOONKEY schleppt deswegen schon Schuld mit (5 in ES hartcodierte Seiten, die th/fr/it/ch gezeigt werden).
  • ×Die Bedeutung eines Locale-Codes annehmen: 'ch' ist hier Schweizerdeutsch, nicht Chinesisch; dokumentiere die mehrdeutigen in der Config, sonst übersetzt jemand falsch.
BD-05

Deploy: Cloudflare, Domains, env

Lektion

Eine Site von localhost auf eine echte URL bei Cloudflare Pages bringen, mit eigener Domain und sicher verwalteten Umgebungsvariablen – und dabei unterscheiden, welche env in den Client geht und welche nie.

Ein Projekt, das nicht deployt ist, existiert für niemanden. Und das Deploy ist die Stelle, an der die meisten Geheimnisse durchsickern: Die Linie zwischen einer öffentlichen und einer privaten Variable ist unsichtbar, bis dein service_role-Key im Produktions-Bundle auftaucht. Gut deployen zu können ist die Hälfte vom Shippen.

DIE LEKTION

MOONKEY wird auf Cloudflare Pages (`moonkeylab.pages.dev`) aus dem Repo `garciafradepablo-pixel/xop` deployt. Der Basis-Flow: Du verbindest das GitHub-Repo mit Cloudflare Pages, definierst das Build Command (`npm run build`) und das Output Directory (`dist/` in Astro). Jeder Push auf main löst einen Build in CI aus und publiziert. Du lädst keine Dateien von Hand hoch: Das Deploy ist eine Folge von git push. Das gibt dir Reproduzierbarkeit – was in Produktion ist, ist exakt das, was im Commit ist.

Für ein reines SSG wie MOONKEY liefert Cloudflare nur die statischen HTML des Builds aus; es gibt keinen Server-Runtime. Das ist zentral fürs Sicherheitsmodell (siehst du in Ops): Da es keinen server-side Handler gibt, zählt in Produktion nur die env, die IM BUILD injiziert wurde. Wenn deine App Server-Logik braucht (nicht der Fall bei MOONKEY noch bei XNLAB-Content), kommen da Cloudflare Functions/Workers ins Spiel – aber setz sie nicht ein, wenn du sie nicht brauchst.

Die Grenze der Geheimnisse ist DIE Lektion dieses Moduls. In Astro/Vite werden die Variablen `PUBLIC_*` (Astro) oder `VITE_*` (reines Vite wie Espejo) ins Client-Bundle injiziert – sie sind SICHTBAR für jeden, der die DevTools öffnet. Alles andere bleibt im Build. MOONKEY setzt den Supabase anon key absichtlich öffentlich: CLAUDE.md sagt 'nur anon key' in `src/lib/supabase.ts`, weil der anon key DAFÜR GEDACHT IST, öffentlich zu sein (seine Macht begrenzt RLS, nicht das Geheimnis). Die absolute Regel: Der `service_role`-Key trägt NIE ein öffentliches Präfix, geht NIE auf eine statische Site. Wenn du ihn dort hinsetzt, ist er im Bundle, und es ist game over.

In Cloudflare konfigurierst du die env vars im Dashboard (Settings → Environment variables) und trennst Production von Preview. Die, die dein Build dem Client zeigen muss, tragen das richtige Präfix; die, die nur der Build-Prozess nutzt (API-Tokens von Dritten zum Generieren von Inhalten), tragen es nicht und landen nicht in `dist/`. Pflicht-Verifikation nach dem Deploy: Lade dein Produktions-JS herunter und mach `grep -ri 'service_role\|secret\|sk_'` über `dist/`. Taucht etwas auf, hast du ein Leck, das du sofort rotieren musst.

Eigene Domain: In Cloudflare Pages, Custom domains → du fügst deine Domain hinzu, Cloudflare erstellt die DNS-Einträge (ist die Domain bei Cloudflare, geht das automatisch; sonst fügst du einen CNAME auf das `*.pages.dev` hinzu). HTTPS ist automatisch. Das `.pages.dev` funktioniert weiter als Backup-URL. Für Previews generiert jeder PR/Branch seine eigene flüchtige URL – nutze sie, um Änderungen vor dem Merge auf main zu prüfen, ohne Produktion anzufassen.

Echte Deploy-Failure-Modes: (1) Der Build läuft lokal durch, scheitert aber in CI, weil eine Abhängigkeit lokal in `node_modules` war, aber nicht in `package.json` – lauf `npm ci` (nicht `install`) im sauberen Zustand, um CI zu reproduzieren. (2) Variablen, die lokal existieren (`.env`), die du aber nicht im Dashboard konfiguriert hast → Build läuft durch, aber die App scheitert im Runtime mit undefined. (3) Die kosmetische Astro-Warnung zu `Astro.request.headers` in statischen Builds mit i18n-Rewrite – `astro.config.mjs` dokumentiert sie als harmlos, der Build läuft durch. Jag keinen Warnungen hinterher, die die Config bereits als erwartet markiert hat.

ÜBUNG

Deploy eine Astro-Site auf Cloudflare Pages aus einem GitHub-Repo: konfiguriere Build Command `npm run build`, Output `dist/`, und eine Variable `PUBLIC_API_BASE` sowie eine NICHT öffentliche `BUILD_TOKEN`. Nach dem Deploy lade das Produktions-JS herunter und weise mit `grep` nach, dass `PUBLIC_API_BASE` im Bundle auftaucht und `BUILD_TOKEN` NICHT. Füge eine Domain (oder Subdomain) hinzu und verifiziere HTTPS.

ERGEBNIS

Eine lebendige öffentliche URL + ein `DEPLOY.md` mit: der Build-Config, der Tabelle, welche Variable öffentlich ist und warum, und dem `grep`-Log, das nachweist, dass das Build-Token nicht zum Client durchgesickert ist.

KERNEINSICHT

Das Deploy ist kein 'Dateien hochladen', es ist das Einfrieren eines Commits auf eine URL mit reproduzierbarem CI. Und der einzige irreparable Fehler ist der mit Geheimnissen: Da eine statische Site keinen Server hat, wird alles, was sie in Produktion braucht, im Build injiziert – deshalb kann der anon key (von RLS begrenzt) öffentlich sein und der service_role nie. Das `grep` über `dist/` ist dein letztes Netz, bevor du etwas durchsickern lässt.

ZU VERMEIDENDE FEHLER

  • ×Einen privaten Key (service_role, API-Secret) mit Präfix `PUBLIC_`/`VITE_` oder auf eine statische Site setzen – er bleibt sichtbar im Bundle; nur Keys, die dafür gedacht sind, öffentlich zu sein (anon key, von RLS begrenzt), gehen in den Client.
  • ×Den Build von Hand hochladen, statt das Repo zu verbinden: Du verlierst die Reproduzierbarkeit und weisst nicht, welcher Commit in Produktion ist.
  • ×env vars in der lokalen `.env` konfigurieren, sie aber im Cloudflare-Dashboard vergessen → der Build läuft durch und die App zerschellt im Runtime.
  • ×`npm install` nutzen, um CI zu reproduzieren; nimm `npm ci` im sauberen Zustand, das die Lockfile respektiert und den echten Abhängigkeitsfehler reproduziert.
  • ×Nach dem Deploy nicht mit `grep` über `dist/` prüfen: Das Durchsickern eines Geheimnisses ist still, bis es jemand findet.
BD-06

Von localStorage zum Backend

Lektion

Die Datenschicht von localStorage auf ein Backend (Supabase) migrieren, ohne die Anwendung neu zu schreiben – indem du den Store-Seam nutzt, sodass der Wechsel ein Implementierungs-Swap ist, kein Refactor.

Jede App fängt mit lokalen Daten an, um schnell zu sein, aber localStorage teilt nicht zwischen Geräten, persistiert nicht wirklich und hat keine echte Sicherheit. Der Sprung zum Backend ist unvermeidlich – und wenn deine Architektur ihn nicht vorgesehen hat, ist dieser Sprung eine schmerzhafte Neufassung. Mit dem richtigen Seam ist er ein Nachmittag.

DIE LEKTION

Hol dir den Seam aus BD-03 zurück: In Espejo definiert `src/brand/store.ts` ein `interface Store` mit async-Signaturen, und `LocalStore` implementiert es über localStorage. Der Kommentar ist die ganze These des Moduls: 'Heute: localStorage. Morgen: Supabase (gleiche Signaturen).' Weil das Interface BEREITS async ist (`Promise<Brand | null>`), merkt die UI den Unterschied zwischen Lesen aus dem Speicher oder aus dem Netz nicht – das `await` ist schon da. Eine synchrone API hätte die Migration unmöglich gemacht, ohne jeden Aufruf anzufassen. Den Seam von Tag eins an async zu entwerfen ist die Entscheidung, die sich hier auszahlt.

Der konkrete Swap: Du schreibst eine Klasse `SupabaseStore implements Store`. `getBrand(slug)` wechselt vom Lesen aus localStorage zu `await supabase.from('brands').select().eq('slug', slug).single()`. `addSubscriber` wechselt von `write()` zu `.insert()`. Die Signaturen sind IDENTISCH. Am Ende ändert eine Zeile die Welt: `export const store: Store = new SupabaseStore()` statt `new LocalStore()`. Null angefasste Komponenten. Das ist es, was 'die Datenschicht wechseln, ohne die App neu zu schreiben' bedeutet: Der restliche Code hängt von `Store` ab, nicht von localStorage.

Aber den Storage zu migrieren ist die einfache Hälfte. Die ernste Hälfte ist die SICHERHEIT, und hier ändert sich das ganze Modell. In localStorage gibt es keine Grenzen: Alles lebt im Browser des Nutzers. In Supabase, da der Client nur den anon key trägt (MOONKEY: `src/lib/supabase.ts`, 'nur anon key'), ist die Autorität NICHT der Client – es ist Postgres RLS. MOONKEY formuliert es unmissverständlich: 'Autorität = RLS, nicht der Client'. Wenn Espejo die Subscribers migriert, MUSS der Filter `WHERE brandSlug = slug`, den es heute in JS macht, zu einer RLS-Policy werden, die garantiert, dass ein Tenant nur SEINE Subscribers liest. Der Client-Filter war Bequemlichkeit; RLS ist Sicherheit.

RLS-Referenzpattern in MOONKEY: Die Tabellen `profiles/progress/feedback/proofs` haben SELECT 'self-or-admin' über `is_admin()` (SECURITY DEFINER-Funktion), per Impersonation verifiziert – ein Nicht-Admin kann keine fremden Zeilen lesen. `proofs` INSERT verlangt eine Session und bindet `user_id = auth.uid()` (Policy `proofs_insert_self`). Sensible Spalten (`role`, `founder_badge`) sind für Nicht-Admins unveränderlich über einen Trigger-Guard, sodass sich niemand selbst hochstuft. Das ist die mentale Vorlage für jede Migration: Jede Operation (SELECT/INSERT/UPDATE) braucht eine Policy, die hält, AUCH WENN der Angreifer den Client kontrolliert.

Hygiene der Datenmigration: Du brauchst ein Schema (Tabelle `brands`, Tabelle `subscribers` mit `brand_slug`), angewandt als versionierte MIGRATION (kein loses SQL in der Konsole – MOONKEY: 'Änderungen an RLS/Sicherheit: versionierte Migration + `get_advisors` danach'). Dann ein einmaliges Skript, das das bestehende localStorage liest und ein Massen-`insert` ins Backend macht, idempotent (sodass du es ohne Duplizieren neu laufen lassen kannst). Und eine optionale Doppel-Schreibphase, falls du die App nicht stoppen kannst: Du schreibst in beide, bis du Parität bestätigst, dann kappst du localStorage.

Achtung auf die ethische Grenze, die Espejo mitschleppt: Das Projektgedächtnis verbietet verdecktes Abschöpfen persönlicher/biometrischer Daten; nur eingewilligte First-Party-Daten und anonyme Aggregate. Die Migration zum Backend ERHÖHT die Macht der Daten (jetzt persistieren sie, werden verknüpft, exportiert), sodass das Einwilligungsmodell, das in localStorage fast theoretisch war, real und prüfbar wird. Die technische Migration und die Verantwortung über die Daten steigen gemeinsam – migriere den Storage nicht, ohne auch die Datenschutzgarantien zu migrieren.

ÜBUNG

Nimm den `white-label-seam` aus BD-03 (interface Store + LocalStore). Schreibe einen `SupabaseStore implements Store` mit den GLEICHEN Signaturen gegen eine echte Supabase-Tabelle. Migriere, indem du EINE Zeile änderst (`new LocalStore()` → `new SupabaseStore()`), ohne Komponenten anzufassen. Wende eine RLS-Policy an, die garantiert, dass `listItems(slug)` nur Zeilen des richtigen Tenants zurückgibt, und verifiziere es, indem du einen anderen Nutzer impersonierst.

ERGEBNIS

Ordner `store-migration/` mit `LocalStore` und `SupabaseStore` nebeneinander, dem Ein-Zeilen-Diff, der den Swap macht, der versionierten SQL-Migration mit der RLS-Policy, und einem Log, das nachweist, dass ein Tenant die Daten eines anderen NICHT lesen kann (Impersonations-Test).

KERNEINSICHT

Der Store-Seam verwandelt eine Backend-Migration von einer Neufassung in einen Ein-Zeilen-Swap – aber nur, wenn das Interface von Anfang an async war. Und der Teil, der wirklich zählt, ist nicht das Verschieben der Bytes: Es ist, dass beim Übergang zu einem Client mit anon key die Autorität sich von der App zu Postgres RLS verlagert. Der Slug-Filter, den du in JS hattest, war keine Sicherheit; jetzt muss er es sein, in der Datenbank.

ZU VERMEIDENDE FEHLER

  • ×Das Store-Interface synchron entwerfen und in der Migration entdecken, dass jeder Aufruf `await` braucht – mach es von Tag eins an async (Promise), auch wenn localStorage es nicht braucht.
  • ×Glauben, den Storage zu migrieren sei die Arbeit: Der ernste Teil ist, die Isolation vom JS-Client-Filter zu RLS-Policies in Postgres zu verlagern ('Autorität = RLS, nicht der Client').
  • ×Das Schema und die Policies als loses SQL in der Konsole anwenden statt als versionierte Migrationen + `get_advisors` danach.
  • ×Ein nicht-idempotentes Datenmigrations-Skript: Wenn du es nicht ohne Duplizieren neu laufen lassen kannst, lässt dich eine Unterbrechung mit korrupten Daten zurück.
  • ×Zum Backend migrieren, ohne die Einwilligung zu verstärken: Persönliche Daten zu persistieren und zu verknüpfen erhöht das Risiko; in Espejo ist die Grenze eingewilligte First-Party-Daten, kein verdecktes Abschöpfen.

Nächste Konstellation

Signal

Quant & Research