From 7428930a76a376b28ac38dd7dbd6d8a2ddc83dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 07:54:18 +0200 Subject: [PATCH] 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) --- .../plans/2026-04-28-prerender-snapshot.md | 2211 +++++++++++++++++ 1 file changed, 2211 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-28-prerender-snapshot.md diff --git a/docs/superpowers/plans/2026-04-28-prerender-snapshot.md b/docs/superpowers/plans/2026-04-28-prerender-snapshot.md new file mode 100644 index 0000000..8c2268e --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-prerender-snapshot.md @@ -0,0 +1,2211 @@ +# Prerender-Snapshot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Post-Detailseiten unter `https://joerg-lohrer.de//` werden zur Build-Zeit zu statischem HTML mit OG-/Twitter-/JSON-LD-Tags prerendered, auf Basis eines Deno-Snapshot-Tools, das die Post-Daten aus den Relays in JSON-Artefakte schreibt. + +**Architecture:** Drei entkoppelte Stufen — `publish` bleibt unverändert; ein neues `snapshot/`-Modul (Deno) liest Events von den Relays und schreibt JSON; SvelteKit prerendert die Detail-Routen aus diesen JSON-Dateien. Frische Nostr-first-Posts fallen weiter über `adapter-static`-`fallback: 'index.html'` auf Runtime-Hydration. + +**Tech Stack:** Deno (`@std/path`, `@std/yaml`, `nostr-tools`, `applesauce-relay`, `rxjs`), SvelteKit 2 mit `adapter-static`, `marked` + `isomorphic-dompurify` + `highlight.js`, Vitest (jsdom + node), bash + `lftp` für Deploy. + +**Spec:** [`docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md`](../specs/2026-04-21-prerender-snapshot-design.md). + +**Migrations-Strategie:** Sechs entkoppelte Etappen, jede einzeln getestet, einzeln committed, einzeln rollback-bar. Reihenfolge ist Pflicht — frühere Etappen sind Vorbedingung für spätere. + +--- + +## Etappe 1 — `renderMarkdown` Node-kompatibel + +Heute wirft `renderMarkdown` hart, wenn `window === undefined`. Der SvelteKit-Build läuft in Node — die Funktion muss dort funktionieren, ohne dass die Browser-Variante kaputtgeht. + +### Task 1.1: Failing Node-Test für renderMarkdown + +**Files:** +- Test: `app/src/lib/render/markdown.node.test.ts` (neu) + +- [ ] **Step 1.1.1: Test schreiben** + +```ts +// 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('Kursiv'); + expect(html).toContain('href="https://example.com"'); + }); + + it('sanitisiert XSS-Versuche', () => { + const html = renderMarkdown('\n\nText'); + expect(html).not.toContain(' { + const html = renderMarkdown('```ts\nconst x: number = 1;\n```'); + expect(html).toContain('class="hljs'); + expect(html).toContain('language-ts'); + }); +}); +``` + +- [ ] **Step 1.1.2: Test laufen lassen → muss fehlschlagen** + +Run: `cd app && npx vitest run src/lib/render/markdown.node.test.ts` +Expected: FAIL — entweder mit `renderMarkdown: DOM-Kontext erforderlich` oder mit ReferenceError zu `window`/`document`. + +- [ ] **Step 1.1.3: Commit Test-Datei** + +```bash +git add app/src/lib/render/markdown.node.test.ts +git commit -m "test: failing node-test fuer renderMarkdown + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 1.2: isomorphic-dompurify einführen + +**Files:** +- Modify: `app/package.json` +- Modify: `app/src/lib/render/markdown.ts` + +- [ ] **Step 1.2.1: Dependency installieren** + +```bash +cd app && npm install isomorphic-dompurify +``` + +Erwartung: `isomorphic-dompurify` landet unter `dependencies` in `package.json`. + +- [ ] **Step 1.2.2: `markdown.ts` umstellen** + +Komplette neue Fassung von `app/src/lib/render/markdown.ts`: + +```ts +import { Marked } from 'marked'; +import DOMPurify from 'isomorphic-dompurify'; +import hljs from 'highlight.js/lib/core'; +import javascript from 'highlight.js/lib/languages/javascript'; +import bash from 'highlight.js/lib/languages/bash'; +import typescript from 'highlight.js/lib/languages/typescript'; +import json from 'highlight.js/lib/languages/json'; + +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('js', javascript); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('ts', typescript); +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('sh', bash); +hljs.registerLanguage('json', json); + +const markedInstance = new Marked({ + breaks: true, + gfm: true, + renderer: { + code({ text, lang }) { + const language = lang && hljs.getLanguage(lang) ? lang : undefined; + const highlighted = language + ? hljs.highlight(text, { language }).value + : hljs.highlightAuto(text).value; + const cls = language ? ` language-${language}` : ''; + return `
${highlighted}
`; + } + } +}); + +export function renderMarkdown(md: string): string { + const raw = markedInstance.parse(md, { async: false }) as string; + return DOMPurify.sanitize(raw); +} +``` + +- [ ] **Step 1.2.3: Node-Test laufen lassen → muss passen** + +Run: `cd app && npx vitest run src/lib/render/markdown.node.test.ts` +Expected: PASS — alle drei Test-Cases grün. + +- [ ] **Step 1.2.4: Bestehende Tests laufen lassen → keine Regression** + +Run: `cd app && npm run test:unit` +Expected: alle Tests grün, inklusive der Browser/jsdom-Cases (die wegen `isomorphic-dompurify` automatisch im Browser auf das DOMPurify-Browser-Backend zurückfallen). + +- [ ] **Step 1.2.5: TypeScript-Check** + +Run: `cd app && npm run check` +Expected: 0 errors, 0 warnings. + +- [ ] **Step 1.2.6: Commit** + +```bash +git add app/package.json app/package-lock.json app/src/lib/render/markdown.ts +git commit -m "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) " +``` + +--- + +## Etappe 2 — Snapshot-Modul (Deno) + +Neues Verzeichnis `snapshot/` als Geschwister zu `publish/`. Liest Events von Relays, schreibt JSON. Keine Änderung an SPA in dieser Etappe. + +### Task 2.1: Modul-Skelett mit deno.jsonc + +**Files:** +- Create: `snapshot/deno.jsonc` +- Create: `snapshot/.gitignore` +- Create: `snapshot/README.md` + +- [ ] **Step 2.1.1: deno.jsonc anlegen** + +```jsonc +{ + "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"] + } + } +} +``` + +- [ ] **Step 2.1.2: .gitignore anlegen** + +``` +output/ +.last-snapshot.json +``` + +- [ ] **Step 2.1.3: README.md anlegen** + +```markdown +# 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) +``` + +- [ ] **Step 2.1.4: Commit Skelett** + +```bash +git add snapshot/deno.jsonc snapshot/.gitignore snapshot/README.md +git commit -m "feat(snapshot): modul-skelett + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.2: Config-Modul mit Tests + +**Files:** +- Create: `snapshot/src/core/config.ts` +- Test: `snapshot/tests/config.test.ts` + +- [ ] **Step 2.2.1: Failing Test** + +```ts +// snapshot/tests/config.test.ts +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') +}) +``` + +- [ ] **Step 2.2.2: Test laufen lassen → muss fehlschlagen** + +Run: `cd snapshot && deno test tests/config.test.ts` +Expected: FAIL — Modul existiert noch nicht. + +- [ ] **Step 2.2.3: Implementation** + +```ts +// snapshot/src/core/config.ts +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') + return { authorPubkeyHex, bootstrapRelay } +} +``` + +- [ ] **Step 2.2.4: Test → muss passen** + +Run: `cd snapshot && deno test tests/config.test.ts` +Expected: PASS, 3 Tests grün. + +- [ ] **Step 2.2.5: Commit** + +```bash +git add snapshot/src/core/config.ts snapshot/tests/config.test.ts +git commit -m "feat(snapshot): config-loader mit env-validierung + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.3: Dedup-by-d-tag mit Test + +**Files:** +- Create: `snapshot/src/core/dedup.ts` +- Test: `snapshot/tests/dedup.test.ts` + +- [ ] **Step 2.3.1: Failing Test** + +```ts +// snapshot/tests/dedup.test.ts +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') +}) +``` + +- [ ] **Step 2.3.2: Types-Modul anlegen** + +```ts +// snapshot/src/core/types.ts +export interface SignedEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig: string +} +``` + +- [ ] **Step 2.3.3: Implementation** + +```ts +// snapshot/src/core/dedup.ts +import type { SignedEvent } from './types.ts' + +export function dedupByDtag(events: SignedEvent[]): SignedEvent[] { + const byDtag = new Map() + 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()] +} +``` + +- [ ] **Step 2.3.4: Tests → grün** + +Run: `cd snapshot && deno test tests/dedup.test.ts` +Expected: PASS, 2 Tests grün. + +- [ ] **Step 2.3.5: Commit** + +```bash +git add snapshot/src/core/types.ts snapshot/src/core/dedup.ts snapshot/tests/dedup.test.ts +git commit -m "feat(snapshot): dedup-by-d-tag + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.4: NIP-09-Filter mit Test + +**Files:** +- Create: `snapshot/src/core/nip09-filter.ts` +- Test: `snapshot/tests/nip09-filter.test.ts` + +- [ ] **Step 2.4.1: Failing Test** + +```ts +// snapshot/tests/nip09-filter.test.ts +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) +}) +``` + +- [ ] **Step 2.4.2: Implementation** + +```ts +// snapshot/src/core/nip09-filter.ts +import type { SignedEvent } from './types.ts' + +export function filterDeleted( + events: SignedEvent[], + deletions: SignedEvent[], + authorPubkey: string, +): SignedEvent[] { + const deletedCoords = new Set() + 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]) deletedCoords.add(tag[1]) + } + } + 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}` + return !deletedCoords.has(coord) + }) +} +``` + +- [ ] **Step 2.4.3: Tests → grün** + +Run: `cd snapshot && deno test tests/nip09-filter.test.ts` +Expected: PASS, 2 Tests grün. + +- [ ] **Step 2.4.4: Commit** + +```bash +git add snapshot/src/core/nip09-filter.ts snapshot/tests/nip09-filter.test.ts +git commit -m "feat(snapshot): NIP-09-filter + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.5: Plausibilitäts-Checks mit Test + +**Files:** +- Create: `snapshot/src/core/checks.ts` +- Test: `snapshot/tests/checks.test.ts` + +- [ ] **Step 2.5.1: Failing Test** + +```ts +// snapshot/tests/checks.test.ts +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, + }) +}) +``` + +- [ ] **Step 2.5.2: Implementation** + +```ts +// snapshot/src/core/checks.ts +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}`, + ) + } + 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.`, + ) + } + } +} +``` + +- [ ] **Step 2.5.3: Tests → grün** + +Run: `cd snapshot && deno test tests/checks.test.ts` +Expected: PASS, 6 Tests grün. + +- [ ] **Step 2.5.4: Commit** + +```bash +git add snapshot/src/core/checks.ts snapshot/tests/checks.test.ts +git commit -m "feat(snapshot): plausibilitaets-checks (relay-quorum, drop, min-events) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.6: JSON-Builder pro Post mit Test + +**Files:** +- Create: `snapshot/src/core/post-json.ts` +- Test: `snapshot/tests/post-json.test.ts` + +- [ ] **Step 2.6.1: Failing Test** + +```ts +// snapshot/tests/post-json.test.ts +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') +}) +``` + +- [ ] **Step 2.6.2: Implementation** + +```ts +// snapshot/src/core/post-json.ts +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).map((t) => t[1]) +} + +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, +): 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, + }) + + 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, + } +} +``` + +- [ ] **Step 2.6.3: Tests → grün** + +Run: `cd snapshot && deno test tests/post-json.test.ts` +Expected: PASS, 5 Tests grün. + +- [ ] **Step 2.6.4: Commit** + +```bash +git add snapshot/src/core/post-json.ts snapshot/tests/post-json.test.ts +git commit -m "feat(snapshot): post-json-builder mit fallback-summary + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.6.b: Cover-Image-HEAD-Probe + +**Files:** +- Create: `snapshot/src/core/cover-probe.ts` +- Test: `snapshot/tests/cover-probe.test.ts` + +Spec Algorithmus-Schritt 6: HEAD-Request auf den Cover-URL-Kandidaten; bei 200 als `url` schreiben, bei Fehler Warnung loggen + URL trotzdem schreiben (Blossom ist content-addressed, URL kommt zurück). Wir kapseln das als reine Funktion mit injizierbarem Fetch-Stub. + +- [ ] **Step 2.6.b.1: Failing Test** + +```ts +// snapshot/tests/cover-probe.test.ts +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 }) +}) +``` + +- [ ] **Step 2.6.b.2: Implementation** + +```ts +// snapshot/src/core/cover-probe.ts +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 { + try { + const r = await fetcher(url) + return { reachable: r.ok, status: r.status } + } catch { + return { reachable: false, status: 0 } + } +} +``` + +- [ ] **Step 2.6.b.3: Tests → grün** + +Run: `cd snapshot && deno test tests/cover-probe.test.ts` +Expected: PASS, 3 Tests grün. + +- [ ] **Step 2.6.b.4: CLI um Probe-Aufruf erweitern** + +In `snapshot/src/cli.ts` nach dem `postJsons`-Build pro Post mit `cover_image`: + +```ts +import { probeCover } from './core/cover-probe.ts' + +// ... innerhalb main(), nach `const postJsons = filtered.map(...)`: +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`, + ) + } +} +``` + +- [ ] **Step 2.6.b.5: Commit** + +```bash +git add snapshot/src/core/cover-probe.ts snapshot/tests/cover-probe.test.ts snapshot/src/cli.ts +git commit -m "feat(snapshot): cover-image-HEAD-probe mit warnung bei unreachable + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.7: Cache-/Last-known-good-Modul + +**Files:** +- Create: `snapshot/src/core/cache.ts` +- Test: `snapshot/tests/cache.test.ts` + +- [ ] **Step 2.7.1: Failing Test** + +```ts +// snapshot/tests/cache.test.ts +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) +}) +``` + +- [ ] **Step 2.7.2: Implementation** + +```ts +// snapshot/src/core/cache.ts +export interface CacheState { + lastKnownGoodCount: number + deletedCoords: string[] +} + +export async function readCache(path: string): Promise { + try { + const text = await Deno.readTextFile(path) + return JSON.parse(text) as CacheState + } catch (err) { + if (err instanceof Deno.errors.NotFound) return undefined + throw err + } +} + +export async function writeCache(path: string, state: CacheState): Promise { + await Deno.writeTextFile(path, JSON.stringify(state, null, 2) + '\n') +} +``` + +- [ ] **Step 2.7.3: Tests → grün** + +Run: `cd snapshot && deno test tests/cache.test.ts` +Expected: PASS, 2 Tests grün. + +- [ ] **Step 2.7.4: Commit** + +```bash +git add snapshot/src/core/cache.ts snapshot/tests/cache.test.ts +git commit -m "feat(snapshot): cache-state fuer last-known-good + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.8: Output-Writer (index.json + posts/.json) + +**Files:** +- Create: `snapshot/src/core/output.ts` +- Test: `snapshot/tests/output.test.ts` + +- [ ] **Step 2.8.1: Failing Test** + +```ts +// snapshot/tests/output.test.ts +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/.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') +}) +``` + +- [ ] **Step 2.8.2: Implementation** + +```ts +// snapshot/src/core/output.ts +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 { + 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', + ) + } +} +``` + +- [ ] **Step 2.8.3: Tests → grün** + +Run: `cd snapshot && deno test tests/output.test.ts` +Expected: PASS. + +- [ ] **Step 2.8.4: Commit** + +```bash +git add snapshot/src/core/output.ts snapshot/tests/output.test.ts +git commit -m "feat(snapshot): output-writer (index.json + posts/.json) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.9: Relay-Loader (Bootstrap + Event-Fetch) + +**Files:** +- Create: `snapshot/src/core/relays.ts` +- Test: `snapshot/tests/relays.test.ts` + +Diese Schicht hat einen echten externen Bestandteil (Relay-Verbindung). Wir testen nur die Logik, die den Pool orchestriert — die Pool-Calls werden via injizierter Funktion gestubbt. + +- [ ] **Step 2.9.1: Failing Test** + +```ts +// snapshot/tests/relays.test.ts +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']) +}) +``` + +- [ ] **Step 2.9.2: Implementation** + +```ts +// snapshot/src/core/relays.ts +import { Relay } from 'applesauce-relay' +import { firstValueFrom, timeout } from 'rxjs' +import type { SignedEvent } from './types.ts' + +export type RelayListLoader = ( + bootstrapRelay: string, + authorPubkey: string, +) => Promise + +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 { + 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 + +export const defaultEventFetcher: EventFetcher = async (relay, pubkey) => { + const out: SignedEvent[] = [] + const r = new Relay(relay) + return await new Promise((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), + }) + setTimeout(() => sub.unsubscribe(), 11_000) + }) +} + +export async function fetchEvents( + relays: string[], + authorPubkey: string, + fetcher: EventFetcher = defaultEventFetcher, +): Promise { + 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, + } +} +``` + +- [ ] **Step 2.9.3: Tests → grün** + +Run: `cd snapshot && deno test tests/relays.test.ts` +Expected: PASS, 3 Tests grün. + +- [ ] **Step 2.9.4: Commit** + +```bash +git add snapshot/src/core/relays.ts snapshot/tests/relays.test.ts +git commit -m "feat(snapshot): relay-loader (kind:10002 + event-fetch) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.10: CLI-Entrypoint + +**Files:** +- Create: `snapshot/src/cli.ts` + +CLI verdrahtet alle Module. Wir testen kein CLI-Parsing — das ist `@std/cli`-Standard. Stattdessen nutzen wir die End-to-End-Verifikation in Task 2.11. + +- [ ] **Step 2.10.1: Implementation** + +```ts +// snapshot/src/cli.ts +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 { 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 { + 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() + 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)) + + await writeOutput(outDir, { + generatedAt: new Date().toISOString(), + authorPubkey: cfg.authorPubkeyHex, + relaysQueried: fetched.queried, + relaysResponded: fetched.responded, + posts: postJsons, + }) + + const allDeletedCoords = deletions.flatMap((d) => + d.tags.filter((t) => t[0] === 'a' && t[1]).map((t) => t[1] as string) + ) + const newCache: CacheState = { + lastKnownGoodCount: filtered.length, + deletedCoords: [...new Set(allDeletedCoords)], + } + 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) + } +} +``` + +- [ ] **Step 2.10.2: Type-Check** + +Run: `cd snapshot && deno check src/cli.ts` +Expected: 0 errors. + +- [ ] **Step 2.10.3: Commit** + +```bash +git add snapshot/src/cli.ts +git commit -m "feat(snapshot): cli-entrypoint + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 2.11: End-to-End Smoke-Run gegen echte Relays + +**Files:** keine + +- [ ] **Step 2.11.1: Snapshot ausführen** + +```bash +cd snapshot && deno task snapshot +``` + +Erwartung: +- Console-Output zeigt 5/5 Relays geantwortet. +- 27 (oder mehr) Posts gefunden. +- `snapshot/output/index.json` existiert mit `post_count >= 27`. +- `snapshot/output/posts/bibel-selfies.json` existiert mit `lang: "de"` und einer `translations[]`-Liste, die `bible-selfies` enthält. +- `snapshot/output/.last-snapshot.json` existiert. + +- [ ] **Step 2.11.2: Spot-Checks** + +```bash +cd snapshot +jq '.post_count, .posts[0]' output/index.json +jq '.title, .lang, .translations' output/posts/bibel-selfies.json +jq '.title, .lang, .translations' output/posts/bible-selfies.json +``` + +Erwartung: +- Beide Sprachvarianten verweisen wechselseitig aufeinander. +- DE-Post hat `lang: "de"`, EN-Post hat `lang: "en"`. + +- [ ] **Step 2.11.3: Commit "snapshot output ist nicht im Repo"** + +`output/` ist via `.gitignore` ausgeschlossen — nichts zu committen. + +--- + +## Etappe 3 — Snapshot in CI + +### Task 3.1: Workflow-Datei erweitern + +**Files:** +- Modify: `.github/workflows/publish.yml` + +- [ ] **Step 3.1.1: Aktuellen Workflow lesen** + +```bash +cat .github/workflows/publish.yml +``` + +- [ ] **Step 3.1.2: Snapshot-Job ergänzen** + +Den existierenden Workflow um einen `snapshot`-Step erweitern, der **vor** dem SvelteKit-Build läuft. Konkrete YAML-Syntax orientiert sich am bestehenden Workflow — nach dem `publish`-Step kommt: + +```yaml + - name: Run snapshot + working-directory: snapshot + env: + AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }} + BOOTSTRAP_RELAY: wss://relay.primal.net + run: deno task snapshot +``` + +(Die Build/Deploy-Etappen folgen erst in Etappe 4 — in diesem Schritt erzeugen wir nur den Snapshot, das Output verbleibt im CI-Artefakt-Cache, beeinflusst die SPA noch nicht.) + +- [ ] **Step 3.1.3: Workflow-Lint mit actionlint (falls verfügbar)** + +```bash +which actionlint && actionlint .github/workflows/publish.yml || echo "actionlint nicht installiert — manuelle YAML-Validierung" +``` + +Wenn nicht verfügbar: YAML-Indent händisch prüfen. + +- [ ] **Step 3.1.4: Commit** + +```bash +git add .github/workflows/publish.yml +git commit -m "ci: snapshot-job vor svelte-build + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +- [ ] **Step 3.1.5: Push + Action-Run prüfen** + +```bash +git push origin main +gh run watch +``` + +Erwartung: Snapshot-Step grün, Output-Statistik im Log sichtbar. + +--- + +## Etappe 4 — Detail-Route auf Prerender umstellen + +In dieser Etappe wird die SPA umgebaut: `[...slug]/+page.ts` lädt aus Snapshot-JSON, `+page.svelte` rendert daraus, `` setzt OG-Tags, der Runtime-Fallback bleibt für Slugs außerhalb des Snapshots erhalten. + +### Task 4.1: SSR + Prerender für Detail-Route aktivieren + +**Files:** +- Create: `app/src/routes/[...slug]/+page.ts` (rewrite) + +Hinweis: `+layout.ts` hat global `ssr = false`. Pro-Route-Override durch lokale Page-Optionen. + +- [ ] **Step 4.1.1: `+page.ts` umschreiben** + +Komplette neue Fassung: + +```ts +import { error, redirect } from '@sveltejs/kit' +import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy' +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 { + 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 { + 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 }) => { + const pathname = url.pathname + + const legacyDtag = parseLegacyUrl(pathname) + if (legacyDtag) { + throw redirect(301, canonicalPostPath(legacyDtag)) + } + + const segments = pathname.replace(/^\/+|\/+$/g, '').split('/') + if (segments.length !== 1 || !segments[0]) { + throw error(404, 'Seite nicht gefunden') + } + const dtag = decodeURIComponent(segments[0]) + + if (!browser) { + const snapshot = await readPost(dtag) + if (snapshot) return { dtag, snapshot } + } + + return { dtag, snapshot: null } +} +``` + +Begründung des `browser`-Guards: Während des Builds läuft `load` in Node und liest aus `snapshot/output/`. Im Browser (Runtime-Hydration für nicht-prerenderte Slugs) gibt's kein Snapshot, dort fällt `data.snapshot` auf `null`, und `+page.svelte` lädt via Runtime-Relay-Fetch. + +- [ ] **Step 4.1.2: Type-Check** + +Run: `cd app && npm run check` +Expected: 0 errors. + +- [ ] **Step 4.1.3: Commit** + +```bash +git add app/src/routes/[\.\.\.slug]/+page.ts +git commit -m "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/.json. +runtime-fallback bleibt fuer slugs ausserhalb des snapshots. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 4.2: `+page.svelte` mit Snapshot-Rendering und OG-Tags + +**Files:** +- Modify: `app/src/routes/[...slug]/+page.svelte` + +- [ ] **Step 4.2.1: Komplette neue Fassung schreiben** + +```svelte + + +{#if snapshot} + + {snapshot.title} – Jörg Lohrer + + + + + + + + + + {#if snapshot.cover_image?.width} + + {/if} + {#if snapshot.cover_image?.height} + + {/if} + + + + + + {#each snapshot.translations as alt} + + {/each} + + {@html ``} + +{/if} + + + +{#if snapshot} +
+

