20 KiB
Prerender-Snapshot für Nostr-Langform-Posts — Design
Datum: 2026-04-21 Status: Entwurf 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.
Plan-Pendant unter docs/superpowers/plans/archive/2026-04-21-prerender-snapshot.md
liegt unbearbeitet vor — die Umsetzung ist eingefroren bis zur Entscheidung,
ob das Vorhaben weiterverfolgt wird.
Schwester-Specs:
2026-04-15-nostr-page-design.md— SPA2026-04-15-publish-pipeline-design.md— Publish2026-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.
- Kein Prerender für Listen-Seiten (Homepage, Archiv,
/tag/<name>/) in dieser Iteration. Sie bleiben SPA-gerendert über denadapter-static-fallback: 'index.html'-Mechanismus (Crawler auf/tag/nostr/→ bekommenindex.html, Seite rendert nach Hydration). Geteilt werden Artikel, nicht Listen. - 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+deploysind 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 zumsnapshot/-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)--allow-shrink(Override des Drop-Checks, für Fälle in denen bewusst massiv gelöscht wurde und keinkind:5als Signal existiert)
Algorithmus:
- Bootstrap.
BOOTSTRAP_RELAYanfragen,kind:10002des Autors holen → Read-Relay-Liste extrahieren. Fallback:FALLBACK_READ_RELAYSwennkind:10002nicht ladbar. - Event-Fetch. Pro Read-Relay
kind:30023,authors:[pubkey]parallel abfragen. Timeout 10 s pro Relay. - Dedup per d-tag. Bei Duplikaten höchster
created_atgewinnt. - NIP-09-Filter.
kind:5-Events des Autors laden. Für jedesa-Tag mit30023:<pk>:<dtag>den entsprechenden Eintrag verwerfen. - 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 - Event-Count ≥
--min-events→ sonst Hard-Fail. Beim allerersten Lauf ohne Cache und ohne explizites Flag ist die Default-Schwelle1(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-shrinkist gesetzt → Check wird übersprungen
- seit letztem Snapshot neue
- mindestens
- Cover-Bild-Probe. HEAD-Request auf
og:image-Kandidat. Bei 200: alsurlschreiben. Bei Fehler: Fallback-Blossom prüfen, alsurlschreiben wenn verfügbar. Beide tot: primäre URL trotzdem schreiben + Warnung loggen (Blossom ist content-addressed, URL wird später wieder erreichbar sein). - Kein Markdown-Rendering im Snapshot. Body des Events wird als
rohes
content_markdownins JSON geschrieben. Das Rendering zu HTML übernimmt der SvelteKit-Prerender-Schritt mit dem bereits existierenden$lib/render/markdown.ts-Modul (marked + DOMPurify + highlight.js). Begründung: Der SvelteKit-Build führtrenderMarkdown()ohnehin aus; eine Duplikation in Deno wäre doppelter Code-Pfad mit identischer Policy. Für Blaupausen-Nutzung ist rohes Markdown zudem portabler — jeder andere Renderer (Astro, Eleventy, …) bringt seinen eigenen Markdown-Prozessor mit und würde fertiges HTML eher als Bürde empfinden. - Fallback-Politik für fehlende Felder:
- fehlt
summaryim Event → auscontent_markdowndie ersten 200 Zeichen (Whitespace normalisiert, abgeschnitten an Wortgrenze, Suffix…) extrahieren und alssummaryschreiben - fehlt
imageim Event →cover_imageistnull; der Prerender nutzt das Site-Default-OG-Bildapp/static/joerg-profil-2024.webpalsog:image. Dimensionen werden zur Build-Zeit aus der Datei bestimmt und mitog:image:width/og:image:heightausgegeben. - fehlt
published_at-Tag →created_atwird alspublished_atübernommen
- fehlt
- JSON-Output schreiben.
Output-Format:
<out>/index.json — Gesamtkatalog:
{
"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:
{
"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",
"width": 1600,
"height": 900,
"alt": "Alt-Text",
"mime": "image/jpeg"
},
"content_markdown": "…full markdown body, raw — Renderer sanitizes und rendert on demand…",
"tags": ["Nostr", "Bibel"],
"naddr": "naddr1...",
"habla_url": "https://habla.news/a/naddr1...",
"translations": [
{ "lang": "en", "slug": "bible-selfies", "title": "Bible-Selfies" }
]
}
Semantik der cover_image-Felder:
url→ primäre Bild-URL, wird vom Prerender alsog:image-Wert in den HTML-Head geschrieben. Crawler sehen nur diese URL. Blossom ist content-addressed; ein Ausfall des primären Servers ist seltener und rechtfertigt zum jetzigen Stand keinen zweiten URL-Slot. Falls in der Praxis Bedarf entsteht (z.B. anhaltende Ausfälle), kann einfallback_url-Feld nachgereicht werden — dann mit konkretem Konsumenten, nicht spekulativ.
Semantik von created_at vs. published_at:
published_at→ Redaktions-Zeitpunkt (menschlich), auspublished_at- Tag des Events. Ändert sich nicht bei Re-Publish. Wird alsarticle:published_timein 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, wirdcreated_atübernommen (siehe Algorithmus, Schritt 8).
Semantik der translations[]-Einträge:
- Jeder Eintrag enthält
lang,slugundtitleder fremdsprachlichen Version. Prerender nutztlang/slugfürhreflang-Links, undtitlefür den SPA-Sprach-Switcher (📖 DE | EN). Damit entfällt ein Runtime-Relay-Fetch beim Switcher.
CLI:
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
deno task snapshot --allow-shrink # Drop-Check aus
Stufe 3 — build+deploy
Zwei Änderungen an der SvelteKit-SPA, eine Änderung am Deploy-Script.
3.1 SvelteKit-Route [...slug]/+page.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>aussnapshot.title<meta name="description">aussnapshot.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">mitArticle-Schema<html lang="...">aussnapshot.lang(via Layout)
Post-Body wird aus snapshot.content_markdown per renderMarkdown()
zur Build-Zeit zu HTML gerendert und dann via {@html …} eingesetzt.
Die bestehende $lib/render/markdown.ts wird so angepasst, dass sie
im Node-Build-Kontext funktioniert (Umstellung auf
isomorphic-dompurify oder äquivalente Build-Zeit-DOM-Bereitstellung).
ReplyList/ReplyComposer bleiben clientseitig unverändert.
Der SPA-interne Sprach-Switcher liest snapshot.translations[] direkt
aus Page-Data — kein Relay-Fetch zur Laufzeit mehr nötig.
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.
Upload-Reihenfolge (kritisch wegen Hash-benannten JS-Bundles):
- Zuerst Assets hochladen (
_app/immutable/**, Bilder, CSS) — reine Upload-Phase ohne Server-seitiges Löschen. Neue Hash-Bundles landen zusätzlich zu den alten auf dem Server. - Danach HTML-Seiten hochladen (
index.html,<slug>/index.html,404.html), ebenfalls ohne Löschen. Ab diesem Punkt zeigen die neuen HTMLs auf ihre zugehörigen neuen Asset-Hashes — konsistent. - Zum Schluss ein separater Delete-Pass, der Server-Dateien
entfernt, die im aktuellen Build-Output nicht mehr existieren (alte
Hash-Bundles, gelöschte Post-HTMLs, veraltete Snapshot-JSONs). Nichts
wird in dieser Phase erneut hochgeladen. Konkrete
lftp-Flag-Kombi in der Planungsphase festzulegen — wichtig ist nur die Phasen-Trennung: Upload zuerst, Delete zuletzt, kein paralleler Mirror-Call.
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.
Von --delete ausgeschlossen bleiben außerhalb des SvelteKit-Builds
verwaltete Dateien (Hero-Bild, Favicons im Root, .well-known/,
Webspace-Spezifika) via --exclude-glob.
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 |
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 |
| Blossom-Cover nicht erreichbar | Warnung loggen, URL trotzdem schreiben |
Event ohne summary |
summary aus Body-Anfang abgeleitet |
Event ohne image |
cover_image: null, Prerender nutzt app/static/joerg-profil-2024.webp |
| NIP-09-gelöschter Post | Aus Katalog weggelassen, Deploy-Sync löscht HTML |
| 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. |
| 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
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:
renderMarkdownNode-kompatibel machen. DOM-Abhängigkeit aufisomorphic-dompurifyumstellen, sodass das Modul sowohl im Browser als auch im SvelteKit-Build-Node-Kontext funktioniert. Unit-Test-Verhalten gegen Regression sichern. Rollback: Commit revert.- Snapshot-Modul ergänzen.
snapshot/mit Deno-Task, CLI, Tests. Schreibt JSON mitcontent_markdown, keine HTML-Erzeugung. Keine Änderung an SPA. Rollback: Verzeichnis löschen. - Snapshot in CI einbauen. GitHub-Actions-Schritt vor SvelteKit-Build. Rollback: Workflow-Schritt entfernen.
- SvelteKit-Route auf Prerender umstellen, mit Laufzeit-Fallback.
[...slug]/+page.tsbekommtprerender = true+entries()+ Load aus JSON.+page.svelterendertcontent_markdownperrenderMarkdown()zur Build-Zeit. Slugs, die zur Build-Zeit im Snapshot stehen, erzeugen statische<slug>/index.html-Dateien; Slugs außerhalb des Snapshots (z.B. ganz frisch Nostr-first publiziert) landen überadapter-static-fallback: 'index.html'weiterhin auf der SPA-Shell, die ihren bisherigen Runtime-Relay-Fetch ausführt. Beide Pfade leben damit parallel — kein Workaround, das ist das Default-Verhalten vonadapter-staticmit Fallback. Rollback: Commit revert, alle Slugs gehen zurück auf reine SPA-Hydration. - Runtime-Relay-Fetch der Detail-Seite entfernen. Wenn Schritt 4 sich stabil zeigt, wird der Fallback-Code-Pfad abgebaut. Die Detail-Seite lebt dann ausschließlich vom Snapshot. Neue Nostr-first- Posts erscheinen erst nach dem nächsten Snapshot+Build-Lauf. Rollback: Commit revert.
- Deploy-Script erweitern.
lftp mirror --deletemit Upload-Reihenfolge. Rollback: Script revert — Site bleibt, nur Obsolete-Cleanup fehlt.
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:
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.
ReplyList/ReplyComposermüssen auf Prerender-Seiten weiterhin clientseitig hydrieren und Live-Relay-Fetch ausführen. Erwartung: ja, weil<svelte:head>statisch und Reaktions-Komponenten Client-Bound sind; im Plan-Schritt 4 als Teil der Verifikation prüfen.