# Nostr-Page auf Basis von Events — Design-Spec **Datum:** 2026-04-15 **Status:** Entwurf, ausstehende User-Freigabe **Scope:** Ablösung der Hugo-Seite `joerg-lohrer.de` durch eine SvelteKit-SPA, die Blog-Posts live aus Nostr-Events rendert. Diese Spec beschreibt **nur die SPA und den Event-/URL-Kontrakt**. Publish-Pipeline (Markdown → Event → Relays + Assets-Upload) ist separate Spec. --- ## 1. Gesamtarchitektur ``` Browser JS-loser Client / Bot │ │ ▼ ▼ ┌─────────────────────────────┐ ┌───────────────────────────┐ │ All-Inkl (statisches Hosting)│ │ All-Inkl liefert │ │ • index.html (SPA-Shell) │ │ index.html (leere Shell, │ │ • _app/*.js, *.css │ │ kein Post-Inhalt, keine │ │ • .htaccess (SPA-Fallback) │ │ OG-Tags für Posts) │ │ • images/ bzw. Permalink- │ └───────────────────────────┘ │ Pfade für Altbilder │ └──────────────┬──────────────┘ │ ▼ ┌─────────────────────────────────────┐ │ Public Nostr-Relays (wss://) │ │ damus.io, nos.lol, nostr.wine, │ │ relay.nostr.band … │ │ (später erweiterbar um eigene) │ └─────────────────────────────────────┘ Publish-Flow (separate Spec, nur Artefakte hier relevant): Markdown → signiertes kind:30023 Event → Public-Relays Bilder (Altbestand) → All-Inkl unter Post-Permalink-Pfad Bilder (neu) → Blossom-Server (Multi-Upload, single URL im Markdown) ``` ### Kernprinzipien - **All-Inkl hostet nur eine statische SPA-Shell** plus Assets: Altbilder (unter Post-Permalink-Pfaden), Site-Icons, `robots.txt`. Kein Post-Body, keine Stubs, kein Backend. - **Posts existieren als signierte NIP-23-Events auf mehreren Public-Relays.** Die SPA holt sie zur Laufzeit. - **URL-Struktur bleibt kompatibel zur bestehenden Hugo-Seite.** Backlinks brechen nicht. - **Minimale eigene Infrastruktur jetzt** (keine eigene Relay-Instanz, kein eigener Blossom-Server). Alles nachrüstbar ohne Bruch. - **Bewusst akzeptierte Kosten:** kein SEO, keine Social-Previews in Phase 1. Siehe Risiken und Evolutionspfad. ### Kostenübersicht Phase 1 - All-Inkl: unverändert (vorhandener Tarif). - Public-Relays: 0 €. - Public-Blossom-Server (nur Neu-Bilder ab Phase 2): 0 €. - Domain `joerg-lohrer.de`: unverändert. - **Zusatzkosten: keine.** --- ## 2. URL-Struktur, Routing, Event-Kontrakt ### URL-Schema (kompatibel zur bestehenden Hugo-Struktur) | URL | Inhalt | |---|---| | `/` | SPA-Shell. SPA rendert Startseite (Profilkachel + Post-Liste). | | `/YYYY/MM/DD/.html/` | SPA-Shell (via `.htaccess`-Fallback). SPA-Router extrahiert `` und lädt Event. | | `/archives/` | SPA-Shell, SPA rendert chronologische Liste. | | `/tag//` | SPA-Shell, SPA rendert Tag-Filter. | | `/impressum/` | Statisches HTML (rechtlicher Content, liegt wirklich auf Server). | | `/YYYY/MM/DD/.html/` | echte Bilddateien der 18 Altposts, liegen unter dem jeweiligen Post-Permalink. | | `/favicon.ico`, `/logo.png`, `/robots.txt` | globale Site-Assets. | **Datum in der URL** dient nur der Backlink-Kompatibilität. Die SPA benötigt zur Event-Abfrage nur den `dtag`; sie fragt Relays mit `kinds:[30023], authors:[], #d:[]`. Das Datum ist URL-Dekoration. ### `.htaccess` ```apache RewriteEngine On # HTTPS forcieren RewriteCond %{HTTPS} !=on RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] # Existierende Datei oder Verzeichnis? Direkt ausliefern (Bilder, _app/*, favicon etc.). RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^ - [L] # Alles andere → SPA-Fallback. RewriteRule ^ /index.html [L] ``` ### Event-Kontrakt (NIP-23, `kind:30023`) Normatives Schema; Publish-Spec muss es einhalten, SPA verlässt sich darauf. **Pflicht-Tags pro Event:** - `["d", ""]` — URL-Slug. Stabiler Identifier über Edits hinweg (replaceable-Semantik). - `["title", ""]` - `["published_at", ""]` — ursprüngliches Veröffentlichungsdatum. **Empfohlene Tags:** - `["summary", ""]` — genutzt in Listen-Previews. - `["image", ""]` — Vorschaubild. - `["t", ""]` — mehrfach erlaubt, Kategorien/Tags. **Content:** Markdown. Bilder als Markdown-Syntax mit absoluten URLs. **Bild-URL-Policy pro Post-Ära:** - Migrations-Posts (bestehende 18 Posts, 2013–2024): Bild-URLs auf `joerg-lohrer.de` unter Post-Permalink-Pfad, z. B. `https://joerg-lohrer.de/2023/03/23/gleichnis-vom-saemann.html/bild1.jpeg`. Bilder liegen tatsächlich dort. - Neue Posts (ab Phase 2): Bild-URLs auf Blossom-Server, z. B. `https://blossom.primal.net/abc123…def.jpeg`. Upload zu 2–3 Blossom-Servern parallel (BUD-03-Pattern), Markdown referenziert jeweils nur eine URL. Die SPA unterscheidet die beiden Eras nicht — sie rendert Markdown, der Browser lädt die absolute URL, wo sie auch liegt. ### SPA-Routing SvelteKit mit `adapter-static`, `ssr: false`, Fallback-Page `index.html`. Routen: - `/` → Home - `/[year]/[month]/[day]/[dtag].html/` → PostView (nur `dtag` genutzt) - `/archives/` → Archives - `/tag/[name]/` → TagView - `/impressum/` → eigener Impressum-Pfad (oder statisch außerhalb der SPA) **Hinweis zum `.html`-Suffix im Routing:** SvelteKit unterstützt statische Dateiendungen in dynamischen Segmenten nicht direkt. Lösung: Entweder den `.html`-Suffix im Parameter-Wert mitbehandeln (`[dtag]` matched den String inklusive `.html`, davon wird beim Auslesen `.html` abgeschnitten) oder den Pfad als Catch-All-Route `[...slug]` aufnehmen und die Teile im Load-Handler selbst parsen. Details in der Implementation. ### Relay-Konfiguration Relay-Liste kommt aus dem NIP-65-Outbox-Event (`kind:10002`) des Autors. Das Event wird einmalig manuell publiziert (siehe Publish-Spec, Abschnitt „Pre-Flight-Setup") und enthält die bevorzugten Read- und Write-Relays. **Auflösung zur Laufzeit:** 1. SPA kennt genau einen hartcodierten **Bootstrap-Relay** (`wss://relay.damus.io`). 2. Beim Boot: SPA fragt Bootstrap-Relay nach `{ kinds:[10002], authors:[PUBKEY] }`. 3. Aus dem Event werden die `["r", ]`-Tags extrahiert (Read-Relays für die SPA-Abfragen). 4. Diese Liste wird für alle weiteren Nostr-Requests genutzt. **Fallback:** Falls Bootstrap-Relay nicht antwortet oder `kind:10002` nicht existiert, nutzt die SPA eine hartcodierte Fallback-Liste. ```ts // src/lib/nostr/config.ts export const BOOTSTRAP_RELAY = 'wss://relay.damus.io' // TODO bei Implementierung: npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9 // in hex decodieren (nip19.decode) und hier eintragen. export const AUTHOR_PUBKEY_HEX = '' // Nur Fallback: wenn kind:10002 nicht geladen werden kann. export const FALLBACK_READ_RELAYS = [ 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band', 'wss://nostr.wine', ] ``` **Vorteil:** Änderungen an der Relay-Liste (z. B. späteres Hinzufügen eines eigenen Relays) erfordern nur ein neues `kind:10002`-Event, keinen Code-Deploy. Blossom-Server-Liste wird analog aus `kind:10063` (BUD-03) aufgelöst — siehe Publish-Spec für das normative Schema beider Events. --- ## 3. SPA-Komponenten & Datenfluss ### Stack - **Framework:** SvelteKit, Adapter `adapter-static`, `ssr: false`, Prerender-Disabled. - **Nostr-SDK:** `applesauce-relay`, `applesauce-loaders`, `applesauce-signers` (höherstufig, RxJS-basiert, tree-shakable). - **Markdown-Rendering:** `marked` + `DOMPurify` + `highlight.js` (3–5 Sprachen registriert). - **Signing:** NIP-07 (Browser-Extension wie Alby oder nos2x). ### Dateistruktur ``` src/ ├── app.html # SPA-Shell -Defaults ├── lib/ │ ├── nostr/ │ │ ├── config.ts # Relays, Pubkey │ │ ├── pool.ts # RelayPool Singleton (applesauce-relay) │ │ ├── loaders.ts # loadPostList, loadPost, loadReplies, loadReactions │ │ ├── signer.ts # NIP-07 Wrapper (applesauce-signers) │ │ └── outbox.ts # NIP-65 Read-Relay-Resolution für fremde Autoren │ ├── render/ │ │ ├── markdown.ts # renderMarkdown(md: string): string — SINGLE EXPORT │ │ └── naddr.ts # nip19.naddrEncode Helper │ └── components/ │ ├── PostCard.svelte │ ├── PostView.svelte │ ├── ReplyList.svelte │ ├── Reactions.svelte │ └── ReplyComposer.svelte └── routes/ ├── +layout.svelte ├── +page.svelte ├── archives/+page.svelte ├── tag/[name]/+page.svelte ├── impressum/+page.svelte └── [year]/[month]/[day]/[dtag=html_extension]/+page.svelte # s. Hinweis unten ``` ### Datenfluss: Post-Seite 1. Browser ruft `/2024/01/26/offenheit-das-wesentliche.html/` auf. 2. All-Inkl `.htaccess` → `/index.html` (Fallback, keine Datei unter dem Pfad). 3. SPA bootet, SvelteKit-Router matcht Route, extrahiert `dtag`. 4. `loadPost(dtag)` fragt via `applesauce-loaders` den `RelayPool` mit Filter `{ kinds:[30023], authors:[PUBKEY], '#d':[dtag] }`. 5. Observable emittiert Event (bei Versionen: neueste gewinnt, replaceable-Semantik via `applesauce`). 6. `renderMarkdown(event.content)` → sanitized HTML → `{@html}` in `PostView`. 7. Parallel: `loadReplies(event)` (kind:1 mit `#a` oder `#e`) und `loadReactions(event)` (kind:7), nonblocking. ### Datenfluss: Home/Archiv ``` loadPostList() → req({ kinds:[30023], authors:[PUBKEY], limit:100 }) → Observable streamt Events → Dedup per d-Tag, sortiert nach published_at desc → PostCard-Liste rendert reaktiv ``` ### Datenfluss: Kommentar schreiben (NIP-07) ``` ReplyComposer öffnet → signer.getPublicKey() # Alby/nos2x prompted ggf. → User tippt, klickt Senden → Event bauen (kind:1, Tags: ['a', addr], ['e', eventId], ['p', authorPk]) → signer.signEvent(event) # Extension prompted zur Freigabe → pool.publish(writeRelays, signedEvent) → bestehende ReplyList-Subscription zeigt Event optimistisch sofort an ``` `writeRelays` kommt bevorzugt aus dem NIP-65-Outbox-Event des Signers; Fallback: `READ_RELAYS`. ### State-Management RxJS-Observables direkt in Svelte konsumiert: ```svelte {#if $post$}
{@html renderMarkdown($post$.content)}
{:else}

