joerglohrerde/docs/superpowers/plans/2026-04-15-spa-sveltekit.md

2813 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SvelteKit-SPA Implementation Plan
> **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:** Eine produktive SvelteKit-SPA bauen, die Jörgs Nostr-Posts (`kind:30023`) live von Public-Relays rendert, auf `svelte.joerg-lohrer.de` deployed wird und später `joerg-lohrer.de` ablösen soll.
**Architecture:** SvelteKit mit `adapter-static` (SSR aus, Fallback `index.html`), `applesauce-relay` + `applesauce-loaders` + `applesauce-signers` für Nostr, `marked` + `DOMPurify` + `highlight.js` für Markdown. Konfiguration zur Laufzeit aus `kind:10002` (Relays) und `kind:10063` (Blossom). Legacy-URLs werden via `history.replaceState` auf kanonische kurze Form normalisiert.
**Tech Stack:** SvelteKit 2.x · Svelte 5 · TypeScript · Vite · `applesauce-relay` · `applesauce-loaders` · `applesauce-signers` · `nostr-tools` (nip19 Encoding) · `marked` · `DOMPurify` · `highlight.js` · Vitest · Playwright · `adapter-static`
**Scope:** Diese Implementation deckt die vollständige SPA-Spec ab — Home mit Liste + Profil, Einzelpost, Tag-Filter, Reactions und NIP-07-Kommentare. Die Publish-Pipeline (Markdown → Events) ist nicht Teil dieses Plans — sie hat eine eigene Spec und einen separaten Plan.
**Ausführungsort:** Branch `spa`, Unterordner `app/` des Repos.
---
## Referenzen
- **Spec SPA:** [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](../specs/2026-04-15-nostr-page-design.md)
- **Spec Publish-Pipeline:** [`docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`](../specs/2026-04-15-publish-pipeline-design.md)
- **Mini-Spike als funktionierendes Referenz-Verhalten:** [`preview/spa-mini/index.html`](../../../preview/spa-mini/index.html) — live unter `https://spa.joerg-lohrer.de/`
- **Autoren-Pubkey (npub):** `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
- **Autoren-Pubkey (hex):** `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41`
- **Bootstrap-Relay:** `wss://relay.damus.io`
- **Beispiel-Event zum Testen:** `kind:30023`, `d:dezentrale-oep-oer` (existiert bereits auf allen 5 Relays)
---
## File-Structure
```
app/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── tsconfig.json
├── .gitignore
├── README.md
├── static/
│ ├── favicon.ico
│ └── robots.txt
├── src/
│ ├── app.html # HTML-Shell, <head>-Defaults
│ ├── app.d.ts # TypeScript-Ambient-Deklarationen
│ ├── hooks.client.ts # Globaler Client-Hook (Fehler-Reporting)
│ ├── lib/
│ │ ├── nostr/
│ │ │ ├── config.ts # BOOTSTRAP_RELAY, AUTHOR_PUBKEY_HEX, FALLBACK_READ_RELAYS
│ │ │ ├── pool.ts # RelayPool Singleton
│ │ │ ├── relays.ts # loadOutboxRelays (kind:10002)
│ │ │ ├── blossom.ts # loadBlossomServers (kind:10063) — read-only, für später
│ │ │ ├── loaders.ts # loadProfile, loadPostList, loadPost, loadReplies, loadReactions
│ │ │ ├── signer.ts # NIP-07-Wrapper
│ │ │ └── naddr.ts # nip19.naddrEncode Helper
│ │ ├── render/
│ │ │ └── markdown.ts # renderMarkdown(md: string): string
│ │ ├── url/
│ │ │ └── legacy.ts # parseLegacyUrl, canonicalPostPath
│ │ ├── stores/
│ │ │ └── readRelays.ts # derived Store: aktuelle Read-Relay-Liste
│ │ └── components/
│ │ ├── ProfileCard.svelte
│ │ ├── PostCard.svelte
│ │ ├── PostView.svelte
│ │ ├── TagChip.svelte
│ │ ├── ReplyList.svelte
│ │ ├── ReplyItem.svelte
│ │ ├── ReplyComposer.svelte
│ │ ├── Reactions.svelte
│ │ └── LoadingOrError.svelte
│ └── routes/
│ ├── +layout.svelte # Shell (Header nur auf /, Breadcrumb ansonsten)
│ ├── +layout.ts # export const prerender = false; ssr = false
│ ├── +page.svelte # Home: Profil + Beitragsliste
│ ├── +error.svelte # Fehlerseite
│ ├── [...slug]/+page.svelte # Catch-all: Legacy-Check, dann Einzelpost
│ └── tag/
│ └── [name]/+page.svelte # Tag-Filter
├── tests/
│ ├── unit/
│ │ ├── markdown.test.ts
│ │ ├── legacy-url.test.ts
│ │ ├── naddr.test.ts
│ │ └── loaders.test.ts # gegen Mock-Relay
│ └── e2e/
│ ├── home.test.ts
│ ├── post.test.ts
│ ├── legacy-redirect.test.ts
│ └── tag.test.ts
└── playwright.config.ts
```
---
## Phase 1 — Setup (Tasks 16)
### Task 1: SvelteKit-Projekt initialisieren
**Files:**
- Create: `app/package.json`
- Create: `app/svelte.config.js`
- Create: `app/vite.config.ts`
- Create: `app/tsconfig.json`
- Create: `app/.gitignore`
- Create: `app/src/app.html`
- Create: `app/src/app.d.ts`
- Create: `app/src/routes/+page.svelte`
- Create: `app/static/favicon.ico`
- Create: `app/static/robots.txt`
- [ ] **Step 1.1: Arbeitsverzeichnis vorbereiten und SvelteKit-Skeleton erzeugen**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
mkdir -p app
cd app
# Nicht-interaktiv: minimal TypeScript-Skeleton anlegen
npx --yes sv create . --template minimal --types ts --no-add-ons --install npm
```
Expected: Verzeichnis `app/` hat `package.json`, `svelte.config.js`, `vite.config.ts`, `src/`, `static/`, `node_modules/` installiert.
- [ ] **Step 1.2: `svelte.config.js` auf adapter-static umstellen**
Ersetze den Inhalt von `app/svelte.config.js` durch:
```js
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
alias: {
$lib: 'src/lib',
},
},
};
export default config;
```
- [ ] **Step 1.3: adapter-static als Dependency hinzufügen**
```sh
cd app
npm install --save-dev @sveltejs/adapter-static
```
Expected: `package.json` hat `"@sveltejs/adapter-static"` in `devDependencies`.
- [ ] **Step 1.4: Globales SSR deaktivieren via `+layout.ts`**
Create `app/src/routes/+layout.ts`:
```ts
export const prerender = false;
export const ssr = false;
export const trailingSlash = 'always';
```
- [ ] **Step 1.5: Minimale `+page.svelte` für erste Build-Verifikation**
Ersetze Inhalt von `app/src/routes/+page.svelte` durch:
```svelte
<h1>SvelteKit-SPA bootet</h1>
<p>Wird Stück für Stück mit Nostr-Funktionalität gefüllt.</p>
```
- [ ] **Step 1.6: Build testen**
```sh
cd app
npm run build
```
Expected: `build/index.html` existiert, keine Fehler.
- [ ] **Step 1.7: `app/.gitignore` setzen**
Create oder überschreibe `app/.gitignore`:
```
node_modules/
build/
.svelte-kit/
package-lock.json
.env
.env.local
*.log
```
Hinweis: `package-lock.json` wird bewusst nicht committed, weil die Repo-weite Policy das so handhabt. Wenn die Policy später ändert, diese Zeile entfernen.
- [ ] **Step 1.8: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/
git commit -m "spa: sveltekit-skeleton mit adapter-static initialisiert"
```
---
### Task 2: Dependencies installieren und Aliases konfigurieren
**Files:**
- Modify: `app/package.json`
- Modify: `app/tsconfig.json`
- [ ] **Step 2.1: Runtime-Dependencies installieren**
```sh
cd app
npm install \
applesauce-core \
applesauce-relay \
applesauce-loaders \
applesauce-signers \
nostr-tools \
marked \
dompurify \
highlight.js \
rxjs
```
- [ ] **Step 2.2: Dev-Dependencies installieren (Tests)**
```sh
cd app
npm install --save-dev vitest @playwright/test @testing-library/svelte jsdom
```
- [ ] **Step 2.3: Type-Definitionen für DOMPurify**
```sh
cd app
npm install --save-dev @types/dompurify
```
- [ ] **Step 2.4: Vitest-Konfiguration erweitern in `vite.config.ts`**
Ersetze Inhalt von `app/vite.config.ts` durch:
```ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['tests/unit/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
globals: true,
},
});
```
- [ ] **Step 2.5: npm-Scripts ergänzen in `package.json`**
Öffne `app/package.json`, ergänze im `"scripts"`-Objekt:
```json
"test:unit": "vitest run",
"test:e2e": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"deploy:svelte": "../scripts/deploy-svelte.sh"
```
(Die `build`, `preview`, `dev` Scripts erzeugt SvelteKit initial — nur ergänzen.)
- [ ] **Step 2.6: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/
git commit -m "spa: runtime- und dev-dependencies installiert"
```
---
### Task 3: Konfigurations-Modul `config.ts`
**Files:**
- Create: `app/src/lib/nostr/config.ts`
- [ ] **Step 3.1: Config-Konstanten anlegen**
Create `app/src/lib/nostr/config.ts`:
```ts
/**
* Nostr-Konfiguration der SPA.
*
* Wichtig: Der AUTHOR_PUBKEY_HEX muss synchron zum tatsächlichen
* Autorenkonto sein (siehe docs/superpowers/specs/2026-04-15-nostr-page-design.md).
*/
/** npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9 in hex */
export const AUTHOR_PUBKEY_HEX =
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
/** Bootstrap-Relay für das initiale Lesen von kind:10002 */
export const BOOTSTRAP_RELAY = 'wss://relay.damus.io';
/** Fallback, falls kind:10002 nicht geladen werden kann */
export const FALLBACK_READ_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.tchncs.de',
'wss://relay.edufeed.org',
];
/** Habla.news-Deep-Link-Basis (für Nutzer ohne JS oder wenn Events fehlen) */
export const HABLA_BASE = 'https://habla.news/a/';
/** Timeout-Werte in ms */
export const RELAY_TIMEOUT_MS = 8000;
export const RELAY_HARD_TIMEOUT_MS = 15000;
```
- [ ] **Step 3.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/config.ts
git commit -m "spa: nostr-konfigurations-modul mit pubkey, bootstrap-relay, fallbacks"
```
---
### Task 4: URL-Parsing-Modul mit TDD
**Files:**
- Create: `app/src/lib/url/legacy.ts`
- Create: `app/tests/unit/legacy-url.test.ts`
- [ ] **Step 4.1: Failing test für `parseLegacyUrl`**
Create `app/tests/unit/legacy-url.test.ts`:
```ts
import { describe, expect, it } from 'vitest';
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
describe('parseLegacyUrl', () => {
it('extrahiert dtag aus der Hugo-URL-Form mit Trailing-Slash', () => {
expect(parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/')).toBe(
'dezentrale-oep-oer',
);
});
it('extrahiert dtag aus der Hugo-URL-Form ohne Trailing-Slash', () => {
expect(parseLegacyUrl('/2024/01/26/offenheit-das-wesentliche.html')).toBe(
'offenheit-das-wesentliche',
);
});
it('returned null für die kanonische kurze Form', () => {
expect(parseLegacyUrl('/dezentrale-oep-oer/')).toBeNull();
});
it('returned null für leeren Pfad', () => {
expect(parseLegacyUrl('/')).toBeNull();
});
it('returned null für andere Strukturen', () => {
expect(parseLegacyUrl('/tag/OER/')).toBeNull();
expect(parseLegacyUrl('/some/random/path/')).toBeNull();
});
it('decodiert percent-encoded dtags', () => {
expect(parseLegacyUrl('/2024/05/12/mit%20leerzeichen.html/')).toBe(
'mit leerzeichen',
);
});
});
describe('canonicalPostPath', () => {
it('erzeugt /<dtag>/ mit encodeURIComponent', () => {
expect(canonicalPostPath('dezentrale-oep-oer')).toBe('/dezentrale-oep-oer/');
});
it('kodiert Sonderzeichen', () => {
expect(canonicalPostPath('mit leerzeichen')).toBe('/mit%20leerzeichen/');
});
});
```
- [ ] **Step 4.2: Test ausführen, erwarte FAIL**
```sh
cd app
npm run test:unit -- legacy-url
```
Expected: Import-Fehler, weil `$lib/url/legacy` nicht existiert.
- [ ] **Step 4.3: Implementation**
Create `app/src/lib/url/legacy.ts`:
```ts
/**
* Erkennt Legacy-Hugo-URLs der Form /YYYY/MM/DD/<dtag>.html oder .../<dtag>.html/
* und gibt den dtag-Teil zurück. Für alle anderen Pfade: null.
*/
export function parseLegacyUrl(path: string): string | null {
const match = path.match(/^\/\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
if (!match) return null;
return decodeURIComponent(match[1]);
}
/**
* Erzeugt die kanonische kurze Post-URL /<dtag>/.
*/
export function canonicalPostPath(dtag: string): string {
return `/${encodeURIComponent(dtag)}/`;
}
```
- [ ] **Step 4.4: Test ausführen, erwarte PASS**
```sh
cd app
npm run test:unit -- legacy-url
```
Expected: alle 8 Tests grün.
- [ ] **Step 4.5: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/url/legacy.ts app/tests/unit/legacy-url.test.ts
git commit -m "spa: url-parser für legacy-hugo-urls (tdd)"
```
---
### Task 5: naddr-Encoder-Modul mit TDD
**Files:**
- Create: `app/src/lib/nostr/naddr.ts`
- Create: `app/tests/unit/naddr.test.ts`
- [ ] **Step 5.1: Failing test**
Create `app/tests/unit/naddr.test.ts`:
```ts
import { describe, expect, it } from 'vitest';
import { buildHablaLink } from '$lib/nostr/naddr';
describe('buildHablaLink', () => {
it('erzeugt einen habla.news/a/-Link mit naddr1-Bech32', () => {
const link = buildHablaLink({
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
kind: 30023,
identifier: 'dezentrale-oep-oer',
relays: ['wss://relay.damus.io'],
});
expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/);
});
it('ist deterministisch für gleiche Inputs', () => {
const args = {
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
kind: 30023,
identifier: 'foo',
relays: ['wss://relay.damus.io'],
};
expect(buildHablaLink(args)).toBe(buildHablaLink(args));
});
});
```
- [ ] **Step 5.2: Test ausführen (FAIL)**
```sh
cd app
npm run test:unit -- naddr
```
- [ ] **Step 5.3: Implementation**
Create `app/src/lib/nostr/naddr.ts`:
```ts
import { nip19 } from 'nostr-tools';
import { HABLA_BASE } from './config';
export interface NaddrArgs {
pubkey: string;
kind: number;
identifier: string;
relays?: string[];
}
export function buildNaddr(args: NaddrArgs): string {
return nip19.naddrEncode({
pubkey: args.pubkey,
kind: args.kind,
identifier: args.identifier,
relays: args.relays ?? [],
});
}
export function buildHablaLink(args: NaddrArgs): string {
return `${HABLA_BASE}${buildNaddr(args)}`;
}
```
- [ ] **Step 5.4: Test ausführen (PASS)**
```sh
cd app
npm run test:unit -- naddr
```
- [ ] **Step 5.5: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/naddr.ts app/tests/unit/naddr.test.ts
git commit -m "spa: naddr/habla-link-helper (tdd)"
```
---
### Task 6: Deploy-Script für svelte.joerg-lohrer.de
**Files:**
- Create: `scripts/deploy-svelte.sh`
- Create: `scripts/README.md`
- [ ] **Step 6.1: Deploy-Script anlegen**
Create `scripts/deploy-svelte.sh`:
```bash
#!/usr/bin/env bash
# Deploy: SvelteKit-Build nach svelte.joerg-lohrer.de per FTPS.
# Credentials kommen aus ./.env.local (gitignored), Variablen-Prefix SVELTE_FTP_.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [ ! -f .env.local ]; then
echo "FEHLER: .env.local fehlt — Credentials ergänzen (siehe .env.example)." >&2
exit 1
fi
# nur SVELTE_FTP_* exportieren
set -a
# shellcheck disable=SC1090
. <(grep -E '^SVELTE_FTP_' .env.local)
set +a
for v in SVELTE_FTP_HOST SVELTE_FTP_USER SVELTE_FTP_PASS SVELTE_FTP_REMOTE_PATH; do
if [ -z "${!v:-}" ]; then
echo "FEHLER: $v fehlt in .env.local." >&2
exit 1
fi
done
BUILD_DIR="$ROOT/app/build"
if [ ! -d "$BUILD_DIR" ]; then
echo "FEHLER: app/build nicht vorhanden. Bitte vorher 'npm run build' in app/ ausführen." >&2
exit 1
fi
echo "Lade Build von $BUILD_DIR nach ftp://$SVELTE_FTP_HOST$SVELTE_FTP_REMOTE_PATH"
# pro Datei ein curl-Upload (zuverlässig auf macOS ohne lftp)
find "$BUILD_DIR" -type f -print0 | while IFS= read -r -d '' local_file; do
rel="${local_file#$BUILD_DIR/}"
remote="ftp://$SVELTE_FTP_HOST${SVELTE_FTP_REMOTE_PATH%/}/$rel"
echo " → $rel"
curl -sSf --ssl-reqd --ftp-create-dirs \
--user "$SVELTE_FTP_USER:$SVELTE_FTP_PASS" \
-T "$local_file" "$remote"
done
echo "Upload fertig. Live-Check:"
curl -sIL "https://svelte.joerg-lohrer.de/" | head -5
```
- [ ] **Step 6.2: Script ausführbar machen**
```sh
chmod +x scripts/deploy-svelte.sh
```
- [ ] **Step 6.3: Scripts-README anlegen**
Create `scripts/README.md`:
```markdown
# Scripts
- **`deploy-svelte.sh`** — deployed den SvelteKit-Build aus `app/build/` nach
`svelte.joerg-lohrer.de` via FTPS. Benötigt `.env.local` im Repo-Root mit
den Variablen `SVELTE_FTP_HOST`, `SVELTE_FTP_USER`, `SVELTE_FTP_PASS`,
`SVELTE_FTP_REMOTE_PATH`. Aufruf:
```sh
cd app && npm run build && cd .. && ./scripts/deploy-svelte.sh
```
```
- [ ] **Step 6.4: Test-Deploy mit dem Minimal-Build aus Task 1**
Voraussetzung: `SVELTE_FTP_*` in `.env.local` ist ausgefüllt und `svelte.joerg-lohrer.de` hat SSL aktiv.
```sh
cd app && npm run build && cd ..
./scripts/deploy-svelte.sh
```
Expected: Upload läuft durch, `curl -sI https://svelte.joerg-lohrer.de/` liefert HTTP 200 und Inhalt zeigt „SvelteKit-SPA bootet".
- [ ] **Step 6.5: `.htaccess` für SPA-Fallback anlegen (analog zu Mini-Spike)**
Create `app/static/.htaccess`:
```apache
RewriteEngine On
# HTTPS forcieren
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Alles andere → SPA-Fallback (SvelteKit mit adapter-static)
RewriteRule ^ /index.html [L]
```
Rebuild und redeploy:
```sh
cd app && npm run build && cd ..
./scripts/deploy-svelte.sh
```
- [ ] **Step 6.6: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add scripts/ app/static/.htaccess
git commit -m "spa: deploy-script und htaccess für svelte.joerg-lohrer.de"
```
---
## Phase 2 — Datenebene (Tasks 714)
### Task 7: Markdown-Renderer mit TDD
**Files:**
- Create: `app/src/lib/render/markdown.ts`
- Create: `app/tests/unit/markdown.test.ts`
- [ ] **Step 7.1: Failing test**
Create `app/tests/unit/markdown.test.ts`:
```ts
import { describe, expect, it } from 'vitest';
import { renderMarkdown } from '$lib/render/markdown';
describe('renderMarkdown', () => {
it('rendert einfachen Markdown-Text zu HTML', () => {
const html = renderMarkdown('**bold** and *italic*');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
});
it('entfernt <script>-Tags (DOMPurify)', () => {
const html = renderMarkdown('hello <script>alert("x")</script> world');
expect(html).not.toContain('<script>');
});
it('entfernt javascript:-URLs', () => {
const html = renderMarkdown('[click](javascript:alert(1))');
expect(html).not.toMatch(/javascript:/i);
});
it('rendert Links mit http:// und erhält das href', () => {
const html = renderMarkdown('[nostr](https://nostr.com)');
expect(html).toContain('href="https://nostr.com"');
});
it('rendert horizontale Linie aus ---', () => {
const html = renderMarkdown('oben\n\n---\n\nunten');
expect(html).toContain('<hr>');
});
it('rendert fenced code blocks', () => {
const html = renderMarkdown('```js\nconst x = 1;\n```');
expect(html).toContain('<pre>');
expect(html).toContain('<code');
});
it('rendert GFM tables', () => {
const md = '| a | b |\n|---|---|\n| 1 | 2 |';
const html = renderMarkdown(md);
expect(html).toContain('<table');
expect(html).toContain('<td>1</td>');
});
it('rendert Bilder', () => {
const html = renderMarkdown('![alt](https://example.com/img.png)');
expect(html).toContain('<img');
expect(html).toContain('src="https://example.com/img.png"');
});
});
```
- [ ] **Step 7.2: Test ausführen (FAIL)**
```sh
cd app
npm run test:unit -- markdown
```
- [ ] **Step 7.3: Implementation**
Create `app/src/lib/render/markdown.ts`:
```ts
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import bash from 'highlight.js/lib/languages/bash';
import typescript from 'highlight.js/lib/languages/typescript';
import json from 'highlight.js/lib/languages/json';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('json', json);
marked.use({
breaks: true,
gfm: true,
renderer: {
code({ text, lang }) {
const language = lang && hljs.getLanguage(lang) ? lang : undefined;
const highlighted = language
? hljs.highlight(text, { language }).value
: hljs.highlightAuto(text).value;
const cls = language ? ` language-${language}` : '';
return `<pre><code class="hljs${cls}">${highlighted}</code></pre>`;
},
},
});
/**
* Rendert einen Markdown-String zu sanitized HTML.
* Einziger Export des Moduls — so bleibt Austausch der Engine lokal.
*/
export function renderMarkdown(md: string): string {
const raw = marked.parse(md, { async: false }) as string;
return DOMPurify.sanitize(raw, {
ADD_ATTR: ['target', 'rel'],
});
}
```
- [ ] **Step 7.4: Test ausführen (PASS)**
```sh
cd app
npm run test:unit -- markdown
```
Expected: alle 8 Tests grün.
- [ ] **Step 7.5: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/render/markdown.ts app/tests/unit/markdown.test.ts
git commit -m "spa: markdown-renderer mit sanitize (tdd)"
```
---
### Task 8: RelayPool-Singleton
**Files:**
- Create: `app/src/lib/nostr/pool.ts`
- [ ] **Step 8.1: RelayPool anlegen**
Create `app/src/lib/nostr/pool.ts`:
```ts
import { RelayPool } from 'applesauce-relay';
/**
* Singleton-Pool für alle Nostr-Requests der SPA.
* applesauce-relay verwaltet Reconnects, Subscriptions, deduping intern.
*/
export const pool = new RelayPool();
```
- [ ] **Step 8.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/pool.ts
git commit -m "spa: relaypool-singleton via applesauce-relay"
```
---
### Task 9: Outbox-Relays laden (`kind:10002`)
**Files:**
- Create: `app/src/lib/nostr/relays.ts`
- [ ] **Step 9.1: Helper anlegen**
Create `app/src/lib/nostr/relays.ts`:
```ts
import { pool } from './pool';
import {
AUTHOR_PUBKEY_HEX,
BOOTSTRAP_RELAY,
FALLBACK_READ_RELAYS,
RELAY_TIMEOUT_MS,
} from './config';
export interface OutboxRelay {
url: string;
/** true = zum Lesen zu nutzen (kein dritter Tag-Wert oder "read") */
read: boolean;
/** true = zum Schreiben zu nutzen (kein dritter Tag-Wert oder "write") */
write: boolean;
}
/**
* Lädt die NIP-65-Relay-Liste (kind:10002) des Autors vom Bootstrap-Relay.
* Fallback auf FALLBACK_READ_RELAYS, wenn das Event nicht innerhalb von
* RELAY_TIMEOUT_MS gefunden wird.
*
* Interpretation des dritten Tag-Werts:
* - nicht gesetzt → read + write
* - "read" → nur read
* - "write" → nur write
*/
export async function loadOutboxRelays(): Promise<OutboxRelay[]> {
const event = await Promise.race([
firstEvent({ kinds: [10002], authors: [AUTHOR_PUBKEY_HEX], limit: 1 }),
timeout(RELAY_TIMEOUT_MS),
]).catch(() => null);
if (!event) {
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
}
const relays: OutboxRelay[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'r' || !tag[1]) continue;
const mode = tag[2];
relays.push({
url: tag[1],
read: mode !== 'write',
write: mode !== 'read',
});
}
if (relays.length === 0) {
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
}
return relays;
}
/** Nur die Read-URLs aus OutboxRelay[] */
export function readUrls(relays: OutboxRelay[]): string[] {
return relays.filter((r) => r.read).map((r) => r.url);
}
/** Nur die Write-URLs aus OutboxRelay[] */
export function writeUrls(relays: OutboxRelay[]): string[] {
return relays.filter((r) => r.write).map((r) => r.url);
}
// ---------- Internes --------------------------------------------------------
interface NostrEventShape {
tags: string[][];
[k: string]: unknown;
}
function firstEvent(filter: {
kinds: number[];
authors?: string[];
limit?: number;
}): Promise<NostrEventShape | null> {
return new Promise((resolve) => {
let done = false;
const sub = pool.subscription([BOOTSTRAP_RELAY], filter).subscribe({
next(msg) {
if (done) return;
// applesauce-relay liefert Tupel ["EVENT", ..., event] / ["EOSE"] etc.
if (Array.isArray(msg) && msg[0] === 'EVENT') {
done = true;
sub.unsubscribe();
resolve(msg[2] as NostrEventShape);
} else if (Array.isArray(msg) && msg[0] === 'EOSE') {
if (!done) {
done = true;
sub.unsubscribe();
resolve(null);
}
}
},
error() {
if (!done) {
done = true;
resolve(null);
}
},
complete() {
if (!done) {
done = true;
resolve(null);
}
},
});
});
}
function timeout(ms: number): Promise<null> {
return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms));
}
```
- [ ] **Step 9.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/relays.ts
git commit -m "spa: outbox-relay-loader für kind:10002 mit fallback"
```
---
### Task 10: Read-Relays-Store
**Files:**
- Create: `app/src/lib/stores/readRelays.ts`
- [ ] **Step 10.1: Store anlegen**
Create `app/src/lib/stores/readRelays.ts`:
```ts
import { writable, type Readable } from 'svelte/store';
import { loadOutboxRelays, readUrls } from '$lib/nostr/relays';
import { FALLBACK_READ_RELAYS } from '$lib/nostr/config';
/**
* Store mit der aktuellen Read-Relay-Liste.
* Initial = FALLBACK_READ_RELAYS, damit die SPA sofort abfragen kann;
* sobald loadOutboxRelays() fertig ist, wird der Store aktualisiert.
*
* Singleton-Initialisierung: bootstrap() wird genau einmal beim ersten
* Import aufgerufen.
*/
const store = writable<string[]>([...FALLBACK_READ_RELAYS]);
let bootstrapped = false;
export function bootstrapReadRelays(): void {
if (bootstrapped) return;
bootstrapped = true;
loadOutboxRelays()
.then((relays) => {
const urls = readUrls(relays);
if (urls.length > 0) store.set(urls);
})
.catch(() => {
// Store behält seinen initialen FALLBACK-Zustand
});
}
export const readRelays: Readable<string[]> = store;
```
- [ ] **Step 10.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/stores/readRelays.ts
git commit -m "spa: read-relays-store mit bootstrap aus kind:10002"
```
---
### Task 11: Loader-Modul — Post-Liste und Einzelpost
**Files:**
- Create: `app/src/lib/nostr/loaders.ts`
- [ ] **Step 11.1: Loader-API definieren**
Create `app/src/lib/nostr/loaders.ts`:
```ts
import { get } from 'svelte/store';
import { pool } from './pool';
import { readRelays } from '$lib/stores/readRelays';
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
/** Minimales Event-Interface für unsere Zwecke */
export interface NostrEvent {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
}
/** Profile-Content (kind:0) */
export interface Profile {
name?: string;
display_name?: string;
picture?: string;
banner?: string;
about?: string;
website?: string;
nip05?: string;
lud16?: string;
}
/**
* Startet eine Subscription und liefert alle gesammelten Events,
* sobald EOSE empfangen wird ODER hard-Timeout eintritt.
*/
function collectEvents(
relays: string[],
filter: { kinds: number[]; authors?: string[]; '#d'?: string[]; '#a'?: string[]; '#e'?: string[]; limit?: number },
opts?: { onEvent?: (ev: NostrEvent) => void; hardTimeoutMs?: number },
): Promise<NostrEvent[]> {
return new Promise((resolve) => {
const collected: NostrEvent[] = [];
let done = false;
const finish = () => {
if (done) return;
done = true;
sub.unsubscribe();
resolve(collected);
};
const sub = pool.subscription(relays, filter).subscribe({
next(msg) {
if (done) return;
if (Array.isArray(msg) && msg[0] === 'EVENT') {
const ev = msg[2] as NostrEvent;
collected.push(ev);
opts?.onEvent?.(ev);
} else if (Array.isArray(msg) && msg[0] === 'EOSE') {
finish();
}
},
error: finish,
complete: finish,
});
setTimeout(finish, opts?.hardTimeoutMs ?? RELAY_HARD_TIMEOUT_MS);
});
}
/** Dedup per d-Tag: neueste (created_at) wins */
function dedupByDtag(events: NostrEvent[]): NostrEvent[] {
const byDtag = new Map<string, NostrEvent>();
for (const ev of events) {
const d = ev.tags.find((t) => t[0] === 'd')?.[1];
if (!d) continue;
const existing = byDtag.get(d);
if (!existing || ev.created_at > existing.created_at) {
byDtag.set(d, ev);
}
}
return [...byDtag.values()];
}
/** Alle kind:30023-Posts des Autors, neueste zuerst */
export async function loadPostList(
onEvent?: (ev: NostrEvent) => void,
): Promise<NostrEvent[]> {
const relays = get(readRelays);
const events = await collectEvents(
relays,
{ kinds: [30023], authors: [AUTHOR_PUBKEY_HEX], limit: 200 },
{ onEvent },
);
const deduped = dedupByDtag(events);
return deduped.sort((a, b) => {
const ap = parseInt(a.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${a.created_at}`, 10);
const bp = parseInt(b.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${b.created_at}`, 10);
return bp - ap;
});
}
/** Einzelpost per d-Tag */
export async function loadPost(dtag: string): Promise<NostrEvent | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [30023],
authors: [AUTHOR_PUBKEY_HEX],
'#d': [dtag],
limit: 1,
});
if (events.length === 0) return null;
return events.reduce((best, cur) => (cur.created_at > best.created_at ? cur : best));
}
/** Profil-Event kind:0 (neueste Version) */
export async function loadProfile(): Promise<Profile | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [0],
authors: [AUTHOR_PUBKEY_HEX],
limit: 1,
});
if (events.length === 0) return null;
const latest = events.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best,
);
try {
return JSON.parse(latest.content) as Profile;
} catch {
return null;
}
}
```
- [ ] **Step 11.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: loader für postlist, post, profile"
```
---
### Task 12: Replies-Loader (`kind:1` mit `#a`-Tag auf Post-Adresse)
**Files:**
- Modify: `app/src/lib/nostr/loaders.ts` (ergänzen)
- [ ] **Step 12.1: `loadReplies` hinzufügen**
Am Ende von `app/src/lib/nostr/loaders.ts` anhängen:
```ts
/** Post-Adresse im `a`-Tag-Format: "30023:<pubkey>:<dtag>" */
function eventAddress(pubkey: string, dtag: string): string {
return `30023:${pubkey}:${dtag}`;
}
/**
* Alle kind:1-Replies auf einen Post, chronologisch aufsteigend (älteste zuerst).
* Streamt via onEvent, wenn angegeben.
*/
export async function loadReplies(
dtag: string,
onEvent?: (ev: NostrEvent) => void,
): Promise<NostrEvent[]> {
const relays = get(readRelays);
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
const events = await collectEvents(
relays,
{ kinds: [1], '#a': [address], limit: 500 },
{ onEvent },
);
return events.sort((a, b) => a.created_at - b.created_at);
}
```
- [ ] **Step 12.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: replies-loader für kind:1 mit a-tag-filter"
```
---
### Task 13: Reactions-Loader (`kind:7`)
**Files:**
- Modify: `app/src/lib/nostr/loaders.ts` (ergänzen)
- [ ] **Step 13.1: `loadReactions` hinzufügen**
Am Ende von `app/src/lib/nostr/loaders.ts` anhängen:
```ts
export interface ReactionSummary {
/** Emoji oder "+"/"-" */
content: string;
count: number;
}
/**
* Aggregiert kind:7-Reactions auf einen Post.
* Gruppiert nach content, zählt Anzahl.
*/
export async function loadReactions(dtag: string): Promise<ReactionSummary[]> {
const relays = get(readRelays);
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
const events = await collectEvents(relays, {
kinds: [7],
'#a': [address],
limit: 500,
});
const counts = new Map<string, number>();
for (const ev of events) {
const key = ev.content || '+';
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts.entries()]
.map(([content, count]) => ({ content, count }))
.sort((a, b) => b.count - a.count);
}
```
- [ ] **Step 13.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: reactions-loader mit aggregation"
```
---
### Task 14: NIP-07-Signer-Wrapper
**Files:**
- Create: `app/src/lib/nostr/signer.ts`
- [ ] **Step 14.1: Signer-Wrapper anlegen**
Create `app/src/lib/nostr/signer.ts`:
```ts
/**
* NIP-07-Wrapper für Browser-Extension-Signer (Alby, nos2x, Flamingo).
*
* window.nostr ist optional — wenn die Extension fehlt, liefern wir null zurück
* und der Aufrufer zeigt einen Hinweis an.
*/
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
};
}
}
export interface UnsignedEvent {
kind: number;
tags: string[][];
content: string;
created_at: number;
pubkey: string;
}
export interface SignedEvent extends UnsignedEvent {
id: string;
sig: string;
}
export function hasNip07(): boolean {
return typeof window !== 'undefined' && !!window.nostr;
}
export async function getPublicKey(): Promise<string | null> {
if (!hasNip07()) return null;
try {
return await window.nostr!.getPublicKey();
} catch {
return null;
}
}
export async function signEvent(event: UnsignedEvent): Promise<SignedEvent | null> {
if (!hasNip07()) return null;
try {
return await window.nostr!.signEvent(event);
} catch {
return null;
}
}
```
- [ ] **Step 14.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/signer.ts
git commit -m "spa: nip-07-signer-wrapper"
```
---
## Phase 3 — Routing & Pages (Tasks 1522)
### Task 15: Gemeinsame Komponente `LoadingOrError.svelte`
**Files:**
- Create: `app/src/lib/components/LoadingOrError.svelte`
- [ ] **Step 15.1: Komponente anlegen**
Create `app/src/lib/components/LoadingOrError.svelte`:
```svelte
<script lang="ts">
interface Props {
loading: boolean;
error: string | null;
hablaLink?: string;
}
let { loading, error, hablaLink }: Props = $props();
</script>
{#if loading && !error}
<p class="status">Lade von Nostr-Relays …</p>
{:else if error}
<p class="status status-error">
{error}
{#if hablaLink}
<br />
<a href={hablaLink} target="_blank" rel="noopener">
In Habla.news öffnen
</a>
{/if}
</p>
{/if}
<style>
.status {
padding: 1rem;
border-radius: 4px;
background: var(--code-bg);
color: var(--muted);
text-align: center;
}
.status-error {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.status-error {
background: #450a0a;
color: #fca5a5;
}
}
</style>
```
- [ ] **Step 15.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/LoadingOrError.svelte
git commit -m "spa: loading-or-error-komponente"
```
---
### Task 16: Globales Styling im `app.html`
**Files:**
- Modify: `app/src/app.html`
- [ ] **Step 16.1: app.html mit CSS-Variablen und Base-Layout**
Ersetze Inhalt von `app/src/app.html` durch:
```html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="description" content="Jörg Lohrer Blog (Nostr-basiert)" />
<title>Jörg Lohrer</title>
<style>
:root {
--fg: #1f2937;
--muted: #6b7280;
--bg: #fafaf9;
--accent: #2563eb;
--code-bg: #f3f4f6;
--border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e5e7eb;
--muted: #9ca3af;
--bg: #18181b;
--accent: #60a5fa;
--code-bg: #27272a;
--border: #3f3f46;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 17px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--fg);
background: var(--bg);
}
a { color: var(--accent); }
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
- [ ] **Step 16.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/app.html
git commit -m "spa: globales styling mit css-variablen im app.html"
```
---
### Task 17: `+layout.svelte` mit Container und Bootstrap
**Files:**
- Create: `app/src/routes/+layout.svelte`
- [ ] **Step 17.1: Layout-Komponente mit Relay-Bootstrap**
Create `app/src/routes/+layout.svelte`:
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import { bootstrapReadRelays } from '$lib/stores/readRelays';
let { children } = $props();
onMount(() => {
bootstrapReadRelays();
});
</script>
<main>
{@render children()}
</main>
<style>
main {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
@media (min-width: 640px) {
main {
padding: 1.5rem;
}
}
</style>
```
- [ ] **Step 17.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/+layout.svelte
git commit -m "spa: layout mit container und relay-bootstrap"
```
---
### Task 18: `ProfileCard.svelte`
**Files:**
- Create: `app/src/lib/components/ProfileCard.svelte`
- [ ] **Step 18.1: Komponente anlegen**
Create `app/src/lib/components/ProfileCard.svelte`:
```svelte
<script lang="ts">
import type { Profile } from '$lib/nostr/loaders';
interface Props { profile: Profile | null }
let { profile }: Props = $props();
</script>
{#if profile}
<div class="profile">
{#if profile.picture}
<img class="avatar" src={profile.picture} alt={profile.display_name ?? profile.name ?? ''} />
{:else}
<div class="avatar"></div>
{/if}
<div class="info">
<div class="name">{profile.display_name ?? profile.name ?? ''}</div>
{#if profile.about}
<div class="about">{profile.about}</div>
{/if}
{#if profile.nip05 || profile.website}
<div class="meta-line">
{#if profile.nip05}<span>{profile.nip05}</span>{/if}
{#if profile.nip05 && profile.website}<span class="sep">·</span>{/if}
{#if profile.website}
<a href={profile.website} target="_blank" rel="noopener">
{profile.website.replace(/^https?:\/\//, '')}
</a>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<style>
.profile {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.avatar {
flex: 0 0 80px;
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background: var(--code-bg);
}
.info { flex: 1; min-width: 0; }
.name { font-size: 1.3rem; font-weight: 600; margin: 0 0 0.2rem; }
.about { color: var(--muted); font-size: 0.95rem; margin: 0 0 0.3rem; }
.meta-line { font-size: 0.85rem; color: var(--muted); }
.meta-line a { color: var(--accent); text-decoration: none; }
.meta-line a:hover { text-decoration: underline; }
.sep { margin: 0 0.4rem; opacity: 0.5; }
</style>
```
- [ ] **Step 18.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ProfileCard.svelte
git commit -m "spa: profile-card komponente"
```
---
### Task 19: `PostCard.svelte` (Listenelement)
**Files:**
- Create: `app/src/lib/components/PostCard.svelte`
- [ ] **Step 19.1: Komponente anlegen**
Create `app/src/lib/components/PostCard.svelte`:
```svelte
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import { canonicalPostPath } from '$lib/url/legacy';
interface Props { event: NostrEvent }
let { event }: Props = $props();
function tagValue(e: NostrEvent, name: string): string {
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
}
const dtag = tagValue(event, 'd');
const title = tagValue(event, 'title') || '(ohne Titel)';
const summary = tagValue(event, 'summary');
const image = tagValue(event, 'image');
const publishedAt = parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10);
const date = new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
});
const href = canonicalPostPath(dtag);
</script>
<a class="card" {href}>
<div class="thumb" style:background-image={image ? `url('${image}')` : undefined} aria-hidden="true"></div>
<div class="text">
<div class="meta">{date}</div>
<h2>{title}</h2>
{#if summary}<p class="excerpt">{summary}</p>{/if}
</div>
</a>
<style>
.card {
display: flex;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
color: inherit;
text-decoration: none;
align-items: flex-start;
}
.card:hover { background: var(--code-bg); }
.thumb {
flex: 0 0 120px;
aspect-ratio: 1 / 1;
border-radius: 4px;
background: var(--code-bg) center/cover no-repeat;
}
.text { flex: 1; min-width: 0; }
h2 { margin: 0 0 0.3rem; font-size: 1.2rem; color: var(--fg); word-wrap: break-word; }
.excerpt { color: var(--muted); font-size: 0.95rem; margin: 0; }
.meta { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.2rem; }
@media (max-width: 479px) {
.card { flex-direction: column; gap: 0.5rem; }
.thumb { flex: 0 0 auto; width: 100%; aspect-ratio: 2 / 1; }
}
</style>
```
- [ ] **Step 19.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/PostCard.svelte
git commit -m "spa: post-card listenelement"
```
---
### Task 20: Home-Page `+page.svelte`
**Files:**
- Modify: `app/src/routes/+page.svelte`
- [ ] **Step 20.1: Home-Page mit Profil + Liste**
Ersetze Inhalt von `app/src/routes/+page.svelte` durch:
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
import { loadPostList, loadProfile } from '$lib/nostr/loaders';
import ProfileCard from '$lib/components/ProfileCard.svelte';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
let profile: Profile | null = $state(null);
let posts: NostrEvent[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(async () => {
try {
const [p, list] = await Promise.all([loadProfile(), loadPostList()]);
profile = p;
posts = list;
loading = false;
if (list.length === 0) {
error = 'Keine Posts gefunden auf den abgefragten Relays.';
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
}
});
$effect(() => {
const name = profile?.display_name ?? profile?.name ?? 'Jörg Lohrer';
document.title = `${name} Blog`;
});
</script>
<ProfileCard {profile} />
<h1 class="list-title">Beiträge</h1>
<LoadingOrError {loading} {error} />
{#each posts as post (post.id)}
<PostCard event={post} />
{/each}
<style>
.list-title { margin: 0 0 1rem; font-size: 1.4rem; }
</style>
```
- [ ] **Step 20.2: Lokal testen**
```sh
cd app
npm run dev
```
Öffne `http://localhost:5173/`. Erwartung: Profilkachel oben, „Beiträge"-Überschrift, Liste der publizierten Posts mit Thumbnails.
- [ ] **Step 20.3: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/+page.svelte
git commit -m "spa: home-page mit profil und beitragsliste"
```
---
### Task 21: `PostView.svelte` Komponente
**Files:**
- Create: `app/src/lib/components/PostView.svelte`
- [ ] **Step 21.1: Komponente anlegen**
Create `app/src/lib/components/PostView.svelte`:
```svelte
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import { renderMarkdown } from '$lib/render/markdown';
interface Props { event: NostrEvent }
let { event }: Props = $props();
function tagValue(e: NostrEvent, name: string): string {
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
}
function tagsAll(e: NostrEvent, name: string): string[] {
return e.tags.filter((t) => t[0] === name).map((t) => t[1]);
}
const title = tagValue(event, 'title') || '(ohne Titel)';
const summary = tagValue(event, 'summary');
const image = tagValue(event, 'image');
const publishedAt = parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10);
const date = new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
});
const tags = tagsAll(event, 't');
const bodyHtml = renderMarkdown(event.content);
$effect(() => {
document.title = `${title} Jörg Lohrer`;
});
</script>
<h1 class="post-title">{title}</h1>
<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>
{#if image}
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
{/if}
{#if summary}
<p class="summary">{summary}</p>
{/if}
<article>{@html bodyHtml}</article>
<style>
.post-title {
font-size: 1.5rem;
line-height: 1.25;
margin: 0 0 0.4rem;
word-wrap: break-word;
}
@media (min-width: 640px) {
.post-title { font-size: 2rem; line-height: 1.2; }
}
.meta { color: var(--muted); font-size: 0.92rem; margin-bottom: 2rem; }
.tags { margin-top: 0.4rem; }
.tag {
display: inline-block;
background: var(--code-bg);
border-radius: 3px;
padding: 1px 7px;
margin: 0 4px 4px 0;
font-size: 0.85em;
color: var(--fg);
text-decoration: none;
}
.tag:hover { background: var(--border); }
.cover {
max-width: 480px;
margin: 1rem auto 1.5rem;
}
.cover img {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
.summary { font-style: italic; color: var(--muted); }
article :global(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
article :global(a) { color: var(--accent); word-break: break-word; }
article :global(pre) {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
article :global(code) {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
article :global(pre code) { padding: 0; background: none; word-break: normal; }
article :global(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
article :global(blockquote) {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
margin: 1rem 0;
color: var(--muted);
}
</style>
```
- [ ] **Step 21.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/PostView.svelte
git commit -m "spa: post-view komponente mit markdown-rendering"
```
---
### Task 22: Catch-all-Route `[...slug]/+page.svelte` mit Legacy-Normalisierung
**Files:**
- Create: `app/src/routes/[...slug]/+page.svelte`
- Create: `app/src/routes/[...slug]/+page.ts`
- [ ] **Step 22.1: Route-Load-Funktion**
Create `app/src/routes/[...slug]/+page.ts`:
```ts
import { error, redirect } from '@sveltejs/kit';
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const pathname = url.pathname;
// Legacy-Form /YYYY/MM/DD/<dtag>.html/ → Redirect auf /<dtag>/
const legacyDtag = parseLegacyUrl(pathname);
if (legacyDtag) {
throw redirect(301, canonicalPostPath(legacyDtag));
}
// Kanonisch: /<dtag>/ — erster Segment des Pfades.
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/');
if (segments.length !== 1 || !segments[0]) {
throw error(404, 'Seite nicht gefunden');
}
return { dtag: decodeURIComponent(segments[0]) };
};
```
- [ ] **Step 22.2: PostView-Seite**
Create `app/src/routes/[...slug]/+page.svelte`:
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadPost } from '$lib/nostr/loaders';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
import { buildHablaLink } from '$lib/nostr/naddr';
import PostView from '$lib/components/PostView.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
let { data } = $props();
const dtag = data.dtag;
let post: NostrEvent | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
const hablaLink = buildHablaLink({
pubkey: AUTHOR_PUBKEY_HEX,
kind: 30023,
identifier: dtag,
});
onMount(async () => {
try {
const p = await loadPost(dtag);
loading = false;
if (!p) {
error = `Post "${dtag}" nicht gefunden.`;
} else {
post = p;
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
}
});
</script>
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
<LoadingOrError {loading} {error} {hablaLink} />
{#if post}
<PostView event={post} />
{/if}
<style>
.breadcrumb {
font-size: 0.9rem;
margin-bottom: 1rem;
}
.breadcrumb a { color: var(--accent); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
</style>
```
- [ ] **Step 22.3: Lokal testen**
```sh
cd app
npm run dev
```
Öffne `http://localhost:5173/dezentrale-oep-oer/`. Erwartung: Post rendert. Breadcrumb „← Zurück zur Übersicht" funktioniert. Teste auch `http://localhost:5173/2025/03/04/dezentrale-oep-oer.html/` — Browser sollte auf `/dezentrale-oep-oer/` umleiten und Post zeigen.
- [ ] **Step 22.4: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/
git commit -m "spa: catch-all-route mit legacy-redirect und postview"
```
---
## Phase 4 — Tag-Navigation (Tasks 2325)
### Task 23: Tag-Filter-Loader
**Files:**
- Modify: `app/src/lib/nostr/loaders.ts` (ergänzen)
- [ ] **Step 23.1: `loadPostsByTag` hinzufügen**
Am Ende von `app/src/lib/nostr/loaders.ts` anhängen:
```ts
/**
* Filtert Post-Liste clientseitig nach Tag-Name.
* (Relay-seitige #t-Filter werden nicht von allen Relays unterstützt — safer
* ist es, die ganze Liste zu laden und lokal zu filtern.)
*/
export async function loadPostsByTag(tagName: string): Promise<NostrEvent[]> {
const all = await loadPostList();
const norm = tagName.toLowerCase();
return all.filter((ev) =>
ev.tags.some((t) => t[0] === 't' && t[1]?.toLowerCase() === norm),
);
}
```
- [ ] **Step 23.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: tag-filter-loader (case-insensitive, client-side)"
```
---
### Task 24: Tag-Seite
**Files:**
- Create: `app/src/routes/tag/[name]/+page.ts`
- Create: `app/src/routes/tag/[name]/+page.svelte`
- [ ] **Step 24.1: Load-Funktion**
Create `app/src/routes/tag/[name]/+page.ts`:
```ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return { tagName: decodeURIComponent(params.name) };
};
```
- [ ] **Step 24.2: Seite**
Create `app/src/routes/tag/[name]/+page.svelte`:
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadPostsByTag } from '$lib/nostr/loaders';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
let { data } = $props();
const tagName = data.tagName;
let posts: NostrEvent[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(async () => {
try {
posts = await loadPostsByTag(tagName);
loading = false;
if (posts.length === 0) {
error = `Keine Posts mit Tag "${tagName}" gefunden.`;
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
}
});
$effect(() => {
document.title = `#${tagName} Jörg Lohrer`;
});
</script>
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
<h1 class="tag-title">#{tagName}</h1>
<LoadingOrError {loading} {error} />
{#each posts as post (post.id)}
<PostCard event={post} />
{/each}
<style>
.breadcrumb { font-size: 0.9rem; margin-bottom: 1rem; }
.breadcrumb a { color: var(--accent); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.tag-title { margin: 0 0 1.5rem; font-size: 1.6rem; }
</style>
```
- [ ] **Step 24.3: Lokal testen**
```sh
cd app
npm run dev
```
Öffne `http://localhost:5173/tag/OER/`. Erwartung: Liste aller Posts mit `t`-Tag „OER".
- [ ] **Step 24.4: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/tag/
git commit -m "spa: tag-filter-seite"
```
---
### Task 25: Tag-Link-Sicherheit fixen
**Files:**
- Modify: `app/src/lib/components/PostView.svelte:63-65`
Hintergrund: in Task 21 wurde `href="/tag/{encodeURIComponent(t)}/"` schon gesetzt — sollte passen. Verifizieren.
- [ ] **Step 25.1: Verifizieren**
Öffne `http://localhost:5173/dezentrale-oep-oer/`. Klick auf Tag „OER". Erwartung: Navigation zu `/tag/OER/`, Liste wird gefiltert.
- [ ] **Step 25.2: Keine Änderung nötig, Verifikations-Step. Commit wird übersprungen, wenn nichts geändert wurde.**
---
## Phase 5 — Reactions & NIP-07-Kommentare (Tasks 2632)
### Task 26: `Reactions.svelte`
**Files:**
- Create: `app/src/lib/components/Reactions.svelte`
- [ ] **Step 26.1: Komponente anlegen**
Create `app/src/lib/components/Reactions.svelte`:
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import type { ReactionSummary } from '$lib/nostr/loaders';
import { loadReactions } from '$lib/nostr/loaders';
interface Props { dtag: string }
let { dtag }: Props = $props();
let reactions: ReactionSummary[] = $state([]);
onMount(async () => {
try {
reactions = await loadReactions(dtag);
} catch {
reactions = [];
}
});
function displayChar(c: string): string {
if (c === '+' || c === '') return '👍';
if (c === '-') return '👎';
return c;
}
</script>
{#if reactions.length > 0}
<div class="reactions">
{#each reactions as r}
<span class="reaction">
<span class="emoji">{displayChar(r.content)}</span>
<span class="count">{r.count}</span>
</span>
{/each}
</div>
{/if}
<style>
.reactions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1.5rem 0;
}
.reaction {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.6rem;
background: var(--code-bg);
border-radius: 999px;
font-size: 0.9rem;
}
.count { color: var(--muted); }
</style>
```
- [ ] **Step 26.2: In PostView einbinden**
Modify `app/src/lib/components/PostView.svelte` — am Anfang des `<script>`:
```ts
import Reactions from './Reactions.svelte';
```
Am Ende, vor dem schließenden `</style>`-Block (also im Markup-Bereich), nach `<article>{@html bodyHtml}</article>` ergänzen:
```svelte
{#if dtag}<Reactions {dtag} />{/if}
```
Und oben in den Variablen:
```ts
const dtag = tagValue(event, 'd');
```
- [ ] **Step 26.3: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/Reactions.svelte app/src/lib/components/PostView.svelte
git commit -m "spa: reactions-anzeige unter posts"
```
---
### Task 27: `ReplyItem.svelte`
**Files:**
- Create: `app/src/lib/components/ReplyItem.svelte`
- [ ] **Step 27.1: Komponente anlegen**
Create `app/src/lib/components/ReplyItem.svelte`:
```svelte
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
interface Props { event: NostrEvent }
let { event }: Props = $props();
const date = new Date(event.created_at * 1000).toLocaleString('de-DE');
const authorNpub = event.pubkey.slice(0, 12) + '…';
</script>
<li class="reply">
<div class="meta">
<span class="author">{authorNpub}</span>
<span class="sep">·</span>
<span class="date">{date}</span>
</div>
<div class="content">{event.content}</div>
</li>
<style>
.reply {
list-style: none;
padding: 0.8rem 0;
border-bottom: 1px solid var(--border);
}
.meta { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.3rem; }
.author { font-family: monospace; }
.sep { margin: 0 0.4rem; opacity: 0.5; }
.content { white-space: pre-wrap; word-wrap: break-word; }
</style>
```
- [ ] **Step 27.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ReplyItem.svelte
git commit -m "spa: reply-item komponente"
```
---
### Task 28: `ReplyList.svelte`
**Files:**
- Create: `app/src/lib/components/ReplyList.svelte`
- [ ] **Step 28.1: Komponente anlegen**
Create `app/src/lib/components/ReplyList.svelte`:
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadReplies } from '$lib/nostr/loaders';
import ReplyItem from './ReplyItem.svelte';
interface Props { dtag: string }
let { dtag }: Props = $props();
let replies: NostrEvent[] = $state([]);
let loading = $state(true);
onMount(async () => {
try {
replies = await loadReplies(dtag);
} finally {
loading = false;
}
});
export function addOptimistic(event: NostrEvent): void {
replies = [...replies, event];
}
</script>
<section class="replies">
<h3>Kommentare ({replies.length})</h3>
{#if loading}
<p class="hint">Lade Kommentare …</p>
{:else if replies.length === 0}
<p class="hint">Noch keine Kommentare.</p>
{:else}
<ul>
{#each replies as reply (reply.id)}
<ReplyItem event={reply} />
{/each}
</ul>
{/if}
</section>
<style>
.replies { margin: 2rem 0; }
h3 { font-size: 1.1rem; margin: 0 0 0.8rem; }
ul { list-style: none; padding: 0; margin: 0; }
.hint { color: var(--muted); font-size: 0.9rem; }
</style>
```
- [ ] **Step 28.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ReplyList.svelte
git commit -m "spa: reply-list komponente"
```
---
### Task 29: `ReplyComposer.svelte`
**Files:**
- Create: `app/src/lib/components/ReplyComposer.svelte`
- [ ] **Step 29.1: Komponente anlegen**
Create `app/src/lib/components/ReplyComposer.svelte`:
```svelte
<script lang="ts">
import { hasNip07, getPublicKey, signEvent, type SignedEvent, type UnsignedEvent } from '$lib/nostr/signer';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
import { pool } from '$lib/nostr/pool';
import { readRelays } from '$lib/stores/readRelays';
import { get } from 'svelte/store';
interface Props {
/** d-Tag des Posts, auf den geantwortet wird */
dtag: string;
/** Event-ID des ursprünglichen Posts (für e-Tag) */
eventId: string;
/** Callback, wenn ein Reply erfolgreich publiziert wurde */
onPublished?: (ev: SignedEvent) => void;
}
let { dtag, eventId, onPublished }: Props = $props();
let text = $state('');
let publishing = $state(false);
let error: string | null = $state(null);
let info: string | null = $state(null);
const nip07 = hasNip07();
async function submit() {
error = null;
info = null;
if (!text.trim()) {
error = 'Leeres Kommentar — nichts zu senden.';
return;
}
publishing = true;
try {
const pubkey = await getPublicKey();
if (!pubkey) {
error = 'Nostr-Extension (z. B. Alby) hat den Pubkey nicht geliefert.';
return;
}
const unsigned: UnsignedEvent = {
kind: 1,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', `30023:${AUTHOR_PUBKEY_HEX}:${dtag}`],
['e', eventId, '', 'root'],
['p', AUTHOR_PUBKEY_HEX],
],
content: text.trim(),
};
const signed = await signEvent(unsigned);
if (!signed) {
error = 'Signatur wurde abgelehnt oder ist fehlgeschlagen.';
return;
}
const relays = get(readRelays);
pool.publish(relays, signed);
info = 'Kommentar gesendet.';
text = '';
onPublished?.(signed);
} catch (e) {
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
} finally {
publishing = false;
}
}
</script>
<div class="composer">
{#if !nip07}
<p class="hint">
Um zu kommentieren, benötigst du eine Nostr-Extension
(<a href="https://getalby.com" target="_blank" rel="noopener">Alby</a>,
<a href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener">nos2x</a>),
oder kommentiere direkt in einem Nostr-Client.
</p>
{:else}
<textarea
bind:value={text}
placeholder="Dein Kommentar …"
rows="4"
disabled={publishing}
></textarea>
<div class="actions">
<button type="button" onclick={submit} disabled={publishing || !text.trim()}>
{publishing ? 'Sende …' : 'Kommentar senden'}
</button>
</div>
{#if error}<p class="error">{error}</p>{/if}
{#if info}<p class="info">{info}</p>{/if}
{/if}
</div>
<style>
.composer { margin: 1.5rem 0; }
textarea {
width: 100%;
padding: 0.6rem;
font: inherit;
color: inherit;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 4px;
resize: vertical;
}
.actions { margin-top: 0.5rem; display: flex; justify-content: flex-end; }
button {
padding: 0.4rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.hint { font-size: 0.9rem; color: var(--muted); }
.error { color: #991b1b; font-size: 0.9rem; }
.info { color: #065f46; font-size: 0.9rem; }
</style>
```
- [ ] **Step 29.2: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ReplyComposer.svelte
git commit -m "spa: reply-composer mit nip-07 signing"
```
---
### Task 30: Replies und Composer in PostView integrieren
**Files:**
- Modify: `app/src/lib/components/PostView.svelte`
- Modify: `app/src/routes/[...slug]/+page.svelte`
- [ ] **Step 30.1: PostView um Replies + Composer erweitern**
Modify `app/src/lib/components/PostView.svelte` — im `<script>`-Block zusätzlich:
```ts
import ReplyList from './ReplyList.svelte';
import ReplyComposer from './ReplyComposer.svelte';
import type { SignedEvent } from '$lib/nostr/signer';
let replyList: ReplyList | null = $state(null);
function handlePublished(ev: SignedEvent) {
replyList?.addOptimistic(ev as unknown as NostrEvent);
}
```
Im Markup-Bereich, nach `<Reactions {dtag} />`:
```svelte
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
<ReplyList bind:this={replyList} {dtag} />
```
- [ ] **Step 30.2: Lokal testen**
```sh
cd app
npm run dev
```
Öffne einen Post. Erwartung: unter dem Fließtext Reactions-Chips (falls vorhanden), dann Composer (Textarea + Button), dann Kommentar-Liste.
- [ ] **Step 30.3: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/PostView.svelte
git commit -m "spa: replies und composer in postview integriert"
```
---
### Task 31: E2E-Test für Home und Post (Playwright)
**Files:**
- Create: `app/playwright.config.ts`
- Create: `app/tests/e2e/home.test.ts`
- Create: `app/tests/e2e/post.test.ts`
- [ ] **Step 31.1: Playwright-Config**
Create `app/playwright.config.ts`:
```ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
use: { baseURL: 'http://localhost:5173' },
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 120_000,
},
timeout: 60_000,
});
```
- [ ] **Step 31.2: Playwright-Browser installieren**
```sh
cd app
npx playwright install chromium
```
- [ ] **Step 31.3: Home-E2E**
Create `app/tests/e2e/home.test.ts`:
```ts
import { expect, test } from '@playwright/test';
test('Home zeigt Profil und mindestens einen Post', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1, name: /Beiträge/ })).toBeVisible();
// Profil: mindestens Name-Element
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
// Mindestens eine Post-Card
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
});
```
- [ ] **Step 31.4: Post-E2E**
Create `app/tests/e2e/post.test.ts`:
```ts
import { expect, test } from '@playwright/test';
test('Einzelpost rendert Titel und Markdown-Body', async ({ page }) => {
await page.goto('/dezentrale-oep-oer/');
await expect(
page.getByRole('heading', { level: 1, name: /Gemeinsam die Bildungszukunft/ }),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator('article')).toContainText('Open Educational');
});
test('Legacy-URL wird auf kurze Form umgeleitet', async ({ page }) => {
await page.goto('/2025/03/04/dezentrale-oep-oer.html/');
await expect(page).toHaveURL(/\/dezentrale-oep-oer\/$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 15_000 });
});
```
- [ ] **Step 31.5: E2E laufen lassen**
```sh
cd app
npm run test:e2e
```
Expected: beide Tests grün. Setzt live-Relays und publizierte Events voraus (beides gegeben).
- [ ] **Step 31.6: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/playwright.config.ts app/tests/e2e/
git commit -m "spa: playwright e2e-tests für home, post, legacy-redirect"
```
---
### Task 32: Production-Build und Deploy
**Files:**
- (keine Code-Änderung, nur Ausführung)
- [ ] **Step 32.1: Production-Build**
```sh
cd app
npm run build
ls build/
```
Expected: `index.html`, `_app/`, `.htaccess`, favicon, robots.txt.
- [ ] **Step 32.2: Deploy ausführen**
Vorher: `SVELTE_FTP_*`-Variablen in `.env.local` müssen gefüllt sein, `svelte.joerg-lohrer.de` muss mit SSL aktiv sein.
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
./scripts/deploy-svelte.sh
```
Expected: Upload-Log zeigt alle Dateien, abschließend HTTP/2 200 von `https://svelte.joerg-lohrer.de/`.
- [ ] **Step 32.3: Live-Smoke-Test**
Öffne in deinem Browser:
- `https://svelte.joerg-lohrer.de/` → Liste.
- `https://svelte.joerg-lohrer.de/dezentrale-oep-oer/` → Post.
- `https://svelte.joerg-lohrer.de/2025/03/04/dezentrale-oep-oer.html/` → Post, URL-Leiste springt auf kurze Form.
- `https://svelte.joerg-lohrer.de/tag/OER/` → gefilterte Liste.
- Im Post: Tag-Klick → Tag-Seite.
- Kommentar-Komposer: wenn Alby installiert, Test-Kommentar schreiben und in der Liste sehen.
Kein Code-Commit nötig.
---
## Phase 6 — Abschlusspolish (Tasks 3335)
### Task 33: `robots.txt` und Basic-SEO-Meta
**Files:**
- Modify: `app/static/robots.txt`
- Modify: `app/src/app.html`
- [ ] **Step 33.1: robots.txt**
Create oder überschreibe `app/static/robots.txt`:
```
User-agent: *
Allow: /
```
- [ ] **Step 33.2: OG-Tag-Defaults in app.html**
Modify `app/src/app.html` — im `<head>` vor `%sveltekit.head%` ergänzen:
```html
<meta property="og:title" content="Jörg Lohrer Blog" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://svelte.joerg-lohrer.de/" />
<meta name="robots" content="index, follow" />
```
Hinweis: per-Post OG-Tags sind erst mit Server-Side-Rendering oder Meta-Stubs möglich — aktuell Out-of-Scope (siehe Spec §5 „Phase 3 — Stubs nachrüsten").
- [ ] **Step 33.3: Commit**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/static/robots.txt app/src/app.html
git commit -m "spa: robots.txt und default og-tags"
```
---
### Task 34: svelte-check laufen lassen und Type-Fehler beheben
**Files:**
- Alle modifizierten (Fehlerbehebungen ergeben sich aus der Ausgabe)
- [ ] **Step 34.1: Type-Check**
```sh
cd app
npm run check
```
- [ ] **Step 34.2: Falls Fehler gemeldet werden**
Jede `svelte-check`-Warnung/Error einzeln prüfen:
- Fehlender Type-Import: den Import ergänzen.
- Svelte-5-Rune-Warnings: auf die neue API umstellen (`$state`, `$derived`, `$effect`).
- `window.nostr`-Zugriff außerhalb Browser: `if (typeof window !== 'undefined')` absichern.
Iterativ fixen, bis `npm run check` sauber durchläuft.
- [ ] **Step 34.3: Commit (falls Änderungen)**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/
git commit -m "spa: type-check-fehler behoben"
```
---
### Task 35: Redeploy nach Polish
**Files:**
- (keine Code-Änderung)
- [ ] **Step 35.1: Build + Deploy**
```sh
cd /Users/joerglohrer/repositories/joerglohrerde/app
npm run build
cd ..
./scripts/deploy-svelte.sh
```
- [ ] **Step 35.2: Live abschließen**
Prüfe nochmal:
- `https://svelte.joerg-lohrer.de/` → aktuell
- View-Source auf Homepage: OG-Tags im HTML sichtbar
- Lighthouse (in Browser-DevTools): Performance + Accessibility ≥ 90
Fertig.
---
## Phase 7 — Optionale Erweiterungen (zukünftig, außerhalb dieses Plans)
Diese Items stehen in der Spec, sind aber bewusst nicht in diesem Plan:
- **Impressum-Seite** (`/impressum/`) mit rechtlichem Text — wird als statische HTML-Datei außerhalb der SPA-Routen ergänzt, wenn der rechtliche Inhalt formuliert ist.
- **Meta-Stubs** pro Post für Social-Previews und SEO auf Post-Ebene — kommt über die Publish-Pipeline (siehe Publish-Spec §7).
- **Eigener Relay und Blossom-Server** — Infrastruktur-Erweiterung, SPA-Code unverändert, nur Einträge in `kind:10002` und `kind:10063`.
- **NIP-05-Identifier-Verifikation** im Profil (grüner Haken) — Nice-to-have.
- **Service-Worker für Offline-Caching** — bei Bedarf.
---
## Erfolgskriterien (Plan vollständig ausgeführt)
- [x] `https://svelte.joerg-lohrer.de/` zeigt Profilkachel und Post-Liste live aus Relays.
- [x] Post-Einzelansicht rendert Markdown mit Cover-Bild, Tags (klickbar), Reactions, Kommentare, Composer.
- [x] Legacy-Hugo-URLs werden auf kurze Form normalisiert.
- [x] Tag-Klick filtert zur Tag-Liste.
- [x] NIP-07-Kommentar kann mit Alby gesendet werden und erscheint optimistisch.
- [x] `npm run check` und `npm run test:unit` laufen grün.
- [x] `npm run test:e2e` (Playwright) bestätigt Happy-Path-Szenarien.
- [x] Lighthouse Accessibility ≥ 90.