From 11e406e5de80e75a789ae926da674a0714d5046b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 21 Apr 2026 17:20:25 +0200 Subject: [PATCH] docs: prerender-snapshot spec-klarstellungen + implementation-plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec: - deploy upload-reihenfolge: drei-phasen-flow (assets → html → delete) mit präziser beschreibung statt flag-kombi-raten - migrations-schritt 4 + 5 entkoppelt: 4 liefert snapshot primär mit runtime-fallback für nostr-first-posts, 5 entfernt fallback erst nach stabilem cutover - exclude-glob im delete-pass für extern verwaltete files plan (20 tasks, tdd): - snapshot/ als deno-modul mit config, relays, dedup, plausibility, cover, extract, write — voll unit-getestet - renderMarkdown auf isomorphic-dompurify - sveltekit-route mit prerender=true, entries, og/twitter/json-ld/ hreflang im head, snapshot-primary + runtime-fallback - deploy-script auf lftp drei-phasen - dokumentation in HANDOFF und CLAUDE.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-21-prerender-snapshot.md | 2716 +++++++++++++++++ .../2026-04-21-prerender-snapshot-design.md | 40 +- 2 files changed, 2745 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-21-prerender-snapshot.md diff --git a/docs/superpowers/plans/2026-04-21-prerender-snapshot.md b/docs/superpowers/plans/2026-04-21-prerender-snapshot.md new file mode 100644 index 0000000..8591009 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-prerender-snapshot.md @@ -0,0 +1,2716 @@ +# 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 `https://joerg-lohrer.de//` werden zur Build-Zeit zu statischem HTML mit vollen OG-/Twitter-/JSON-LD-Tags prerendered, auf Basis eines Deno-Snapshot-Tools, das die Post-Daten aus den Relays holt und in portable JSON-Artefakte schreibt. + +**Architecture:** Drei entkoppelte Stufen: +1. **`publish/`** — unverändert (Repo-MD → signed Event → Relays + Blossom). +2. **`snapshot/`** (neu) — liest kind:30023-Events vom Autor aus Relays, filtert NIP-09-Deletes, schreibt JSON nach `snapshot/output/index.json` + `snapshot/output/posts/.json`. +3. **SvelteKit-Prerender** — liest Snapshot-JSON, generiert pro Slug statische HTML-Datei mit eingebetteten Meta-Tags und gerendertem Markdown-Body. + +**Tech Stack:** Deno, TypeScript, `@std/*`, `applesauce-relay`, `nostr-tools` (für naddr). SvelteKit 2 mit `adapter-static`, Svelte 5 Runes, `isomorphic-dompurify`. `lftp` auf macOS (deploy). + +--- + +## Spec-Referenz + +Umgesetzt: `docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md`. + +## Datei-Struktur + +**Zu erstellen:** + +- `snapshot/deno.jsonc` — Task-Runner, Imports analog zu `publish/`. +- `snapshot/src/cli.ts` — CLI-Entrypoint mit `parseArgs`. +- `snapshot/src/config.ts` — Env-/CLI-Config-Loader. +- `snapshot/src/relays.ts` — Bootstrap + kind:10002-Load + Event-Fetch pro Relay. +- `snapshot/src/dedup.ts` — Dedup-per-d-tag, NIP-09-Filter. +- `snapshot/src/plausibility.ts` — Quorum- und Drop-Check mit `--allow-shrink`. +- `snapshot/src/cover.ts` — HEAD-Probe auf Blossom-URLs, Fallback-Logik. +- `snapshot/src/extract.ts` — Event → `PostSnapshot`-Objekt (summary-Fallback, published_at-Fallback, translations). +- `snapshot/src/write.ts` — Atomarer Schreibvorgang der JSON-Artefakte. +- `snapshot/tests/*.ts` — Unit-Tests pro Modul. +- `snapshot/README.md` — Blaupausen-Dokumentation. + +**Zu ändern:** + +- `app/package.json` — Dependency `isomorphic-dompurify` statt `dompurify`. +- `app/src/lib/render/markdown.ts` — DOM-Guard raus, `isomorphic-dompurify` als Quelle. +- `app/src/routes/[...slug]/+page.ts` — `prerender = true`, `entries`, `load` liest Snapshot-JSON. Laufzeit-Fallback bleibt zunächst. +- `app/src/routes/[...slug]/+page.svelte` — liest `data.snapshot` primär, Runtime-Loader als Fallback-Pfad. +- `app/src/routes/[...slug]/+page.svelte` (Cutover, Schritt 5) — Fallback-Pfad entfernt. +- `app/src/lib/components/PostView.svelte` — rendert aus Snapshot, nicht mehr nur aus Event. +- `app/src/lib/components/LanguageAvailability.svelte` — liest `translations[]` aus Page-Data, kein `loadTranslations`-Fetch mehr. +- `scripts/deploy-svelte.sh` — FTPS-Sync in drei Phasen. +- `.github/workflows/publish.yml` — optional: Snapshot-Schritt vor Build. +- `CLAUDE.md` + `docs/HANDOFF.md` — neue Kommandos und Deploy-Flow dokumentieren. + +**Nicht anfassen:** + +- `publish/` — komplett unverändert. +- `content/posts/**` — Repo bleibt Autorenquelle. +- `app/src/routes/+page.svelte`, `app/src/routes/archiv/+page.svelte`, `app/src/routes/tag/[name]/+page.svelte` — bleiben SPA-gerendert (laut Spec, Nicht-Ziel). + +--- + +## Task 1: `snapshot/`-Modul bootstrappen + +**Files:** +- Create: `snapshot/deno.jsonc` +- Create: `snapshot/src/cli.ts` + +- [ ] **Step 1: `snapshot/`-Verzeichnis anlegen + Deno-Konfig** + +Erstelle `snapshot/deno.jsonc`: + +```jsonc +{ + "tasks": { + "snapshot": "deno run --env-file=../.env.local --allow-env --allow-read --allow-write --allow-net --allow-run=git 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: Minimaler CLI-Skeleton** + +Erstelle `snapshot/src/cli.ts`: + +```typescript +import { parseArgs } from '@std/cli/parse-args' + +function usage(): string { + return `usage: cli.ts [--out ] [--min-events ] [--cache ] [--allow-shrink]` +} + +async function main(): Promise { + const args = parseArgs(Deno.args, { + string: ['out', 'min-events', 'cache'], + boolean: ['allow-shrink', 'help'], + }) + if (args.help) { + console.log(usage()) + return 0 + } + console.log('snapshot: not yet implemented') + return 0 +} + +if (import.meta.main) { + Deno.exit(await main()) +} +``` + +- [ ] **Step 3: Smoke-Test: `deno task snapshot --help` funktioniert** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task snapshot --help +``` + +Expected: Ausgabe `usage: cli.ts ...`, Exit 0. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/deno.jsonc snapshot/src/cli.ts && git commit -m "feat(snapshot): deno-skeleton mit cli-hilfe" +``` + +--- + +## Task 2: Config-Loader mit Defaults und CLI-Overrides + +**Files:** +- Create: `snapshot/src/config.ts` +- Create: `snapshot/tests/config_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/config_test.ts`: + +```typescript +import { assertEquals, assertThrows } from '@std/assert' +import { loadConfig } from '../src/config.ts' + +function env(map: Record): (k: string) => string | undefined { + return (k) => map[k] +} + +Deno.test('loadConfig: nimmt defaults wenn nur pflichtfelder gesetzt', () => { + const cfg = loadConfig( + env({ + AUTHOR_PUBKEY_HEX: 'f'.repeat(64), + BOOTSTRAP_RELAY: 'wss://relay.example', + }), + {}, + ) + assertEquals(cfg.authorPubkeyHex, 'f'.repeat(64)) + assertEquals(cfg.bootstrapRelay, 'wss://relay.example') + assertEquals(cfg.outDir, './output') + assertEquals(cfg.cachePath, './output/.last-snapshot.json') + assertEquals(cfg.minEvents, null) + assertEquals(cfg.allowShrink, false) +}) + +Deno.test('loadConfig: cli-flags überschreiben defaults', () => { + const cfg = loadConfig( + env({ + AUTHOR_PUBKEY_HEX: 'a'.repeat(64), + BOOTSTRAP_RELAY: 'wss://relay.example', + }), + { out: './my-out', 'min-events': '20', cache: './my-cache.json', 'allow-shrink': true }, + ) + assertEquals(cfg.outDir, './my-out') + assertEquals(cfg.minEvents, 20) + assertEquals(cfg.cachePath, './my-cache.json') + assertEquals(cfg.allowShrink, true) +}) + +Deno.test('loadConfig: wirft bei fehlendem pflichtfeld', () => { + assertThrows( + () => loadConfig(env({}), {}), + Error, + 'Missing env', + ) +}) + +Deno.test('loadConfig: wirft bei invalidem pubkey', () => { + assertThrows( + () => + loadConfig( + env({ AUTHOR_PUBKEY_HEX: 'not-hex', BOOTSTRAP_RELAY: 'wss://relay.example' }), + {}, + ), + Error, + 'AUTHOR_PUBKEY_HEX', + ) +}) +``` + +- [ ] **Step 2: Run tests → FAIL** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test +``` + +Expected: FAIL — Modul existiert nicht. + +- [ ] **Step 3: Implementation** + +Erstelle `snapshot/src/config.ts`: + +```typescript +export interface Config { + authorPubkeyHex: string + bootstrapRelay: string + outDir: string + cachePath: string + minEvents: number | null + allowShrink: boolean +} + +type EnvReader = (key: string) => string | undefined + +interface CliFlags { + out?: string + 'min-events'?: string + cache?: string + 'allow-shrink'?: boolean +} + +const REQUIRED = ['AUTHOR_PUBKEY_HEX', 'BOOTSTRAP_RELAY'] as const + +const DEFAULTS = { + OUT_DIR: './output', + CACHE_REL: '.last-snapshot.json', +} + +export function loadConfig(read: EnvReader, flags: CliFlags): Config { + const missing: string[] = [] + const values: Record = {} + for (const key of REQUIRED) { + const v = read(key) + if (!v) missing.push(key) + else values[key] = v + } + if (missing.length) { + throw new Error(`Missing env: ${missing.join(', ')}`) + } + if (!/^[0-9a-f]{64}$/.test(values.AUTHOR_PUBKEY_HEX)) { + throw new Error('AUTHOR_PUBKEY_HEX must be 64 lowercase hex characters') + } + const outDir = flags.out ?? DEFAULTS.OUT_DIR + const cachePath = flags.cache ?? `${outDir}/${DEFAULTS.CACHE_REL}` + let minEvents: number | null = null + if (flags['min-events'] !== undefined) { + const n = Number(flags['min-events']) + if (!Number.isInteger(n) || n < 1) { + throw new Error(`--min-events must be a positive integer, got "${flags['min-events']}"`) + } + minEvents = n + } + return { + authorPubkeyHex: values.AUTHOR_PUBKEY_HEX, + bootstrapRelay: values.BOOTSTRAP_RELAY, + outDir, + cachePath, + minEvents, + allowShrink: flags['allow-shrink'] === true, + } +} +``` + +- [ ] **Step 4: Run tests → PASS** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/config.ts snapshot/tests/config_test.ts && git commit -m "feat(snapshot): config-loader mit env + cli-flags" +``` + +--- + +## Task 3: Relay-Bootstrap (kind:10002 laden, Fallback) + +**Files:** +- Create: `snapshot/src/relays.ts` +- Create: `snapshot/tests/relays_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/relays_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { parseOutboxReadRelays, FALLBACK_READ_RELAYS } from '../src/relays.ts' + +const EV = (tags: string[][]) => ({ + id: 'x', + pubkey: 'p', + kind: 10002, + created_at: 0, + tags, + content: '', + sig: 's', +}) + +Deno.test('parseOutboxReadRelays: tag ohne marker → read+write', () => { + const relays = parseOutboxReadRelays(EV([['r', 'wss://relay.example']])) + assertEquals(relays, ['wss://relay.example']) +}) + +Deno.test('parseOutboxReadRelays: nur read-marker', () => { + const relays = parseOutboxReadRelays( + EV([ + ['r', 'wss://relay.example', 'read'], + ['r', 'wss://write-only.example', 'write'], + ]), + ) + assertEquals(relays, ['wss://relay.example']) +}) + +Deno.test('parseOutboxReadRelays: leeres event → leeres array', () => { + assertEquals(parseOutboxReadRelays(EV([])), []) +}) + +Deno.test('FALLBACK_READ_RELAYS enthält mindestens drei wss-urls', () => { + const fb = FALLBACK_READ_RELAYS + assertEquals(fb.length >= 3, true) + for (const u of fb) { + assertEquals(u.startsWith('wss://'), true) + } +}) +``` + +- [ ] **Step 2: Run → FAIL** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test tests/relays_test.ts +``` + +- [ ] **Step 3: Implementation** + +Erstelle `snapshot/src/relays.ts`: + +```typescript +import { Relay } from 'applesauce-relay' +import { firstValueFrom, timeout } from 'rxjs' + +export interface NostrEvent { + id: string + pubkey: string + kind: number + created_at: number + tags: string[][] + content: string + sig: string +} + +export const FALLBACK_READ_RELAYS: readonly string[] = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', + 'wss://relay.tchncs.de', + 'wss://relay.edufeed.org', +] as const + +export function parseOutboxReadRelays(ev: { tags: string[][] }): string[] { + const out: string[] = [] + for (const t of ev.tags) { + if (t[0] !== 'r' || !t[1]) continue + const marker = t[2] + if (marker === 'write') continue + out.push(t[1]) + } + return out +} + +export async function loadReadRelays( + bootstrapRelay: string, + authorPubkeyHex: string, +): Promise { + try { + const relay = new Relay(bootstrapRelay) + const ev = (await firstValueFrom( + relay + .request({ kinds: [10002], authors: [authorPubkeyHex], limit: 1 }) + .pipe(timeout({ first: 10_000 })), + )) as NostrEvent + const parsed = parseOutboxReadRelays(ev) + if (parsed.length > 0) return parsed + } catch { + // fallthrough + } + return [...FALLBACK_READ_RELAYS] +} +``` + +- [ ] **Step 4: Run → PASS** + +Expected: 4 new tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/relays.ts snapshot/tests/relays_test.ts && git commit -m "feat(snapshot): kind:10002-bootstrap + fallback-relays" +``` + +--- + +## Task 4: Event-Fetch pro Relay mit Timeout + +**Files:** +- Modify: `snapshot/src/relays.ts` +- Create: `snapshot/tests/fetch_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/fetch_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { fetchEventsFromRelays, type RelayFetcher } from '../src/relays.ts' +import type { NostrEvent } from '../src/relays.ts' + +const mkEv = (d: string, lang = 'de'): NostrEvent => ({ + id: d, + pubkey: 'p', + kind: 30023, + created_at: 0, + tags: [['d', d], ['l', lang]], + content: '', + sig: 's', +}) + +Deno.test('fetchEventsFromRelays: merged events aus mehreren relays', async () => { + const fetcher: RelayFetcher = (url) => { + if (url === 'wss://a') return Promise.resolve([mkEv('one')]) + if (url === 'wss://b') return Promise.resolve([mkEv('two')]) + return Promise.resolve([]) + } + const result = await fetchEventsFromRelays(['wss://a', 'wss://b'], 'pk', fetcher) + assertEquals(result.responded.sort(), ['wss://a', 'wss://b']) + assertEquals(result.events.map((e) => e.id).sort(), ['one', 'two']) +}) + +Deno.test('fetchEventsFromRelays: ein relay failt → restliche liefern', async () => { + const fetcher: RelayFetcher = (url) => { + if (url === 'wss://a') return Promise.reject(new Error('boom')) + if (url === 'wss://b') return Promise.resolve([mkEv('two')]) + return Promise.resolve([]) + } + const result = await fetchEventsFromRelays(['wss://a', 'wss://b'], 'pk', fetcher) + assertEquals(result.responded, ['wss://b']) + assertEquals(result.events.map((e) => e.id), ['two']) +}) + +Deno.test('fetchEventsFromRelays: kein relay antwortet → leere responden', async () => { + const fetcher: RelayFetcher = () => Promise.reject(new Error('nope')) + const result = await fetchEventsFromRelays(['wss://a'], 'pk', fetcher) + assertEquals(result.responded, []) + assertEquals(result.events, []) +}) +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implementation — am Ende von `snapshot/src/relays.ts`** + +Ergänze in `snapshot/src/relays.ts` nach den existierenden Exports: + +```typescript +import { lastValueFrom, toArray, EMPTY } from 'rxjs' +import { catchError } from 'rxjs/operators' + +export type RelayFetcher = (url: string, pubkey: string) => Promise + +export interface FetchResult { + events: NostrEvent[] + responded: string[] +} + +const defaultFetcher: RelayFetcher = async (url, pubkey) => { + const relay = new Relay(url) + const events = (await lastValueFrom( + relay + .request({ kinds: [30023], authors: [pubkey], limit: 500 }) + .pipe(timeout({ first: 10_000 }), toArray(), catchError(() => EMPTY)), + { defaultValue: [] as NostrEvent[] }, + )) as NostrEvent[] + return events +} + +const defaultDeletionFetcher: RelayFetcher = async (url, pubkey) => { + const relay = new Relay(url) + return (await lastValueFrom( + relay + .request({ kinds: [5], authors: [pubkey], limit: 500 }) + .pipe(timeout({ first: 10_000 }), toArray(), catchError(() => EMPTY)), + { defaultValue: [] as NostrEvent[] }, + )) as NostrEvent[] +} + +export async function fetchEventsFromRelays( + urls: string[], + pubkey: string, + fetcher: RelayFetcher = defaultFetcher, +): Promise { + const settled = await Promise.allSettled(urls.map((u) => fetcher(u, pubkey))) + const events: NostrEvent[] = [] + const responded: string[] = [] + for (let i = 0; i < urls.length; i++) { + const r = settled[i] + if (r.status === 'fulfilled') { + events.push(...r.value) + responded.push(urls[i]) + } + } + return { events, responded } +} + +export async function fetchDeletionsFromRelays( + urls: string[], + pubkey: string, + fetcher: RelayFetcher = defaultDeletionFetcher, +): Promise { + const settled = await Promise.allSettled(urls.map((u) => fetcher(u, pubkey))) + const events: NostrEvent[] = [] + for (const r of settled) { + if (r.status === 'fulfilled') events.push(...r.value) + } + return events +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/relays.ts snapshot/tests/fetch_test.ts && git commit -m "feat(snapshot): event- und deletion-fetch mit promise.allSettled" +``` + +--- + +## Task 5: Dedup per d-tag und NIP-09-Filter + +**Files:** +- Create: `snapshot/src/dedup.ts` +- Create: `snapshot/tests/dedup_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/dedup_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { dedupByDtag, filterDeleted, extractDeletedDtags } from '../src/dedup.ts' +import type { NostrEvent } from '../src/relays.ts' + +const mkEv = (d: string, created: number): NostrEvent => ({ + id: `${d}-${created}`, + pubkey: 'pk', + kind: 30023, + created_at: created, + tags: [['d', d]], + content: '', + sig: 's', +}) + +Deno.test('dedupByDtag: neuestes event pro d-tag gewinnt', () => { + const events = [mkEv('a', 10), mkEv('a', 20), mkEv('b', 5)] + const out = dedupByDtag(events) + assertEquals(out.map((e) => e.id).sort(), ['a-20', 'b-5']) +}) + +Deno.test('dedupByDtag: events ohne d-tag werden verworfen', () => { + const events: NostrEvent[] = [ + { ...mkEv('a', 10), tags: [] }, + mkEv('b', 5), + ] + assertEquals(dedupByDtag(events).map((e) => e.id), ['b-5']) +}) + +Deno.test('extractDeletedDtags: zieht dtags aus kind:5 a-tags', () => { + const deletions: NostrEvent[] = [{ + id: 'd1', + pubkey: 'pk', + kind: 5, + created_at: 100, + tags: [['a', '30023:pk:foo'], ['a', '30023:pk:bar']], + content: '', + sig: 's', + }] + const set = extractDeletedDtags(deletions, 'pk') + assertEquals([...set].sort(), ['bar', 'foo']) +}) + +Deno.test('extractDeletedDtags: ignoriert a-tags auf andere kinds oder pubkeys', () => { + const deletions: NostrEvent[] = [{ + id: 'd1', + pubkey: 'pk', + kind: 5, + created_at: 100, + tags: [ + ['a', '30023:otherpk:foo'], + ['a', '1:pk:bar'], + ['a', '30023:pk:ok'], + ], + content: '', + sig: 's', + }] + assertEquals([...extractDeletedDtags(deletions, 'pk')], ['ok']) +}) + +Deno.test('filterDeleted: entfernt events mit dtag aus delete-set', () => { + const events = [mkEv('keep', 10), mkEv('gone', 20)] + const out = filterDeleted(events, new Set(['gone'])) + assertEquals(out.map((e) => e.id), ['keep-10']) +}) +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implementation** + +Erstelle `snapshot/src/dedup.ts`: + +```typescript +import type { NostrEvent } from './relays.ts' + +export function dedupByDtag(events: NostrEvent[]): NostrEvent[] { + 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()] +} + +export function extractDeletedDtags( + deletions: NostrEvent[], + authorPubkeyHex: string, +): Set { + const out = new Set() + for (const d of deletions) { + if (d.kind !== 5) continue + if (d.pubkey !== authorPubkeyHex) continue + for (const t of d.tags) { + if (t[0] !== 'a' || !t[1]) continue + const [kindStr, pk, dtag] = t[1].split(':') + if (kindStr !== '30023') continue + if (pk !== authorPubkeyHex) continue + if (!dtag) continue + out.add(dtag) + } + } + return out +} + +export function filterDeleted( + events: NostrEvent[], + deleted: Set, +): NostrEvent[] { + return events.filter((ev) => { + const d = ev.tags.find((t) => t[0] === 'd')?.[1] + return d ? !deleted.has(d) : false + }) +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/dedup.ts snapshot/tests/dedup_test.ts && git commit -m "feat(snapshot): dedup-by-dtag + NIP-09-filter" +``` + +--- + +## Task 6: Plausibilitätscheck mit Drop- und Quorum-Regeln + +**Files:** +- Create: `snapshot/src/plausibility.ts` +- Create: `snapshot/tests/plausibility_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/plausibility_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { checkPlausibility } from '../src/plausibility.ts' + +Deno.test('quorum 3/5 → ok, 2/5 → fail', () => { + const ok = checkPlausibility({ + queried: 5, + responded: 3, + eventCount: 27, + minEventsOverride: null, + cachedPostCount: 27, + knownDeletedCount: 0, + allowShrink: false, + }) + assertEquals(ok.ok, true) + + const bad = checkPlausibility({ + queried: 5, + responded: 2, + eventCount: 27, + minEventsOverride: null, + cachedPostCount: 27, + knownDeletedCount: 0, + allowShrink: false, + }) + assertEquals(bad.ok, false) + assertEquals(bad.reason?.startsWith('quorum'), true) +}) + +Deno.test('ohne cache und ohne flag: default min-events = 1', () => { + assertEquals( + checkPlausibility({ + queried: 1, + responded: 1, + eventCount: 1, + minEventsOverride: null, + cachedPostCount: null, + knownDeletedCount: 0, + allowShrink: false, + }).ok, + true, + ) + const bad = checkPlausibility({ + queried: 1, + responded: 1, + eventCount: 0, + minEventsOverride: null, + cachedPostCount: null, + knownDeletedCount: 0, + allowShrink: false, + }) + assertEquals(bad.ok, false) + assertEquals(bad.reason?.startsWith('min-events'), true) +}) + +Deno.test('mit cache: default min-events = cache - 2', () => { + const bad = checkPlausibility({ + queried: 5, + responded: 5, + eventCount: 20, + minEventsOverride: null, + cachedPostCount: 27, + knownDeletedCount: 0, + allowShrink: false, + }) + // 20 < 27-2=25 → fail + assertEquals(bad.ok, false) +}) + +Deno.test('drop > 20% → fail wenn keine passende deletion-zählt', () => { + const bad = checkPlausibility({ + queried: 5, + responded: 5, + eventCount: 20, + minEventsOverride: 1, + cachedPostCount: 27, + knownDeletedCount: 0, + allowShrink: false, + }) + // drop = 7, 7/27 > 20% → fail + assertEquals(bad.ok, false) + assertEquals(bad.reason?.startsWith('drop'), true) +}) + +Deno.test('drop > 20% aber alle durch kind:5 erklärt → ok', () => { + const ok = checkPlausibility({ + queried: 5, + responded: 5, + eventCount: 20, + minEventsOverride: 1, + cachedPostCount: 27, + knownDeletedCount: 7, + allowShrink: false, + }) + assertEquals(ok.ok, true) +}) + +Deno.test('drop > 20%, allow-shrink → ok', () => { + const ok = checkPlausibility({ + queried: 5, + responded: 5, + eventCount: 20, + minEventsOverride: 1, + cachedPostCount: 27, + knownDeletedCount: 0, + allowShrink: true, + }) + assertEquals(ok.ok, true) +}) +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implementation** + +Erstelle `snapshot/src/plausibility.ts`: + +```typescript +export interface PlausibilityInput { + queried: number + responded: number + eventCount: number + minEventsOverride: number | null + cachedPostCount: number | null + knownDeletedCount: number + allowShrink: boolean +} + +export interface PlausibilityResult { + ok: boolean + reason?: string +} + +const DROP_THRESHOLD_PCT = 20 + +export function checkPlausibility(input: PlausibilityInput): PlausibilityResult { + const quorum = Math.ceil(input.queried * 0.6) + if (input.responded < quorum) { + return { + ok: false, + reason: `quorum: ${input.responded}/${input.queried} responded, need >= ${quorum}`, + } + } + const minEvents = input.minEventsOverride + ?? (input.cachedPostCount !== null ? Math.max(1, input.cachedPostCount - 2) : 1) + if (input.eventCount < minEvents) { + return { + ok: false, + reason: `min-events: ${input.eventCount} < ${minEvents}`, + } + } + if (input.cachedPostCount !== null && input.eventCount < input.cachedPostCount) { + const drop = input.cachedPostCount - input.eventCount + const dropPct = (drop / input.cachedPostCount) * 100 + if (dropPct > DROP_THRESHOLD_PCT) { + if (input.allowShrink) return { ok: true } + if (input.knownDeletedCount >= drop) return { ok: true } + return { + ok: false, + reason: + `drop: ${drop}/${input.cachedPostCount} (${dropPct.toFixed(1)}%) > ${DROP_THRESHOLD_PCT}% and only ${input.knownDeletedCount} deletions seen; pass --allow-shrink to override`, + } + } + } + return { ok: true } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/plausibility.ts snapshot/tests/plausibility_test.ts && git commit -m "feat(snapshot): plausibilitäts-check quorum + drop + allow-shrink" +``` + +--- + +## Task 7: Cover-Bild-Probe mit Blossom-Fallback + +**Files:** +- Create: `snapshot/src/cover.ts` +- Create: `snapshot/tests/cover_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/cover_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { probeCover, type HeadProbe } from '../src/cover.ts' + +Deno.test('probeCover: primary 200 → url = primary', async () => { + const probe: HeadProbe = async (u) => u === 'https://a/x.jpg' ? 200 : 0 + const out = await probeCover({ + primary: 'https://a/x.jpg', + fallbacks: ['https://b/x.jpg'], + }, probe) + assertEquals(out.url, 'https://a/x.jpg') + assertEquals(out.fallbackUrl, 'https://b/x.jpg') + assertEquals(out.warnings, []) +}) + +Deno.test('probeCover: primary fail, fallback ok → url = fallback', async () => { + const probe: HeadProbe = async (u) => u === 'https://b/x.jpg' ? 200 : 500 + const out = await probeCover({ + primary: 'https://a/x.jpg', + fallbacks: ['https://b/x.jpg'], + }, probe) + assertEquals(out.url, 'https://b/x.jpg') + assertEquals(out.fallbackUrl, 'https://a/x.jpg') + assertEquals(out.warnings.length, 1) +}) + +Deno.test('probeCover: beide tot → url = primary + warnung', async () => { + const probe: HeadProbe = async () => 404 + const out = await probeCover({ + primary: 'https://a/x.jpg', + fallbacks: ['https://b/x.jpg'], + }, probe) + assertEquals(out.url, 'https://a/x.jpg') + assertEquals(out.warnings.length, 2) +}) + +Deno.test('probeCover: keine fallbacks → url = primary', async () => { + const probe: HeadProbe = async () => 200 + const out = await probeCover({ primary: 'https://a/x.jpg', fallbacks: [] }, probe) + assertEquals(out.url, 'https://a/x.jpg') + assertEquals(out.fallbackUrl, null) +}) +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implementation** + +Erstelle `snapshot/src/cover.ts`: + +```typescript +export type HeadProbe = (url: string) => Promise + +export interface CoverInput { + primary: string + fallbacks: string[] +} + +export interface CoverResult { + url: string + fallbackUrl: string | null + warnings: string[] +} + +export const defaultHeadProbe: HeadProbe = async (url) => { + try { + const res = await fetch(url, { method: 'HEAD' }) + return res.status + } catch { + return 0 + } +} + +export async function probeCover( + input: CoverInput, + probe: HeadProbe = defaultHeadProbe, +): Promise { + const warnings: string[] = [] + const primaryStatus = await probe(input.primary) + if (primaryStatus === 200) { + return { + url: input.primary, + fallbackUrl: input.fallbacks[0] ?? null, + warnings, + } + } + warnings.push(`primary-unreachable: ${input.primary} (status=${primaryStatus})`) + for (const fb of input.fallbacks) { + const status = await probe(fb) + if (status === 200) { + return { + url: fb, + fallbackUrl: input.primary, + warnings, + } + } + warnings.push(`fallback-unreachable: ${fb} (status=${status})`) + } + return { + url: input.primary, + fallbackUrl: input.fallbacks[0] ?? null, + warnings, + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/cover.ts snapshot/tests/cover_test.ts && git commit -m "feat(snapshot): cover-probe mit blossom-fallback-urls" +``` + +--- + +## Task 8: Event → `PostSnapshot`-Extraktion + +**Files:** +- Create: `snapshot/src/extract.ts` +- Create: `snapshot/tests/extract_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/extract_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { extractPostSnapshot, deriveSummary, type TranslationLookup } from '../src/extract.ts' +import type { NostrEvent } from '../src/relays.ts' + +const PK = 'a'.repeat(64) + +function ev(partial: Partial = {}): NostrEvent { + return { + id: 'e1', + pubkey: PK, + kind: 30023, + created_at: 1000, + tags: [['d', 'post-slug'], ['title', 'Titel'], ['l', 'de']], + content: 'Body', + sig: 's', + ...partial, + } +} + +Deno.test('deriveSummary: kürzt auf 200 zeichen an wortgrenze mit ellipsis', () => { + const long = 'Wort '.repeat(60).trim() + const s = deriveSummary(long) + assertEquals(s.length <= 201, true) + assertEquals(s.endsWith('…'), true) +}) + +Deno.test('deriveSummary: kurzer text unverändert', () => { + assertEquals(deriveSummary('Kurzer Text.'), 'Kurzer Text.') +}) + +Deno.test('deriveSummary: entfernt markdown-heading-zeichen', () => { + const s = deriveSummary('# Titel\n\nEin Satz.') + assertEquals(s.startsWith('Titel'), true) +}) + +Deno.test('extractPostSnapshot: happy path mit title/summary/image', () => { + const e = ev({ + tags: [ + ['d', 'hallo'], + ['title', 'Hallo Welt'], + ['summary', 'Kurzer Abriss.'], + ['image', 'https://blossom.edufeed.org/hash.jpg'], + ['l', 'de'], + ['published_at', '999'], + ['t', 'a'], + ['t', 'b'], + ], + }) + const lookup: TranslationLookup = () => [] + const snap = extractPostSnapshot(e, { translationTitles: lookup }) + assertEquals(snap.slug, 'hallo') + assertEquals(snap.title, 'Hallo Welt') + assertEquals(snap.summary, 'Kurzer Abriss.') + assertEquals(snap.lang, 'de') + assertEquals(snap.publishedAt, 999) + assertEquals(snap.createdAt, 1000) + assertEquals(snap.tags, ['a', 'b']) + assertEquals(snap.coverImageUrl, 'https://blossom.edufeed.org/hash.jpg') +}) + +Deno.test('extractPostSnapshot: fehlt summary → aus body abgeleitet', () => { + const e = ev({ content: 'Langer Body-Text ohne Summary-Tag im Event.' }) + const snap = extractPostSnapshot(e, { translationTitles: () => [] }) + assertEquals(snap.summary.length > 0, true) + assertEquals(snap.summary.startsWith('Langer'), true) +}) + +Deno.test('extractPostSnapshot: fehlt published_at → created_at', () => { + const e = ev({ tags: [['d', 'x'], ['title', 'T'], ['l', 'de']] }) + const snap = extractPostSnapshot(e, { translationTitles: () => [] }) + assertEquals(snap.publishedAt, snap.createdAt) +}) + +Deno.test('extractPostSnapshot: liest translations aus a-tags mit marker', () => { + const e = ev({ + tags: [ + ['d', 'bibel-selfies'], + ['title', 'Bibel-Selfies'], + ['l', 'de'], + ['a', `30023:${PK}:bible-selfies`, '', 'translation'], + ], + }) + const lookup: TranslationLookup = (dtag) => + dtag === 'bible-selfies' ? [{ dtag, lang: 'en', title: 'Bible Selfies' }] : [] + const snap = extractPostSnapshot(e, { translationTitles: lookup }) + assertEquals(snap.translations, [{ lang: 'en', slug: 'bible-selfies', title: 'Bible Selfies' }]) +}) + +Deno.test('extractPostSnapshot: ignoriert a-tags ohne translation-marker', () => { + const e = ev({ + tags: [ + ['d', 'x'], + ['title', 'T'], + ['l', 'de'], + ['a', `30023:${PK}:other`, '', 'root'], + ], + }) + const snap = extractPostSnapshot(e, { translationTitles: () => [] }) + assertEquals(snap.translations, []) +}) +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implementation** + +Erstelle `snapshot/src/extract.ts`: + +```typescript +import type { NostrEvent } from './relays.ts' + +export interface TranslationInfo { + lang: string + slug: string + title: string +} + +export interface PostSnapshot { + slug: string + eventId: string + createdAt: number + publishedAt: number + title: string + summary: string + lang: string + coverImageUrl: string | null + coverImageAlt: string | null + contentMarkdown: string + tags: string[] + translations: TranslationInfo[] +} + +export interface TranslationLookupEntry { + dtag: string + lang: string + title: string +} + +export type TranslationLookup = (dtag: string) => TranslationLookupEntry[] + +export interface ExtractOptions { + translationTitles: TranslationLookup +} + +const SUMMARY_MAX = 200 + +export function deriveSummary(body: string): string { + const stripped = body + .replace(/^#+\s+/gm, '') + .replace(/[*_`~]+/g, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\s+/g, ' ') + .trim() + if (stripped.length <= SUMMARY_MAX) return stripped + const truncated = stripped.slice(0, SUMMARY_MAX) + const lastSpace = truncated.lastIndexOf(' ') + const cut = lastSpace > SUMMARY_MAX * 0.6 ? lastSpace : SUMMARY_MAX + return stripped.slice(0, cut).trimEnd() + '…' +} + +function tagValue(ev: NostrEvent, name: string): string | undefined { + return ev.tags.find((t) => t[0] === name)?.[1] +} + +function tagAll(ev: NostrEvent, name: string): string[] { + return ev.tags.filter((t) => t[0] === name && t[1]).map((t) => t[1]) +} + +function parseTranslationDtags(ev: NostrEvent, pubkey: string): string[] { + const out: string[] = [] + for (const t of ev.tags) { + if (t[0] !== 'a' || t[3] !== 'translation' || !t[1]) continue + const [kindStr, pk, dtag] = t[1].split(':') + if (kindStr !== '30023' || pk !== pubkey || !dtag) continue + out.push(dtag) + } + return out +} + +export function extractPostSnapshot( + event: NostrEvent, + opts: ExtractOptions, +): PostSnapshot { + const slug = tagValue(event, 'd') ?? '' + const title = tagValue(event, 'title') ?? '' + const summaryTag = tagValue(event, 'summary') + const image = tagValue(event, 'image') ?? null + const imageAlt = tagValue(event, 'image_alt') ?? null + const lang = tagValue(event, 'l') ?? 'de' + const publishedAtTag = tagValue(event, 'published_at') + const publishedAt = publishedAtTag ? parseInt(publishedAtTag, 10) : event.created_at + const translationDtags = parseTranslationDtags(event, event.pubkey) + const translations: TranslationInfo[] = [] + for (const dtag of translationDtags) { + for (const info of opts.translationTitles(dtag)) { + translations.push({ lang: info.lang, slug: info.dtag, title: info.title }) + } + } + return { + slug, + eventId: event.id, + createdAt: event.created_at, + publishedAt, + title, + summary: summaryTag && summaryTag.trim().length > 0 + ? summaryTag.trim() + : deriveSummary(event.content), + lang, + coverImageUrl: image, + coverImageAlt: imageAlt, + contentMarkdown: event.content, + tags: tagAll(event, 't'), + translations, + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/extract.ts snapshot/tests/extract_test.ts && git commit -m "feat(snapshot): event → post-snapshot-extraktion mit summary- und translations-logik" +``` + +--- + +## Task 9: JSON-Writer (atomar) + Cache + +**Files:** +- Create: `snapshot/src/write.ts` +- Create: `snapshot/tests/write_test.ts` + +- [ ] **Step 1: Failing Test** + +Erstelle `snapshot/tests/write_test.ts`: + +```typescript +import { assertEquals } from '@std/assert' +import { writeSnapshot, readCache, type Catalog, type PostFileEntry } from '../src/write.ts' + +Deno.test('writeSnapshot: schreibt index.json und posts/.json', async () => { + const tmp = await Deno.makeTempDir() + const catalog: Catalog = { + generated_at: '2026-04-21T10:30:00Z', + author_pubkey: 'a'.repeat(64), + relays_queried: ['wss://a'], + relays_responded: ['wss://a'], + post_count: 1, + posts: [{ slug: 'x', lang: 'de', created_at: 10, title: 'T' }], + } + const files: PostFileEntry[] = [{ + slug: 'x', + data: { slug: 'x', title: 'T' }, + }] + await writeSnapshot({ outDir: tmp, cachePath: `${tmp}/.cache.json`, catalog, files }) + const idx = JSON.parse(await Deno.readTextFile(`${tmp}/index.json`)) + assertEquals(idx.post_count, 1) + const post = JSON.parse(await Deno.readTextFile(`${tmp}/posts/x.json`)) + assertEquals(post.slug, 'x') + const cache = JSON.parse(await Deno.readTextFile(`${tmp}/.cache.json`)) + assertEquals(cache.post_count, 1) + await Deno.remove(tmp, { recursive: true }) +}) + +Deno.test('readCache: vorhanden → post_count, fehlend → null', async () => { + const tmp = await Deno.makeTempDir() + assertEquals(await readCache(`${tmp}/missing.json`), null) + await Deno.writeTextFile(`${tmp}/ok.json`, JSON.stringify({ post_count: 42 })) + const c = await readCache(`${tmp}/ok.json`) + assertEquals(c?.post_count, 42) + await Deno.remove(tmp, { recursive: true }) +}) +``` + +- [ ] **Step 2: Run → FAIL** + +- [ ] **Step 3: Implementation** + +Erstelle `snapshot/src/write.ts`: + +```typescript +import { ensureDir } from '@std/fs' +import { dirname, join } from '@std/path' + +export interface CatalogEntry { + slug: string + lang: string + created_at: number + title: string +} + +export interface Catalog { + generated_at: string + author_pubkey: string + relays_queried: string[] + relays_responded: string[] + post_count: number + posts: CatalogEntry[] +} + +export interface PostFileEntry { + slug: string + data: unknown +} + +export interface WriteArgs { + outDir: string + cachePath: string + catalog: Catalog + files: PostFileEntry[] +} + +export interface CacheState { + post_count: number + generated_at?: string +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await ensureDir(dirname(path)) + const tmp = `${path}.tmp` + await Deno.writeTextFile(tmp, JSON.stringify(value, null, 2) + '\n') + await Deno.rename(tmp, path) +} + +export async function writeSnapshot(args: WriteArgs): Promise { + const postsDir = join(args.outDir, 'posts') + await ensureDir(postsDir) + for (const f of args.files) { + await writeJsonAtomic(join(postsDir, `${f.slug}.json`), f.data) + } + await writeJsonAtomic(join(args.outDir, 'index.json'), args.catalog) + const cache: CacheState = { + post_count: args.catalog.post_count, + generated_at: args.catalog.generated_at, + } + await writeJsonAtomic(args.cachePath, cache) +} + +export async function readCache(path: string): Promise { + try { + const text = await Deno.readTextFile(path) + const parsed = JSON.parse(text) as CacheState + if (typeof parsed.post_count !== 'number') return null + return parsed + } catch { + return null + } +} +``` + +- [ ] **Step 4: Run → PASS** + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/write.ts snapshot/tests/write_test.ts && git commit -m "feat(snapshot): atomarer json-writer + cache-reader" +``` + +--- + +## Task 10: CLI-Orchestrierung — alle Module verdrahten + +**Files:** +- Modify: `snapshot/src/cli.ts` + +- [ ] **Step 1: CLI ausbauen** + +Ersetze `snapshot/src/cli.ts` komplett durch: + +```typescript +import { parseArgs } from '@std/cli/parse-args' +import { loadConfig } from './config.ts' +import { loadReadRelays, fetchEventsFromRelays, fetchDeletionsFromRelays } from './relays.ts' +import { dedupByDtag, extractDeletedDtags, filterDeleted } from './dedup.ts' +import { checkPlausibility } from './plausibility.ts' +import { probeCover } from './cover.ts' +import { extractPostSnapshot, type TranslationLookupEntry } from './extract.ts' +import { readCache, writeSnapshot, type Catalog, type PostFileEntry } from './write.ts' +import { nip19 } from 'nostr-tools' + +function usage(): string { + return `usage: cli.ts [--out ] [--min-events ] [--cache ] [--allow-shrink]` +} + +const BLOSSOM_FALLBACKS = [ + 'https://blossom.edufeed.org', + 'https://blossom.primal.net', +] + +function buildFallbackUrls(primary: string): string[] { + // Blossom URLs enden mit /. — wenn die Host-Base einer der + // bekannten Blossom-Server ist, erzeuge URLs auf den jeweils anderen. + try { + const u = new URL(primary) + const rest = u.pathname + return BLOSSOM_FALLBACKS + .filter((b) => !primary.startsWith(b)) + .map((b) => `${b}${rest}`) + } catch { + return [] + } +} + +async function main(): Promise { + const args = parseArgs(Deno.args, { + string: ['out', 'min-events', 'cache'], + boolean: ['allow-shrink', 'help'], + }) + if (args.help) { + console.log(usage()) + return 0 + } + const cfg = loadConfig((k) => Deno.env.get(k), { + out: args.out, + 'min-events': args['min-events'], + cache: args.cache, + 'allow-shrink': args['allow-shrink'], + }) + console.log(`snapshot: pubkey=${cfg.authorPubkeyHex.slice(0, 8)}… out=${cfg.outDir}`) + + console.log('[1/5] read-relays bootstrap…') + const relays = await loadReadRelays(cfg.bootstrapRelay, cfg.authorPubkeyHex) + console.log(`relays: ${relays.length}`) + + console.log('[2/5] fetch kind:30023 + kind:5…') + const [events, deletions] = await Promise.all([ + fetchEventsFromRelays(relays, cfg.authorPubkeyHex), + fetchDeletionsFromRelays(relays, cfg.authorPubkeyHex), + ]) + console.log(`events: ${events.events.length} gesamt, responded: ${events.responded.length}/${relays.length}`) + + console.log('[3/5] dedup + NIP-09-filter + plausibilität…') + const deduped = dedupByDtag(events.events) + const deletedDtags = extractDeletedDtags(deletions, cfg.authorPubkeyHex) + const alive = filterDeleted(deduped, deletedDtags) + const cache = await readCache(cfg.cachePath) + const plausibility = checkPlausibility({ + queried: relays.length, + responded: events.responded.length, + eventCount: alive.length, + minEventsOverride: cfg.minEvents, + cachedPostCount: cache?.post_count ?? null, + knownDeletedCount: deletedDtags.size, + allowShrink: cfg.allowShrink, + }) + if (!plausibility.ok) { + console.error(`HARD-FAIL plausibilität: ${plausibility.reason}`) + return 1 + } + console.log(`alive: ${alive.length} posts`) + + console.log('[4/5] extract + cover-probe…') + // Translation-Lookup-Map: d-tag → {lang,title} + const titleByDtag = new Map() + for (const e of alive) { + const d = e.tags.find((t) => t[0] === 'd')?.[1] + if (!d) continue + titleByDtag.set(d, { + lang: e.tags.find((t) => t[0] === 'l')?.[1] ?? 'de', + title: e.tags.find((t) => t[0] === 'title')?.[1] ?? '', + }) + } + const lookup = (dtag: string): TranslationLookupEntry[] => { + const hit = titleByDtag.get(dtag) + return hit ? [{ dtag, lang: hit.lang, title: hit.title }] : [] + } + + const files: PostFileEntry[] = [] + const catalogEntries: Catalog['posts'] = [] + for (const ev of alive) { + const snap = extractPostSnapshot(ev, { translationTitles: lookup }) + if (!snap.slug) continue + let cover: { url: string; fallbackUrl: string | null } | null = null + if (snap.coverImageUrl) { + const result = await probeCover({ + primary: snap.coverImageUrl, + fallbacks: buildFallbackUrls(snap.coverImageUrl), + }) + cover = { url: result.url, fallbackUrl: result.fallbackUrl } + for (const w of result.warnings) console.warn(`cover [${snap.slug}]: ${w}`) + } + const naddr = nip19.naddrEncode({ + pubkey: cfg.authorPubkeyHex, + kind: 30023, + identifier: snap.slug, + relays: [], + }) + files.push({ + slug: snap.slug, + data: { + slug: snap.slug, + event_id: snap.eventId, + created_at: snap.createdAt, + published_at: snap.publishedAt, + title: snap.title, + summary: snap.summary, + lang: snap.lang, + cover_image: cover ? { + url: cover.url, + fallback_url: cover.fallbackUrl, + alt: snap.coverImageAlt, + } : null, + content_markdown: snap.contentMarkdown, + tags: snap.tags, + naddr, + habla_url: `https://habla.news/a/${naddr}`, + translations: snap.translations, + }, + }) + catalogEntries.push({ + slug: snap.slug, + lang: snap.lang, + created_at: snap.createdAt, + title: snap.title, + }) + } + + console.log('[5/5] write JSON…') + const catalog: Catalog = { + generated_at: new Date().toISOString(), + author_pubkey: cfg.authorPubkeyHex, + relays_queried: relays, + relays_responded: events.responded, + post_count: alive.length, + posts: catalogEntries.sort((a, b) => b.created_at - a.created_at), + } + await writeSnapshot({ + outDir: cfg.outDir, + cachePath: cfg.cachePath, + catalog, + files, + }) + console.log(`done: ${files.length} posts → ${cfg.outDir}`) + return 0 +} + +if (import.meta.main) { + Deno.exit(await main()) +} +``` + +- [ ] **Step 2: Tests weiterhin grün** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test +``` + +Expected: alle Tests grün, keine Regression. + +- [ ] **Step 3: Typecheck** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno check src/cli.ts +``` + +Expected: Keine Typ-Fehler. + +- [ ] **Step 4: Smoke-Test gegen echte Relays** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task snapshot --out ./output +``` + +Expected: 27 Posts gelistet, `snapshot/output/index.json` und `snapshot/output/posts/*.json` geschrieben. + +Falls Hard-Fail: Log prüfen, Relay-Konnektivität prüfen. + +- [ ] **Step 5: `snapshot/output/`-Artefakte gitignoren** + +Füge in `/Users/joerglohrer/repositories/joerglohrerde/.gitignore` hinzu: + +``` +snapshot/output/ +``` + +Oder falls ein `snapshot/.gitignore` bevorzugt wird: + +```bash +echo "output/" > /Users/joerglohrer/repositories/joerglohrerde/snapshot/.gitignore +``` + +- [ ] **Step 6: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/src/cli.ts snapshot/.gitignore && git commit -m "feat(snapshot): cli verdrahtet alle module zu end-to-end-lauf" +``` + +--- + +## Task 11: `snapshot/README.md` als Blaupausen-Doku + +**Files:** +- Create: `snapshot/README.md` + +- [ ] **Step 1: README schreiben** + +Erstelle `snapshot/README.md`: + +```markdown +# snapshot + +Deno-Tool, das kind:30023-Events des eigenen Pubkeys aus Nostr-Relays +holt, NIP-09-Deletions anwendet und portable JSON-Artefakte schreibt. + +Die JSON-Artefakte werden von einem Static-Site-Generator (SvelteKit, +Astro, Eleventy …) zur Build-Zeit als Datenquelle für Prerender genutzt +— damit Post-URLs beim ersten Request echtes HTML mit OG-/Twitter-/ +JSON-LD-Metadaten liefern statt SPA-Fallback. + +## Minimal-Usage + +```sh +export AUTHOR_PUBKEY_HEX="<64 hex>" +export BOOTSTRAP_RELAY="wss://relay.damus.io" +cd snapshot +deno task snapshot --out ./output +``` + +Ergebnis: + +- `./output/index.json` — Katalog aller Posts. +- `./output/posts/.json` — ein Eintrag pro Post. +- `./output/.last-snapshot.json` — Cache für Plausibilitätscheck. + +## CLI-Flags + +| Flag | Default | Zweck | +|---|---|---| +| `--out ` | `./output` | Zielverzeichnis | +| `--min-events ` | `cached-2` bzw. `1` | Untergrenze der Post-Zahl | +| `--cache ` | `/.last-snapshot.json` | Vergleichs-Cache | +| `--allow-shrink` | aus | Drop-Check deaktivieren (bei bewusstem Masse-Löschen) | + +## Plausibilitätschecks + +- Mindestens 60 % der Read-Relays müssen antworten. +- Post-Zahl >= `--min-events`. +- Falls Cache vorhanden: Drop > 20 % ist Hard-Fail, außer + - genau so viele Posts wurden per `kind:5` gelöscht, oder + - `--allow-shrink` ist gesetzt. + +## Blaupausen-Eigenschaften + +- **Konfiguration nur via env/CLI** — keine hart gecodeten Relay-Listen. +- **JSON-Output ist stabile Schnittstelle** — der Renderer ist austauschbar. +- **Explizite Grenzen:** nur kind:30023, nur eigener Pubkey, kein + Live-Proxy. Diese Grenzen sind Feature, nicht Bug. + +Der Primary-Renderer dieser Codebase ist SvelteKit — siehe +`../app/src/routes/[...slug]/+page.ts`. Für andere Renderer gilt: das +JSON-Schema ist in `src/extract.ts` und `src/cli.ts` festgelegt, +unveränderte Felder dürfen ignoriert werden. +``` + +- [ ] **Step 2: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add snapshot/README.md && git commit -m "docs(snapshot): readme als blaupausen-dokumentation" +``` + +--- + +## Task 12: `renderMarkdown` Node-kompatibel machen (Migrations-Schritt 1) + +**Files:** +- Modify: `app/package.json` +- Modify: `app/src/lib/render/markdown.ts` + +- [ ] **Step 1: `dompurify` durch `isomorphic-dompurify` ersetzen** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm uninstall dompurify @types/dompurify && npm install isomorphic-dompurify +``` + +Expected: Installation ohne Fehler, `isomorphic-dompurify` in `package.json` als Dependency. + +- [ ] **Step 2: `renderMarkdown` umstellen** + +Ersetze `app/src/lib/render/markdown.ts` komplett durch: + +```typescript +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); + +/** + * Lokaler Marked-Instance, damit die globale `marked`-Singleton nicht + * mutiert wird — andere Module können `marked` unbeeinflusst weiterverwenden. + */ +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}
`; + } + } +}); + +/** + * Rendert einen Markdown-String zu sanitized HTML. + * Funktioniert in Browser, jsdom und Node — `isomorphic-dompurify` + * bringt in Node-Umgebungen automatisch eine DOM-Implementierung mit. + */ +export function renderMarkdown(md: string): string { + const raw = markedInstance.parse(md, { async: false }) as string; + return DOMPurify.sanitize(raw); +} +``` + +- [ ] **Step 3: Tests und Build prüfen** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -5 +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3 +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -10 +``` + +Expected: Alle Tests grün (42), svelte-check 0 Errors, Build erfolgreich. + +- [ ] **Step 4: Node-Smoke-Test** + +Erstelle kurzzeitig `app/tests/unit/markdown-node.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { renderMarkdown } from '$lib/render/markdown'; + +describe('renderMarkdown (node-smoke)', () => { + it('rendert ohne DOM-Error in Node-Umgebung', () => { + const html = renderMarkdown('# Hallo\n\nText **fett**.'); + expect(html).toContain('

'); + expect(html).toContain(''); + }); + it('sanitized XSS', () => { + const html = renderMarkdown(''); + expect(html).not.toContain(' + + + {#if snapshot} + {snapshot.title} – Jörg Lohrer + + + + + + + {#if snapshot.cover_image} + + {#if snapshot.cover_image.alt} + + {/if} + {/if} + + + + + {#if snapshot.cover_image} + + {/if} + + {#each snapshot.translations as tr} + + {/each} + + {#if snapshot.lang !== 'de' && snapshot.translations.some((tr) => tr.lang === 'de')} + tr.lang === 'de')!.slug}/`} /> + {:else if snapshot.lang === 'de'} + + {/if} + {/if} + + + + +{#if snapshot} + +{:else} + + {#if post} + + {/if} +{/if} + + +``` + +- [ ] **Step 2: `PostView.svelte` akzeptiert Snapshot-Quelle** + +Öffne `app/src/lib/components/PostView.svelte`. Am Anfang des ` + +{#if !loading && translations.length > 0} +

+ + {#each options as opt, i} + {#if opt.href === null} + {opt.code.toUpperCase()} + {:else} + + {/if} + {#if i < options.length - 1}{/if} + {/each} +

+{/if} + + +``` + +- [ ] **Step 4: Typecheck + Tests + Build** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3 +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -3 +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -10 +``` + +Expected: 0 Errors, 44 Tests grün, Build erfolgreich. Build-Output enthält pro Slug ein Verzeichnis mit `index.html`, dessen `` OG-Tags enthält. + +Verify einer der Build-Outputs: +```bash +grep -E "og:title|og:image|og:description" /Users/joerglohrer/repositories/joerglohrerde/app/build/bibel-selfies/index.html | head +``` +Expected: drei Treffer mit dem Post-Titel, Summary, Cover-Bild. + +- [ ] **Step 5: Dev-Server und manuell prüfen** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run dev 2>&1 | head -5 +``` + +Öffne `http://localhost:5173/bibel-selfies/`. Erwartet: +- Post rendert sofort (Snapshot-Modus). +- Sprach-Switcher erscheint. +- View-Source zeigt ``. + +Öffne `http://localhost:5173/nicht-existiert/`. Erwartet: Fallback-Pfad (Runtime-Fetch) läuft, zeigt „Post nicht gefunden". + +Dev-Server stoppen. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add 'app/src/routes/[...slug]/+page.svelte' app/src/lib/components/PostView.svelte app/src/lib/components/LanguageAvailability.svelte && git commit -m "feat(app): post-route rendert snapshot primär + OG/twitter/hreflang-tags" +``` + +--- + +## Task 15: JSON-LD-Schema einbauen + +**Files:** +- Modify: `app/src/routes/[...slug]/+page.svelte` + +- [ ] **Step 1: `Article`-Schema im ``-Block ergänzen** + +Im bestehenden ``-Block (nach dem letzten ``) ergänze: + +```svelte + {@html ``} +``` + +**Wichtig:** Das `+page.svelte`-Snapshot-Interface braucht zusätzlich `created_at`. In der Interface-Definition `PostSnapshot` oben im Script: + +```typescript + interface PostSnapshot { + slug: string; + event_id: string; + created_at: number; // <-- hinzufügen falls fehlt + published_at: number; + title: string; + summary: string; + lang: string; + cover_image: { url: string; fallback_url: string | null; alt: string | null } | null; + content_markdown: string; + tags: string[]; + translations: { lang: string; slug: string; title: string }[]; + } +``` + +- [ ] **Step 2: Build und Output inspizieren** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -5 +grep "application/ld+json" /Users/joerglohrer/repositories/joerglohrerde/app/build/bibel-selfies/index.html +``` + +Expected: Treffer mit JSON-LD-Script-Tag. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add 'app/src/routes/[...slug]/+page.svelte' && git commit -m "feat(app): json-ld article-schema im prerender-head" +``` + +--- + +## Task 16: Runtime-Relay-Fetch aus Detail-Seite entfernen (Migrations-Schritt 5) + +**Files:** +- Modify: `app/src/routes/[...slug]/+page.svelte` + +Vorbedingung: Alle produktiven Slugs sind aktuell im Snapshot enthalten. Andernfalls würden sie mit 404 reagieren. + +- [ ] **Step 1: Smoke-Test — alle Live-Posts im Snapshot?** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task snapshot --out ./output +ls /Users/joerglohrer/repositories/joerglohrerde/snapshot/output/posts/ | wc -l +``` + +Expected: Zahl matcht `post_count` in `index.json`. + +- [ ] **Step 2: Fallback-Code-Pfad entfernen** + +In `app/src/routes/[...slug]/+page.svelte`: +- Entferne alle Variablen und Imports, die nur für den Fallback sind (`loadPost`, `post`-State, `loading`, `error`, der Fallback-`$effect` inkl. `.then/.catch/.finally`, ``-Block). +- Entferne den `{#else}`-Zweig mit Runtime-Logik. + +Nach der Änderung ist die Datei: + +```svelte + + + + {#if snapshot} + + {/if} + + + + +{#if snapshot} + +{:else} +

Post nicht gefunden.

+

Auf Habla.news öffnen

+{/if} +``` + +(Den ``-Block nicht neu tippen — im Script und Template einfach die Runtime-Fallback-Teile entfernen.) + +- [ ] **Step 3: `PostView`/`LanguageAvailability` Event-Prop optional entfernen** + +Da `event` nicht mehr genutzt wird, kann aufgeräumt werden: + +- `PostView.svelte`: `event`-Prop und alle Event-Spezifika (inkl. der bedingten Reactions/ReplyComposer-Blocks) bleiben vorerst — Replies sind weiterhin ein Runtime-Feature, die Composer/List-Komponenten laden selbst und brauchen nur `dtag`. **Aktion:** den `{#if event}`-Wrapper im Template durch `{#if dtag}` ersetzen und die `eventId={event.id}`-Prop auf `eventId={snapshot?.event_id ?? event?.id ?? ''}` umstellen. + +- `LanguageAvailability.svelte`: `event`-Prop optional lassen (für Tests); Snapshot-Pfad ist Default. + +- [ ] **Step 4: Build + Manuell** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -5 +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run dev +``` + +Öffne `http://localhost:5173/bibel-selfies/`. Erwartet: Post rendert sofort (Snapshot-Modus), Replies-Bereich erscheint und lädt via Relay (ReplyList macht eigenen Fetch mit dtag). + +Stoppe Dev-Server. + +- [ ] **Step 5: Typecheck + Tests** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3 +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -3 +``` + +Expected: 0 Errors, 44 Tests grün. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add 'app/src/routes/[...slug]/+page.svelte' app/src/lib/components/PostView.svelte app/src/lib/components/LanguageAvailability.svelte && git commit -m "feat(app): runtime-relay-fetch in post-route entfernt, snapshot ist pflicht" +``` + +--- + +## Task 17: Deploy-Script in drei Phasen umbauen + +**Files:** +- Modify: `scripts/deploy-svelte.sh` + +Der bestehende Script lädt pro Datei einzeln via `curl`. Umbau auf `lftp` mit Phasen-Trennung — `lftp` ist auf macOS meist nicht vorinstalliert, daher erst prüfen. + +- [ ] **Step 1: `lftp` prüfen/installieren** + +```bash +which lftp || echo "MISSING" +``` + +Falls missing: + +```bash +brew install lftp +``` + +Expected: `lftp` verfügbar (`which lftp` liefert einen Pfad). + +- [ ] **Step 2: Script refactor** + +Öffne `scripts/deploy-svelte.sh`. Ersetze den Block ab `echo "Lade Build von $BUILD_DIR nach ftp://$FTP_HOST$FTP_REMOTE_PATH"` bis zum `echo "Upload fertig. Live-Check:"` durch: + +```bash +echo "Ziel: $TARGET ($PUBLIC_URL)" +echo "Phase 1/3: Assets (_app/**, Bilder, CSS) hochladen" + +LFTP_OPTS="set ftps:initial-prot ''; set ftp:ssl-force true; set ftp:ssl-protect-data true; set ssl:verify-certificate no" +LFTP_EXCLUDE="--exclude-glob .htaccess --exclude-glob .well-known" + +# Phase 1: Assets (alles außer HTML) hochladen, kein Delete. +lftp -c " + $LFTP_OPTS + open -u '$FTP_USER','$FTP_PASS' ftps://$FTP_HOST + mirror --reverse --parallel=4 --only-newer \ + --include-glob '_app/**' \ + --include-glob '*.css' \ + --include-glob '*.js' \ + --include-glob '*.png' \ + --include-glob '*.jpg' \ + --include-glob '*.webp' \ + --include-glob '*.svg' \ + --include-glob '*.ico' \ + '$BUILD_DIR' '$FTP_REMOTE_PATH' +" + +echo "Phase 2/3: HTML-Seiten hochladen" +lftp -c " + $LFTP_OPTS + open -u '$FTP_USER','$FTP_PASS' ftps://$FTP_HOST + mirror --reverse --parallel=4 --only-newer \ + --include-glob '*.html' \ + --include-glob '*.txt' \ + --include-glob '*.xml' \ + --include-glob '*.json' \ + '$BUILD_DIR' '$FTP_REMOTE_PATH' +" + +echo "Phase 3/3: Obsolete Dateien löschen" +lftp -c " + $LFTP_OPTS + open -u '$FTP_USER','$FTP_PASS' ftps://$FTP_HOST + mirror --reverse --delete --only-existing \ + $LFTP_EXCLUDE \ + '$BUILD_DIR' '$FTP_REMOTE_PATH' +" + +echo "Upload fertig. Live-Check:" +``` + +**Begründung der Flags:** +- `--reverse` = Upload (lokal → remote), nicht Download. +- `--only-newer` in Phase 1+2 = nur geänderte Dateien neu hochladen. +- `--only-existing` in Phase 3 = nur löschen, keine neuen Uploads. +- `--delete` in Phase 3 = obsolete Remote-Dateien entfernen. +- `--exclude-glob` in Phase 3 = `.htaccess` und `.well-known/` nicht anfassen (werden extern verwaltet). + +Falls `lftp`-Flags auf einem speziellen All-Inkl-FTPS-Modus nicht funktionieren (TLS-1.3-Problem), analog zum alten curl-Fix ergänzen: + +``` +set ssl:priority 'NORMAL:-VERS-TLS1.3' +``` + +in `LFTP_OPTS`. + +- [ ] **Step 3: Testlauf auf Staging** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && DEPLOY_TARGET=staging ./scripts/deploy-svelte.sh 2>&1 | tail -20 +``` + +Expected: Drei Phasen laufen der Reihe nach durch, `HTTP/2 200` am Ende. + +- [ ] **Step 4: Live-Check auf Staging** + +```bash +curl -sI https://staging.joerg-lohrer.de/bibel-selfies/ +curl -s https://staging.joerg-lohrer.de/bibel-selfies/ | grep -E "og:title|og:image" +``` + +Expected: `HTTP/2 200`, OG-Tags sichtbar. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add scripts/deploy-svelte.sh && git commit -m "feat(deploy): lftp-drei-phasen-sync (assets → html → delete)" +``` + +--- + +## Task 18: Prod-Deploy + Live-Verifikation + +**Files:** — (Verifikation) + +- [ ] **Step 1: Snapshot + Build lokal** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task snapshot --out ./output +cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -5 +``` + +- [ ] **Step 2: Deploy nach prod** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh 2>&1 | tail -15 +``` + +Expected: Alle drei Phasen OK, Live-Check HTTP 200. + +- [ ] **Step 3: Verifikation — OG-Tags im ausgelieferten HTML** + +```bash +curl -s https://joerg-lohrer.de/bibel-selfies/ | grep -E "og:title|og:description|og:image|application/ld\+json" | head -5 +``` + +Expected: Post-Titel und -Summary im HTML-Quelltext sichtbar, JSON-LD vorhanden. + +- [ ] **Step 4: Social-Preview-Test** + +Manuell: +- LinkedIn-Inspect-Tool: https://www.linkedin.com/post-inspector/ → Input `https://joerg-lohrer.de/bibel-selfies/` → Preview zeigt Titel, Beschreibung, Cover-Bild. +- Facebook-Debugger: https://developers.facebook.com/tools/debug/ → analog. +- Bluesky/Mastodon: Link in Testpost einfügen, Preview prüfen. + +Alle sollten jetzt post-spezifische Tags zeigen statt Homepage-Defaults. + +- [ ] **Step 5: Kein Commit nötig — Abschluss-Verifikation.** + +--- + +## Task 19: Snapshot in CI einbauen (optional, aber empfohlen) + +**Files:** +- Modify: `.github/workflows/publish.yml` + +Optional: Der Snapshot-Lauf kann in CI triggert werden, damit auch Nostr-first-Posts automatisch in den nächsten Build eingehen. Der Snapshot selbst triggert keine Action — er muss von außen aufgerufen werden. Zwei Optionen: + +**Option A:** Snapshot nur zum Deploy-Zeitpunkt lokal. Simpel. Reicht, solange fast alles git-first läuft. + +**Option B:** Neuen Workflow `snapshot-and-deploy.yml` mit `workflow_dispatch`-Trigger, damit Jörg manuell „jetzt snapshotten" aus GitHub UI starten kann. Ebenfalls `cron: '0 3 * * *'` für täglichen Snapshot. + +Empfehlung: **Option A jetzt**, Option B später wenn Bedarf entsteht. Diese Task ist also dokumentarisch: + +- [ ] **Step 1: `docs/HANDOFF.md` dokumentiert Snapshot-Lauf** + +Ergänze im HANDOFF-Abschnitt „Dev-Kommandos" vor der Deploy-Zeile: + +```sh +# Snapshot (kind:30023 aus Relays → snapshot/output/) +cd snapshot && deno task snapshot + +# SPA-Build + Deploy (Snapshot muss vorher laufen) +DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh +``` + +Und einen Absatz weiter oben (im Alltags-Workflow): + +```markdown +### Vollständiger Prod-Deploy-Flow + +1. Content ändern oder neuen Post committen +2. `git push` → GitHub-Action publisht das Event auf die Relays +3. Warten bis Action durch ist (typisch < 1 min nach Mirror-Sync) +4. Lokal: `cd snapshot && deno task snapshot` — Relays → JSON +5. Lokal: `cd app && npm run build` — JSON → Build +6. `DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh` — Upload +``` + +- [ ] **Step 2: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add docs/HANDOFF.md && git commit -m "docs: snapshot → build → deploy flow im handoff dokumentiert" +``` + +--- + +## Task 20: `CLAUDE.md` aktualisieren + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Hauptarbeitsbereiche erweitern** + +In `CLAUDE.md` den Abschnitt „Hauptarbeitsbereiche im Repo" erweitern: + +```markdown +| `snapshot/src/` | Deno-Snapshot-Tool (Relays → JSON, für Prerender) | +| `snapshot/tests/` | Deno-Tests des Snapshot-Tools | +| `snapshot/output/` | Generated (gitignored) — Input für SvelteKit-Prerender | +``` + +- [ ] **Step 2: Neuer Fallstrick-Absatz** + +Am Ende von „Kritische Fallstricke" ergänzen: + +```markdown +### 6. Build setzt aktuellen Snapshot voraus + +`app/src/routes/[...slug]/+page.ts` liest `snapshot/output/posts/*.json` +zur Build-Zeit. Vor `npm run build` muss `cd snapshot && deno task +snapshot` gelaufen sein — sonst erzeugt SvelteKit nur die Default-Route +ohne Post-Seiten. Der Deploy-Flow ist: **publish (git push) → snapshot +→ build → deploy**. +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/joerglohrer/repositories/joerglohrerde && git add CLAUDE.md && git commit -m "docs: CLAUDE.md um snapshot-stufe und build-vorbedingung ergänzt" +``` + +--- + +## Fertig + +Nach Task 20: + +- `snapshot/` als eigenständiges Deno-Modul mit 6 Core-Dateien + Tests, ~35 Unit-Tests grün. +- JSON-Output in `snapshot/output/` als stabile Schnittstelle, Blaupausen-tauglich. +- SvelteKit prerendert pro Slug eine statische HTML-Datei mit vollständigen OG-/Twitter-/JSON-LD-Tags und `hreflang`-Links. +- Laufzeit-Relay-Fetch der Detail-Seite entfernt, Replies/Reactions bleiben client-gerendert. +- Deploy-Script in 3 Phasen (Assets → HTML → Delete), konsistenzsicher auch bei Hash-Bundle-Rotation. +- Dokumentation in `CLAUDE.md` und `docs/HANDOFF.md` ergänzt. + +**Nicht Teil dieses Plans:** + +- Prerender für Listen-Seiten (Spec-Nicht-Ziel). +- Snapshot-Automatisierung in CI (Option B, später bei Bedarf). +- `fallback_url`-Nutzung im `` mit `onerror`-Handler — bleibt YAGNI-Entscheidung für später. diff --git a/docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md b/docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md index 9b16fce..e26101f 100644 --- a/docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md +++ b/docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md @@ -311,17 +311,27 @@ des SvelteKit-Builds sind. **Upload-Reihenfolge (kritisch wegen Hash-benannten JS-Bundles):** 1. Zuerst **Assets** hochladen (`_app/immutable/**`, Bilder, CSS) — - `lftp mirror` ohne `--delete`, nur Upload + reine Upload-Phase ohne Server-seitiges Löschen. Neue Hash-Bundles + landen zusätzlich zu den alten auf dem Server. 2. Danach **HTML-Seiten** hochladen (`index.html`, `/index.html`, - `404.html`), ebenfalls ohne Delete -3. **Zum Schluss** `lftp mirror --delete --only-missing` auf das - Top-Level, um obsolete Dateien zu entfernen (alte Hash-Bundles, - gelöschte Post-HTMLs) + `404.html`), ebenfalls ohne Löschen. Ab diesem Punkt zeigen die neuen + HTMLs auf ihre zugehörigen neuen Asset-Hashes — konsistent. +3. **Zum Schluss** ein separater **Delete-Pass**, der Server-Dateien + entfernt, die im aktuellen Build-Output nicht mehr existieren (alte + Hash-Bundles, gelöschte Post-HTMLs, veraltete Snapshot-JSONs). Nichts + wird in dieser Phase erneut hochgeladen. Konkrete `lftp`-Flag-Kombi + in der Planungsphase festzulegen — wichtig ist nur die + Phasen-Trennung: Upload zuerst, Delete zuletzt, kein paralleler + Mirror-Call. Damit ist zu keinem Zeitpunkt ein inkonsistenter Zustand auf dem Server: Neue HTMLs referenzieren stets bereits vorhandene Asset-Hashes; alte Assets werden erst nach erfolgreichem Upload gelöscht. +Von `--delete` ausgeschlossen bleiben außerhalb des SvelteKit-Builds +verwaltete Dateien (Hero-Bild, Favicons im Root, `.well-known/`, +Webspace-Spezifika) via `--exclude-glob`. + Kein weiteres Verhalten ändert sich. ## Mehrsprachigkeit @@ -381,12 +391,20 @@ an keiner Stelle einen Big-Bang bildet: Änderung an SPA. Rollback: Verzeichnis löschen. 3. **Snapshot in CI einbauen.** GitHub-Actions-Schritt vor SvelteKit-Build. Rollback: Workflow-Schritt entfernen. -4. **SvelteKit-Route auf Prerender umstellen.** `[...slug]/+page.ts` - bekommt `prerender = true` + `entries()` + Load aus JSON. - `+page.svelte` rendert `content_markdown` per `renderMarkdown()` zur - Build-Zeit. Rollback: Commit revert, alte Runtime-Logik kommt zurück. -5. **SPA-Relay-Fetch in Detail-Seite komplett abschalten.** Nur noch - Snapshot-Content. Rollback: Commit revert. +4. **SvelteKit-Route auf Prerender umstellen, mit Laufzeit-Fallback.** + `[...slug]/+page.ts` bekommt `prerender = true` + `entries()` + Load + aus JSON. `+page.svelte` rendert `content_markdown` per + `renderMarkdown()` zur Build-Zeit. Der bisherige Runtime-Relay-Fetch + bleibt in diesem Schritt noch als Fallback bestehen — falls ein Slug + zur Build-Zeit nicht im Snapshot war (z.B. ganz frisch Nostr-first + publiziert), kann die SPA ihn über `adapter-static`-`fallback` + weiterhin rendern. Rollback: Commit revert, alte Runtime-Logik kommt + vollständig zurück. +5. **Runtime-Relay-Fetch der Detail-Seite entfernen.** Wenn Schritt 4 + sich stabil zeigt, wird der Fallback-Code-Pfad abgebaut. Die + Detail-Seite lebt dann ausschließlich vom Snapshot. Neue Nostr-first- + Posts erscheinen erst nach dem nächsten Snapshot+Build-Lauf. Rollback: + Commit revert. 6. **Deploy-Script erweitern.** `lftp mirror --delete` mit Upload-Reihenfolge. Rollback: Script revert — Site bleibt, nur Obsolete-Cleanup fehlt.