Lade …

{/if} ``` Keine separaten Svelte-Stores. Observable ist der Store. ### Fehler- und Loading-Zustände - **Soft-Timeout 8s:** „noch am Suchen …". - **Hard-Timeout 15s:** Fallback-Hinweis mit Habla-Deeplink (`https://habla.news/a/`). - **Kein Event gefunden:** 404-Komponente mit Habla-Link. - **Alle Relays offline:** Banner + Retry-Button. - **Replies/Reactions:** best-effort, Fehler silently — dürfen die Post-Anzeige nicht blockieren. ### Markdown-Rendering (Isolation für Zukunft) Alles Rendering-Zeug in `src/lib/render/markdown.ts`. Externer Export genau eine Funktion: ```ts export function renderMarkdown(md: string): string ``` Kein Import von `marked` oder `DOMPurify` außerhalb dieser Datei. Das macht Austausch (z. B. zu `svelte-markdown`, `markdown-it` oder `unified`) später zu einem lokal begrenzten Refactor. Implementierung (Skizze): ```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' hljs.registerLanguage('javascript', javascript) hljs.registerLanguage('bash', bash) marked.use({ breaks: true, gfm: true, renderer: { code(code, lang) { const highlighted = lang && hljs.getLanguage(lang) ? hljs.highlight(code, { language: lang }).value : hljs.highlightAuto(code).value return `
${highlighted}
` }, link(href, title, text) { const internal = href?.startsWith('/') || href?.includes('joerg-lohrer.de') const attrs = internal ? '' : ' target="_blank" rel="noopener"' return `${text}` }, }, }) export function renderMarkdown(md: string): string { const raw = marked.parse(md) as string return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] }) } ``` ### Sicherheit - **DOMPurify für allen gerenderten Content** — Hauptposts (von dir signiert) und Replies (von Dritten). - **Pubkey-Whitelist:** PostsLoader filtert strikt auf `authors:[PUBKEY]`. Fremde Events für Hauptinhalte nicht anzeigen. Replies/Reactions explizit ausgenommen, aber sanitized. - **Privat-Key liegt nie im Bundle, nicht auf All-Inkl, nicht im Browser.** Signieren ausschließlich lokal (Publish-Spec) oder via NIP-07 (Besucher-Kommentare). ### Bundle-Budget Schätzung gzip: - SvelteKit Runtime: ~15 KB - applesauce-relay + -loaders + -signers: ~30 KB - rxjs (tree-shaken): ~15 KB - marked + DOMPurify: ~25 KB - highlight.js Core + 3 Sprachen: ~15 KB - App-Code: ~10 KB - **Total: ~110 KB gzip** — vertretbar. --- ## 4. Hosting, Deployment, Migrationspfad ### Warum Webspace für SvelteKit ausreicht Häufige Sorge: „kann mein einfaches Webspace-Paket eine SvelteKit-App hosten, ich habe ja keinen vServer und kein SSH?" Antwort: ja, problemlos. Hier warum. **SvelteKit produziert reine statische Dateien.** Mit dem `adapter-static` (siehe §3) erzeugt `npm run build` einen Ordner `build/`, der nichts anderes enthält als: - `index.html` — eine HTML-Datei - `_app/` mit JS/CSS-Bundles — kompilierte Dateien - weitere statische Files (favicon etc.) Das ist exakt das gleiche Material, das Hugo bisher in `public/` produziert hat — nur mit JS statt vielen einzelnen HTML-Seiten. **Beides ist statisches Hosting, beides funktioniert auf jedem Webspace, der HTML ausliefern kann.** **Was du brauchst:** - Ordner zum Hochladen ✅ (jeder Webspace) - Webserver, der HTML/JS/CSS ausliefert ✅ (jeder Webspace) - Apache mit `mod_rewrite` für SPA-Fallback (eine `.htaccess`) ✅ (All-Inkl-Standard) **Was du nicht brauchst:** - ❌ Node.js auf dem Server - ❌ Datenbank - ❌ vServer/SSH (für *Hosting* nicht; SSH wird nur für komfortablen *Upload* via rsync genutzt — FTP funktioniert genauso) - ❌ irgendetwas, das dauerhaft serverseitig läuft **Unterschied Hugo vs. SvelteKit aus Server-Sicht:** - Hugo erzeugt **viele HTML-Dateien**, eine pro Post. Server liefert pro URL eine spezifische Datei. - SvelteKit (SPA) erzeugt **eine HTML-Datei** und ein JS-Bundle. Server liefert immer dieselben Dateien, der Browser entscheidet per JavaScript, was angezeigt wird (basierend auf der URL). Für den Server ist Variante 2 sogar **simpler** — er hat weniger Dateien zu verwalten und muss nichts dynamisch generieren. **Was die `.htaccess` macht:** wenn jemand `https://joerg-lohrer.de/2025/03/04/dezentrale-oep-oer.html/` aufruft, gibt es diese Datei nicht physisch auf dem Server — der Pfad ist eine virtuelle SPA-Route. Apache würde 404 antworten. Eine kleine `.htaccess`-Datei sagt Apache: „wenn die angeforderte Datei nicht existiert, liefere `/index.html` aus." Browser bekommt die SPA-Shell, JavaScript liest die URL, lädt das richtige Event vom Relay, rendert den Post. **Was am Ende auf dem Webspace liegt** (siehe Dateistruktur weiter unten): ungefähr 30–80 Dateien, zusammen 100–200 KB. Weniger als ein einziges Foto. Komplett statisch, kein Backend. ### Hosting bei All-Inkl Webhosting-Paket, Standardfeatures: - statische Dateiauslieferung - `mod_rewrite` (Standard) - HTTPS (Let's Encrypt inklusive, ggf. schon aktiv) Keine PHP, kein MySQL, keine Cronjobs, kein Backend. ### Dateistruktur auf Webspace ``` / ├── index.html # SPA-Shell ├── _app/ # SvelteKit-Bundle ├── .htaccess ├── robots.txt ├── favicon.ico ├── impressum/ │ └── index.html # statische HTML-Datei ├── 2024/01/26/offenheit-das-wesentliche.html/ │ ├── bild1.jpeg # Altbilder │ └── bild2.jpeg ├── 2023/03/23/gleichnis-vom-saemann.html/ │ └── … └── … ``` ### Upload-Mechanik - **SvelteKit-Bundle:** `npm run build` → `build/` → rsync/FTP in Webspace-Root. npm-Script `deploy`. - **Altbilder:** einmalig per rsync aus `content/posts/` an die Permalink-Pfade übertragen. - **Neue Bilder:** nicht auf All-Inkl (Phase 2, Blossom-Upload via Publish-Spec). **Credentials:** FTP-Login in `.env` des lokalen Repos, nicht committed. ### Migrationspfad (die 18 bestehenden Posts) Um Verwechslung mit den „Phasen" im Evolutionspfad (Abschnitt 5) zu vermeiden, werden die Migrationsschritte mit **Schritt A–E** bezeichnet. **Schritt A — Pre-Launch:** Hugo-Seite bleibt live, nichts anfassen. **Schritt B — SPA entwickeln:** Lokal bauen, gegen Public-Relays mit Test-Events validieren. **Schritt C — Posts als Events publizieren (Publish-Spec):** Alle 18 `.md` zu `kind:30023` signieren und zu 4–5 Public-Relays pushen. `d`-Tag = bisheriger Hugo-Slug; `published_at` = Frontmatter-Datum. Verifikation: Events in Habla-Client gegenchecken. **Schritt D — Cutover:** 1. Altbilder in Permalink-Pfade hochladen. 2. SPA-Bundle in Webspace-Root deployen. 3. `index.html` und `.htaccess` ersetzen (alte Hugo-Version wird überschrieben). 4. Alte, nicht mehr benötigte Hugo-Artefakte können bleiben oder gelöscht werden; `.htaccess`-Fallback macht sie harmlos. **Schritt E — Validierung:** - Alle 18 URLs im Browser prüfen (Inhalt stimmt, Bilder laden). - Stichprobenhaft `curl -A "Mozilla/5.0" ` → Shell wird ausgeliefert. - Link auf Mastodon posten → Preview fehlt (erwartet im Minimal-Launch). **Rollback-Fähigkeit:** Alter Hugo-`public/`-Stand als ZIP lokal archivieren. Rollback = zurückkopieren. ### URL-Kompatibilität (verifiziert) Hugo-URL: `https://joerg-lohrer.de/2024/01/26/offenheit-das-wesentliche.html/` Neu: Pfad existiert nicht als Datei → `.htaccess` → `/index.html` → SPA routed via `dtag`. Für externe Links: identische URL, identische Inhaltsanzeige. Backlinks aus Mastodon, Google, Bookmarks funktionieren weiter. --- ## 5. Testing, Risiken, Evolution ### Testing-Strategie **Unit-Tests (Vitest):** - `renderMarkdown()`: Input/Output, XSS-Vektoren (`javascript:`-URLs, `