Compare commits

..

39 Commits

Author SHA1 Message Date
Jörg Lohrer 48cfdf9aa3 feat: prerender-snapshot — post-detailseiten zur build-zeit gerendered
Sechs entkoppelte etappen, je rollback-bar:

1. renderMarkdown auf isomorphic-dompurify (node-faehig fuer prerender).
2. Neues snapshot/-modul (Deno) mit 32 tests — liest events von relays,
   schreibt JSON-artefakte (NIP-09-aware mit zeitlicher reihenfolge,
   plausibilitaetschecks, cover-probe, last-known-good-cache).
3. GitHub-Action zieht snapshot nach jedem publish als artifact.
4. SvelteKit-detail-route auf prerender=true mit <svelte:head> fuer
   OG/Twitter/JSON-LD/hreflang. <html lang> + og:image-dimensionen
   pro post; x-default zeigt auf DE-slug.
5. Runtime-relay-fetch fuer detail-route entfernt — quelle der wahrheit
   ist jetzt der snapshot.
6. (Geskippt — lftp mirror in 3 phasen war optional.)

Plus toter code der pre-prerender-aera (PostView, LanguageAvailability,
loadPost, loadTranslations, translations.ts) entfernt; deploy-skript
zieht snapshot vor build; doku (CLAUDE/STATUS/HANDOFF) aktualisiert.

Live verifiziert auf svelte.joerg-lohrer.de — OG-tags, JSON-LD,
hreflang, multilingual-rendering korrekt.

Spec: docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md
Plan: docs/superpowers/plans/2026-04-28-prerender-snapshot.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:35:12 +02:00
Jörg Lohrer 3449042051 docs: top-level-doku auf prerender-snapshot-stand
Aus dem code-review der etappe (2026-04-28):

- CLAUDE.md: snapshot/-verzeichnis in hauptarbeitsbereiche-tabelle
  ergaenzt; neue stolperfalle "snapshot-output muss vor npm run build
  da sein" eingefuegt.
- STATUS.md: kurzfassung um den prerender-stand erweitert; repo-baum
  zeigt snapshot/; erledigt-eintrag mit den sechs etappen.
- HANDOFF.md: stolperfalle "snapshot vor build" am anfang der liste —
  haeufigster fallstrick fuer naechste session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:29:36 +02:00
Jörg Lohrer 47282f3c30 fix(deploy): __HTML_LANG__-substitution pro detail-HTML
Begleiter zu app.html-aenderung in bb9d350, die <html lang> auf
__HTML_LANG__ umgestellt hat. Deploy-skript leitet den lang-wert
pro <slug>/index.html aus snapshot/output/posts/<slug>.json ab
(grep nach "lang"-feld im JSON), faellt sonst auf 'de' zurueck.

Spec-§3.2: <html lang> aus snapshot.lang. Vorher hartcodiert
"de", was englische posts (z.B. bible-selfies) crawlern als
deutschsprachig auswies — google search console hat das als
lang-mismatch geflagt, screen reader sprechen englischen body
mit deutscher engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:25:59 +02:00
Jörg Lohrer bb9d35076d chore(spa): toter code aus pre-prerender-aera entfernt
Nach etappe 5 (runtime-fallback entfernt) sind diese files/exports
ohne aufrufer:

Files (ganz weg):
- app/src/lib/components/PostView.svelte
- app/src/lib/components/LanguageAvailability.svelte
- app/src/lib/nostr/translations.ts
- app/src/lib/nostr/translations.test.ts
- app/src/lib/nostr/loaders.loadTranslations.test.ts

Aus app/src/lib/nostr/loaders.ts entfernt:
- loadPost(), loadTranslations(), TranslationInfo
- resolveTranslationsFromRefs() (nur von loadTranslations.test.ts genutzt)
- TranslationRef-import von ./translations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:25:14 +02:00
Jörg Lohrer 0ec72f9426 refactor(spa): detail-route nur noch snapshot-pfad
Runtime-fallback (loadPost + LoadingOrError + PostView) entfernt.
Detail-seite rendert jetzt ausschliesslich aus dem snapshot. Imports
und state, die nur fuer den fallback gebraucht wurden, sind weg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:05:53 +02:00
Jörg Lohrer a57ae1e8bf refactor(spa): detail-route 404 statt runtime-fallback
Slugs ausserhalb des snapshots werfen jetzt 404 (server-side beim
build, browser-side via static-fallback). Vorher kam in dem fall
{ dtag, snapshot: null } zurueck — die svelte-seite versuchte dann
clientseitig via loadPost() zu laden.

Frische nostr-first-posts erscheinen ab jetzt erst nach dem
naechsten snapshot+build-lauf, nicht mehr live aus den relays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:05:31 +02:00
Jörg Lohrer 10bdf603a9 fix(spa): %sveltekit.head% nicht im app.html-kommentar erwaehnen
User-feedback: kommentar-inhalt aus app.html erschien sichtbar im
body der detail-seiten. Ursache: SvelteKit ersetzt jeden vorkommen
von %sveltekit.head%, auch innerhalb von HTML-kommentaren. Mein
beschreibender kommentar erwaehnte das wort als beispiel — wurde
dann zur build-zeit durch tatsaechliche head-tags ersetzt, was den
kommentar-block aufbrach (eingefuegte tags enthalten >, das schliesst
den kommentar) und den rest des kommentar-textes in den body
wandern liess.

