# 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.