2026-04-21 16:56:41 +02:00
# Prerender-Snapshot für Nostr-Langform-Posts — Design
**Stand:** 2026-04-21
**Scope:** SEO- und Social-Media-Tauglichkeit der Post-Detailseiten.
Post-URLs sollen beim ersten Request echtes HTML mit OG-Metadaten liefern —
ohne Runtime-Relay-Fetch, ohne Node-/Go-Server auf dem Hosting.
Schwester-Specs:
- [`2026-04-15-nostr-page-design.md` ](2026-04-15-nostr-page-design.md ) — SPA
- [`2026-04-15-publish-pipeline-design.md` ](2026-04-15-publish-pipeline-design.md ) — Publish
- [`2026-04-21-multilingual-posts-design.md` ](2026-04-21-multilingual-posts-design.md ) — Mehrsprachigkeit
## Problem
Aktueller Zustand: SvelteKit-SPA auf All-Inkl-Shared-Hosting mit
`adapter-static` + `fallback: 'index.html'` . Post-Detailseiten unter
`https://joerg-lohrer.de/<d-tag>/` liefern beim Erstaufruf die generische
`index.html` mit Homepage-OG-Defaults. Post-Titel, Summary, Cover-Bild
werden erst nach JavaScript-Ausführung aus den Relays geladen.
Daraus entstehen drei Defizite:
- **Social-Media-Previews** (LinkedIn, Mastodon, Bluesky, Signal, iMessage)
zeigen nur die generischen Homepage-Tags, keine post-spezifischen.
- **Suchmaschinen** indexieren entweder nichts (kein crawler-readable
Content zur Request-Zeit) oder zeigen Treffer ohne Titel/Snippet.
- **Accessibility-/No-JS-Nutzer** sehen leere Detailseiten.
## Ziel
Bei jedem HTTP-GET auf `https://joerg-lohrer.de/<d-tag>/` liefert
All-Inkl eine statische `<d-tag>/index.html` , die enthält:
- korrekter `<title>` und `<meta name="description">`
- vollständige OG-Tags (`og:title`, `og:description` , `og:image` ,
`og:locale` , `og:type=article` , `og:url` , `article:published_time` )
- Twitter-Cards (`summary_large_image`)
- `article` -JSON-LD-Schema für Google
- bidirektionale `<link rel="alternate" hreflang>` für Sprachvarianten
- vollständig gerenderten Post-Body (Markdown → HTML)
Die SPA hydriert über diesem HTML weiter und behält alle bisherigen
Laufzeit-Funktionen (Sprach-Switcher, Navigation, Reply-Loader).
## Nicht-Ziele
- **Kein generischer Nostr-Renderer.** Nur eigene `kind:30023` -Events mit
bekannter Pubkey. Fremde Events werden nie unter der eigenen Domain
gerendert (rechtliche Verantwortung nur für eigenen Content).
- **Kein Live-Proxy.** Relays werden zur Build-Zeit befragt, nicht pro
HTTP-Request.
- **Keine Edge-Function, kein VPS, kein PHP-Shim.** Lösung funktioniert
auf jedem Static-Hoster.
2026-04-21 17:05:43 +02:00
- **Kein Prerender für Listen-Seiten** (Homepage, Archiv, `/tag/<name>/` )
in dieser Iteration. Sie bleiben SPA-gerendert über den
`adapter-static` -`fallback: 'index.html'`-Mechanismus (Crawler auf
`/tag/nostr/` → bekommen `index.html` , Seite rendert nach Hydration).
Geteilt werden Artikel, nicht Listen.
2026-04-21 16:56:41 +02:00
- **Keine Änderung am Publish-Flow.** `publish` -Pipeline bleibt exakt
wie heute (Git-MD → Nostr-Event).
## Grundprinzipien
- **Repo ist Quelle der Wahrheit im Autorenprozess.** Das bleibt.
- **Relays sind Ort der Wahrheit zum Build-Zeitpunkt.** Der Snapshot fragt
die Relays, nicht das Repo. So werden auch Nostr-first publizierte
Posts (die nicht im Repo liegen) beim nächsten Build mitgerendert.
- **Blaupausen-Qualität.** Das Snapshot-Tool soll auch andere
Nostr-basierte Sites bedienen können. Keine harten Kopplungen an das
Blog-Setup.
- **Entkoppelte Stufen.** `publish` , `snapshot` , `build+deploy` sind
drei separate Kommandos. Sie können einzeln, hintereinander oder in
unterschiedlichen Kontexten ausgeführt werden.
## Architektur
```
┌─────────────────────────────┐
│ 1. publish (Deno) │ unverändert
│ Repo-MD → signed Event │ → Relays
│ + Blossom-Upload │ → GH-Actions-Trigger bei content/posts/**
└─────────────────────────────┘
┌─────────────────────────────┐
│ 2. snapshot (Deno, neu) │ neu
│ Relays → JSON-Artefakte │ schreibt snapshot/output/index.json
│ + NIP-09-Filter │ schreibt snapshot/output/posts/< slug > .json
│ + Plausibilitätschecks │
└─────────────────────────────┘
┌─────────────────────────────┐
│ 3. build+deploy (SvelteKit) │ erweitert
│ Prerender aus JSON │ → build/< slug > /index.html
│ + FTPS-Sync nach All-Inkl│ → lftp mirror --delete
└─────────────────────────────┘
```
### Stufe 1 — `publish`
Unverändert. Diese Spec modifiziert publish **nicht** .
### Stufe 2 — `snapshot`
Neues Deno-Modul. Verzeichnis: `snapshot/` als Geschwister zu `publish/` .
**Input:**
- `AUTHOR_PUBKEY_HEX` (env, 64 hex chars)
- `BOOTSTRAP_RELAY` (env, wss-URL)
- `--out <path>` (default: `./output/` , relativ zum `snapshot/` -Modulverzeichnis)
- `--min-events <n>` (Plausibilitätsschwelle, absolute Zahl; ohne Flag:
Last-known-good-Count aus Cache minus 2; ohne Cache: `1` )
- `--cache <path>` (default: `<out>/.last-snapshot.json` )
2026-04-21 17:05:43 +02:00
- `--allow-shrink` (Override des Drop-Checks, für Fälle in denen bewusst
massiv gelöscht wurde und kein `kind:5` als Signal existiert)
2026-04-21 16:56:41 +02:00
**Algorithmus:**
1. **Bootstrap.** `BOOTSTRAP_RELAY` anfragen, `kind:10002` des Autors
holen → Read-Relay-Liste extrahieren. Fallback: `FALLBACK_READ_RELAYS`
wenn `kind:10002` nicht ladbar.
2. **Event-Fetch.** Pro Read-Relay `kind:30023` , `authors:[pubkey]`
parallel abfragen. Timeout 10 s pro Relay.
3. **Dedup per d-tag.** Bei Duplikaten höchster `created_at` gewinnt.
4. **NIP-09-Filter.** `kind:5` -Events des Autors laden. Für jedes
`a` -Tag mit `30023:<pk>:<dtag>` den entsprechenden Eintrag verwerfen.
5. **Plausibilitätscheck:**
- mindestens `ceil(N × 0.6)` der N Read-Relays müssen geantwortet haben
(bei 5 Relays: 3, bei 3 Relays: 2) → sonst Hard-Fail
2026-04-21 17:05:43 +02:00
- Event-Count ≥ `--min-events` → sonst Hard-Fail. Beim allerersten
Lauf ohne Cache und ohne explizites Flag ist die Default-Schwelle `1`
(d.h. mindestens ein Event muss vorhanden sein) — der Drop-Check
greift erst beim zweiten Lauf.
- Event-Count-Drop > 20 % gegenüber Cache → Hard-Fail, **außer** :
- seit letztem Snapshot neue `kind:5` -Deletions von genau so vielen
Events wurden erkannt (Drop ist bewusst) → Check wird übersprungen
- `--allow-shrink` ist gesetzt → Check wird übersprungen
2026-04-21 16:56:41 +02:00
6. **Cover-Bild-Probe.** HEAD-Request auf `og:image` -Kandidat. Bei 200:
als `url` schreiben. Bei Fehler: Fallback-Blossom prüfen, als `url`
schreiben wenn verfügbar. Beide tot: primäre URL trotzdem schreiben +
Warnung loggen (Blossom ist content-addressed, URL wird später wieder
erreichbar sein).
2026-04-21 17:05:43 +02:00
7. **Markdown-zu-HTML-Rendering.** Body des Events wird mit `marked`
gerendert, dann mit `DOMPurify` gemäß gemeinsamer Policy sanitized,
dann Code-Blöcke mit `highlight.js` hervorgehoben. Gemeinsame
Konfiguration (Allowlist, Syntax-Sprachen) liegt als Konstanten-Modul
in `shared/markdown-policy.ts` und wird von Snapshot **und** SPA
identisch importiert. Ergebnis wird als `content_html` ins JSON
geschrieben. Das rohe `content_markdown` bleibt ebenfalls im JSON
(Debuggability, alternative Renderer, die der HTML-Sanitization nicht
trauen).
8. **Fallback-Politik für fehlende Felder:**
- fehlt `summary` im Event → aus `content_markdown` die ersten 200
Zeichen (Whitespace normalisiert, abgeschnitten an Wortgrenze,
Suffix `…` ) extrahieren und als `summary` schreiben
- fehlt `image` im Event → `cover_image` ist `null` ; der Prerender
nutzt ein Site-Default-OG-Bild (definiert in `app/static/` , z.B.
Profilbild oder Logo-Banner, als `og:image` bei null-Cover)
- fehlt `published_at` -Tag → `created_at` wird als
`published_at` übernommen
9. **JSON-Output schreiben.**
2026-04-21 16:56:41 +02:00
**Output-Format:**
`<out>/index.json` — Gesamtkatalog:
```json
{
"generated_at": "2026-04-21T10:30:00Z",
"author_pubkey": "4fa5d1c4...",
"relays_queried": ["wss://relay.damus.io", "..."],
"relays_responded": ["wss://relay.damus.io", "..."],
"post_count": 27,
"posts": [
{
"slug": "bibel-selfies",
"lang": "de",
"created_at": 1713456789,
"title": "Bibel-Selfies"
}
]
}
```
`<out>/posts/<slug>.json` — pro Post:
```json
{
"slug": "bibel-selfies",
"event_id": "abc123...",
"created_at": 1713456789,
"published_at": 1713456000,
"title": "Bibel-Selfies",
"summary": "Kurzbeschreibung für OG und Google.",
"lang": "de",
"cover_image": {
"url": "https://blossom.edufeed.org/< hash > .jpg",
"fallback_url": "https://blossom.primal.net/< hash > .jpg",
"width": 1600,
"height": 900,
"alt": "Alt-Text",
"mime": "image/jpeg"
},
2026-04-21 17:05:43 +02:00
"content_html": "< p > …sanitized HTML with highlighted code blocks…< / p > ",
"content_markdown": "…full markdown, raw, for debugging or alternative renderers…",
2026-04-21 16:56:41 +02:00
"tags": ["Nostr", "Bibel"],
"naddr": "naddr1...",
"habla_url": "https://habla.news/a/naddr1...",
"translations": [
2026-04-21 17:05:43 +02:00
{ "lang": "en", "slug": "bible-selfies", "title": "Bible-Selfies" }
2026-04-21 16:56:41 +02:00
]
}
```
**Semantik der `cover_image` -Felder:**
- `url` → primäre Bild-URL, wird vom Prerender als `og:image` -Wert in
den HTML-Head geschrieben. Crawler sehen nur diese URL.
- `fallback_url` → zweiter Blossom-Server mit demselben Hash (Blossom
2026-04-21 17:05:43 +02:00
ist content-addressed). Nicht Teil der OG-Tags. Nutzungsszenario:
Falls der Snapshot-HTML ein `<img>` -Element im Post-Body erzeugt, das
auf `url` zeigt, kann ein client-seitiger `onerror` -Handler bei
Ladefehler auf `fallback_url` umschalten. Ist das nicht gewünscht
(YAGNI), wird das Feld entfernt — Entscheidung in der Planungsphase.
**Semantik von `created_at` vs. `published_at` :**
- `published_at` → Redaktions-Zeitpunkt (menschlich), aus `published_at` -
Tag des Events. Ändert sich nicht bei Re-Publish. Wird als
`article:published_time` in OG-Tags gerendert. Hauptanzeige-Datum.
- `created_at` → technischer Event-Zeitstempel, ändert sich bei jedem
Update (z.B. bei Korrekturen). Kann als „zuletzt aktualisiert"
angezeigt werden. In OG nicht verwendet.
- Fehlt `published_at` -Tag im Event, wird `created_at` übernommen
(siehe Algorithmus, Schritt 8).
**Semantik der `translations[]` -Einträge:**
- Jeder Eintrag enthält `lang` , `slug` **und** `title` der fremdsprachlichen
Version. Prerender nutzt `lang` /`slug` für `hreflang` -Links, und
`title` für den SPA-Sprach-Switcher (📖 DE | EN). Damit entfällt ein
Runtime-Relay-Fetch beim Switcher.
2026-04-21 16:56:41 +02:00
**CLI:**
```sh
cd snapshot
deno task snapshot # default
deno task snapshot --out ./out # alternatives Ziel
deno task snapshot --min-events 20 # Schwelle
deno task snapshot --cache ./.last.json # Vergleich
2026-04-21 17:05:43 +02:00
deno task snapshot --allow-shrink # Drop-Check aus
2026-04-21 16:56:41 +02:00
```
### Stufe 3 — `build+deploy`
Zwei Änderungen an der SvelteKit-SPA, eine Änderung am Deploy-Script.
**3.1 SvelteKit-Route `[...slug]/+page.ts` :**
```ts
import type { EntryGenerator, PageLoad } from './$types';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
export const prerender = true;
const SNAPSHOT_DIR = resolve('../snapshot/output');
export const entries: EntryGenerator = () => {
const catalog = JSON.parse(
readFileSync(`${SNAPSHOT_DIR}/index.json`, 'utf-8')
);
return catalog.posts.map((p: { slug: string }) => ({ slug: p.slug }));
};
export const load: PageLoad = async ({ params }) => {
const postData = JSON.parse(
readFileSync(`${SNAPSHOT_DIR}/posts/${params.slug}.json`, 'utf-8')
);
return { dtag: params.slug, snapshot: postData };
};
```
**3.2 SvelteKit-Route `[...slug]/+page.svelte` :**
Die Route rendert den Snapshot-Content statt Relay-Fetch. Im
`<svelte:head>` werden alle Meta-Tags eingesetzt:
- `<title>` aus `snapshot.title`
- `<meta name="description">` aus `snapshot.summary`
- `<meta property="og:*">` aus Snapshot-Feldern
- `<meta name="twitter:*">` (summary_large_image)
- `<link rel="canonical">` auf `${SITE_URL}/${slug}/`
- `<link rel="alternate" hreflang="...">` für jede Translation
- `<link rel="alternate" hreflang="x-default">` auf DE-Slug
- `<script type="application/ld+json">` mit `Article` -Schema
- `<html lang="...">` aus `snapshot.lang` (via Layout)
2026-04-21 17:05:43 +02:00
Post-Body wird direkt aus `snapshot.content_html` eingesetzt
(`{@html …}`), da der HTML bereits im Snapshot-Schritt sanitized ist.
2026-04-21 16:56:41 +02:00
`ReplyList` /`ReplyComposer` bleiben clientseitig unverändert.
2026-04-21 17:05:43 +02:00
Der SPA-interne Sprach-Switcher liest `snapshot.translations[]` direkt
aus Page-Data — kein Relay-Fetch zur Laufzeit mehr nötig.
2026-04-21 16:56:41 +02:00
**3.3 Deploy-Script `scripts/deploy-svelte.sh` :**
FTPS-Upload wird auf `lftp mirror --delete` umgestellt, damit gelöschte
Posts (die nicht mehr im Build-Output stehen) auch auf dem Server
entfernt werden. Für die Site-Root wird `--exclude-glob` gesetzt, damit
nicht versehentlich Favicons/Hero-Bild gelöscht werden, die nicht Teil
des SvelteKit-Builds sind.
2026-04-21 17:05:43 +02:00
**Upload-Reihenfolge (kritisch wegen Hash-benannten JS-Bundles):**
1. Zuerst **Assets** hochladen (`_app/immutable/**`, Bilder, CSS) —
`lftp mirror` ohne `--delete` , nur Upload
2. Danach **HTML-Seiten** hochladen (`index.html`, `<slug>/index.html` ,
`404.html` ), ebenfalls ohne Delete
3. **Zum Schluss** `lftp mirror --delete --only-missing` auf das
Top-Level, um obsolete Dateien zu entfernen (alte Hash-Bundles,
gelöschte Post-HTMLs)
Damit ist zu keinem Zeitpunkt ein inkonsistenter Zustand auf dem Server:
Neue HTMLs referenzieren stets bereits vorhandene Asset-Hashes; alte
Assets werden erst nach erfolgreichem Upload gelöscht.
2026-04-21 16:56:41 +02:00
Kein weiteres Verhalten ändert sich.
## Mehrsprachigkeit
Pro Sprache ein Event mit eigenem d-tag (z.B. `bibel-selfies` /
`bible-selfies` ). Das bestehende bidirektionale `a` -Tag mit Marker
`translation` (siehe `2026-04-21-multilingual-posts-design.md` ) wird vom
Snapshot als `translations[]` -Array im JSON serialisiert.
Der Prerender generiert pro d-tag eine eigene `<slug>/index.html` .
`hreflang` -Links im `<head>` verweisen bidirektional auf die Pendants.
`x-default` zeigt auf den DE-Slug (Autor arbeitet DE-first).
UI-Chrome-Locale via `activeLocale` -Store bleibt vom Prerender
unabhängig — die URL bestimmt Post-Sprache (hart), der Store bestimmt
nur die UI-Sprache (weich, umschaltbar).
## Fehlerszenarien
| Szenario | Verhalten |
|---|---|
| < 40 % Relays down | Snapshot mergt , was da ist , fährt fort |
| ≥ 40 % Relays down | Hard-Fail, Output nicht überschrieben |
2026-04-21 17:05:43 +02:00
| Event-Count-Drop > 20 % ohne korrespondierende `kind:5` | Hard-Fail (Override via `--allow-shrink` ) |
| Event-Count-Drop > 20 % mit korrespondierenden `kind:5` | Check übersprungen, fährt fort |
2026-04-21 16:56:41 +02:00
| Blossom-Cover nicht erreichbar | Warnung loggen, URL trotzdem schreiben |
2026-04-21 17:05:43 +02:00
| Event ohne `summary` | `summary` aus Body-Anfang abgeleitet |
| Event ohne `image` | `cover_image: null` , Prerender nutzt Site-Default-OG-Bild |
2026-04-21 16:56:41 +02:00
| NIP-09-gelöschter Post | Aus Katalog weggelassen, Deploy-Sync löscht HTML |
2026-04-21 17:05:43 +02:00
| Repo-Post mit allen Relay-Events via NIP-09 gelöscht | Delete gewinnt: Post wird nicht gerendert, `<slug>/index.html` wird entfernt. Crawler erhalten 404. Gewolltes Verhalten — Relays sind Ort der Wahrheit. |
2026-04-21 16:56:41 +02:00
| Nostr-first-Post nicht im Repo | Wird trotzdem snapshot'd + gerendert |
| Alle Relays down | Hard-Fail, letzter Snapshot-Stand bleibt liegen |
## Rollback
**Snapshot-Ebene:** Der vorletzte Snapshot-Output bleibt als
`.last-snapshot.json` erhalten. Bei defektem Snapshot kann er manuell
wieder aktiviert werden.
**SPA-Ebene:** Reproduzierbar aus Git + Snapshot-JSONs.
**FTPS-Ebene:** Optional `tar.gz` des Webroots vor Upload (nicht Teil
der ersten Implementierung).
## Migrations-Weg
2026-04-21 17:05:43 +02:00
Inkrementell, jeder Schritt einzeln testbar und rollback-bar. Jeder
Schritt hat eine eigene Rollback-Strategie, sodass die Gesamtänderung
an keiner Stelle einen Big-Bang bildet:
1. ** `shared/markdown-policy.ts` ergänzen.** Gemeinsame marked- +
DOMPurify- + highlight.js-Konfiguration als importierbares Modul.
Rollback: Datei löschen.
2. **Snapshot-Modul ergänzen.** `snapshot/` mit Deno-Task, CLI, Tests.
Keine Änderung an SPA. Rollback: Verzeichnis löschen.
3. **Snapshot in CI einbauen.** GitHub-Actions-Schritt vor SvelteKit-Build.
Rollback: Workflow-Schritt entfernen.
4. **SvelteKit-Route auf Prerender umstellen.** `[...slug]/+page.ts`
2026-04-21 16:56:41 +02:00
bekommt `prerender = true` + `entries()` + Load aus JSON.
2026-04-21 17:05:43 +02:00
`+page.svelte` rendert aus Snapshot. Rollback: Commit revert, alte
Runtime-Logik kommt zurück.
5. **SPA-Relay-Fetch in Detail-Seite komplett abschalten.** Nur noch
Snapshot-Content. Rollback: Commit revert.
6. **Deploy-Script erweitern.** `lftp mirror --delete` mit
Upload-Reihenfolge. Rollback: Script revert — Site bleibt, nur
Obsolete-Cleanup fehlt.
2026-04-21 16:56:41 +02:00
## Blaupausen-Anforderungen
Damit das Tool als Vorlage für andere Nostr-Sites dient:
- **Konfiguration via env/CLI**, keine hart gecodeten Relay-Listen
- **JSON-Output als stabile Schnittstelle** — Renderer austauschbar
(SvelteKit, Astro, Eleventy, …)
- **Dokumentiertes Minimum-Viable-Use-Interface:**
```sh
export AUTHOR_PUBKEY_HEX="< 64 hex > "
export BOOTSTRAP_RELAY="wss://..."
deno task snapshot --out ./my-site/snapshot-data
# eigener Site-Builder liest ./my-site/snapshot-data/index.json
```
- **Explizite Grenzen:** nur kind:30023, nur eigener Pubkey, kein
Live-Proxy — diese Beschränkungen sind Feature, nicht Bug.
## Offene Punkte
- Ob der SvelteKit-Prerender deterministisch identische HTML für
unveränderte Inputs produziert (für Diff-Builds / Cache-Invalidation).
Vermutlich ja, nachprüfen.
2026-04-21 17:05:43 +02:00
- Ob `fallback_url` im `cover_image` tatsächlich gebraucht wird. Wenn
der Snapshot-HTML keine `onerror` -Substitution implementiert, ist
das Feld toter Code. Entscheidung: mit `fallback_url` starten, bei
fehlender Nutzung in der SPA wieder entfernen (YAGNI).
- Site-Default-OG-Bild: welches konkret? Vermutlich Profilbild oder
Logo-Banner mit Überschrift „Jörg Lohrer". Entscheidung in
Planungsphase unter Abgleich mit vorhandenen `static/` -Assets.