diff --git a/docs/superpowers/plans/2026-04-21-multilingual-posts-i18n.md b/docs/superpowers/plans/2026-04-21-multilingual-posts-i18n.md new file mode 100644 index 0000000..feaa53b --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-multilingual-posts-i18n.md @@ -0,0 +1,992 @@ +# Multilinguale SPA — UI-Lokalisierung + Listen-Filter (Plan 3/3) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** UI-Chrome-Strings (Menü, Footer, Buttons, Seitentitel, Meldungen) werden über `svelte-i18n` lokalisiert — `de` als Default, `en` als zweite Sprache, Browser-Locale als Initial-Auswahl. Ein Locale-Store steuert zusätzlich die Listen-Seiten (Startseite + Archiv), sodass nur Posts in der aktiven Sprache erscheinen. Ein dezenter Umschalter im Header wechselt die Sprache. + +**Architecture:** Locale-Store (`writable<'de'|'en'>`) bootstrapt aus `navigator.language`, persistiert in `localStorage`, speist `svelte-i18n` und die Listen-Filter. UI-Strings liegen in `app/src/lib/i18n/messages/{de,en}.json`, werden via `$t(...)` in Templates genutzt. Listen-Seiten (`+page.svelte`, `archiv/+page.svelte`) filtern `posts` client-seitig nach `l`-Tag gegen den aktiven Locale. + +**Tech Stack:** SvelteKit (Svelte 5 Runes), TypeScript, `svelte-i18n` (runtime, ~10 KB), Vitest. + +--- + +## Spec-Referenz + +Umsetzt den Abschnitt **UI-Lokalisierung (Chrome)** sowie die noch offene „Nur Posts der aktiven Sprache in Listen zeigen"-Forderung aus `docs/superpowers/specs/2026-04-21-multilingual-posts-design.md`. Damit sind alle Spec-Anforderungen nach Plan 1/2/3 umgesetzt. + +## Datei-Struktur + +**Zu erstellen:** +- `app/src/lib/i18n/index.ts` — initialisiert `svelte-i18n`, registriert Locale-Bundles, exportiert `t`, `locale` und den projekteigenen `activeLocale`-Store. +- `app/src/lib/i18n/messages/de.json` — DE-Strings für UI-Chrome. +- `app/src/lib/i18n/messages/en.json` — EN-Strings, identische Keys. +- `app/src/lib/i18n/activeLocale.ts` — Custom-Writable-Store, der Locale persistiert (localStorage) und mit `svelte-i18n` syncronisiert. +- `app/src/lib/i18n/activeLocale.test.ts` — Unit-Tests für Bootstrap, Persistence, Fallback. +- `app/src/lib/components/LanguageSwitcher.svelte` — Umschalter im Header, zwei Buttons „DE/EN". + +**Zu ändern:** +- `app/src/routes/+layout.svelte` — Menü-/Footer-Strings via `$t`, `LanguageSwitcher` einbinden, i18n-Init im Script. +- `app/src/routes/+page.svelte` — Hero-Texte via `$t`, Liste nach `activeLocale` filtern. +- `app/src/routes/archiv/+page.svelte` — Seitenüberschrift via `$t`, Liste nach `activeLocale` filtern. +- `app/src/routes/impressum/+page.svelte` — Alle statischen Strings via `$t`. +- `app/src/routes/[...slug]/+page.svelte` — Breadcrumb („← Zurück zur Übersicht") via `$t`; Fehlermeldungen via `$t`. +- `app/src/lib/components/LoadingOrError.svelte` — falls es hartkodierte Strings enthält, auf `$t` umstellen. +- `app/src/lib/components/LanguageAvailability.svelte` — „Auch verfügbar in:" via `$t` statt hartkodiert. +- `app/src/lib/components/PostView.svelte` — „(ohne Titel)" + Datumsformat-Locale via `$t` bzw. `activeLocale`. +- `app/package.json` — `svelte-i18n` als Dependency. + +**Nicht angefasst:** +- Post-Content (`event.content`) — Markdown-Body bleibt in Autorensprache, wird nicht übersetzt. +- `app/src/lib/nostr/*` — Relay-Loader sind sprach-agnostisch. +- URL-Schema — weiterhin `//`, kein Sprach-Präfix. + +--- + +## Task 1: svelte-i18n installieren und Messages-Files anlegen + +**Files:** +- Create: `app/src/lib/i18n/messages/de.json`, `app/src/lib/i18n/messages/en.json` +- Modify: `app/package.json`, `app/package-lock.json` + +- [ ] **Step 1: Dependency installieren** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm install svelte-i18n +``` + +Prüfen: +```bash +grep "svelte-i18n" /Users/joerglohrer/repositories/joerglohrerde/app/package.json +``` +Expected: eine Zeile wie `"svelte-i18n": "^4.x.x"`. + +- [ ] **Step 2: Messages-Dateien anlegen** + +Erstelle `app/src/lib/i18n/messages/de.json`: + +```json +{ + "nav": { + "home": "Home", + "archive": "Archiv", + "imprint": "Impressum", + "brand_aria": "Zur Startseite" + }, + "home": { + "greeting": "Hi 🖖 Willkommen auf meinem Blog 🤗", + "latest": "Neueste Beiträge", + "more_archive": "Alle Beiträge im Archiv →", + "empty": "Keine Posts gefunden auf den abgefragten Relays." + }, + "archive": { + "title": "Archiv", + "subtitle": "Alle Beiträge, nach Jahr gruppiert.", + "doc_title": "Archiv – Jörg Lohrer" + }, + "post": { + "back_to_overview": "← Zurück zur Übersicht", + "untitled": "(ohne Titel)", + "published_on": "Veröffentlicht am {date}", + "also_available_in": "Auch verfügbar in:", + "not_found": "Post \"{slug}\" nicht gefunden.", + "unknown_error": "Unbekannter Fehler" + }, + "imprint": { + "doc_title": "Impressum – Jörg Lohrer" + }, + "lang": { + "de": "Deutsch", + "en": "English", + "switch_aria": "Sprache wechseln" + } +} +``` + +Erstelle `app/src/lib/i18n/messages/en.json` mit denselben Keys: + +```json +{ + "nav": { + "home": "Home", + "archive": "Archive", + "imprint": "Imprint", + "brand_aria": "Go to homepage" + }, + "home": { + "greeting": "Hi 🖖 Welcome to my blog 🤗", + "latest": "Latest posts", + "more_archive": "All posts in the archive →", + "empty": "No posts found on the queried relays." + }, + "archive": { + "title": "Archive", + "subtitle": "All posts, grouped by year.", + "doc_title": "Archive – Jörg Lohrer" + }, + "post": { + "back_to_overview": "← Back to overview", + "untitled": "(untitled)", + "published_on": "Published on {date}", + "also_available_in": "Also available in:", + "not_found": "Post \"{slug}\" not found.", + "unknown_error": "Unknown error" + }, + "imprint": { + "doc_title": "Imprint – Jörg Lohrer" + }, + "lang": { + "de": "German", + "en": "English", + "switch_aria": "Switch language" + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/package.json app/package-lock.json app/src/lib/i18n/messages/de.json app/src/lib/i18n/messages/en.json && git commit -m "chore(app): svelte-i18n + ui-messages-files (de/en)" +``` + +--- + +## Task 2: `activeLocale`-Store mit Persistence + +**Files:** +- Create: `app/src/lib/i18n/activeLocale.ts` +- Create: `app/src/lib/i18n/activeLocale.test.ts` + +- [ ] **Step 1: Test schreiben** + +Erstelle `app/src/lib/i18n/activeLocale.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { detectInitialLocale } from './activeLocale'; + +describe('detectInitialLocale', () => { + beforeEach(() => { + // Kein localStorage zwischen Tests + globalThis.localStorage?.clear?.(); + }); + + it('nimmt wert aus localStorage, wenn vorhanden und gültig', () => { + const storage = new Map([['locale', 'en']]); + expect(detectInitialLocale({ + storage: { + getItem: (k) => storage.get(k) ?? null, + setItem: () => {} + }, + navigatorLanguage: 'de-DE', + supported: ['de', 'en'] + })).toBe('en'); + }); + + it('fällt auf navigator.language zurück, wenn storage leer', () => { + expect(detectInitialLocale({ + storage: { + getItem: () => null, + setItem: () => {} + }, + navigatorLanguage: 'en-US', + supported: ['de', 'en'] + })).toBe('en'); + }); + + it('normalisiert navigator.language (de-DE → de)', () => { + expect(detectInitialLocale({ + storage: { + getItem: () => null, + setItem: () => {} + }, + navigatorLanguage: 'de-AT', + supported: ['de', 'en'] + })).toBe('de'); + }); + + it('fällt auf ersten supported eintrag, wenn navigator unbekannt', () => { + expect(detectInitialLocale({ + storage: { + getItem: () => null, + setItem: () => {} + }, + navigatorLanguage: 'fr-FR', + supported: ['de', 'en'] + })).toBe('de'); + }); + + it('ignoriert ungültige werte im storage', () => { + const storage = new Map([['locale', 'fr']]); + expect(detectInitialLocale({ + storage: { + getItem: (k) => storage.get(k) ?? null, + setItem: () => {} + }, + navigatorLanguage: 'en-US', + supported: ['de', 'en'] + })).toBe('en'); + }); +}); +``` + +- [ ] **Step 2: Test laufen, Erwartung FAIL** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit +``` + +Expected: FAIL — Modul existiert nicht. + +- [ ] **Step 3: Store und Detect-Funktion implementieren** + +Erstelle `app/src/lib/i18n/activeLocale.ts`: + +```typescript +import { writable, type Writable } from 'svelte/store'; + +export type SupportedLocale = 'de' | 'en'; +export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['de', 'en'] as const; +const STORAGE_KEY = 'locale'; + +interface Storage { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; +} + +export interface DetectArgs { + storage: Storage; + navigatorLanguage: string | undefined; + supported: readonly string[]; +} + +export function detectInitialLocale(args: DetectArgs): SupportedLocale { + const stored = args.storage.getItem(STORAGE_KEY); + if (stored && (args.supported as readonly string[]).includes(stored)) { + return stored as SupportedLocale; + } + const nav = (args.navigatorLanguage ?? '').slice(0, 2).toLowerCase(); + if ((args.supported as readonly string[]).includes(nav)) { + return nav as SupportedLocale; + } + return args.supported[0] as SupportedLocale; +} + +function createActiveLocale(): Writable & { bootstrap: () => void } { + const store = writable('de'); + let bootstrapped = false; + + function bootstrap() { + if (bootstrapped) return; + bootstrapped = true; + if (typeof window === 'undefined') return; + const initial = detectInitialLocale({ + storage: window.localStorage, + navigatorLanguage: window.navigator.language, + supported: SUPPORTED_LOCALES + }); + store.set(initial); + store.subscribe((v) => { + try { + window.localStorage.setItem(STORAGE_KEY, v); + } catch { + // private-mode / quota — ignorieren + } + }); + } + + return { + subscribe: store.subscribe, + set: store.set, + update: store.update, + bootstrap + }; +} + +export const activeLocale = createActiveLocale(); +``` + +- [ ] **Step 4: Test laufen, Erwartung PASS** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit +``` + +Expected: 46 passed (41 pre-existing + 5 neue). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/i18n/activeLocale.ts app/src/lib/i18n/activeLocale.test.ts && git commit -m "feat(app): activeLocale-store mit persistence + initial-detection" +``` + +--- + +## Task 3: `i18n/index.ts` — svelte-i18n-Registrierung und Sync + +**Files:** +- Create: `app/src/lib/i18n/index.ts` + +- [ ] **Step 1: Datei erstellen** + +Erstelle `app/src/lib/i18n/index.ts`: + +```typescript +import { addMessages, init, locale, _ } from 'svelte-i18n'; +import de from './messages/de.json'; +import en from './messages/en.json'; +import { activeLocale, SUPPORTED_LOCALES } from './activeLocale'; + +let initialized = false; + +export function initI18n(): void { + if (initialized) return; + initialized = true; + addMessages('de', de); + addMessages('en', en); + init({ + fallbackLocale: 'de', + initialLocale: 'de' + }); + activeLocale.bootstrap(); + activeLocale.subscribe((l) => { + locale.set(l); + }); +} + +export { _ as t, locale, activeLocale, SUPPORTED_LOCALES }; +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -5 +``` + +Expected: 0 errors, 0 warnings. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/i18n/index.ts && git commit -m "feat(app): i18n-init registriert messages und syncs mit activeLocale" +``` + +--- + +## Task 4: `LanguageSwitcher`-Komponente + +**Files:** +- Create: `app/src/lib/components/LanguageSwitcher.svelte` + +- [ ] **Step 1: Komponente erstellen** + +Erstelle `app/src/lib/components/LanguageSwitcher.svelte`: + +```svelte + + +
+ {#each SUPPORTED_LOCALES as code} + + {/each} +
+ + +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -5 +``` + +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/components/LanguageSwitcher.svelte && git commit -m "feat(app): LanguageSwitcher-komponente mit de/en-buttons" +``` + +--- + +## Task 5: Layout lokalisieren + Switcher einbinden + +**Files:** +- Modify: `app/src/routes/+layout.svelte` + +- [ ] **Step 1: Imports und i18n-Init ergänzen** + +Öffne `app/src/routes/+layout.svelte`. Im ` +``` + +Ersetze `Impressum – Jörg Lohrer` durch: + +```svelte + + {$t('imprint.doc_title')} + +``` + +Der restliche Inhalt bleibt auf Deutsch — juristische Anbieterkennzeichnung nach deutschem Recht; eine englische Version würde neue rechtliche Prüfung erfordern (Out of Scope). + +- [ ] **Step 3: Typecheck** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3 +``` + +Expected: 0 Errors. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/routes/impressum/+page.svelte && git commit -m "feat(app): impressum-seitentitel lokalisiert (inhalt bleibt DE)" +``` + +--- + +## Task 10: Ende-zu-Ende-Test im Browser + +**Files:** — (reine Verifikation) + +- [ ] **Step 1: Dev-Server** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run dev +``` + +- [ ] **Step 2: Browser-Tests** + +Öffne `http://localhost:5173/`. Prüfe: + +1. **Standard-Locale:** Erstes Öffnen — Sprache ist `de` (wenn Browser deutsch) oder `en` (wenn englisch). Titel, Menü, Greeting in dieser Sprache. +2. **Switcher klicken:** Klick auf „EN" im Header-Switcher — Menü, Greeting, „Latest posts", Archiv-Link-Text wechseln. URL ändert sich **nicht**. +3. **Liste gefiltert:** Startseite zeigt nur englische Posts (Bible Selfies), Archiv nur englische. Klick auf „DE" — deutsche Posts erscheinen. +4. **Post-Detail:** Klick auf einen deutschen Post (Liste auf DE) — Datumsformat deutsch („17. April 2025"). Klick auf „Auch verfügbar in: English" auf `bibel-selfies` — englische Version erscheint, Datumsformat englisch („April 17, 2025"), Breadcrumb „← Back to overview". +5. **Persistence:** Browser-Seite reload — aktive Sprache bleibt (aus localStorage). +6. **404-Case:** Öffne `/nicht-da/` — Fehlermeldung im aktiven Locale. + +Stoppe Dev-Server. + +- [ ] **Step 3: Kein Commit nötig.** + +--- + +## Task 11: Gesamt-Testlauf + Deploy + +**Files:** — (Verifikation + Deploy) + +- [ ] **Step 1: Vitest** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit +``` + +Expected: 46 passed. + +- [ ] **Step 2: Svelte-check** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3 +``` + +Expected: 0 errors, 0 warnings. + +- [ ] **Step 3: Build** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -10 +``` + +Expected: Build erfolgreich. + +- [ ] **Step 4: Push und Deploy auf prod** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git push +DEPLOY_TARGET=prod /Users/joerglohrer/repositories/joerglohrerde/scripts/deploy-svelte.sh 2>&1 | tail -10 +``` + +**WICHTIG:** `DEPLOY_TARGET=prod` explizit setzen. Der Skript-Default zielt auf `svelte.joerg-lohrer.de` (historischer Cutover-Stand). Prod-Cutover läuft über STAGING_FTP_*-Webroot mit SITE_URL `https://joerg-lohrer.de` — das ist das `prod`-Target. + +Expected: Upload fertig, Live-Check HTTP 200 mit aktuellem `last-modified`. + +- [ ] **Step 5: Live-Verifikation** + +Öffne `https://joerg-lohrer.de/` und wiederhole Task 10 Step 2 auf der Live-Seite. + +- [ ] **Step 6: Kein Commit — Abschluss.** + +--- + +## Fertig + +Nach Task 11: +- `svelte-i18n` aktiv, UI-Chrome in `de`/`en` +- Locale-Store mit Persistence + Browser-Locale-Default +- `LanguageSwitcher` im Header, zwei Buttons +- Listen-Seiten (Startseite + Archiv) nur Posts des aktiven Locales +- PostView zeigt Datum im aktiven Locale, Hinweise übersetzt +- Impressum-Titel übersetzt, juristischer Inhalt bewusst DE +- Live-Deploy auf prod + +**Damit ist die Spec `2026-04-21-multilingual-posts-design.md` vollständig umgesetzt — über Plan 1 (Publish-Pipeline), Plan 2 (SPA-Translation-Links) und Plan 3 (UI-i18n + Listen-Filter).**