{snapshot.title}

+ {#if snapshot.cover_image} +

+ {snapshot.cover_image.alt +

+ {/if} + {#if snapshot.summary} +

{snapshot.summary}

+ {/if} +
{@html bodyHtmlPrerendered}
+
+{:else} + + {#if post} + + {/if} +{/if} + + +``` + +Diese Fassung rendert auf Prerender-Pfad direkt aus dem Snapshot (inklusive `` mit OG/Twitter/JSON-LD/hreflang) und fällt für Slugs ohne Snapshot zurück auf die alte `loadPost`+`PostView`-Logik. Reactions/Replies kommen in Task 4.3. + +- [ ] **Step 4.2.2: Type-Check** + +Run: `cd app && npm run check` +Expected: 0 errors. + +- [ ] **Step 4.2.3: Commit** + +```bash +git add 'app/src/routes/[...slug]/+page.svelte' +git commit -m "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. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 4.3: Reactions, Replies, Sprach-Switcher auf Snapshot-Pfad + +**Files:** +- Modify: `app/src/routes/[...slug]/+page.svelte` + +Snapshot-Pfad braucht weiterhin Reactions, Replies, Sprach-Switcher. Diese Komponenten existieren als wiederverwendbare Bausteine (`Reactions.svelte`, `ReplyComposer.svelte`, `ReplyList.svelte`, `LanguageAvailability.svelte`, `ExternalClientLinks.svelte`) und brauchen unterschiedliche Inputs. + +- [ ] **Step 4.3.1: Snapshot-Pfad um interaktive Komponenten erweitern** + +Im snapshot-Block der `+page.svelte` (innerhalb `{#if snapshot}` `
...
`) nach `
{@html bodyHtmlPrerendered}
` einfügen — und die nötigen Imports oben ergänzen (`Reactions`, `ReplyComposer`, `ReplyList`, `ExternalClientLinks`, sowie `SignedEvent` für die optimistische Reply-Liste; `LanguageAvailability` braucht ein `NostrEvent` und passt darum nicht direkt — Snapshot-Pfad rendert den Switcher inline aus `snapshot.translations`): + +```svelte + {#if snapshot.translations.length > 0} +

+ {$t(snapshot.lang === 'de' ? 'lang_switch.also_in_en' : 'lang_switch.also_in_de')} + {#each snapshot.translations as alt} + {alt.title} + {/each} +

+ {/if} + {#if snapshot.tags.length > 0} +
+ {#each snapshot.tags as tag} + {tag} + {/each} +
+ {/if} + + + + +``` + +Imports ganz oben ergänzen: + +```svelte + import Reactions from '$lib/components/Reactions.svelte' + import ReplyList from '$lib/components/ReplyList.svelte' + import ReplyComposer from '$lib/components/ReplyComposer.svelte' + import ExternalClientLinks from '$lib/components/ExternalClientLinks.svelte' + import type { SignedEvent } from '$lib/nostr/signer' + + let optimisticReplies: NostrEvent[] = $state([]) + function handlePublished(signed: SignedEvent) { + optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent] + } +``` + +I18n-Keys, die hinzukommen: +- `lang_switch.also_in_en` (DE: „Auch auf Englisch verfügbar:") +- `lang_switch.also_in_de` (EN: „Also available in German:") + +- [ ] **Step 4.3.2: i18n-Messages ergänzen** + +Files: `app/src/lib/i18n/messages/de.json`, `app/src/lib/i18n/messages/en.json` + +DE-File: Eintrag unter `lang_switch` ergänzen: + +```json +"lang_switch": { + "also_in_en": "Auch auf Englisch verfügbar:", + "also_in_de": "Also available in German:" +} +``` + +EN-File analog (Werte gleich, Key-Struktur gleich). + +Hinweis: existierende `lang_switch`-Keys (z.B. `also_in_en`/`also_in_de` aus `LanguageAvailability`) — bevor neu anlegen, prüfen, ob die Strings unter den Namen schon existieren. In dem Fall den existierenden Key wiederverwenden, keine Duplikate. + +```bash +grep -A 5 'lang_switch' app/src/lib/i18n/messages/de.json +``` + +- [ ] **Step 4.3.3: Type-Check** + +Run: `cd app && npm run check` +Expected: 0 errors. + +- [ ] **Step 4.3.4: Build lokal testen** + +```bash +cd snapshot && deno task snapshot +cd ../app && npm run build +ls build | head -20 +ls build/bibel-selfies/index.html +grep -o 'og:title[^<]*' build/bibel-selfies/index.html +grep -o 'og:image[^<]*' build/bibel-selfies/index.html +grep -o 'application/ld+json[^<]*' build/bibel-selfies/index.html +``` + +Erwartung: +- `build//index.html` für jeden Snapshot-Slug existiert. +- OG-Tags und JSON-LD im HTML enthalten. +- Keine Build-Fehler. + +- [ ] **Step 4.3.5: Dev-Server smoke-test** + +```bash +cd app && npm run dev & +sleep 3 +curl -s http://localhost:5173/bibel-selfies/ | head -20 +kill %1 +``` + +Erwartung: HTML-Ausgabe, kein 500. + +- [ ] **Step 4.3.6: Commit** + +```bash +git add 'app/src/routes/[...slug]/+page.svelte' app/src/lib/i18n/messages/ +git commit -m "feat(spa): snapshot-pfad mit reactions/replies/langs/tags + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task 4.4: Hosting-Test auf svelte.joerg-lohrer.de + +**Files:** keine + +- [ ] **Step 4.4.1: Snapshot lokal frisch ziehen** + +```bash +cd snapshot && deno task snapshot +``` + +- [ ] **Step 4.4.2: Deploy auf Entwicklungs-Subdomain** + +```bash +DEPLOY_TARGET=svelte ./scripts/deploy-svelte.sh +``` + +- [ ] **Step 4.4.3: Live-Verifikation** + +```bash +curl -s https://svelte.joerg-lohrer.de/bibel-selfies/ | grep -o 'og:title[^<]*' +curl -s https://svelte.joerg-lohrer.de/bibel-selfies/ | grep -o 'og:image[^<]*' +curl -s https://svelte.joerg-lohrer.de/bibel-selfies/ | grep -c 'application/ld+json' +``` + +Erwartung: +- OG-Tags mit korrektem Titel. +- `og:image` zeigt auf Blossom oder Site-Default. +- Genau 1 JSON-LD-Block. + +- [ ] **Step 4.4.4: Browser-Smoke-Test** + +Manuell https://svelte.joerg-lohrer.de/bibel-selfies/ im Browser öffnen, prüfen: +- Post wird angezeigt. +- Reactions, Replies, Sprach-Switcher funktionieren (= clientseitige Hydration läuft). +- Browser-Tab-Title stimmt. +- View-Source zeigt vollständigen Post-Body als HTML (= Crawler bekommt das gleiche). + +- [ ] **Step 4.4.5: Wenn alles ok — Push für CI** + +```bash +git push origin main +gh run watch +``` + +Erwartung: Action grün, Snapshot+Build+Deploy auf prod-target durchgelaufen. + +--- + +## Etappe 5 — Runtime-Relay-Fetch in Detail-Route entfernen + +Wenn Etappe 4 stabil ist, entfernen wir den Fallback-Pfad. Detail-Seite lebt dann ausschließlich vom Snapshot. Frische Nostr-first-Posts brauchen einen neuen Snapshot+Build, um zu erscheinen. + +### Task 5.1: Fallback-Pfad ausbauen + +**Files:** +- Modify: `app/src/routes/[...slug]/+page.svelte` +- Modify: `app/src/routes/[...slug]/+page.ts` + +- [ ] **Step 5.1.1: `+page.ts` — 404 für unbekannte Slugs** + +Komplettersatz für `load`: + +```ts +export const load: PageLoad = async ({ url }) => { + const pathname = url.pathname + + const legacyDtag = parseLegacyUrl(pathname) + if (legacyDtag) { + throw redirect(301, canonicalPostPath(legacyDtag)) + } + + const segments = pathname.replace(/^\/+|\/+$/g, '').split('/') + if (segments.length !== 1 || !segments[0]) { + 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 } + } + + throw error(404, 'Post nicht gefunden') +} +``` + +- [ ] **Step 5.1.2: `+page.svelte` — Fallback-Pfad weg** + +Im `