Fix: kommentar umformuliert, sodass der platzhalter-name nicht mehr
woertlich vorkommt ("via SvelteKit-head-injection" statt "via
%sveltekit.head%").

Live-verifiziert auf banksy-high-court-prophet — body startet jetzt
sauber mit <header>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:02:18 +02:00
Jörg Lohrer f6824019c8 feat(deploy): snapshot vor svelte-build im deploy-skript
Damit DEPLOY_TARGET=svelte/staging/prod immer mit aktuellem
snapshot/output baut. Ohne diesen step wuerde ein veralteter
snapshot ins HTML wandern, frische posts oder uebersetzungen
saessen erst beim naechsten deploy drin.

Schlaegt der snapshot fehl (relays down, env fehlt), bricht das
skript ab — bewusst hard-fail, damit kein verfaelschter build
hochgeladen wird.

Live-verifiziert auf https://svelte.joerg-lohrer.de/bibel-selfies/:
- HTTP 200, og-tags + hreflang + json-ld korrekt
- __SITE_URL__-substitution greift sauber
- <title>, og:title, json-ld stimmen mit snapshot ueberein

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:55:53 +02:00
Jörg Lohrer 3e31caacef fix(spa): prerender-build laeuft + meta/json-ld korrekt
Drei build-blocker beim ersten prerender-versuch identifiziert und gefixt:

1. svelte.config.js: handleHttpError + handleMissingId fuer den
   prerender-crawler. Der crawler folgt zur build-zeit allen hrefs/srcs
   im HTML — sieht dabei
   - __SITE_URL__-platzhalter in canonical/hreflang (werden im deploy
     per sed ersetzt, sind keine echten routes)
   - relative bild-paths in alten posts (z.B. h01-json-import.png)
   - anchor-links auf headings ohne id-attribute (#ACF-JSON-Export)
   Alle drei sind keine echten 404s — handlers ignorieren sie.

2. +page.svelte: <script type="application/ld+json">{jsonLd}</script>
   in <svelte:head> rendert {jsonLd} als literalen string, weil svelte
   den script-tag-inhalt nicht als expression evaluiert. Zurueck zu
   {@html ...} mit </script>-escape-hardening, damit titel oder
   beschreibungen mit </script> den output nicht aufbrechen koennen.

3. app.html behaelt seine homepage-defaults fuer og:title/og:url/
   og:description/canonical — der prerender-crawler rendert nur
   detail-routen (/<slug>/), die homepage bleibt SPA-only und braucht
   die defaults im app.html-template, weil dort kein svelte:head greift.
   Detail-routen ueberschreiben per <svelte:head>; last-wins greift bei
   LinkedIn/Mastodon/Browser. Facebook/Twitter (first-wins) haetten
   einen homepage-prerender-schritt noetig — folge-aufgabe.

Plus snapshot/deno.lock committed — deno empfiehlt lockfile-commit fuer
reproduzierbare CI-builds, analog package-lock.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:50:27 +02:00
Jörg Lohrer 2ad27adf1f feat(spa): snapshot-pfad mit reactions/replies/langs/tags
Snapshot-pfad bekommt feature-paritaet mit dem runtime-fallback:
- Sprach-switcher (inline, gleiche optik wie LanguageAvailability,
  ohne neue i18n-keys — verwendet snapshot.translations direkt)
- Tag-liste mit links auf /tag/<name>/
- Reactions, ExternalClientLinks, ReplyComposer, ReplyList
  (alle dtag-basiert, brauchen keine NostrEvent-konstruktion)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:46:12 +02:00
Jörg Lohrer 4e4a5efa42 docs: plan-korrektur — svelte:head muss top-level stehen
Bei der umsetzung von task 4.2 stellte sich heraus, dass svelte
<svelte:head> nicht in einem {#if}-block stehen darf. Plan-code
korrigiert von

  {#if snapshot}<svelte:head>...</svelte:head>{/if}

zu

  <svelte:head>{#if snapshot}...{/if}</svelte:head>

Semantisch identisch (head-content erscheint nur wenn snapshot da ist),
aber svelte-konform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:42:07 +02:00
Jörg Lohrer 63e59bffb9 feat(spa): post-detail rendert prerendered aus snapshot
Snapshot-pfad: page+head komplett aus json, mit og/twitter/jsonld/hreflang.
Runtime-fallback: falls data.snapshot null, loadPost+PostView wie bisher.
Reactions/replies kommen im naechsten task.

svelte:head auf top-level verschoben (svelte-constraint: keine meta-tags
innerhalb von {#if}-bloecken erlaubt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:41:11 +02:00
Jörg Lohrer b5772b8aa2 feat(spa): detail-route auf prerender + ssr=true
Lokaler override des global ssr=false. entries() liest aus
snapshot/output/index.json, load() pro-slug aus posts/<slug>.json.
runtime-fallback bleibt fuer slugs ausserhalb des snapshots.

@types/node als devDependency ergaenzt, da node:fs/promises-Typen
fuer den SSR-Pfad benoetigt werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:40:17 +02:00
Jörg Lohrer 3fa85fcb07 ci: snapshot-step nach publish + output als artifact
Etappe 3 des prerender-snapshot-plans (variante 1: minimal — kein
neuer workflow, deploy bleibt manuell via scripts/deploy-svelte.sh):

- 'Snapshot'-step laeuft nach publish, ruft deno-snapshot-cli auf
- output (index.json + posts/*.json + .last-snapshot.json) wird als
  github-actions-artifact fuer 30 tage aufgehoben — debug-pfad falls
  ein deploy-bug nachvollzogen werden muss
- AUTHOR_PUBKEY_HEX + BOOTSTRAP_RELAY werden aus existierenden secrets
  uebernommen, keine neuen secrets noetig

Reihenfolge "publish dann snapshot": neue events muessen erst auf den
relays sein, bevor sie gesnapshottet werden koennen. Bei publish-fail
laeuft snapshot nicht — gewollt, weil unklarer relay-stand zu
fehlerhaftem snapshot-output fuehren wuerde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:37:59 +02:00
Jörg Lohrer 2c4bceb768 fix(snapshot): cache akkumuliert deletedCoords + timeout-kommentar
Code-review-feedback aus etappe 2.9/2.10:

1. cli.ts: deletedCoords im cache wird ab jetzt akkumuliert statt
   ersetzt. Vorher wurden bei einem run nur die aktuell von relays
   gelieferten kind:5-coords geschrieben — wenn ein relay beim
   naechsten run die alten deletions nicht mehr liefert (GC,
   relay-tausch), waere die geschichte verloren und newDeletionsCount
   im naechsten lauf wieder "neu" -> false-positive hard-fail im
   drop-check.

2. relays.ts: kommentar zum belt-and-suspenders-setTimeout neben dem
   RxJS-timeout-operator, damit der zweck (handle-cleanup falls beide
   subscribe-callbacks verschluckt werden) klar ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:27:12 +02:00
Jörg Lohrer d7bb62d469 feat(snapshot): cli-entrypoint verdrahtet alle module
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:23:55 +02:00
Jörg Lohrer d8a29ca389 feat(snapshot): relay-loader (kind:10002 + event-fetch)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:23:25 +02:00
Jörg Lohrer 10cb0d947d docs(snapshot): multi-lang-TODO fuer translation-inferenz
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:21:08 +02:00
Jörg Lohrer 49c740d908 docs(snapshot): dedup-tie-break dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:21:00 +02:00
Jörg Lohrer 63b68411e4 feat(snapshot): cache validiert format beim lesen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:20:51 +02:00
Jörg Lohrer 998e08e073 feat(snapshot): config validiert BOOTSTRAP_RELAY-prefix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:20:29 +02:00
Jörg Lohrer 1827817ad5 docs(snapshot): drop-check-semantik dokumentiert
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:20:01 +02:00
Jörg Lohrer 715c1f5e1e fix(snapshot): tagsAll filtert tags ohne value
Vorher konnten malformed tags wie ['t'] (ohne second element)
undefined ins string[]-array werfen, das im JSON als null landete.
Code-review-feedback aus etappe 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:19:51 +02:00
Jörg Lohrer 848cdf763e fix(snapshot): NIP-09-filter beachtet zeitliche reihenfolge
Per NIP-09 darf ein deletion nur events mit created_at <= deletion.created_at
loeschen. Vorher wurde ein re-publizierter post nach geloeschtem vorgaenger
stumm wegfiltern. Code-review-feedback aus etappe 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:19:20 +02:00
Jörg Lohrer 0755498937 feat(snapshot): output-writer (index.json + posts/<slug>.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:12:01 +02:00
Jörg Lohrer a199f1daf1 feat(snapshot): cache-state fuer last-known-good
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:11:36 +02:00
Jörg Lohrer 2af44035b8 feat(snapshot): cover-image-HEAD-probe-modul
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:11:17 +02:00
Jörg Lohrer 4b2c157938 feat(snapshot): post-json-builder mit fallback-summary
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:10:55 +02:00
Jörg Lohrer 7e38b73785 feat(snapshot): plausibilitaets-checks (relay-quorum, drop, min-events)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:10:07 +02:00
Jörg Lohrer ccd7daf14d feat(snapshot): NIP-09-filter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:09:37 +02:00
Jörg Lohrer 300cd9bea9 feat(snapshot): dedup-by-d-tag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:09:14 +02:00
Jörg Lohrer 45df54f2b3 feat(snapshot): config-loader mit env-validierung
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:08:46 +02:00
Jörg Lohrer b6366ea1fe feat(snapshot): modul-skelett
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:08:17 +02:00
Jörg Lohrer c391df0d55 chore(render): alte dompurify-deps entfernt + design-rationale-kommentar
Dead-code aus etappe 1 nachgezogen:
- dompurify + @types/dompurify aus package.json (jetzt isomorphic-dompurify
  als einziger sanitizer, bringt eigene typen mit)
- design-rationale-kommentar fuer markedInstance zurueckgebracht
  (Spec §3: lokale ersetzbarkeit der engine — nicht aus dem code ablesbar)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:05:37 +02:00
Jörg Lohrer e0d723df14 feat(render): renderMarkdown auf isomorphic-dompurify umgestellt
Funktioniert jetzt sowohl in Browser/jsdom als auch in Node (SvelteKit-Build).
Schritt 1 der prerender-snapshot-migration. Verhalten in der SPA unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:00:56 +02:00
Jörg Lohrer f606748c3e test: failing node-test fuer renderMarkdown
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:00:13 +02:00
Jörg Lohrer 0b287f9ff6 docs: jsonld via svelte-tag statt {@html}
User-feedback: <script type="application/ld+json"> kann svelte direkt
rendern. Kein @html-Notausgang noetig, sauberere variante mit
auto-escape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:56:58 +02:00
Jörg Lohrer 7428930a76 docs: implementation-plan fuer prerender-snapshot
Frischer plan gegen die ueberarbeitete spec (fallback_url raus,
OG-default joerg-profil-2024.webp, prerender+runtime-fallback klar
als adapter-static-default). Sechs etappen, jede einzeln testbar
und rollback-bar:

1. renderMarkdown auf isomorphic-dompurify (node-faehig)
2. Snapshot-modul (deno) mit 9 testbaren cores + cli
3. Snapshot in CI
4. Detail-route auf prerender mit runtime-fallback
5. Runtime-fallback entfernen (snapshot ist quelle)
6. lftp mirror in drei phasen (optional, nicht-blockierend)

Alter plan bleibt im archive als geschichte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:54:18 +02:00
Jörg Lohrer a278f65edf docs: prerender-snapshot-spec — drei klarstellungen
- fallback_url im cover_image rausgenommen (YAGNI; primary url
  reicht, blossom ist content-addressed). Beim ersten konkreten
  bedarf nachreichen.
- Site-default-OG-bild auf app/static/joerg-profil-2024.webp
  festgelegt (vorhandenes asset, nicht spekulativ).
- prerender + runtime-fallback klarer formuliert: das ist das
  default-verhalten von adapter-static mit fallback, kein
  workaround. ReplyList/ReplyComposer-hydration als
  verifikations-punkt im plan-schritt 4 vermerkt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 07:46:51 +02:00
44 changed files with 3879 additions and 621 deletions

View File

@ -55,3 +55,18 @@ jobs:
name: publish-log name: publish-log
path: ./publish/logs/publish-*.json path: ./publish/logs/publish-*.json
retention-days: 30 retention-days: 30
- name: Snapshot
working-directory: ./snapshot
env:
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
run: |
deno run --allow-env --allow-read --allow-write --allow-net src/cli.ts
- uses: actions/upload-artifact@v4
if: always()
with:
name: snapshot-output
path: ./snapshot/output/
retention-days: 30

View File

@ -79,7 +79,17 @@ mit `../content/posts/...`. Git-Diff liefert aber repo-root-relative
Pfade (`content/posts/...`). `changedPostDirs` normalisiert beides — Pfade (`content/posts/...`). `changedPostDirs` normalisiert beides —
wenn `posts=0` obwohl Änderungen vorliegen, ist das der Hotspot. wenn `posts=0` obwohl Änderungen vorliegen, ist das der Hotspot.
### 5. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/` ### 5. Snapshot-Output muss vor `npm run build` da sein
SvelteKit prerendert `[...slug]/+page.{ts,svelte}` aus
`snapshot/output/`-JSONs (`index.json` + `posts/<slug>.json`). Lokal
buildst du nicht direkt mit `npm run build`, sondern via
`./scripts/deploy-svelte.sh` — das ruft vorher `deno task snapshot`
auf. Wer `cd app && npm run build` direkt nach dem Clone macht, ohne
vorher `cd snapshot && deno task snapshot` auszuführen, scheitert
mit `ENOENT snapshot/output/index.json`.
### 6. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/`
Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post
versehentlich in `content/posts/<slug>/` (ohne Sprach-Ordner) anlegt, versehentlich in `content/posts/<slug>/` (ohne Sprach-Ordner) anlegt,
@ -96,6 +106,9 @@ wird von der Pipeline ignoriert.
| `app/src/routes/` | SvelteKit-Routen (Layout, Home, Archiv, Post, Impressum) | | `app/src/routes/` | SvelteKit-Routen (Layout, Home, Archiv, Post, Impressum) |
| `publish/src/` | Deno-Publish-Pipeline (Deno-Tasks in `publish/deno.jsonc`) | | `publish/src/` | Deno-Publish-Pipeline (Deno-Tasks in `publish/deno.jsonc`) |
| `publish/tests/` | Deno-Tests für die Pipeline | | `publish/tests/` | Deno-Tests für die Pipeline |
| `snapshot/src/` | Deno-Snapshot-Tool (Relays → JSON für Prerender) |
| `snapshot/tests/` | Deno-Tests für den Snapshot |
| `snapshot/output/` | (gitignored) build-zeit-JSON, wird vom SvelteKit-Prerender konsumiert |
| `docs/superpowers/specs/` | Produktdesigns, Konventionen | | `docs/superpowers/specs/` | Produktdesigns, Konventionen |
| `docs/superpowers/plans/archive/` | Umgesetzte Implementierungspläne (Geschichte) | | `docs/superpowers/plans/archive/` | Umgesetzte Implementierungspläne (Geschichte) |
| `scripts/deploy-svelte.sh` | FTPS-Deploy | | `scripts/deploy-svelte.sh` | FTPS-Deploy |

View File

@ -20,7 +20,7 @@
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@types/dompurify": "^3.0.5", "@types/node": "^25.6.0",
"jsdom": "^29.0.2", "jsdom": "^29.0.2",
"svelte": "^5.55.2", "svelte": "^5.55.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
@ -33,8 +33,8 @@
"applesauce-loaders": "^5.1.0", "applesauce-loaders": "^5.1.0",
"applesauce-relay": "^5.2.0", "applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0", "applesauce-signers": "^5.2.0",
"dompurify": "^3.4.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"isomorphic-dompurify": "^3.10.0",
"marked": "^18.0.0", "marked": "^18.0.0",
"nostr-tools": "^2.23.3", "nostr-tools": "^2.23.3",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",

View File

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="__HTML_LANG__">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -10,6 +10,13 @@
<meta property="og:description" content="Jörg Lohrer Blog (Nostr-basiert)" /> <meta property="og:description" content="Jörg Lohrer Blog (Nostr-basiert)" />
<link rel="canonical" href="__SITE_URL__/" /> <link rel="canonical" href="__SITE_URL__/" />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<!--
Detail-seiten (prerender=true) haengen via SvelteKit-head-injection
ihre eigenen og:title/description/url/canonical hinten an. Last-wins
gilt fuer LinkedIn/Mastodon/Browser; Facebook/Twitter nehmen
tendenziell first-wins — fuer perfekte OG-tags muesste die
homepage auch prerendered werden (separate aufgabe).
-->
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />

View File

@ -1,107 +0,0 @@
<script lang="ts">
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
import { loadTranslations } from '$lib/nostr/loaders';
import { activeLocale } from '$lib/i18n';
import type { SupportedLocale } from '$lib/i18n/activeLocale';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
let translations: TranslationInfo[] = $state([]);
let loading = $state(true);
$effect(() => {
const currentId = event.id;
loading = true;
translations = [];
loadTranslations(event)
.then((infos) => {
if (event.id !== currentId) return;
translations = infos;
})
.finally(() => {
if (event.id === currentId) loading = false;
});
});
function currentLang(): string {
return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de';
}
interface Option {
code: string;
href: string | null; // null = aktueller post, kein klick-ziel
}
const options = $derived.by<Option[]>(() => {
const self: Option = { code: currentLang(), href: null };
const others: Option[] = translations.map((t) => ({
code: t.lang,
href: `/${t.slug}/`
}));
// aktuelle sprache zuerst, dann rest sortiert nach code
return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))];
});
function selectOther(code: string, href: string) {
activeLocale.set(code as SupportedLocale);
// hartes location-setzen, damit svelte-kit-router den post-load triggert
window.location.href = href;
}
</script>
{#if !loading && translations.length > 0}
<p class="lang-switch" role="group" aria-label="Article language">
<span class="icon" aria-hidden="true">📖</span>
{#each options as opt, i}
{#if opt.href === null}
<span class="btn active" aria-current="true">{opt.code.toUpperCase()}</span>
{:else}
<button
type="button"
class="btn"
onclick={() => selectOther(opt.code, opt.href!)}
>{opt.code.toUpperCase()}</button>
{/if}
{#if i < options.length - 1}<span class="sep" aria-hidden="true">|</span>{/if}
{/each}
</p>
{/if}
<style>
.lang-switch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.88rem;
color: var(--muted);
margin: 0.25rem 0 1rem;
}
.icon {
font-size: 1rem;
line-height: 1;
}
.btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 3px;
padding: 1px 7px;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.btn:hover:not(.active) {
color: var(--fg);
}
.btn.active {
color: var(--accent);
border-color: var(--accent);
cursor: default;
}
.sep {
opacity: 0.4;
}
</style>

View File

@ -1,174 +0,0 @@
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import type { SignedEvent } from '$lib/nostr/signer';
import { renderMarkdown } from '$lib/render/markdown';
import Reactions from './Reactions.svelte';
import ReplyList from './ReplyList.svelte';
import ReplyComposer from './ReplyComposer.svelte';
import ExternalClientLinks from './ExternalClientLinks.svelte';
import LanguageAvailability from './LanguageAvailability.svelte';
import { t, activeLocale } from '$lib/i18n';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
function tagValue(e: NostrEvent, name: string): string {
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
}
function tagsAll(e: NostrEvent, name: string): string[] {
return e.tags.filter((t) => t[0] === name).map((t) => t[1]);
}
const dtag = $derived(tagValue(event, 'd'));
let currentLocale = $state('de');
activeLocale.subscribe((v) => (currentLocale = v));
const title = $derived(tagValue(event, 'title') || $t('post.untitled'));
const summary = $derived(tagValue(event, 'summary'));
const image = $derived(tagValue(event, 'image'));
const publishedAt = $derived(
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
);
const date = $derived(
new Date(publishedAt * 1000).toLocaleDateString(
currentLocale === 'en' ? 'en-US' : 'de-DE',
{ year: 'numeric', month: 'long', day: 'numeric' }
)
);
const tags = $derived(tagsAll(event, 't'));
const bodyHtml = $derived(renderMarkdown(event.content));
// Optimistisch gesendete Replies: der Composer pusht sie rein,
// ReplyList merged sie mit den vom Relay geladenen Replies (dedup per id).
let optimisticReplies: NostrEvent[] = $state([]);
function handlePublished(signed: SignedEvent) {
optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent];
}
$effect(() => {
document.title = `${title} Jörg Lohrer`;
});
</script>
<h1 class="post-title">{title}</h1>
<div class="meta">
{$t('post.published_on', { values: { date } })}
{#if tags.length > 0}
<div class="tags">
{#each tags as t}
<a class="tag" href="/tag/{encodeURIComponent(t)}/">{t}</a>
{/each}
</div>
{/if}
</div>
<LanguageAvailability {event} />
{#if image}
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
{/if}
{#if summary}
<p class="summary">{summary}</p>
{/if}
<article>{@html bodyHtml}</article>
{#if dtag}
<Reactions {dtag} />
<ExternalClientLinks {dtag} />
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
<ReplyList {dtag} optimistic={optimisticReplies} />
{/if}
<style>
.post-title {
font-size: 1.5rem;
line-height: 1.25;
margin: 0 0 0.4rem;
word-wrap: break-word;
}
@media (min-width: 640px) {
.post-title {
font-size: 2rem;
line-height: 1.2;
}
}
.meta {
color: var(--muted);
font-size: 0.92rem;
margin-bottom: 2rem;
}
.tags {
margin-top: 0.4rem;
}
.tag {
display: inline-block;
background: var(--code-bg);
border-radius: 3px;
padding: 1px 7px;
margin: 0 4px 4px 0;
font-size: 0.85em;
color: var(--fg);
text-decoration: none;
}
.tag:hover {
background: var(--border);
}
.cover {
max-width: 480px;
margin: 1rem auto 1.5rem;
}
.cover img {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
.summary {
font-style: italic;
color: var(--muted);
}
article :global(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
article :global(a) {
color: var(--accent);
word-break: break-word;
}
article :global(pre) {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
article :global(code) {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
article :global(pre code) {
padding: 0;
background: none;
word-break: normal;
}
article :global(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
article :global(blockquote) {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
margin: 1rem 0;
color: var(--muted);
}
</style>

View File

@ -1,74 +0,0 @@
import { describe, it, expect } from 'vitest';
import { resolveTranslationsFromRefs } from './loaders';
import type { NostrEvent } from './loaders';
import type { TranslationRef } from './translations';
function ev(tags: string[][]): NostrEvent {
return {
id: 'x',
pubkey: 'p',
created_at: 0,
kind: 30023,
tags,
content: '',
sig: 's'
} as unknown as NostrEvent;
}
describe('resolveTranslationsFromRefs', () => {
it('liefert lang/slug/title für jeden aufgelösten ref', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p1', dtag: 'hello' }
];
const fetcher = async () => [
ev([
['d', 'hello'],
['title', 'Hello World'],
['L', 'ISO-639-1'],
['l', 'en', 'ISO-639-1']
])
];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([
{ lang: 'en', slug: 'hello', title: 'Hello World' }
]);
});
it('ignoriert refs, zu denen kein event gefunden wird', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p1', dtag: 'hello' },
{ kind: 30023, pubkey: 'p1', dtag: 'missing' }
];
const fetcher = async (r: TranslationRef) =>
r.dtag === 'hello'
? [ev([
['d', 'hello'],
['title', 'Hi'],
['l', 'en', 'ISO-639-1']
])]
: [];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([{ lang: 'en', slug: 'hello', title: 'Hi' }]);
});
it('ignoriert events ohne l-tag (sprache unklar)', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p', dtag: 'x' }
];
const fetcher = async () => [
ev([
['d', 'x'],
['title', 'kein lang-tag']
])
];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([]);
});
it('leere ref-liste → leere ergebnis-liste', async () => {
const fetcher = async () => {
throw new Error('should not be called');
};
expect(await resolveTranslationsFromRefs([], fetcher)).toEqual([]);
});
});

View File

@ -6,7 +6,6 @@ import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter'
import { pool } from './pool'; import { pool } from './pool';
import { readRelays } from '$lib/stores/readRelays'; import { readRelays } from '$lib/stores/readRelays';
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config'; import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
import type { TranslationRef } from './translations';
/** Re-export als sprechenden Alias */ /** Re-export als sprechenden Alias */
export type { NostrEvent }; export type { NostrEvent };
@ -89,21 +88,6 @@ export async function loadPostList(
}); });
} }
/** Einzelpost per d-Tag */
export async function loadPost(dtag: string): Promise<NostrEvent | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [30023],
authors: [AUTHOR_PUBKEY_HEX],
'#d': [dtag],
limit: 1
});
if (events.length === 0) return null;
return events.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best
);
}
/** /**
* Profil-Event kind:0 (neueste Version). * Profil-Event kind:0 (neueste Version).
* Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für * Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für
@ -190,55 +174,3 @@ export async function loadReactions(dtag: string): Promise<ReactionSummary[]> {
.map(([content, count]) => ({ content, count })) .map(([content, count]) => ({ content, count }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
} }
export interface TranslationInfo {
lang: string;
slug: string;
title: string;
}
/**
* Pure Variante für Tests erhält die Events via Fetcher statt Relays.
*/
export async function resolveTranslationsFromRefs(
refs: TranslationRef[],
fetcher: (ref: TranslationRef) => Promise<NostrEvent[]>
): Promise<TranslationInfo[]> {
if (refs.length === 0) return [];
const results = await Promise.all(refs.map(fetcher));
const infos: TranslationInfo[] = [];
for (let i = 0; i < refs.length; i++) {
const evs = results[i];
if (evs.length === 0) continue;
const latest = evs.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best
);
const lang = latest.tags.find((t) => t[0] === 'l')?.[1];
if (!lang) continue;
const slug = latest.tags.find((t) => t[0] === 'd')?.[1] ?? refs[i].dtag;
const title = latest.tags.find((t) => t[0] === 'title')?.[1] ?? '';
infos.push({ lang, slug, title });
}
return infos;
}
/**
* Loader: findet die anderssprachigen Varianten eines Posts.
* Liefert leere Liste, wenn keine a-Tags mit marker "translation" vorhanden.
*/
export async function loadTranslations(
event: NostrEvent
): Promise<TranslationInfo[]> {
const { parseTranslationRefs } = await import('./translations');
const refs = parseTranslationRefs(event);
if (refs.length === 0) return [];
const relays = get(readRelays);
return resolveTranslationsFromRefs(refs, (ref) =>
collectEvents(relays, {
kinds: [ref.kind],
authors: [ref.pubkey],
'#d': [ref.dtag],
limit: 1
})
);
}

