From 937e356f4ce6245164d83a99979f35588e730b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Wed, 15 Apr 2026 14:57:26 +0200 Subject: [PATCH] plan: sveltekit-spa implementierung (35 tasks, 6 phasen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deckt die vollständige SPA-Spec ab: Setup + adapter-static, Daten- ebene (Relay-Pool, Loader, Profil, Markdown, Signer), Routing mit Legacy-URL-Normalisierung, Tag-Filter, Reactions, NIP-07-Kommentare. TDD für pure Transformationen (URL-Parser, naddr, Markdown), E2E mit Playwright für Happy-Paths. Deploy-Ziel: svelte.joerg-lohrer.de. spa.joerg-lohrer.de bleibt als Vanilla-Mini-Spike-MVP erhalten (Referenz-Verhalten). Publish-Pipeline hat eigene Spec und bekommt separaten Plan. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-15-spa-sveltekit.md | 2812 +++++++++++++++++ 1 file changed, 2812 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-spa-sveltekit.md diff --git a/docs/superpowers/plans/2026-04-15-spa-sveltekit.md b/docs/superpowers/plans/2026-04-15-spa-sveltekit.md new file mode 100644 index 0000000..277250e --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-spa-sveltekit.md @@ -0,0 +1,2812 @@ +# 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, -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 1–6) + +### 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 +

SvelteKit-SPA bootet

+

Wird Stück für Stück mit Nostr-Funktionalität gefüllt.

+``` + +- [ ] **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 // 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/.html oder .../.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 //. + */ +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 7–14) + +### 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('bold'); + expect(html).toContain('italic'); + }); + + it('entfernt world'); + expect(html).not.toContain(' + +{#if loading && !error} +

Lade von Nostr-Relays …

+{:else if error} +

+ {error} + {#if hablaLink} +
+ + In Habla.news öffnen + + {/if} +

+{/if} + + +``` + +- [ ] **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 + + + + + + + + Jörg Lohrer + + %sveltekit.head% + + +
%sveltekit.body%
+ + +``` + +- [ ] **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 + + +
+ {@render children()} +
+ + +``` + +- [ ] **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 + + +{#if profile} +
+ {#if profile.picture} + {profile.display_name + {:else} +
+ {/if} +
+
{profile.display_name ?? profile.name ?? ''}
+ {#if profile.about} +
{profile.about}
+ {/if} + {#if profile.nip05 || profile.website} +
+ {#if profile.nip05}{profile.nip05}{/if} + {#if profile.nip05 && profile.website}·{/if} + {#if profile.website} + + {profile.website.replace(/^https?:\/\//, '')} + + {/if} +
+ {/if} +
+
+{/if} + + +``` + +- [ ] **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 + + + + +
+
{date}
+

{title}

+ {#if summary}

{summary}

{/if} +
+
+ + +``` + +- [ ] **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 + + + + +

Beiträge

+ + + +{#each posts as post (post.id)} + +{/each} + + +``` + +- [ ] **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 + + +

{title}

+
+ Veröffentlicht am {date} + {#if tags.length > 0} +
+ {#each tags as t} + {t} + {/each} +
+ {/if} +
+ +{#if image} +

Cover-Bild

+{/if} + +{#if summary} +

{summary}

+{/if} + +
{@html bodyHtml}
+ + +``` + +- [ ] **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/.html/ → Redirect auf // + const legacyDtag = parseLegacyUrl(pathname); + if (legacyDtag) { + throw redirect(301, canonicalPostPath(legacyDtag)); + } + + // Kanonisch: // — 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 + + + + + + +{#if post} + +{/if} + + +``` + +- [ ] **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 23–25) + +### 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 { + 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 + + + + +

#{tagName}

+ + + +{#each posts as post (post.id)} + +{/each} + + +``` + +- [ ] **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 26–32) + +### 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 + + +{#if reactions.length > 0} +
+ {#each reactions as r} + + {displayChar(r.content)} + {r.count} + + {/each} +
+{/if} + + +``` + +- [ ] **Step 26.2: In PostView einbinden** + +Modify `app/src/lib/components/PostView.svelte` — am Anfang des ` + +
  • +
    + {authorNpub} + · + {date} +
    +
    {event.content}
    +
  • + + +``` + +- [ ] **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 + + +
    +

    Kommentare ({replies.length})

    + {#if loading} +

    Lade Kommentare …

    + {:else if replies.length === 0} +

    Noch keine Kommentare.

    + {:else} +
      + {#each replies as reply (reply.id)} + + {/each} +
    + {/if} +
    + + +``` + +- [ ] **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 + + +
    + {#if !nip07} +

    + Um zu kommentieren, benötigst du eine Nostr-Extension + (Alby, + nos2x), + oder kommentiere direkt in einem Nostr-Client. +

    + {:else} + +
    + +
    + {#if error}

    {error}

    {/if} + {#if info}

    {info}

    {/if} + {/if} +
    + + +``` + +- [ ] **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 `