diff --git a/docs/superpowers/plans/2026-04-21-multilingual-posts-spa.md b/docs/superpowers/plans/2026-04-21-multilingual-posts-spa.md new file mode 100644 index 0000000..457e2ce --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-multilingual-posts-spa.md @@ -0,0 +1,785 @@ +# Multilinguale Posts — SvelteKit-SPA (Plan 2/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:** Die SPA liest die `a`-Tags mit Marker `translation` aus einem geladenen Post-Event, löst die referenzierten Events auf und zeigt im UI dezent einen Hinweis „Auch auf Englisch verfügbar" / „Also available in German" — verlinkt auf die Slug-URL der jeweils anderen Sprache. Slug-direkte URL-Aufrufe zeigen den Post immer ohne einschränkende Meldung. + +**Architecture:** Ein neuer Loader `loadTranslations(event)` extrahiert aus dem Event die `a`-Tag-Referenzen mit Marker `translation`, lädt die zugehörigen Events parallel und liefert eine Liste `{ lang, slug, title }`. Ein neuer Svelte-Component `LanguageAvailability` rendert den Hinweis direkt unter dem Post-Titel. Kein Locale-Store, kein URL-Umbau — der aktuelle Post bestimmt die angezeigte Sprache, Umschaltung geschieht per Klick auf den Hinweis-Link. + +**Tech Stack:** SvelteKit (Svelte 5 Runes), TypeScript, `applesauce-core` / `applesauce-relay` (existierend in `app/src/lib/nostr/`), Vitest für Loader-Tests. + +--- + +## Spec-Referenz + +Umsetzt den SPA-Teil der Abschnitte **Verlinkungs-Semantik**, **SPA-Verhalten** und (aus dem Fallback-Block) die einladende Sprach-Hinweis-Logik aus `docs/superpowers/specs/2026-04-21-multilingual-posts-design.md`. Out-of-scope in diesem Plan: UI-Chrome-Lokalisierung via `svelte-i18n` (kommt in Plan 3). + +## Datei-Struktur + +**Zu ändern:** +- `app/src/lib/nostr/loaders.ts` — neue Funktion `loadTranslations(event)`. Keine Änderung bestehender Funktionen. +- `app/src/lib/components/PostView.svelte` — Einbindung einer neuen `LanguageAvailability`-Komponente unter dem Titel; keine Änderung der bestehenden Post-Anzeige. + +**Zu erstellen:** +- `app/src/lib/components/LanguageAvailability.svelte` — rendert den „Also available in …"-Hinweis. Bekommt das geladene Event als Prop. +- `app/src/lib/nostr/translations.ts` — eigene Datei für `parseTranslationRefs(event)` (reine Funktion, gut testbar ohne Relay-Zugriff). Der Loader in `loaders.ts` nutzt diesen Parser. +- `app/src/lib/nostr/translations.test.ts` — Unit-Tests für `parseTranslationRefs`. +- `app/src/lib/nostr/languageNames.ts` — kleine Lookup-Map `de`→„Deutsch", `en`→„English", plus Funktion `displayLanguage(code)`. + +**Nicht angefasst:** +- `app/src/routes/[...slug]/+page.svelte` / `+page.ts` — die Route bleibt Slug-basiert, das Event wird wie bisher geladen. +- `app/src/lib/nostr/config.ts`, `pool.ts`, `relays.ts` — Relay-Setup unverändert. +- `app/src/lib/url/legacy.ts`, Layout, Startseite, Archiv, Tag-Seiten — nicht betroffen. + +--- + +## Vorbereitung: Test-Setup prüfen + +Bevor die Tasks starten: Vitest ist in `app/` vermutlich schon eingerichtet (via `@sveltejs/kit`-Template), aber nicht unbedingt für `.test.ts`-Dateien verwendet. Der erste Task verifiziert das einmalig. + +--- + +## Task 1: Vitest-Setup prüfen und ggf. aktivieren + +**Files:** +- Verify: `app/package.json`, `app/vite.config.ts` (oder `.js`) +- Create (falls nötig): `app/vitest.config.ts` + +- [ ] **Step 1: Prüfen, ob Vitest bereits installiert ist** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && grep -E '"vitest"|"@vitest' package.json +``` + +Wenn eine Zeile ausgegeben wird, Vitest ist installiert → weiter zu Step 2. +Wenn keine Ausgabe: Installation: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm install --save-dev vitest @vitest/ui +``` + +- [ ] **Step 2: Prüfen, ob ein `test`-Script existiert** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && grep -A1 '"scripts"' package.json | grep -E '"test"|"vitest"' +``` + +Wenn keine Zeile mit `"test":` oder `"vitest"` kommt, ergänze in `app/package.json` im `scripts`-Block: + +```json +"test": "vitest run", +"test:watch": "vitest" +``` + +- [ ] **Step 3: Smoke-Test** + +Erstelle temporär `app/src/lib/nostr/smoke.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; + +describe('smoke', () => { + it('vitest läuft', () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test +``` + +Expected: Grüne Ausgabe, 1 passed. + +Falls rot: Vitest-Setup prüfen (Config im `vite.config.ts` oder separat), dann erneut. + +- [ ] **Step 4: Smoke-Datei löschen** + +```bash +rm /Users/joerglohrer/repositories/joerglohrerde/app/src/lib/nostr/smoke.test.ts +``` + +- [ ] **Step 5: Commit (nur falls Dependencies/Scripts geändert wurden)** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/package.json app/package-lock.json && git commit -m "chore(app): vitest-test-runner setup" +``` + +Wenn keine Änderungen: keinen leeren Commit erzeugen, überspringen. + +--- + +## Task 2: `parseTranslationRefs` — Parser für `a`-Tags + +**Files:** +- Create: `app/src/lib/nostr/translations.ts` +- Create: `app/src/lib/nostr/translations.test.ts` + +- [ ] **Step 1: Test schreiben** + +Erstelle `app/src/lib/nostr/translations.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { parseTranslationRefs } from './translations'; +import type { NostrEvent } from './loaders'; + +function ev(tags: string[][]): NostrEvent { + return { + id: 'x', + pubkey: 'p', + created_at: 0, + kind: 30023, + tags, + content: '', + sig: 's' + } as unknown as NostrEvent; +} + +describe('parseTranslationRefs', () => { + it('extrahiert a-tags mit marker "translation"', () => { + const e = ev([ + ['d', 'x'], + ['a', '30023:abc:other-slug', '', 'translation'], + ['a', '30023:abc:third-slug', '', 'translation'] + ]); + expect(parseTranslationRefs(e)).toEqual([ + { kind: 30023, pubkey: 'abc', dtag: 'other-slug' }, + { kind: 30023, pubkey: 'abc', dtag: 'third-slug' } + ]); + }); + + it('ignoriert a-tags ohne marker "translation"', () => { + const e = ev([ + ['a', '30023:abc:root-thread', '', 'root'], + ['a', '30023:abc:x', '', 'reply'] + ]); + expect(parseTranslationRefs(e)).toEqual([]); + }); + + it('ignoriert a-tags mit malformed coordinate', () => { + const e = ev([ + ['a', 'not-a-coord', '', 'translation'], + ['a', '30023:abc:ok', '', 'translation'] + ]); + expect(parseTranslationRefs(e)).toEqual([ + { kind: 30023, pubkey: 'abc', dtag: 'ok' } + ]); + }); + + it('leeres tag-array → leere liste', () => { + expect(parseTranslationRefs(ev([]))).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Test laufen, Erwartung FAIL** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test +``` + +Expected: FAIL — Modul `./translations` existiert noch nicht. + +- [ ] **Step 3: Parser implementieren** + +Erstelle `app/src/lib/nostr/translations.ts`: + +```typescript +import type { NostrEvent } from './loaders'; + +export interface TranslationRef { + kind: number; + pubkey: string; + dtag: string; +} + +const COORD_RE = /^(\d+):([0-9a-f]+):([a-z0-9][a-z0-9-]*)$/; + +export function parseTranslationRefs(event: NostrEvent): TranslationRef[] { + const refs: TranslationRef[] = []; + for (const tag of event.tags) { + if (tag[0] !== 'a') continue; + if (tag[3] !== 'translation') continue; + const coord = tag[1]; + if (typeof coord !== 'string') continue; + const m = coord.match(COORD_RE); + if (!m) continue; + refs.push({ + kind: parseInt(m[1], 10), + pubkey: m[2], + dtag: m[3] + }); + } + return refs; +} +``` + +- [ ] **Step 4: Test laufen, Erwartung PASS** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/nostr/translations.ts app/src/lib/nostr/translations.test.ts && git commit -m "feat(app): parseTranslationRefs extrahiert a-tags mit marker translation" +``` + +--- + +## Task 3: `languageNames` — Code-zu-Anzeigename + +**Files:** +- Create: `app/src/lib/nostr/languageNames.ts` +- Create: `app/src/lib/nostr/languageNames.test.ts` + +- [ ] **Step 1: Test schreiben** + +Erstelle `app/src/lib/nostr/languageNames.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { displayLanguage } from './languageNames'; + +describe('displayLanguage', () => { + it('kennt deutsch', () => { + expect(displayLanguage('de')).toBe('Deutsch'); + }); + it('kennt english', () => { + expect(displayLanguage('en')).toBe('English'); + }); + it('fällt bei unbekanntem code auf uppercase-code zurück', () => { + expect(displayLanguage('fr')).toBe('FR'); + }); + it('fällt bei leerer sprache auf ? zurück', () => { + expect(displayLanguage('')).toBe('?'); + }); +}); +``` + +- [ ] **Step 2: Test laufen, Erwartung FAIL** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test +``` + +Expected: FAIL. + +- [ ] **Step 3: Modul implementieren** + +Erstelle `app/src/lib/nostr/languageNames.ts`: + +```typescript +const NAMES: Record = { + de: 'Deutsch', + en: 'English' +}; + +export function displayLanguage(code: string): string { + if (!code) return '?'; + return NAMES[code] ?? code.toUpperCase(); +} +``` + +- [ ] **Step 4: Test laufen, Erwartung PASS** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test +``` + +Expected: 8 passed (4 neue + 4 aus Task 2). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/nostr/languageNames.ts app/src/lib/nostr/languageNames.test.ts && git commit -m "feat(app): displayLanguage code→anzeigename" +``` + +--- + +## Task 4: `loadTranslations` — Loader für verknüpfte Posts + +**Files:** +- Modify: `app/src/lib/nostr/loaders.ts` (neue Funktion am Ende ergänzen) +- Create: `app/src/lib/nostr/loaders.loadTranslations.test.ts` + +Wir schreiben den Test zuerst gegen eine Mock-Version der `collectEvents`-Schnittstelle — die echte Relay-Kommunikation wird durch Dependency-Injection in der Funktions-Signatur ausgetauscht. + +- [ ] **Step 1: Test schreiben** + +Erstelle `app/src/lib/nostr/loaders.loadTranslations.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { resolveTranslationsFromRefs } from './loaders'; +import type { NostrEvent } from './loaders'; +import type { TranslationRef } from './translations'; + +function ev(tags: string[][]): NostrEvent { + return { + id: 'x', + pubkey: 'p', + created_at: 0, + kind: 30023, + tags, + content: '', + sig: 's' + } as unknown as NostrEvent; +} + +describe('resolveTranslationsFromRefs', () => { + it('liefert lang/slug/title für jeden aufgelösten ref', async () => { + const refs: TranslationRef[] = [ + { kind: 30023, pubkey: 'p1', dtag: 'hello' } + ]; + const fetcher = async () => [ + ev([ + ['d', 'hello'], + ['title', 'Hello World'], + ['L', 'ISO-639-1'], + ['l', 'en', 'ISO-639-1'] + ]) + ]; + const result = await resolveTranslationsFromRefs(refs, fetcher); + expect(result).toEqual([ + { lang: 'en', slug: 'hello', title: 'Hello World' } + ]); + }); + + it('ignoriert refs, zu denen kein event gefunden wird', async () => { + const refs: TranslationRef[] = [ + { kind: 30023, pubkey: 'p1', dtag: 'hello' }, + { kind: 30023, pubkey: 'p1', dtag: 'missing' } + ]; + const fetcher = async (r: TranslationRef) => + r.dtag === 'hello' + ? [ev([ + ['d', 'hello'], + ['title', 'Hi'], + ['l', 'en', 'ISO-639-1'] + ])] + : []; + const result = await resolveTranslationsFromRefs(refs, fetcher); + expect(result).toEqual([{ lang: 'en', slug: 'hello', title: 'Hi' }]); + }); + + it('ignoriert events ohne l-tag (sprache unklar)', async () => { + const refs: TranslationRef[] = [ + { kind: 30023, pubkey: 'p', dtag: 'x' } + ]; + const fetcher = async () => [ + ev([ + ['d', 'x'], + ['title', 'kein lang-tag'] + ]) + ]; + const result = await resolveTranslationsFromRefs(refs, fetcher); + expect(result).toEqual([]); + }); + + it('leere ref-liste → leere ergebnis-liste', async () => { + const fetcher = async () => { + throw new Error('should not be called'); + }; + expect(await resolveTranslationsFromRefs([], fetcher)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Test laufen, Erwartung FAIL** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test +``` + +Expected: FAIL — Funktion `resolveTranslationsFromRefs` nicht exportiert. + +- [ ] **Step 3: Pure Funktion und Loader implementieren** + +In `app/src/lib/nostr/loaders.ts`, ergänze am Ende der Datei: + +```typescript +import type { TranslationRef } from './translations'; + +export interface TranslationInfo { + lang: string; + slug: string; + title: string; +} + +/** + * Pure Variante für Tests — erhält die Events via Fetcher statt Relays. + */ +export async function resolveTranslationsFromRefs( + refs: TranslationRef[], + fetcher: (ref: TranslationRef) => Promise +): Promise { + if (refs.length === 0) return []; + const results = await Promise.all(refs.map(fetcher)); + const infos: TranslationInfo[] = []; + for (let i = 0; i < refs.length; i++) { + const evs = results[i]; + if (evs.length === 0) continue; + const latest = evs.reduce((best, cur) => + cur.created_at > best.created_at ? cur : best + ); + const lang = latest.tags.find((t) => t[0] === 'l')?.[1]; + if (!lang) continue; + const slug = latest.tags.find((t) => t[0] === 'd')?.[1] ?? refs[i].dtag; + const title = latest.tags.find((t) => t[0] === 'title')?.[1] ?? ''; + infos.push({ lang, slug, title }); + } + return infos; +} + +/** + * Loader: findet die anderssprachigen Varianten eines Posts. + * Liefert leere Liste, wenn keine a-Tags mit marker "translation" vorhanden. + */ +export async function loadTranslations( + event: NostrEvent +): Promise { + const { parseTranslationRefs } = await import('./translations'); + const refs = parseTranslationRefs(event); + if (refs.length === 0) return []; + const relays = get(readRelays); + return resolveTranslationsFromRefs(refs, (ref) => + collectEvents(relays, { + kinds: [ref.kind], + authors: [ref.pubkey], + '#d': [ref.dtag], + limit: 1 + }) + ); +} +``` + +**Hinweis:** `get`, `readRelays`, `collectEvents` sind bereits weiter oben in der Datei importiert bzw. definiert. Nur `TranslationRef` muss als Typ importiert werden. + +- [ ] **Step 4: Test laufen, Erwartung PASS** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test +``` + +Expected: 12 passed. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/nostr/loaders.ts app/src/lib/nostr/loaders.loadTranslations.test.ts && git commit -m "feat(app): loadTranslations liefert sprach-varianten eines posts" +``` + +--- + +## Task 5: `LanguageAvailability`-Komponente + +**Files:** +- Create: `app/src/lib/components/LanguageAvailability.svelte` + +- [ ] **Step 1: Komponente erstellen** + +Erstelle `app/src/lib/components/LanguageAvailability.svelte`: + +```svelte + + +{#if !loading && translations.length > 0} +

+ Auch verfügbar in: + {#each translations as t, i} + {displayLanguage(t.lang)}{#if i < translations.length - 1}, {/if} + {/each} +

+{/if} + + +``` + +- [ ] **Step 2: Typecheck** + +Run: +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -20 +``` + +Expected: Keine Fehler im Zusammenhang mit `LanguageAvailability.svelte`. (Es kann pre-existierende Warnings aus anderen Dateien geben — die sind nicht Teil dieser Task.) + +- [ ] **Step 3: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/components/LanguageAvailability.svelte && git commit -m "feat(app): LanguageAvailability-komponente für sprach-varianten-hinweis" +``` + +--- + +## Task 6: Einbindung in `PostView` + +**Files:** +- Modify: `app/src/lib/components/PostView.svelte` + +- [ ] **Step 1: Import und Einbindung** + +Öffne `app/src/lib/components/PostView.svelte`. + +Ergänze im `