View File

@ -1,51 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseTranslationRefs } from './translations';
import type { NostrEvent } from './loaders';
function ev(tags: string[][]): NostrEvent {
return {
id: 'x',
pubkey: 'p',
created_at: 0,
kind: 30023,
tags,
content: '',
sig: 's'
} as unknown as NostrEvent;
}
describe('parseTranslationRefs', () => {
it('extrahiert a-tags mit marker "translation"', () => {
const e = ev([
['d', 'x'],
['a', '30023:abc:other-slug', '', 'translation'],
['a', '30023:abc:third-slug', '', 'translation']
]);
expect(parseTranslationRefs(e)).toEqual([
{ kind: 30023, pubkey: 'abc', dtag: 'other-slug' },
{ kind: 30023, pubkey: 'abc', dtag: 'third-slug' }
]);
});
it('ignoriert a-tags ohne marker "translation"', () => {
const e = ev([
['a', '30023:abc:root-thread', '', 'root'],
['a', '30023:abc:x', '', 'reply']
]);
expect(parseTranslationRefs(e)).toEqual([]);
});
it('ignoriert a-tags mit malformed coordinate', () => {
const e = ev([
['a', 'not-a-coord', '', 'translation'],
['a', '30023:abc:ok', '', 'translation']
]);
expect(parseTranslationRefs(e)).toEqual([
{ kind: 30023, pubkey: 'abc', dtag: 'ok' }
]);
});
it('leeres tag-array → leere liste', () => {
expect(parseTranslationRefs(ev([]))).toEqual([]);
});
});

View File

@ -1,27 +0,0 @@
import type { NostrEvent } from './loaders';
export interface TranslationRef {
kind: number;
pubkey: string;
dtag: string;
}
const COORD_RE = /^(\d+):([0-9a-f]+):([a-z0-9][a-z0-9-]*)$/;
export function parseTranslationRefs(event: NostrEvent): TranslationRef[] {
const refs: TranslationRef[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'a') continue;
if (tag[3] !== 'translation') continue;
const coord = tag[1];
if (typeof coord !== 'string') continue;
const m = coord.match(COORD_RE);
if (!m) continue;
refs.push({
kind: parseInt(m[1], 10),
pubkey: m[2],
dtag: m[3]
});
}
return refs;
}

View File

@ -0,0 +1,26 @@
// app/src/lib/render/markdown.node.test.ts
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from './markdown';
describe('renderMarkdown (Node-Kontext)', () => {
it('rendert einfaches Markdown im Node-Build ohne window', () => {
const html = renderMarkdown('# Hallo\n\nWelt mit *Kursiv* und [Link](https://example.com)');
expect(html).toContain('<h1');
expect(html).toContain('Hallo');
expect(html).toContain('<em>Kursiv</em>');
expect(html).toContain('href="https://example.com"');
});
it('sanitisiert XSS-Versuche', () => {
const html = renderMarkdown('<script>alert(1)</script>\n\nText');
expect(html).not.toContain('<script');
expect(html).toContain('Text');
});
it('hebt code-blocks mit highlight.js hervor', () => {
const html = renderMarkdown('```ts\nconst x: number = 1;\n```');
expect(html).toContain('class="hljs');
expect(html).toContain('language-ts');
});
});

View File

@ -1,5 +1,5 @@
import { Marked } from 'marked'; import { Marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'isomorphic-dompurify';
import hljs from 'highlight.js/lib/core'; import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript'; import javascript from 'highlight.js/lib/languages/javascript';
import bash from 'highlight.js/lib/languages/bash'; import bash from 'highlight.js/lib/languages/bash';
@ -34,20 +34,7 @@ const markedInstance = new Marked({
} }
}); });
/**
* Rendert einen Markdown-String zu sanitized HTML.
* Einziger Export des Moduls so bleibt Austausch der Engine lokal.
*
* Nur im Browser/jsdom aufrufen: DOMPurify braucht ein DOM. Die SPA
* hat SSR global ausgeschaltet (`+layout.ts: ssr = false`), Vitest läuft
* in jsdom beide Szenarien sind abgedeckt. Ein Aufruf in reiner
* Node-Umgebung würde hier laut fehlschlagen statt stumm unsicher
* durchzulaufen.
*/
export function renderMarkdown(md: string): string { export function renderMarkdown(md: string): string {
if (typeof window === 'undefined') {
throw new Error('renderMarkdown: DOM-Kontext erforderlich (Browser oder jsdom).');
}
const raw = markedInstance.parse(md, { async: false }) as string; const raw = markedInstance.parse(md, { async: false }) as string;
return DOMPurify.sanitize(raw); return DOMPurify.sanitize(raw);
} }

View File

