docs: plan 2/3 für multilinguale SPA (a-tag-resolving + sprachhinweis)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b9eb2c0bab
commit
c28a64ed49
|
|
@ -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<string, string> = {
|
||||||
|
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<NostrEvent[]>
|
||||||
|
): Promise<TranslationInfo[]> {
|
||||||
|
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<TranslationInfo[]> {
|
||||||
|
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
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
|
||||||
|
import { loadTranslations } from '$lib/nostr/loaders';
|
||||||
|
import { displayLanguage } from '$lib/nostr/languageNames';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
event: NostrEvent;
|
||||||
|
}
|
||||||
|
let { event }: Props = $props();
|
||||||
|
|
||||||
|
let translations: TranslationInfo[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const currentId = event.id;
|
||||||
|
loading = true;
|
||||||
|
translations = [];
|
||||||
|
loadTranslations(event)
|
||||||
|
.then((infos) => {
|
||||||
|
if (event.id !== currentId) return;
|
||||||
|
translations = infos;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (event.id === currentId) loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !loading && translations.length > 0}
|
||||||
|
<p class="availability">
|
||||||
|
Auch verfügbar in:
|
||||||
|
{#each translations as t, i}
|
||||||
|
<a href="/{t.slug}/" title={t.title}>{displayLanguage(t.lang)}</a>{#if i < translations.length - 1}, {/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.availability {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
.availability a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.availability a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 `<script>`-Block bei den bestehenden Component-Imports (nach `import ExternalClientLinks ...`) die Zeile:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import LanguageAvailability from './LanguageAvailability.svelte';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Komponente im Template platzieren**
|
||||||
|
|
||||||
|
Im Template, direkt **nach** der `.meta`-`<div>` (die Tags enthält) und **vor** `{#if image}`, füge ein:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<LanguageAvailability {event} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Block sieht danach so aus:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="meta">
|
||||||
|
Veröffentlicht am {date}
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="tags">
|
||||||
|
{#each tags as t}
|
||||||
|
<a class="tag" href="/tag/{encodeURIComponent(t)}/">{t}</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LanguageAvailability {event} />
|
||||||
|
|
||||||
|
{#if image}
|
||||||
|
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Dev-Server starten und manuell verifizieren**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run dev 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffne `http://localhost:5173/bibel-selfies/` (oder einen anderen Slug) im Browser. Erwartung:
|
||||||
|
- Post rendert wie bisher.
|
||||||
|
- Unter der Meta-Zeile erscheint entweder **nichts** (keine Übersetzungen vorhanden — aktuell der Normalfall für alle 26 Posts) oder eine Zeile „Auch verfügbar in: English" — aber das passiert erst nach Task 7.
|
||||||
|
|
||||||
|
Stoppe den Dev-Server (`Ctrl+C`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | grep -E "(error|✓|Error)" | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Keine neuen Fehler durch diese Änderung.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/src/lib/components/PostView.svelte && git commit -m "feat(app): PostView zeigt sprach-verfügbarkeit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Erste echte Englisch-Übersetzung + Ende-zu-Ende-Verifikation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `content/posts/de/2025-04-17-bibel-selfies/index.md` (auskommentierten `a:`-Platzhalter durch aktiven Rückverweis ersetzen)
|
||||||
|
- Create: `content/posts/en/bible-selfies/index.md` (erste echte Englisch-Übersetzung, bleibt dauerhaft)
|
||||||
|
|
||||||
|
Dieser Task liefert eine erste Übersetzung in Grundzügen — wird **nicht** wieder gelöscht. Damit ist die E2E-Verifikation nebenbei erledigt und das Feature hat ab sofort sichtbaren Content.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Englische Übersetzung anlegen**
|
||||||
|
|
||||||
|
Erstelle `content/posts/en/bible-selfies/index.md`. Inhaltlich eine verkürzte Übertragung des deutschen Originals (Prompts können in der Originalsprache bleiben; die Prosa wird übersetzt). Die `title`- und `slug`-Werte unterscheiden sich vom deutschen Original — Slug-Eindeutigkeit ist gewahrt.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
layout: post
|
||||||
|
title: "Bible Selfies"
|
||||||
|
slug: "bible-selfies"
|
||||||
|
date: 2025-04-17
|
||||||
|
description: "Bible selfies with Midjourney — prompts and results showing biblical figures as first-person selfies."
|
||||||
|
image: https://cdn.midjourney.com/41d706d7-15ed-40ca-b507-5a2d727e312f/0_2.png
|
||||||
|
tags:
|
||||||
|
- AI-images
|
||||||
|
- Midjourney
|
||||||
|
- Bible
|
||||||
|
- Selfie
|
||||||
|
- religious-education
|
||||||
|
- relilab
|
||||||
|
lang: en
|
||||||
|
license: https://creativecommons.org/publicdomain/zero/1.0/deed.de
|
||||||
|
a:
|
||||||
|
- "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:bibel-selfies"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bible Selfies
|
||||||
|
|
||||||
|
A small experiment: what if biblical scenes had been captured with a smartphone?
|
||||||
|
Using Midjourney, I generated a series of "selfies" from the perspective of biblical figures. The prompts below produced the images — some surprisingly good, some charmingly off. Originally posted in German; this is a condensed English version for the multilingual rollout of the site.
|
||||||
|
|
||||||
|
See the [German original](/bibel-selfies/) for the full gallery with embedded context.
|
||||||
|
|
||||||
|
## Example prompt
|
||||||
|
|
||||||
|
> A selfie of a woman resembling Eve in the time of the Old Testament, blurred body, holding an apple, kneeling in front of Adam. He has a shocked expression with his mouth open and wide eyes, evoking a sense of both fear and surprise. A huge snake looms behind her. Wide-angle lens, surreal humor — reminiscent of the Garden of Eden. `--v 6.0`
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Dateilänge ist bewusst knapp — der Post dient als erste multilinguale Variante im System, nicht als vollwertige Content-Übersetzung. Du kannst den Inhalt später ausbauen; der `a`-Rückverweis und der Slug bleiben dabei stabil.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rückverweis im deutschen Original aktivieren**
|
||||||
|
|
||||||
|
Öffne `content/posts/de/2025-04-17-bibel-selfies/index.md` und ersetze die auskommentierten `a:`-Zeilen am Ende des Frontmatters:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# a:
|
||||||
|
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
||||||
|
```
|
||||||
|
|
||||||
|
durch den aktiven Eintrag:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
a:
|
||||||
|
- "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:bible-selfies"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Beide Posts publishen**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde/publish && deno task publish --post bibel-selfies && deno task publish --post bible-selfies
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `✓ bibel-selfies (update)` und `✓ bible-selfies (new)` — beide mit Relay-Erfolg.
|
||||||
|
|
||||||
|
- [ ] **Step 4: SPA manuell verifizieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run dev 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Öffne nacheinander:
|
||||||
|
1. `http://localhost:5173/bibel-selfies/` — erwartet: Hinweiszeile „Auch verfügbar in: English" unter Meta-Zeile, Link auf `/bible-selfies/`.
|
||||||
|
2. `http://localhost:5173/bible-selfies/` — erwartet: englischer Post rendert, Hinweiszeile „Auch verfügbar in: Deutsch", Link auf `/bibel-selfies/`.
|
||||||
|
3. `http://localhost:5173/moodle-iomad-linux/` — erwartet: **keine** Hinweiszeile (keine Übersetzung verknüpft).
|
||||||
|
|
||||||
|
Stoppe den Dev-Server.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde && git add content/posts/ && git commit -m "feat(content): erste englische übersetzung (bible-selfies) + bidirektionaler a-tag"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Push — GitHub-Action re-publisht automatisch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Push triggert die Action; sie sollte die beiden geänderten/neuen Posts identifizieren und re-publishen. Lokales Publishen in Step 3 ist dennoch sinnvoll, um den manuellen Test (Step 4) sofort gegen echte Relay-Daten fahren zu können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Gesamt-Testlauf
|
||||||
|
|
||||||
|
**Files:** — (reine Verifikation, kein Code-Change)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Vitest**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Alle grün (inkl. der 12 neuen Tests aus diesem Plan).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Svelte-Check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Keine Fehler; Warnings dürfen vorhanden sein.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build erfolgreich, keine Fehler.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Kein Commit nötig.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fertig
|
||||||
|
|
||||||
|
Nach Task 8:
|
||||||
|
- `parseTranslationRefs` extrahiert `a`-Tags mit Marker `translation` aus einem Event.
|
||||||
|
- `loadTranslations` resolvt die Referenzen zu Events und liefert `{ lang, slug, title }`-Liste.
|
||||||
|
- `LanguageAvailability`-Komponente rendert dezent „Auch verfügbar in: …" unter dem Post-Titel.
|
||||||
|
- Bidirektionale Verlinkung funktioniert Ende-zu-Ende (einmalig manuell verifiziert, dann zurückgebaut).
|
||||||
|
- UI-Chrome bleibt unverändert — der nächste Plan (3/3) wird `svelte-i18n` einführen.
|
||||||
|
|
||||||
|
**Nächster Plan:** `svelte-i18n` für Menü, Buttons, Footer, Impressum-Labels.
|
||||||
Loading…
Reference in New Issue