joerglohrerde/docs/superpowers/specs/2026-04-21-prerender-snapsh...

418 lines
18 KiB
Markdown
Raw 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.

# 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.
- **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.
- **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`)
- `--allow-shrink` (Override des Drop-Checks, für Fälle in denen bewusst
massiv gelöscht wurde und kein `kind:5` als Signal existiert)
**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
- 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
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).
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.**
**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"
},
"content_html": "<p>…sanitized HTML with highlighted code blocks…</p>",
"content_markdown": "…full markdown, raw, for debugging or alternative renderers…",
"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 als `og:image`-Wert in
den HTML-Head geschrieben. Crawler sehen nur diese URL.
- `fallback_url` → zweiter Blossom-Server mit demselben Hash (Blossom
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.
**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
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`:**
```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)
Post-Body wird direkt aus `snapshot.content_html` eingesetzt
(`{@html …}`), da der HTML bereits im Snapshot-Schritt sanitized ist.
`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):**
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.
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 Site-Default-OG-Bild |
| 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:
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`
bekommt `prerender = true` + `entries()` + Load aus JSON.
`+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.
## 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.
- 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.