@ -1,58 +1,138 @@
<script lang="ts"> <script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders'; import type { NostrEvent } from '$lib/nostr/loaders'
import { loadPost } from '$lib/nostr/loaders'; import Reactions from '$lib/components/Reactions.svelte'
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config'; import ReplyList from '$lib/components/ReplyList.svelte'
import { buildHablaLink } from '$lib/nostr/naddr'; import ReplyComposer from '$lib/components/ReplyComposer.svelte'
import PostView from '$lib/components/PostView.svelte'; import ExternalClientLinks from '$lib/components/ExternalClientLinks.svelte'
import LoadingOrError from '$lib/components/LoadingOrError.svelte'; import { renderMarkdown } from '$lib/render/markdown'
import { t } from '$lib/i18n'; import { t } from '$lib/i18n'
import { get } from 'svelte/store'; import type { SignedEvent } from '$lib/nostr/signer'
let { data } = $props(); let { data } = $props()
const dtag = $derived(data.dtag); const dtag = $derived(data.dtag)
const snapshot = $derived(data.snapshot)
let post: NostrEvent | null = $state(null); const siteUrl = '__SITE_URL__'
let loading = $state(true);
let error: string | null = $state(null);
const hablaLink = $derived( // Site-default-OG-bild aus app/static. Dimensionen sind hartcodiert,
buildHablaLink({ // weil das asset stabil ist (siehe spec §Algorithmus-Schritt 8).
pubkey: AUTHOR_PUBKEY_HEX, const DEFAULT_OG_IMAGE = `${siteUrl}/joerg-profil-2024.webp`
kind: 30023, const DEFAULT_OG_IMAGE_WIDTH = 512
identifier: dtag const DEFAULT_OG_IMAGE_HEIGHT = 512
})
);
$effect(() => { const canonical = $derived(`${siteUrl}/${snapshot?.slug ?? dtag}/`)
const currentDtag = dtag; const ogImage = $derived(snapshot?.cover_image?.url ?? DEFAULT_OG_IMAGE)
post = null; const ogImageAlt = $derived(
loading = true; snapshot?.cover_image?.alt ?? snapshot?.title ?? 'Jörg Lohrer',
error = null; )
loadPost(currentDtag) const ogImageWidth = $derived(
.then((p) => { snapshot?.cover_image?.width ?? (snapshot?.cover_image ? undefined : DEFAULT_OG_IMAGE_WIDTH),
if (currentDtag !== dtag) return; )
if (!p) { const ogImageHeight = $derived(
error = get(t)('post.not_found', { values: { slug: currentDtag } }); snapshot?.cover_image?.height ?? (snapshot?.cover_image ? undefined : DEFAULT_OG_IMAGE_HEIGHT),
} else { )
post = p; // x-default zeigt auf die DE-variante, weil der autor DE-first arbeitet.
// Bei EN-posts: DE-slug aus translations[] suchen; sonst (DE-post)
// bleibt x-default = canonical.
const xDefaultHref = $derived(
snapshot?.lang === 'en'
? `${siteUrl}/${snapshot.translations.find((tr) => tr.lang === 'de')?.slug ?? snapshot.slug}/`
: canonical,
)
const bodyHtmlPrerendered = $derived(
snapshot ? renderMarkdown(snapshot.content_markdown) : '',
)
let optimisticReplies: NostrEvent[] = $state([])
function handlePublished(signed: SignedEvent) {
optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent]
} }
const jsonLd = $derived(
snapshot
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: snapshot.title,
description: snapshot.summary,
datePublished: new Date(snapshot.published_at * 1000).toISOString(),
dateModified: new Date(snapshot.created_at * 1000).toISOString(),
author: { '@type': 'Person', name: 'Jörg Lohrer' },
inLanguage: snapshot.lang,
image: ogImage,
mainEntityOfPage: canonical,
}) })
.catch((e) => { : '',
if (currentDtag !== dtag) return; )
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
})
.finally(() => {
if (currentDtag === dtag) loading = false;
});
});
</script> </script>
<svelte:head>
{#if snapshot}
<title>{snapshot.title} Jörg Lohrer</title>
<meta name="description" content={snapshot.summary} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="article" />
<meta property="og:title" content={snapshot.title} />
<meta property="og:description" content={snapshot.summary} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content={snapshot.lang === 'de' ? 'de_DE' : 'en_US'} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:alt" content={ogImageAlt} />
{#if ogImageWidth}
<meta property="og:image:width" content={String(ogImageWidth)} />
{/if}
{#if ogImageHeight}
<meta property="og:image:height" content={String(ogImageHeight)} />
{/if}
<meta property="article:published_time" content={new Date(snapshot.published_at * 1000).toISOString()} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={snapshot.title} />
<meta name="twitter:description" content={snapshot.summary} />
<meta name="twitter:image" content={ogImage} />
{#each snapshot.translations as alt}
<link rel="alternate" hreflang={alt.lang} href={`${siteUrl}/${alt.slug}/`} />
{/each}
<link rel="alternate" hreflang="x-default" href={xDefaultHref} />
{@html `<script type="application/ld+json">${jsonLd.replace(/<\/script>/gi, '<\\/script>')}</script>`}
{/if}
</svelte:head>
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav> <nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
<LoadingOrError {loading} {error} {hablaLink} /> {#if snapshot}
<article class="post">
{#if post} <h1 class="post-title">{snapshot.title}</h1>
<PostView event={post} /> {#if snapshot.translations.length > 0}
<p class="lang-switch" role="group" aria-label="Article language">
<span class="icon" aria-hidden="true">📖</span>
<span class="btn active" aria-current="true">{snapshot.lang.toUpperCase()}</span>
{#each [...snapshot.translations].sort((a, b) => a.lang.localeCompare(b.lang)) as alt}
<span class="sep" aria-hidden="true">|</span>
<a class="btn" href={`/${alt.slug}/`}>{alt.lang.toUpperCase()}</a>
{/each}
</p>
{/if}
{#if snapshot.cover_image}
<p class="cover">
<img src={snapshot.cover_image.url} alt={snapshot.cover_image.alt ?? ''} />
</p>
{/if}
{#if snapshot.summary}
<p class="summary">{snapshot.summary}</p>
{/if}
<div class="body">{@html bodyHtmlPrerendered}</div>
{#if snapshot.tags.length > 0}
<div class="tags">
{#each snapshot.tags as tag}
<a class="tag" href={`/tag/${encodeURIComponent(tag)}/`}>{tag}</a>
{/each}
</div>
{/if}
<Reactions dtag={snapshot.slug} />
<ExternalClientLinks dtag={snapshot.slug} />
<ReplyComposer dtag={snapshot.slug} eventId={snapshot.event_id} onPublished={handlePublished} />
<ReplyList dtag={snapshot.slug} optimistic={optimisticReplies} />
</article>
{/if} {/if}
<style> <style>
@ -67,4 +147,118 @@
.breadcrumb a:hover { .breadcrumb a:hover {
text-decoration: underline; text-decoration: underline;
} }
.post-title {
font-size: 1.5rem;
line-height: 1.25;
margin: 0 0 0.4rem;
word-wrap: break-word;
}
@media (min-width: 640px) {
.post-title {
font-size: 2rem;
line-height: 1.2;
}
}
.cover {
max-width: 480px;
margin: 1rem auto 1.5rem;
}
.cover img {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
.summary {
font-style: italic;
color: var(--muted);
}
.body :global(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.body :global(a) {
color: var(--accent);
word-break: break-word;
}
.body :global(pre) {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
.body :global(code) {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
.body :global(pre code) {
padding: 0;
background: none;
word-break: normal;
}
.body :global(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
.body :global(blockquote) {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
margin: 1rem 0;
color: var(--muted);
}
.lang-switch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.88rem;
color: var(--muted);
margin: 0.25rem 0 1rem;
}
.icon {
font-size: 1rem;
line-height: 1;
}
.btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 3px;
padding: 1px 7px;
font-size: 0.8rem;
font-family: inherit;
text-decoration: none;
}
.btn:hover:not(.active) {
color: var(--fg);
}
.btn.active {
color: var(--accent);
border-color: var(--accent);
}
.sep {
opacity: 0.4;
}
.tags {
margin-top: 1.5rem;
}
.tag {
display: inline-block;
background: var(--code-bg);
border-radius: 3px;
padding: 1px 7px;
margin: 0 4px 4px 0;
font-size: 0.85em;
color: var(--fg);
text-decoration: none;
}
.tag:hover {
background: var(--border);
}
</style> </style>

View File

@ -1,21 +1,79 @@
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit'
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy'; import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy'
import type { PageLoad } from './$types'; import type { EntryGenerator, PageLoad } from './$types'
import { browser } from '$app/environment'
export const ssr = true
export const prerender = true
export const trailingSlash = 'always'
interface SnapshotIndex {
posts: Array<{ slug: string; lang: string; title: string }>
}
interface PostJson {
slug: string
event_id: string
created_at: number
published_at: number
title: string
summary: string
lang: string
cover_image: { url: string; alt?: string; width?: number; height?: number; mime?: string } | null
content_markdown: string
tags: string[]
naddr: string
habla_url: string
translations: Array<{ lang: string; slug: string; title: string }>
}
let cachedIndex: SnapshotIndex | undefined
async function readIndex(): Promise<SnapshotIndex> {
if (cachedIndex) return cachedIndex
const fs = await import('node:fs/promises')
const path = await import('node:path')
const dir = path.resolve('../snapshot/output')
const text = await fs.readFile(path.join(dir, 'index.json'), 'utf-8')
cachedIndex = JSON.parse(text) as SnapshotIndex
return cachedIndex
}
async function readPost(slug: string): Promise<PostJson | undefined> {
try {
const fs = await import('node:fs/promises')
const path = await import('node:path')
const dir = path.resolve('../snapshot/output')
const text = await fs.readFile(path.join(dir, 'posts', `${slug}.json`), 'utf-8')
return JSON.parse(text) as PostJson
} catch {
return undefined
}
}
export const entries: EntryGenerator = async () => {
const idx = await readIndex()
return idx.posts.map((p) => ({ slug: p.slug }))
}
export const load: PageLoad = async ({ url }) => { export const load: PageLoad = async ({ url }) => {
const pathname = url.pathname; const pathname = url.pathname
// Legacy-Form /YYYY/MM/DD/<dtag>.html/ → Redirect auf /<dtag>/ const legacyDtag = parseLegacyUrl(pathname)
const legacyDtag = parseLegacyUrl(pathname);
if (legacyDtag) { if (legacyDtag) {
throw redirect(301, canonicalPostPath(legacyDtag)); throw redirect(301, canonicalPostPath(legacyDtag))
} }
// Kanonisch: /<dtag>/ — erster Segment des Pfades. const segments = pathname.replace(/^\/+|\/+$/g, '').split('/')
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/');
if (segments.length !== 1 || !segments[0]) { if (segments.length !== 1 || !segments[0]) {
throw error(404, 'Seite nicht gefunden'); throw error(404, 'Seite nicht gefunden')
}
const dtag = decodeURIComponent(segments[0])
if (!browser) {
const snapshot = await readPost(dtag)
if (!snapshot) throw error(404, 'Post nicht gefunden')
return { dtag, snapshot }
} }
return { dtag: decodeURIComponent(segments[0]) }; throw error(404, 'Post nicht gefunden')
}; }

View File

@ -18,6 +18,29 @@ const config = {
}), }),
alias: { alias: {
$lib: 'src/lib' $lib: 'src/lib'
},
prerender: {
// Der Crawler folgt zur Build-Zeit href/src-attributen im HTML. Zwei
// faelle, in denen 404er kein echter fehler sind:
//
// 1. canonical/hreflang enthalten den `__SITE_URL__`-platzhalter, der
// erst beim deploy per sed durch die echte SITE_URL ersetzt wird.
// Pfade wie `/<slug>/__SITE_URL__/` sind also pseudo-pfade.
// 2. Bild-references mit relativen pfaden (z.B. `h01-json-import.png`)
// in alten posts, die nicht zu Blossom-URLs migriert wurden — die
// sind im post-body als <img src="..."> und vom crawler verfolgte
// pseudo-routes. Die SPA selbst rendert die <img>-tags zwar, aber
// eine 404-route gibt es dafuer nicht.
handleHttpError: ({ path, message }) => {
if (path.includes('__SITE_URL__')) return;
if (/\.(png|jpe?g|gif|webp|svg|avif)\/?$/i.test(path)) return;
throw new Error(message);
},
// Markdown-headings bekommen ohne slugify-plugin keine id-attribute.
// Anchor-links in alten posts (z.B. [link](#ACF-JSON-Export)) sind
// damit zur build-zeit unauffindbar. Kein render-fehler — die SPA
// scrollt im browser entweder zum element oder garnicht.
handleMissingId: 'ignore'
} }
} }
}; };

View File

@ -221,6 +221,14 @@ cd publish && deno task test # tests
## Bekannte Stolperfallen ## Bekannte Stolperfallen
- **Snapshot vor Build:** `app/build` braucht zur Build-Zeit
`snapshot/output/index.json` und `snapshot/output/posts/<slug>.json`.
`./scripts/deploy-svelte.sh` zieht den Snapshot automatisch vor dem
Build. Wer `cd app && npm run build` direkt aufruft, ohne vorher
`cd snapshot && deno task snapshot` auszuführen, scheitert mit
ENOENT auf `index.json`. Frische Posts erscheinen erst nach einem
Snapshot-Re-Run, weil die Detail-Route ausschließlich aus dem
Snapshot rendert (kein Runtime-Relay-Fetch mehr).
- **Amber-Bunker:** bei neuer Bunker-URL müssen die zwei Permissions - **Amber-Bunker:** bei neuer Bunker-URL müssen die zwei Permissions
(`get_public_key`, `sign_event`) in Amber auf „Allow + Always" gesetzt (`get_public_key`, `sign_event`) in Amber auf „Allow + Always" gesetzt
werden, bevor Publish-Requests verarbeitet werden. Siehe werden, bevor Publish-Requests verarbeitet werden. Siehe

View File

@ -3,7 +3,7 @@
> **Rolle dieses Dokuments:** Logbuch — aktueller Stand und Erledigt-Chronologie. > **Rolle dieses Dokuments:** Logbuch — aktueller Stand und Erledigt-Chronologie.
> Konventionen und Workflows stehen in [`HANDOFF.md`](HANDOFF.md). > Konventionen und Workflows stehen in [`HANDOFF.md`](HANDOFF.md).
**Stand:** 2026-04-21 (Mehrsprachigkeit live) **Stand:** 2026-04-28 (Prerender-Snapshot live auf svelte-subdomain)
## Kurzfassung ## Kurzfassung
@ -12,6 +12,14 @@ signierten Nostr-Events (NIP-23, `kind:30023`) auf 5 Public-Relays rendert.
Bilder liegen content-addressed auf 2 Blossom-Servern. Die Hugo-basierte Bilder liegen content-addressed auf 2 Blossom-Servern. Die Hugo-basierte
Altseite ist als `hugo-archive`-Branch eingefroren. Altseite ist als `hugo-archive`-Branch eingefroren.
**Seit 2026-04-28 prerender-snapshot:** Post-Detailseiten werden zur
Build-Zeit prerendered, mit vollen OG-/Twitter-/JSON-LD-Tags. Ein Deno-
Tool (`snapshot/`) liest die Events von den Relays und schreibt sie als
JSON-Artefakte; SvelteKit baut daraus `<slug>/index.html` mit korrekten
Meta-Tags. Crawler und Social-Media-Vorschauen sehen jetzt echte Titel,
Beschreibungen, Cover-Bilder. Live verifiziert auf `svelte.joerg-lohrer.de`,
prod-merge ausstehend.
**Seit 2026-04-21 multilingual:** UI-Chrome (Menü, Footer, Post-Meta) **Seit 2026-04-21 multilingual:** UI-Chrome (Menü, Footer, Post-Meta)
in Deutsch und Englisch via `svelte-i18n`, mit Browser-Locale-Default, in Deutsch und Englisch via `svelte-i18n`, mit Browser-Locale-Default,
`localStorage`-Persistenz und Header-Sprachswitcher. Inhalte pro Sprache `localStorage`-Persistenz und Header-Sprachswitcher. Inhalte pro Sprache
@ -59,9 +67,10 @@ joerglohrerde/
├── content/impressum.md # Statisches Impressum (wird von SPA geladen) ├── content/impressum.md # Statisches Impressum (wird von SPA geladen)
├── app/ ├── app/
│ ├── src/lib/i18n/ # svelte-i18n + activeLocale-Store + Messages │ ├── src/lib/i18n/ # svelte-i18n + activeLocale-Store + Messages
│ ├── src/lib/nostr/ # Relay-Loader, Translations-Resolving │ ├── src/lib/nostr/ # Relay-Loader (Listen, Replies, Reactions, Profile)
│ └── src/lib/components/ # u. a. LanguageSwitcher, LanguageAvailability │ └── src/lib/components/ # u. a. LanguageSwitcher, Reactions, ReplyComposer
├── publish/ # Deno-Publish-Pipeline (Blossom + Nostr) ├── publish/ # Deno-Publish-Pipeline (Blossom + Nostr)
├── snapshot/ # Deno-Snapshot-Tool (Relays → JSON für Prerender)
├── preview/spa-mini/ # Vanilla-HTML-Mini-Spike (historisch) ├── preview/spa-mini/ # Vanilla-HTML-Mini-Spike (historisch)
├── scripts/ ├── scripts/
│ └── deploy-svelte.sh # FTPS-Deploy, Targets: svelte/staging/prod │ └── deploy-svelte.sh # FTPS-Deploy, Targets: svelte/staging/prod
@ -73,9 +82,9 @@ joerglohrerde/
│ ├── wiki-draft-nostr-image-metadata.md │ ├── wiki-draft-nostr-image-metadata.md
│ ├── github-ci-setup.md │ ├── github-ci-setup.md
│ └── superpowers/ │ └── superpowers/
│ ├── specs/ # SPA, Publish-Pipeline, Bild-Metadaten, Multilingual, Prerender (Entwurf) │ ├── specs/ # SPA, Publish-Pipeline, Bild-Metadaten, Multilingual, Prerender, Docs-Cleanup
│ └── plans/ │ └── plans/
│ └── archive/ # Umgesetzte Pläne (Geschichte) + eingefrorener Prerender-Plan │ └── archive/ # Umgesetzte Pläne (Geschichte) + Prerender-Plan (durch 2026-04-28 ersetzt)
├── .github/workflows/ # publish.yml (Forgejo→GitHub Push-Mirror-Trigger) ├── .github/workflows/ # publish.yml (Forgejo→GitHub Push-Mirror-Trigger)
├── .claude/ ├── .claude/
│ ├── skills/ # Repo-spezifischer Claude-Skill │ ├── skills/ # Repo-spezifischer Claude-Skill
@ -117,6 +126,20 @@ Nach Priorität:
## Erledigt (chronologisch seit 2026-04-15) ## Erledigt (chronologisch seit 2026-04-15)
- ✅ **Prerender-Snapshot (2026-04-28)** — Post-Detailseiten werden zur
Build-Zeit prerendered, nicht mehr live aus Relays. Sechs Etappen:
- `renderMarkdown` auf `isomorphic-dompurify` (node-fähig).
- Neues `snapshot/`-Modul (Deno) mit 32 Tests, liest Events von
Relays und schreibt JSON-Artefakte (NIP-09-aware, Plausibilitäts-
Checks, Cover-Probe, Cache mit akkumulierten deletedCoords).
- GitHub-Action zieht Snapshot nach jedem Publish als Artifact.
- SvelteKit-Detail-Route auf `prerender=true` mit `<svelte:head>` für
OG/Twitter/JSON-LD/hreflang. `<html lang>` + `og:image:width/height`
pro Post korrekt gesetzt; `x-default` zeigt auf DE-Slug.
- Runtime-Relay-Fetch der Detail-Route entfernt.
- Deploy-Skript ruft Snapshot vor SvelteKit-Build auf.
- Toten Code aus Pre-Prerender-Ära entfernt (PostView, LanguageAvailability,
loadPost, loadTranslations, translations.ts).
- ✅ Content-Migration: alle 18 Posts haben strukturierte `images:`-Liste - ✅ Content-Migration: alle 18 Posts haben strukturierte `images:`-Liste
im Frontmatter (91 Bilder, mit Alt-Text, Lizenz, Autor:innen, ggf. im Frontmatter (91 Bilder, mit Alt-Text, Lizenz, Autor:innen, ggf.
Caption und Modifications). Caption und Modifications).

File diff suppressed because it is too large Load Diff

View File

@ -159,8 +159,9 @@ Neues Deno-Modul. Verzeichnis: `snapshot/` als Geschwister zu `publish/`.
Zeichen (Whitespace normalisiert, abgeschnitten an Wortgrenze, Zeichen (Whitespace normalisiert, abgeschnitten an Wortgrenze,
Suffix `…`) extrahieren und als `summary` schreiben Suffix `…`) extrahieren und als `summary` schreiben
- fehlt `image` im Event → `cover_image` ist `null`; der Prerender - fehlt `image` im Event → `cover_image` ist `null`; der Prerender
nutzt ein Site-Default-OG-Bild (definiert in `app/static/`, z.B. nutzt das Site-Default-OG-Bild `app/static/joerg-profil-2024.webp`
Profilbild oder Logo-Banner, als `og:image` bei null-Cover) als `og:image`. Dimensionen werden zur Build-Zeit aus der Datei
bestimmt und mit `og:image:width`/`og:image:height` ausgegeben.
- fehlt `published_at`-Tag → `created_at` wird als - fehlt `published_at`-Tag → `created_at` wird als
`published_at` übernommen `published_at` übernommen
9. **JSON-Output schreiben.** 9. **JSON-Output schreiben.**
@ -198,7 +199,6 @@ Neues Deno-Modul. Verzeichnis: `snapshot/` als Geschwister zu `publish/`.
"lang": "de", "lang": "de",
"cover_image": { "cover_image": {
"url": "https://blossom.edufeed.org/<hash>.jpg", "url": "https://blossom.edufeed.org/<hash>.jpg",
"fallback_url": "https://blossom.primal.net/<hash>.jpg",
"width": 1600, "width": 1600,
"height": 900, "height": 900,
"alt": "Alt-Text", "alt": "Alt-Text",
@ -216,13 +216,12 @@ Neues Deno-Modul. Verzeichnis: `snapshot/` als Geschwister zu `publish/`.
**Semantik der `cover_image`-Felder:** **Semantik der `cover_image`-Felder:**
- `url` → primäre Bild-URL, wird vom Prerender als `og:image`-Wert in - `url` → primäre Bild-URL, wird vom Prerender als `og:image`-Wert in
den HTML-Head geschrieben. Crawler sehen nur diese URL. den HTML-Head geschrieben. Crawler sehen nur diese URL. Blossom ist
- `fallback_url` → zweiter Blossom-Server mit demselben Hash (Blossom content-addressed; ein Ausfall des primären Servers ist seltener und
ist content-addressed). Nicht Teil der OG-Tags. Nutzungsszenario: rechtfertigt zum jetzigen Stand keinen zweiten URL-Slot. Falls in der
Falls der Snapshot-HTML ein `<img>`-Element im Post-Body erzeugt, das Praxis Bedarf entsteht (z.B. anhaltende Ausfälle), kann ein
auf `url` zeigt, kann ein client-seitiger `onerror`-Handler bei `fallback_url`-Feld nachgereicht werden — dann mit konkretem Konsumenten,
Ladefehler auf `fallback_url` umschalten. Ist das nicht gewünscht nicht spekulativ.
(YAGNI), wird das Feld entfernt — Entscheidung in der Planungsphase.
**Semantik von `created_at` vs. `published_at`:** **Semantik von `created_at` vs. `published_at`:**
- `published_at` → Redaktions-Zeitpunkt (menschlich), aus `published_at`- - `published_at` → Redaktions-Zeitpunkt (menschlich), aus `published_at`-
@ -364,7 +363,7 @@ nur die UI-Sprache (weich, umschaltbar).
| Event-Count-Drop > 20 % mit korrespondierenden `kind:5` | Check übersprungen, fährt fort | | Event-Count-Drop > 20 % mit korrespondierenden `kind:5` | Check übersprungen, fährt fort |
| Blossom-Cover nicht erreichbar | Warnung loggen, URL trotzdem schreiben | | Blossom-Cover nicht erreichbar | Warnung loggen, URL trotzdem schreiben |
| Event ohne `summary` | `summary` aus Body-Anfang abgeleitet | | Event ohne `summary` | `summary` aus Body-Anfang abgeleitet |
| Event ohne `image` | `cover_image: null`, Prerender nutzt Site-Default-OG-Bild | | 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 | | 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. | | 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 | | Nostr-first-Post nicht im Repo | Wird trotzdem snapshot'd + gerendert |
@ -399,12 +398,14 @@ an keiner Stelle einen Big-Bang bildet:
4. **SvelteKit-Route auf Prerender umstellen, mit Laufzeit-Fallback.** 4. **SvelteKit-Route auf Prerender umstellen, mit Laufzeit-Fallback.**
`[...slug]/+page.ts` bekommt `prerender = true` + `entries()` + Load `[...slug]/+page.ts` bekommt `prerender = true` + `entries()` + Load
aus JSON. `+page.svelte` rendert `content_markdown` per aus JSON. `+page.svelte` rendert `content_markdown` per
`renderMarkdown()` zur Build-Zeit. Der bisherige Runtime-Relay-Fetch `renderMarkdown()` zur Build-Zeit. Slugs, die zur Build-Zeit im
bleibt in diesem Schritt noch als Fallback bestehen — falls ein Slug Snapshot stehen, erzeugen statische `<slug>/index.html`-Dateien;
zur Build-Zeit nicht im Snapshot war (z.B. ganz frisch Nostr-first Slugs außerhalb des Snapshots (z.B. ganz frisch Nostr-first publiziert)
publiziert), kann die SPA ihn über `adapter-static`-`fallback` landen über `adapter-static`-`fallback: 'index.html'` weiterhin auf
weiterhin rendern. Rollback: Commit revert, alte Runtime-Logik kommt der SPA-Shell, die ihren bisherigen Runtime-Relay-Fetch ausführt.
vollständig zurück. Beide Pfade leben damit parallel — kein Workaround, das ist das
Default-Verhalten von `adapter-static` mit Fallback. Rollback:
Commit revert, alle Slugs gehen zurück auf reine SPA-Hydration.
5. **Runtime-Relay-Fetch der Detail-Seite entfernen.** Wenn Schritt 4 5. **Runtime-Relay-Fetch der Detail-Seite entfernen.** Wenn Schritt 4
sich stabil zeigt, wird der Fallback-Code-Pfad abgebaut. Die sich stabil zeigt, wird der Fallback-Code-Pfad abgebaut. Die
Detail-Seite lebt dann ausschließlich vom Snapshot. Neue Nostr-first- Detail-Seite lebt dann ausschließlich vom Snapshot. Neue Nostr-first-
@ -436,10 +437,7 @@ Damit das Tool als Vorlage für andere Nostr-Sites dient:
- Ob der SvelteKit-Prerender deterministisch identische HTML für - Ob der SvelteKit-Prerender deterministisch identische HTML für
unveränderte Inputs produziert (für Diff-Builds / Cache-Invalidation). unveränderte Inputs produziert (für Diff-Builds / Cache-Invalidation).
Vermutlich ja, nachprüfen. Vermutlich ja, nachprüfen.
- Ob `fallback_url` im `cover_image` tatsächlich gebraucht wird. Wenn - `ReplyList`/`ReplyComposer` müssen auf Prerender-Seiten weiterhin
der Snapshot-HTML keine `onerror`-Substitution implementiert, ist clientseitig hydrieren und Live-Relay-Fetch ausführen. Erwartung: ja,
das Feld toter Code. Entscheidung: mit `fallback_url` starten, bei weil `<svelte:head>` statisch und Reaktions-Komponenten Client-Bound
fehlender Nutzung in der SPA wieder entfernen (YAGNI). sind; im Plan-Schritt 4 als Teil der Verifikation prüfen.
- 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.

View File

@ -76,6 +76,18 @@ for pair in "$FTP_HOST_KEY:$FTP_HOST" "$FTP_USER_KEY:$FTP_USER" \
done done
BUILD_DIR="$ROOT/app/build" BUILD_DIR="$ROOT/app/build"
SNAPSHOT_DIR="$ROOT/snapshot/output"
echo "Ziehe Snapshot von Relays …"
(cd "$ROOT/snapshot" && deno task snapshot) || {
echo "FEHLER: Snapshot fehlgeschlagen. 'cd snapshot && deno task snapshot' manuell ausführen zum Debuggen." >&2
exit 1
}
if [ ! -f "$SNAPSHOT_DIR/index.json" ]; then
echo "FEHLER: $SNAPSHOT_DIR/index.json fehlt nach snapshot." >&2
exit 1
fi
echo "Baue SvelteKit …" echo "Baue SvelteKit …"
(cd "$ROOT/app" && npm run build >/dev/null 2>&1) || { (cd "$ROOT/app" && npm run build >/dev/null 2>&1) || {
@ -98,6 +110,23 @@ find "$BUILD_DIR" -type f -name "*.html" -print0 | while IFS= read -r -d '' html
sed -i '' "s|__SITE_URL__|$SITE_URL|g" "$html_file" sed -i '' "s|__SITE_URL__|$SITE_URL|g" "$html_file"
done done
# __HTML_LANG__-Platzhalter pro detail-HTML aus dem snapshot-JSON ableiten:
# /<slug>/index.html → snapshot/output/posts/<slug>.json → .lang
# Alle anderen HTMLs (index, archiv/, impressum/, tag/) bekommen den
# default 'de' — die SPA setzt activeLocale clientseitig nach.
echo "Patche __HTML_LANG__ pro HTML aus snapshot/output …"
find "$BUILD_DIR" -type f -name "index.html" -print0 | while IFS= read -r -d '' html_file; do
rel="${html_file#$BUILD_DIR/}"
slug="${rel%/index.html}"
lang_file="$SNAPSHOT_DIR/posts/${slug}.json"
if [ -f "$lang_file" ]; then
lang=$(grep -o '"lang": *"[a-z][a-z]"' "$lang_file" | head -1 | sed 's/.*"\([a-z][a-z]\)".*/\1/')
else
lang="de"
fi
sed -i '' "s|__HTML_LANG__|${lang:-de}|g" "$html_file"
done
echo "Ziel: $TARGET ($PUBLIC_URL)" echo "Ziel: $TARGET ($PUBLIC_URL)"
echo "Lade Build von $BUILD_DIR nach ftp://$FTP_HOST$FTP_REMOTE_PATH" echo "Lade Build von $BUILD_DIR nach ftp://$FTP_HOST$FTP_REMOTE_PATH"

2
snapshot/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
output/
.last-snapshot.json

22
snapshot/README.md Normal file
View File

@ -0,0 +1,22 @@
# snapshot/
Liest die `kind:30023`-Events des Site-Autors von den Read-Relays und
schreibt sie als JSON-Artefakte für den SvelteKit-Prerender-Schritt.
Kein Live-Proxy: Relays werden nur zur Build-Zeit befragt.
Spec: [`../docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md`](../docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md)
## Nutzung
```sh
cd snapshot
deno task snapshot # default
deno task snapshot --out ./output # alternatives Ziel
deno task snapshot --min-events 20 # Schwelle
deno task snapshot --allow-shrink # Drop-Check aus
```
Erwartet diese Env-Vars (aus `../.env.local`):
- `AUTHOR_PUBKEY_HEX` (64 hex chars)
- `BOOTSTRAP_RELAY` (wss-URL)

31
snapshot/deno.jsonc Normal file
View File

@ -0,0 +1,31 @@
{
"tasks": {
"snapshot": "deno run --env-file=../.env.local --allow-env --allow-read --allow-write --allow-net src/cli.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-net",
"fmt": "deno fmt",
"lint": "deno lint"
},
"imports": {
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"@std/cli": "jsr:@std/cli@^1.0.6",
"@std/fs": "jsr:@std/fs@^1.0.4",
"@std/path": "jsr:@std/path@^1.0.6",
"@std/testing": "jsr:@std/testing@^1.0.3",
"@std/assert": "jsr:@std/assert@^1.0.6",
"@std/encoding": "jsr:@std/encoding@^1.0.5",
"nostr-tools": "npm:nostr-tools@^2.10.4",
"applesauce-relay": "npm:applesauce-relay@^2.0.0",
"rxjs": "npm:rxjs@^7.8.1"
},
"fmt": {
"lineWidth": 100,
"indentWidth": 2,
"semiColons": false,
"singleQuote": true
},
"lint": {
"rules": {
"tags": ["recommended"]
}
}
}

172
snapshot/deno.lock Normal file
View File

@ -0,0 +1,172 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.6": "1.0.19",
"jsr:@std/cli@^1.0.6": "1.0.28",
"jsr:@std/fs@^1.0.4": "1.0.23",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/path@^1.0.6": "1.1.4",
"jsr:@std/path@^1.1.4": "1.1.4",
"npm:applesauce-relay@2": "2.3.0",
"npm:nostr-tools@^2.10.4": "2.23.3",
"npm:rxjs@^7.8.1": "7.8.2"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/cli@1.0.28": {
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/fs@1.0.23": {
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
"dependencies": [
"jsr:@std/internal",
"jsr:@std/path@^1.1.4"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
"@noble/ciphers@2.1.1": {
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="
},
"@noble/curves@2.0.1": {
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"dependencies": [
"@noble/hashes@2.0.1"
]
},
"@noble/hashes@1.8.0": {
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
},
"@noble/hashes@2.0.1": {
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="
},
"@scure/base@1.1.1": {
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
},
"@scure/base@1.2.6": {
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="
},
"@scure/base@2.0.0": {
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="
},
"@scure/bip32@2.0.1": {
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"dependencies": [
"@noble/curves",
"@noble/hashes@2.0.1",
"@scure/base@2.0.0"
]
},
"@scure/bip39@2.0.1": {
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"dependencies": [
"@noble/hashes@2.0.1",
"@scure/base@2.0.0"
]
},
"applesauce-core@2.3.0": {
"integrity": "sha512-rMVrwGMgHxXAHZfrq3ibtMjljAxeEfT95nl5VYLl5mSMmOHXnwjbiPTccJ2UDd6GP+INdHfkPgeB8AOUf5DFog==",
"dependencies": [
"@noble/hashes@1.8.0",
"@scure/base@1.2.6",
"debug",
"fast-deep-equal",
"hash-sum",
"light-bolt11-decoder",
"nanoid",
"nostr-tools",
"rxjs"
]
},
"applesauce-relay@2.3.0": {
"integrity": "sha512-tOijiN1yVyORS5jT5mXe8MTzqc1IVq/AdJXOzTe3uQgeDYhJzQ9lNYgqejDBXW1ahUThsRZgX2RybkOHVjBuHA==",
"dependencies": [
"@noble/hashes@1.8.0",
"applesauce-core",
"nanoid",
"nostr-tools",
"rxjs"
]
},
"debug@4.4.3": {
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": [
"ms"
]
},
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"hash-sum@2.0.0": {
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg=="
},
"light-bolt11-decoder@3.2.0": {
"integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==",
"dependencies": [
"@scure/base@1.1.1"
]
},
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"nanoid@5.1.7": {
"integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==",
"bin": true
},
"nostr-tools@2.23.3": {
"integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==",
"dependencies": [
"@noble/ciphers",
"@noble/curves",
"@noble/hashes@2.0.1",
"@scure/base@2.0.0",
"@scure/bip32",
"@scure/bip39",
"nostr-wasm"
]
},
"nostr-wasm@0.1.0": {
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
},
"rxjs@7.8.2": {
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dependencies": [
"tslib"
]
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@^1.0.6",
"jsr:@std/cli@^1.0.6",
"jsr:@std/encoding@^1.0.5",
"jsr:@std/fs@^1.0.4",
"jsr:@std/path@^1.0.6",
"jsr:@std/testing@^1.0.3",
"jsr:@std/yaml@^1.0.5",
"npm:applesauce-relay@2",
"npm:nostr-tools@^2.10.4",
"npm:rxjs@^7.8.1"
]
}
}

120
snapshot/src/cli.ts Normal file
View File

@ -0,0 +1,120 @@
import { parseArgs } from '@std/cli'
import { join, resolve } from '@std/path'
import { loadConfig } from './core/config.ts'
import { loadReadRelays, fetchEvents } from './core/relays.ts'
import { dedupByDtag } from './core/dedup.ts'
import { filterDeleted } from './core/nip09-filter.ts'
import { runChecks } from './core/checks.ts'
import { buildPostJson } from './core/post-json.ts'
import { probeCover } from './core/cover-probe.ts'
import { writeOutput } from './core/output.ts'
import { readCache, writeCache, type CacheState } from './core/cache.ts'
import type { SignedEvent } from './core/types.ts'
async function main(): Promise<number> {
const args = parseArgs(Deno.args, {
string: ['out', 'cache', 'min-events'],
boolean: ['allow-shrink'],
default: {
out: resolve(import.meta.dirname!, '../output'),
},
})
const outDir = String(args.out)
const cachePath = args.cache ? String(args.cache) : join(outDir, '.last-snapshot.json')
const allowShrink = args['allow-shrink'] === true
const cfg = loadConfig()
const cache = await readCache(cachePath)
const minEvents = args['min-events']
? parseInt(String(args['min-events']), 10)
: cache
? Math.max(1, cache.lastKnownGoodCount - 2)
: 1
console.log('snapshot: bootstrap relay =', cfg.bootstrapRelay)
const readRelays = await loadReadRelays(cfg.bootstrapRelay, cfg.authorPubkeyHex)
console.log('snapshot: read relays =', readRelays.join(', '))
const fetched = await fetchEvents(readRelays, cfg.authorPubkeyHex)
console.log(
`snapshot: ${fetched.responded.length}/${fetched.queried.length} relays geantwortet, ` +
`${fetched.events.length} events roh`,
)
const posts: SignedEvent[] = []
const deletions: SignedEvent[] = []
for (const ev of fetched.events) {
if (ev.kind === 30023) posts.push(ev)
else if (ev.kind === 5) deletions.push(ev)
}
const dedupedPosts = dedupByDtag(posts)
const filtered = filterDeleted(dedupedPosts, deletions, cfg.authorPubkeyHex)
const previousDeletedCoords = new Set(cache?.deletedCoords ?? [])
const newlyDeletedCount = deletions.flatMap((d) =>
d.tags.filter((t) => t[0] === 'a' && t[1] && !previousDeletedCoords.has(t[1])).map((t) => t[1])
).length
runChecks({
relaysQueried: fetched.queried.length,
relaysResponded: fetched.responded.length,
eventCount: filtered.length,
minEvents,
lastKnownGoodCount: cache?.lastKnownGoodCount,
newDeletionsCount: newlyDeletedCount,
allowShrink,
})
const titleByDtag = new Map<string, string>()
for (const ev of filtered) {
const d = ev.tags.find((t) => t[0] === 'd')?.[1]
const title = ev.tags.find((t) => t[0] === 'title')?.[1]
if (d && title) titleByDtag.set(d, title)
}
const postJsons = filtered.map((ev) => buildPostJson(ev, titleByDtag))
for (const p of postJsons) {
if (!p.cover_image) continue
const probe = await probeCover(p.cover_image.url)
if (!probe.reachable) {
console.warn(
`snapshot: cover unreachable [${probe.status}] ${p.cover_image.url} (slug=${p.slug}) — URL wird trotzdem geschrieben`,
)
}
}
await writeOutput(outDir, {
generatedAt: new Date().toISOString(),
authorPubkey: cfg.authorPubkeyHex,
relaysQueried: fetched.queried,
relaysResponded: fetched.responded,
posts: postJsons,
})
const currentDeletedCoords = deletions.flatMap((d) =>
d.tags.filter((t) => t[0] === 'a' && t[1]).map((t) => t[1] as string)
)
// Cache akkumuliert deletedCoords ueber alle bisherigen runs — nicht
// ersetzen: wenn ein relay beim naechsten run die alten kind:5-events
// nicht mehr liefert (GC, relay-tausch), wuerde sonst der vergleich
// gegen previousDeletedCoords im naechsten lauf wieder als "neu"
// werten und einen false-positive hard-fail ausloesen.
const newCache: CacheState = {
lastKnownGoodCount: filtered.length,
deletedCoords: [...new Set([...(cache?.deletedCoords ?? []), ...currentDeletedCoords])],
}
await writeCache(cachePath, newCache)
console.log(`snapshot: ${filtered.length} posts geschrieben nach ${outDir}`)
return 0
}
if (import.meta.main) {
try {
Deno.exit(await main())
} catch (err) {
console.error('snapshot: HARD-FAIL —', err instanceof Error ? err.message : String(err))
Deno.exit(1)
}
}

View File

@ -0,0 +1,28 @@
export interface CacheState {
lastKnownGoodCount: number
deletedCoords: string[]
}
export async function readCache(path: string): Promise<CacheState | undefined> {
let text: string
try {
text = await Deno.readTextFile(path)
} catch (err) {
if (err instanceof Deno.errors.NotFound) return undefined
throw err
}
const parsed = JSON.parse(text) as unknown
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as { lastKnownGoodCount?: unknown }).lastKnownGoodCount !== 'number' ||
!Array.isArray((parsed as { deletedCoords?: unknown }).deletedCoords)
) {
throw new Error('Cache-File hat unbekanntes Format — bitte loeschen und neu starten')
}
return parsed as CacheState
}
export async function writeCache(path: string, state: CacheState): Promise<void> {
await Deno.writeTextFile(path, JSON.stringify(state, null, 2) + '\n')
}

View File

@ -0,0 +1,43 @@
export interface CheckInput {
relaysQueried: number
relaysResponded: number
eventCount: number
minEvents: number
lastKnownGoodCount: number | undefined
newDeletionsCount: number
allowShrink: boolean
}
export function runChecks(input: CheckInput): void {
const quorum = Math.ceil(input.relaysQueried * 0.6)
if (input.relaysResponded < quorum) {
throw new Error(
`Relay-Quorum nicht erreicht: ${input.relaysResponded}/${input.relaysQueried} ` +
`(brauche mindestens ${quorum})`,
)
}
if (input.eventCount < input.minEvents) {
throw new Error(
`Event-Count ${input.eventCount} unter min-events ${input.minEvents}`,
)
}
// Drop-Check: hard-fail bei jedem unerklaerten Event-Verlust > 20%.
// Bedingung "drop > newDeletionsCount" heisst: ein einziges nicht durch
// kind:5 abgedecktes verschwundenes event reicht zum fail. Bewusst strikt,
// weil ein versehentlich verschwundener post schlimmer ist als ein
// false-positive-failure (override mit --allow-shrink). Wer das tunen
// will, sollte die bedingung auf "drop - newDeletionsCount > schwelle"
// umstellen.
if (input.lastKnownGoodCount !== undefined && !input.allowShrink) {
const drop = input.lastKnownGoodCount - input.eventCount
const dropPct = drop / input.lastKnownGoodCount
if (dropPct > 0.2 && drop > input.newDeletionsCount) {
throw new Error(
`Event-Count-Drop ${drop} (${(dropPct * 100).toFixed(0)}%) gegenueber ` +
`last-known-good ${input.lastKnownGoodCount}, ` +
`nur ${input.newDeletionsCount} korrespondierende kind:5. ` +
`Override mit --allow-shrink falls bewusst.`,
)
}
}
}

View File

@ -0,0 +1,18 @@
export interface Config {
authorPubkeyHex: string
bootstrapRelay: string
}
export function loadConfig(): Config {
const authorPubkeyHex = Deno.env.get('AUTHOR_PUBKEY_HEX')
const bootstrapRelay = Deno.env.get('BOOTSTRAP_RELAY')
if (!authorPubkeyHex) throw new Error('AUTHOR_PUBKEY_HEX fehlt in env')
if (!/^[0-9a-f]{64}$/i.test(authorPubkeyHex)) {
throw new Error('AUTHOR_PUBKEY_HEX muss 64 hex chars sein')
}
if (!bootstrapRelay) throw new Error('BOOTSTRAP_RELAY fehlt in env')
if (!bootstrapRelay.startsWith('wss://') && !bootstrapRelay.startsWith('ws://')) {
throw new Error('BOOTSTRAP_RELAY muss eine wss:// (oder ws://) URL sein')
}
return { authorPubkeyHex, bootstrapRelay }
}

View File

@ -0,0 +1,23 @@
export interface ProbeResult {
reachable: boolean
status: number
}
export type HeadFetcher = (url: string) => Promise<{ ok: boolean; status: number }>
export const defaultHeadFetcher: HeadFetcher = async (url) => {
const resp = await fetch(url, { method: 'HEAD' })
return { ok: resp.ok, status: resp.status }
}
export async function probeCover(
url: string,
fetcher: HeadFetcher = defaultHeadFetcher,
): Promise<ProbeResult> {
try {
const r = await fetcher(url)
return { reachable: r.ok, status: r.status }
} catch {
return { reachable: false, status: 0 }
}
}

View File

@ -0,0 +1,16 @@
import type { SignedEvent } from './types.ts'
export function dedupByDtag(events: SignedEvent[]): SignedEvent[] {
const byDtag = new Map<string, SignedEvent>()
// Bei gleicher created_at gewinnt das zuerst gesehene event (relay-delivery-
// reihenfolge ist nicht-deterministisch, equal-timestamp = aequivalent).
for (const ev of events) {
const d = ev.tags.find((t) => t[0] === 'd')?.[1]
if (!d) continue
const existing = byDtag.get(d)
if (!existing || ev.created_at > existing.created_at) {
byDtag.set(d, ev)
}
}
return [...byDtag.values()]
}

View File

@ -0,0 +1,27 @@
import type { SignedEvent } from './types.ts'
export function filterDeleted(
events: SignedEvent[],
deletions: SignedEvent[],
authorPubkey: string,
): SignedEvent[] {
const deletedAtByCoord = new Map<string, number>()
for (const del of deletions) {
if (del.kind !== 5) continue
if (del.pubkey !== authorPubkey) continue
for (const tag of del.tags) {
if (tag[0] !== 'a' || !tag[1]) continue
const previous = deletedAtByCoord.get(tag[1])
if (previous === undefined || del.created_at > previous) {
deletedAtByCoord.set(tag[1], del.created_at)
}
}
}
return events.filter((ev) => {
const d = ev.tags.find((t) => t[0] === 'd')?.[1]
if (!d) return true
const coord = `${ev.kind}:${ev.pubkey}:${d}`
const deletedAt = deletedAtByCoord.get(coord)
return deletedAt === undefined || ev.created_at > deletedAt
})
}

View File

@ -0,0 +1,41 @@
import { ensureDir } from '@std/fs'
import { join } from '@std/path'
import type { PostJson } from './post-json.ts'
export interface OutputInput {
generatedAt: string
authorPubkey: string
relaysQueried: string[]
relaysResponded: string[]
posts: PostJson[]
}
export async function writeOutput(outDir: string, input: OutputInput): Promise<void> {
await ensureDir(outDir)
await ensureDir(join(outDir, 'posts'))
const index = {
generated_at: input.generatedAt,
author_pubkey: input.authorPubkey,
relays_queried: input.relaysQueried,
relays_responded: input.relaysResponded,
post_count: input.posts.length,
posts: input.posts.map((p) => ({
slug: p.slug,
lang: p.lang,
created_at: p.created_at,
title: p.title,
})),
}
await Deno.writeTextFile(
join(outDir, 'index.json'),
JSON.stringify(index, null, 2) + '\n',
)
for (const post of input.posts) {
await Deno.writeTextFile(
join(outDir, 'posts', `${post.slug}.json`),
JSON.stringify(post, null, 2) + '\n',
)
}
}

View File

@ -0,0 +1,114 @@
import { nip19 } from 'nostr-tools'
import type { SignedEvent } from './types.ts'
export interface CoverImage {
url: string
width?: number
height?: number
alt?: string
mime?: string
}
export interface TranslationRef {
lang: string
slug: string
title: string
}
export interface PostJson {
slug: string
event_id: string
created_at: number
published_at: number
title: string
summary: string
lang: string
cover_image: CoverImage | null
content_markdown: string
tags: string[]
naddr: string
habla_url: string
translations: TranslationRef[]
}
const SUMMARY_MAX = 200
function tagValue(ev: SignedEvent, name: string): string | undefined {
return ev.tags.find((t) => t[0] === name)?.[1]
}
function tagsAll(ev: SignedEvent, name: string): string[] {
return ev.tags
.filter((t) => t[0] === name && typeof t[1] === 'string')
.map((t) => t[1] as string)
}
function deriveSummary(content: string): string {
const flat = content.replace(/\s+/g, ' ').trim()
if (flat.length <= SUMMARY_MAX) return flat
const cut = flat.slice(0, SUMMARY_MAX)
const lastSpace = cut.lastIndexOf(' ')
const trimmed = lastSpace > SUMMARY_MAX * 0.5 ? cut.slice(0, lastSpace) : cut
return trimmed + '…'
}
export function buildPostJson(
ev: SignedEvent,
titleByDtag: Map<string, string>,
): PostJson {
const slug = tagValue(ev, 'd') ?? ''
const title = tagValue(ev, 'title') ?? ''
const summaryTag = tagValue(ev, 'summary')
const summary = summaryTag && summaryTag.length > 0 ? summaryTag : deriveSummary(ev.content)
const image = tagValue(ev, 'image')
const publishedAtRaw = tagValue(ev, 'published_at')
const publishedAt = publishedAtRaw ? parseInt(publishedAtRaw, 10) : ev.created_at
const lang = ev.tags.find((t) => t[0] === 'l' && t[2] === 'ISO-639-1')?.[1] ?? 'de'
const cover_image: CoverImage | null = image
? { url: image, alt: title || undefined }
: null
const naddr = nip19.naddrEncode({
kind: ev.kind,
pubkey: ev.pubkey,
identifier: slug,
})
// TODO multi-lang: aktuell ableitung "andere sprache = en wenn lang=de, sonst de"
// funktioniert nur fuer den 2-sprachen-fall. Bei 3+ sprachen muss die lang aus dem
// referenzierten event ausgelesen werden — dafuer braucht buildPostJson zugriff
// auf den event-pool, nicht nur auf titleByDtag.
const translations: TranslationRef[] = []
for (const tag of ev.tags) {
if (tag[0] !== 'a') continue
if (tag[3] !== 'translation') continue
const coord = tag[1]
if (!coord) continue
const parts = coord.split(':')
if (parts.length !== 3) continue
const otherSlug = parts[2]
const otherTitle = titleByDtag.get(otherSlug) ?? otherSlug
translations.push({
lang: lang === 'de' ? 'en' : 'de',
slug: otherSlug,
title: otherTitle,
})
}
return {
slug,
event_id: ev.id,
created_at: ev.created_at,
published_at: publishedAt,
title,
summary,
lang,
cover_image,
content_markdown: ev.content,
tags: tagsAll(ev, 't'),
naddr,
habla_url: `https://habla.news/a/${naddr}`,
translations,
}
}

104
snapshot/src/core/relays.ts Normal file
View File

@ -0,0 +1,104 @@
import { Relay } from 'applesauce-relay'
import { firstValueFrom, timeout } from 'rxjs'
import type { SignedEvent } from './types.ts'
export type RelayListLoader = (
bootstrapRelay: string,
authorPubkey: string,
) => Promise<SignedEvent | undefined>
export const FALLBACK_READ_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.tchncs.de',
'wss://relay.edufeed.org',
]
export function extractReadRelays(kind10002: SignedEvent): string[] {
const out: string[] = []
for (const tag of kind10002.tags) {
if (tag[0] !== 'r' || !tag[1]) continue
const marker = tag[2]
if (marker === 'write') continue
out.push(tag[1])
}
return out
}
export const defaultRelayListLoader: RelayListLoader = async (bootstrap, pubkey) => {
try {
const relay = new Relay(bootstrap)
const ev = await firstValueFrom(
relay.request({ kinds: [10002], authors: [pubkey], limit: 1 })
.pipe(timeout({ first: 5_000 })),
)
return ev as SignedEvent
} catch {
return undefined
}
}
export async function loadReadRelays(
bootstrapRelay: string,
authorPubkey: string,
loader: RelayListLoader = defaultRelayListLoader,
fallback: string[] = FALLBACK_READ_RELAYS,
): Promise<string[]> {
const ev = await loader(bootstrapRelay, authorPubkey)
if (!ev) return fallback
const list = extractReadRelays(ev)
return list.length > 0 ? list : fallback
}
export interface FetchEventsResult {
events: SignedEvent[]
responded: string[]
queried: string[]
}
export type EventFetcher = (relay: string, pubkey: string) => Promise<SignedEvent[]>
export const defaultEventFetcher: EventFetcher = async (relay, pubkey) => {
const out: SignedEvent[] = []
const r = new Relay(relay)
return await new Promise<SignedEvent[]>((resolve) => {
const sub = r.request({ kinds: [30023, 5], authors: [pubkey] })
.pipe(timeout({ first: 10_000 }))
.subscribe({
next: (ev) => out.push(ev as SignedEvent),
error: () => resolve(out),
complete: () => resolve(out),
})
// Belt-and-suspenders: falls subscribe-callback weder error noch
// complete feuert (z.B. timeout-operator wird intern verschluckt),
// schliessen wir nach timeout+1s manuell. Resolve() kommt dann nicht
// mehr durch (Promise schon settled), aber der Relay-Handle wird
// entsorgt — kein leak.
setTimeout(() => sub.unsubscribe(), 11_000)
})
}
export async function fetchEvents(
relays: string[],
authorPubkey: string,
fetcher: EventFetcher = defaultEventFetcher,
): Promise<FetchEventsResult> {
const results = await Promise.all(
relays.map(async (url) => {
try {
const events = await fetcher(url, authorPubkey)
return { url, ok: true as const, events }
} catch {
return { url, ok: false as const, events: [] as SignedEvent[] }
}
}),
)
const events: SignedEvent[] = []
for (const r of results) events.push(...r.events)
return {
events,
responded: results.filter((r) => r.ok).map((r) => r.url),
queried: relays,
}
}

View File

@ -0,0 +1,9 @@
export interface SignedEvent {
id: string
pubkey: string
created_at: number
kind: number
tags: string[][]
content: string
sig: string
}

View File

@ -0,0 +1,34 @@
import { assertEquals } from '@std/assert'
import { join } from '@std/path'
import { readCache, writeCache, type CacheState } from '../src/core/cache.ts'
Deno.test('readCache: file fehlt -> undefined', async () => {
const dir = await Deno.makeTempDir()
const path = join(dir, 'cache.json')
const cache = await readCache(path)
assertEquals(cache, undefined)
})
Deno.test('writeCache + readCache: round-trip', async () => {
const dir = await Deno.makeTempDir()
const path = join(dir, 'cache.json')
const state: CacheState = { lastKnownGoodCount: 27, deletedCoords: ['30023:P:dead'] }
await writeCache(path, state)
const out = await readCache(path)
assertEquals(out, state)
})
Deno.test('readCache wirft bei korruptem cache-file', async () => {
const dir = await Deno.makeTempDir()
const path = join(dir, 'cache.json')
await Deno.writeTextFile(path, '{"unsinn": 42}')
let threw = false
try {
await readCache(path)
} catch (err) {
threw = true
if (!(err instanceof Error)) throw err
if (!err.message.includes('Cache-File')) throw err
}
if (!threw) throw new Error('readCache haette werfen sollen')
})

View File

@ -0,0 +1,59 @@
import { assertEquals, assertThrows } from '@std/assert'
import { runChecks } from '../src/core/checks.ts'
Deno.test('runChecks: weniger als 60% relays geantwortet -> hard-fail', () => {
assertThrows(
() => runChecks({
relaysQueried: 5, relaysResponded: 2,
eventCount: 27, minEvents: 1, lastKnownGoodCount: undefined,
newDeletionsCount: 0, allowShrink: false,
}),
Error, 'Relay-Quorum',
)
})
Deno.test('runChecks: event-count unter min-events -> hard-fail', () => {
assertThrows(
() => runChecks({
relaysQueried: 5, relaysResponded: 5,
eventCount: 0, minEvents: 1, lastKnownGoodCount: undefined,
newDeletionsCount: 0, allowShrink: false,
}),
Error, 'min-events',
)
})
Deno.test('runChecks: drop > 20% ohne kind:5 -> hard-fail', () => {
assertThrows(
() => runChecks({
relaysQueried: 5, relaysResponded: 5,
eventCount: 20, minEvents: 1, lastKnownGoodCount: 27,
newDeletionsCount: 0, allowShrink: false,
}),
Error, 'Event-Count-Drop',
)
})
Deno.test('runChecks: drop > 20% mit korrespondierenden kind:5 -> ok', () => {
runChecks({
relaysQueried: 5, relaysResponded: 5,
eventCount: 20, minEvents: 1, lastKnownGoodCount: 27,
newDeletionsCount: 7, allowShrink: false,
})
})
Deno.test('runChecks: --allow-shrink umgeht drop-check', () => {
runChecks({
relaysQueried: 5, relaysResponded: 5,
eventCount: 1, minEvents: 1, lastKnownGoodCount: 27,
newDeletionsCount: 0, allowShrink: true,
})
})
Deno.test('runChecks: erstlauf ohne cache + min-events=1 -> ok', () => {
runChecks({
relaysQueried: 5, relaysResponded: 5,
eventCount: 1, minEvents: 1, lastKnownGoodCount: undefined,
newDeletionsCount: 0, allowShrink: false,
})
})

View File

@ -0,0 +1,28 @@
import { assertEquals, assertThrows } from '@std/assert'
import { loadConfig } from '../src/core/config.ts'
Deno.test('loadConfig liest pubkey + bootstrap relay', () => {
Deno.env.set('AUTHOR_PUBKEY_HEX', '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41')
Deno.env.set('BOOTSTRAP_RELAY', 'wss://relay.primal.net')
const cfg = loadConfig()
assertEquals(cfg.authorPubkeyHex, '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41')
assertEquals(cfg.bootstrapRelay, 'wss://relay.primal.net')
})
Deno.test('loadConfig wirft bei fehlendem AUTHOR_PUBKEY_HEX', () => {
Deno.env.delete('AUTHOR_PUBKEY_HEX')
Deno.env.set('BOOTSTRAP_RELAY', 'wss://relay.primal.net')
assertThrows(() => loadConfig(), Error, 'AUTHOR_PUBKEY_HEX')
})
Deno.test('loadConfig wirft bei ungueltigem hex', () => {
Deno.env.set('AUTHOR_PUBKEY_HEX', 'nicht-hex')
Deno.env.set('BOOTSTRAP_RELAY', 'wss://relay.primal.net')
assertThrows(() => loadConfig(), Error, '64 hex')
})
Deno.test('loadConfig wirft bei ungueltigem BOOTSTRAP_RELAY (kein wss://)', () => {
Deno.env.set('AUTHOR_PUBKEY_HEX', '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41')
Deno.env.set('BOOTSTRAP_RELAY', 'http://relay.example.com')
assertThrows(() => loadConfig(), Error, 'wss://')
})

View File

@ -0,0 +1,22 @@
import { assertEquals } from '@std/assert'
import { probeCover, type HeadFetcher } from '../src/core/cover-probe.ts'
Deno.test('probeCover: 200 -> reachable=true', async () => {
const fetcher: HeadFetcher = async () => ({ ok: true, status: 200 })
const r = await probeCover('https://blossom.example/abc.jpg', fetcher)
assertEquals(r, { reachable: true, status: 200 })
})
Deno.test('probeCover: 404 -> reachable=false', async () => {
const fetcher: HeadFetcher = async () => ({ ok: false, status: 404 })
const r = await probeCover('https://blossom.example/abc.jpg', fetcher)
assertEquals(r, { reachable: false, status: 404 })
})
Deno.test('probeCover: network error -> reachable=false', async () => {
const fetcher: HeadFetcher = async () => {
throw new Error('ECONNREFUSED')
}
const r = await probeCover('https://blossom.example/abc.jpg', fetcher)
assertEquals(r, { reachable: false, status: 0 })
})

View File

@ -0,0 +1,29 @@
import { assertEquals } from '@std/assert'
import { dedupByDtag } from '../src/core/dedup.ts'
import type { SignedEvent } from '../src/core/types.ts'
function ev(d: string, created_at: number, id: string): SignedEvent {
return {
id, pubkey: 'p', created_at, kind: 30023, sig: 's', content: '',
tags: [['d', d]],
}
}
Deno.test('dedupByDtag behaelt das neueste event pro d-tag', () => {
const out = dedupByDtag([
ev('a', 100, 'a-old'),
ev('a', 200, 'a-new'),
ev('b', 50, 'b-only'),
])
const ids = out.map((e) => e.id).sort()
assertEquals(ids, ['a-new', 'b-only'])
})
Deno.test('dedupByDtag laesst events ohne d-tag weg', () => {
const out = dedupByDtag([
{ id: 'x', pubkey: 'p', created_at: 1, kind: 30023, sig: 's', content: '', tags: [] },
ev('a', 1, 'a'),
])
assertEquals(out.length, 1)
assertEquals(out[0].id, 'a')
})

View File

@ -0,0 +1,57 @@
import { assertEquals } from '@std/assert'
import { filterDeleted } from '../src/core/nip09-filter.ts'
import type { SignedEvent } from '../src/core/types.ts'
function post(d: string, id: string): SignedEvent {
return { id, pubkey: 'P', created_at: 1, kind: 30023, sig: 's', content: '', tags: [['d', d]] }
}
function deletion(coords: string[]): SignedEvent {
return {
id: 'del', pubkey: 'P', created_at: 2, kind: 5, sig: 's', content: '',
tags: coords.map((c) => ['a', c]),
}
}
Deno.test('filterDeleted entfernt events deren coord in einem kind:5 referenziert ist', () => {
const out = filterDeleted(
[post('alive', 'a'), post('dead', 'b')],
[deletion(['30023:P:dead'])],
'P',
)
assertEquals(out.map((e) => e.id), ['a'])
})
Deno.test('filterDeleted ignoriert kind:5 fremder pubkeys', () => {
const fremde: SignedEvent = {
...deletion(['30023:P:alive']), pubkey: 'OTHER',
}
const out = filterDeleted([post('alive', 'a')], [fremde], 'P')
assertEquals(out.length, 1)
})
Deno.test('filterDeleted: re-publizierter post (post.created_at > deletion.created_at) bleibt erhalten', () => {
const oldDelete: SignedEvent = {
id: 'del', pubkey: 'P', created_at: 100, kind: 5, sig: 's', content: '',
tags: [['a', '30023:P:resurrected']],
}
const newPost: SignedEvent = {
id: 'new', pubkey: 'P', created_at: 200, kind: 30023, sig: 's', content: '',
tags: [['d', 'resurrected']],
}
const out = filterDeleted([newPost], [oldDelete], 'P')
assertEquals(out.length, 1)
assertEquals(out[0].id, 'new')
})
Deno.test('filterDeleted: post mit created_at <= deletion.created_at wird entfernt', () => {
const newDelete: SignedEvent = {
id: 'del', pubkey: 'P', created_at: 200, kind: 5, sig: 's', content: '',
tags: [['a', '30023:P:dead']],
}
const oldPost: SignedEvent = {
id: 'old', pubkey: 'P', created_at: 100, kind: 30023, sig: 's', content: '',
tags: [['d', 'dead']],
}
const out = filterDeleted([oldPost], [newDelete], 'P')
assertEquals(out.length, 0)
})

View File

@ -0,0 +1,36 @@
import { assertEquals } from '@std/assert'
import { join } from '@std/path'
import { writeOutput } from '../src/core/output.ts'
import type { PostJson } from '../src/core/post-json.ts'
const samplePost: PostJson = {
slug: 'a', event_id: 'e1', created_at: 1, published_at: 1,
title: 'A', summary: 's', lang: 'de', cover_image: null,
content_markdown: '# A', tags: [], naddr: 'naddr1', habla_url: 'https://habla.news/a/naddr1',
translations: [],
}
Deno.test('writeOutput schreibt index.json + posts/<slug>.json', async () => {
const dir = await Deno.makeTempDir()
await writeOutput(dir, {
generatedAt: '2026-04-28T10:00:00Z',
authorPubkey: 'P',
relaysQueried: ['wss://r1', 'wss://r2'],
relaysResponded: ['wss://r1'],
posts: [samplePost],
})
const indexText = await Deno.readTextFile(join(dir, 'index.json'))
const index = JSON.parse(indexText)
assertEquals(index.author_pubkey, 'P')
assertEquals(index.post_count, 1)
assertEquals(index.posts.length, 1)
assertEquals(index.posts[0].slug, 'a')
assertEquals(index.posts[0].title, 'A')
assertEquals(index.posts[0].lang, 'de')
const postText = await Deno.readTextFile(join(dir, 'posts', 'a.json'))
const post = JSON.parse(postText)
assertEquals(post.slug, 'a')
assertEquals(post.content_markdown, '# A')
})

View File

@ -0,0 +1,99 @@
import { assertEquals } from '@std/assert'
import { buildPostJson } from '../src/core/post-json.ts'
import type { SignedEvent } from '../src/core/types.ts'
const PUBKEY = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41'
function buildEvent(opts: {
d: string
title: string
summary?: string
image?: string
publishedAt?: number
lang?: string
tags?: string[]
translationCoords?: string[]
content: string
}): SignedEvent {
const tags: string[][] = [['d', opts.d], ['title', opts.title]]
if (opts.summary) tags.push(['summary', opts.summary])
if (opts.image) tags.push(['image', opts.image])
if (opts.publishedAt) tags.push(['published_at', String(opts.publishedAt)])
if (opts.lang) {
tags.push(['L', 'ISO-639-1'])
tags.push(['l', opts.lang, 'ISO-639-1'])
}
for (const t of opts.tags ?? []) tags.push(['t', t])
for (const c of opts.translationCoords ?? []) tags.push(['a', c, '', 'translation'])
return {
id: 'event-' + opts.d, pubkey: PUBKEY, created_at: 1700000000, kind: 30023,
sig: 'sig', content: opts.content, tags,
}
}
Deno.test('buildPostJson: vollstaendiges event', () => {
const ev = buildEvent({
d: 'bibel-selfies', title: 'Bibel-Selfies', summary: 'Kurz',
image: 'https://blossom.edufeed.org/abc.jpg',
publishedAt: 1699000000, lang: 'de', tags: ['Bibel'],
translationCoords: [`30023:${PUBKEY}:bible-selfies`],
content: '# body',
})
const titleByDtag = new Map([['bible-selfies', 'Bible-Selfies']])
const json = buildPostJson(ev, titleByDtag)
assertEquals(json.slug, 'bibel-selfies')
assertEquals(json.title, 'Bibel-Selfies')
assertEquals(json.summary, 'Kurz')
assertEquals(json.lang, 'de')
assertEquals(json.tags, ['Bibel'])
assertEquals(json.published_at, 1699000000)
assertEquals(json.cover_image?.url, 'https://blossom.edufeed.org/abc.jpg')
assertEquals(json.translations, [
{ lang: 'en', slug: 'bible-selfies', title: 'Bible-Selfies' },
])
assertEquals(json.content_markdown, '# body')
})
Deno.test('buildPostJson: fallback summary aus content', () => {
const ev = buildEvent({
d: 'no-summary', title: 'X', content: 'Lorem ipsum dolor sit amet.'.repeat(20),
})
const json = buildPostJson(ev, new Map())
if (!json.summary) throw new Error('summary fehlt')
if (json.summary.length > 220) throw new Error('summary zu lang')
if (!json.summary.endsWith('…')) throw new Error('summary ohne ellipsis')
})
Deno.test('buildPostJson: fehlt published_at -> created_at', () => {
const ev = buildEvent({ d: 'no-pub', title: 'X', content: 'x' })
const json = buildPostJson(ev, new Map())
assertEquals(json.published_at, 1700000000)
})
Deno.test('buildPostJson: fehlt image -> cover_image null', () => {
const ev = buildEvent({ d: 'no-img', title: 'X', content: 'x' })
const json = buildPostJson(ev, new Map())
assertEquals(json.cover_image, null)
})
Deno.test('buildPostJson: lang default de wenn keine l-tags', () => {
const ev = buildEvent({ d: 'no-lang', title: 'X', content: 'x' })
const json = buildPostJson(ev, new Map())
assertEquals(json.lang, 'de')
})
Deno.test('buildPostJson: malformed t-tag ohne value wird ignoriert', () => {
const ev: SignedEvent = {
id: 'event-malformed', pubkey: PUBKEY, created_at: 1700000000, kind: 30023,
sig: 'sig', content: 'x',
tags: [
['d', 'malformed'],
['title', 'X'],
['t', 'gut'],
['t'], // malformed: kein value
['t', 'auch-gut'],
],
}
const json = buildPostJson(ev, new Map())
assertEquals(json.tags, ['gut', 'auch-gut'])
})

View File

@ -0,0 +1,33 @@
import { assertEquals } from '@std/assert'
import { extractReadRelays, type RelayListLoader, loadReadRelays } from '../src/core/relays.ts'
import type { SignedEvent } from '../src/core/types.ts'
const KIND_10002: SignedEvent = {
id: 'r', pubkey: 'P', created_at: 1, kind: 10002, sig: 's', content: '',
tags: [
['r', 'wss://relay.damus.io'],
['r', 'wss://nos.lol', 'read'],
['r', 'wss://relay.write-only.example', 'write'],
],
}
Deno.test('extractReadRelays: ohne marker = read+write, "read" = read, "write" = nicht', () => {
assertEquals(extractReadRelays(KIND_10002), [
'wss://relay.damus.io',
'wss://nos.lol',
])
})
Deno.test('loadReadRelays: nutzt fallback wenn kein kind:10002', async () => {
const loader: RelayListLoader = async () => undefined
const relays = await loadReadRelays('wss://bootstrap', 'P', loader, [
'wss://fallback1', 'wss://fallback2',
])
assertEquals(relays, ['wss://fallback1', 'wss://fallback2'])
})
Deno.test('loadReadRelays: nutzt kind:10002 wenn vorhanden', async () => {
const loader: RelayListLoader = async () => KIND_10002
const relays = await loadReadRelays('wss://bootstrap', 'P', loader, ['wss://fallback'])
assertEquals(relays, ['wss://relay.damus.io', 'wss://nos.lol'])
})