# 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 run test:unit ``` 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 run test:unit ``` 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 run test:unit ``` 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 run test:unit ``` 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 run test:unit ``` 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 run test:unit ``` 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 run test:unit ``` 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 `