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

80 KiB
Raw Blame History

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/<slug>/ 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/<slug>.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.tsprerender = 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:

{
  "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:

import { parseArgs } from '@std/cli/parse-args'

function usage(): string {
  return `usage: cli.ts [--out <path>] [--min-events <n>] [--cache <path>] [--allow-shrink]`
}

async function main(): Promise<number> {
  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
cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task snapshot --help

Expected: Ausgabe usage: cli.ts ..., Exit 0.

  • Step 4: Commit
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:

import { assertEquals, assertThrows } from '@std/assert'
import { loadConfig } from '../src/config.ts'

function env(map: Record<string, string | undefined>): (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
cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test

Expected: FAIL — Modul existiert nicht.

  • Step 3: Implementation

Erstelle snapshot/src/config.ts:

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<string, string> = {}
  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
cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test

Expected: 4 passed.

  • Step 5: Commit
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:

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
cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test tests/relays_test.ts
  • Step 3: Implementation

Erstelle snapshot/src/relays.ts:

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<string[]> {
  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
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:

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:

import { lastValueFrom, toArray, EMPTY } from 'rxjs'
import { catchError } from 'rxjs/operators'

export type RelayFetcher = (url: string, pubkey: string) => Promise<NostrEvent[]>

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<FetchResult> {
  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<NostrEvent[]> {
  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

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:

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:

import type { NostrEvent } from './relays.ts'

export function dedupByDtag(events: NostrEvent[]): NostrEvent[] {
  const byDtag = new Map<string, NostrEvent>()
  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<string> {
  const out = new Set<string>()
  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<string>,
): 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

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:

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:

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

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:

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:

export type HeadProbe = (url: string) => Promise<number>

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<CoverResult> {
  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

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:

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> = {}): 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:

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

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:

import { assertEquals } from '@std/assert'
import { writeSnapshot, readCache, type Catalog, type PostFileEntry } from '../src/write.ts'

Deno.test('writeSnapshot: schreibt index.json und posts/<slug>.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:

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<void> {
  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<void> {
  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<CacheState | null> {
  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

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:

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 <path>] [--min-events <n>] [--cache <path>] [--allow-shrink]`
}

const BLOSSOM_FALLBACKS = [
  'https://blossom.edufeed.org',
  'https://blossom.primal.net',
]

function buildFallbackUrls(primary: string): string[] {
  // Blossom URLs enden mit /<hash>.<ext> — 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<number> {
  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<string, { lang: string; title: string }>()
  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
cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task test

Expected: alle Tests grün, keine Regression.

  • Step 3: Typecheck
cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno check src/cli.ts

Expected: Keine Typ-Fehler.

  • Step 4: Smoke-Test gegen echte Relays
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:

echo "output/" > /Users/joerglohrer/repositories/joerglohrerde/snapshot/.gitignore
  • Step 6: Commit
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:

# 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/<slug>.json — ein Eintrag pro Post.
  • ./output/.last-snapshot.json — Cache für Plausibilitätscheck.

CLI-Flags

Flag Default Zweck
--out <path> ./output Zielverzeichnis
--min-events <n> cached-2 bzw. 1 Untergrenze der Post-Zahl
--cache <path> <out>/.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

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:

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 `<pre><code class="hljs${cls}">${highlighted}</code></pre>`;
		}
	}
});

/**
 * 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
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:

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('<h1>');
		expect(html).toContain('<strong>');
	});
	it('sanitized XSS', () => {
		const html = renderMarkdown('<script>alert(1)</script>');
		expect(html).not.toContain('<script>');
	});
});
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -5

Expected: 44 passed (+2 neue).

  • Step 5: Commit
cd /Users/joerglohrer/repositories/joerglohrerde && git add app/package.json app/src/lib/render/markdown.ts 'app/tests/unit/markdown-node.test.ts' && git commit -m "refactor(app): renderMarkdown via isomorphic-dompurify node+browser"

Task 13: Prerender-Route +page.ts auf Snapshot umstellen (mit Fallback)

Files:

  • Modify: app/src/routes/[...slug]/+page.ts

  • Step 1: Snapshot-Pfad konstant definieren

Öffne app/src/routes/[...slug]/+page.ts und ersetze den Inhalt durch:

import { error, redirect } from '@sveltejs/kit';
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { EntryGenerator, PageLoad } from './$types';

export const prerender = true;

const SNAPSHOT_DIR = resolve('../snapshot/output');
const CATALOG_PATH = `${SNAPSHOT_DIR}/index.json`;

interface Catalog {
	posts: { slug: string }[];
}

export const entries: EntryGenerator = () => {
	if (!existsSync(CATALOG_PATH)) return [];
	const catalog = JSON.parse(readFileSync(CATALOG_PATH, 'utf-8')) as Catalog;
	return catalog.posts.map((p) => ({ slug: p.slug }));
};

export const load: PageLoad = async ({ url }) => {
	const pathname = url.pathname;
	const legacyDtag = parseLegacyUrl(pathname);
	if (legacyDtag) {
		throw redirect(301, canonicalPostPath(legacyDtag));
	}

	const segments = pathname.replace(/^\/+|\/+$/g, '').split('/');
	if (segments.length !== 1 || !segments[0]) {
		throw error(404, 'Seite nicht gefunden');
	}
	const dtag = decodeURIComponent(segments[0]);

	// Snapshot-Daten zur Build-Zeit laden, falls vorhanden.
	const postPath = `${SNAPSHOT_DIR}/posts/${dtag}.json`;
	let snapshot: unknown = null;
	if (existsSync(postPath)) {
		snapshot = JSON.parse(readFileSync(postPath, 'utf-8'));
	}

	return { dtag, snapshot };
};
  • Step 2: Typecheck
cd /Users/joerglohrer/repositories/joerglohrerde/app && npx svelte-check --tsconfig tsconfig.json 2>&1 | tail -3

Expected: 0 Errors.

  • Step 3: Snapshot-Lauf voraus

Falls Snapshot-Output noch nicht vorhanden:

cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task snapshot --out ./output
  • Step 4: Build
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run build 2>&1 | tail -15

Expected: SvelteKit ruft entries() auf und erzeugt pro Slug ein Build-Verzeichnis in app/build/<slug>/index.html. Keine Build-Fehler.

Verify:

ls /Users/joerglohrer/repositories/joerglohrerde/app/build/ | head -10
ls /Users/joerglohrer/repositories/joerglohrerde/app/build/bibel-selfies/

Expected: index.html existiert pro Slug.

  • Step 5: Commit
cd /Users/joerglohrer/repositories/joerglohrerde && git add 'app/src/routes/[...slug]/+page.ts' && git commit -m "feat(app): post-route liest snapshot-json + prerender=true"

Task 14: +page.svelte rendert Snapshot primär, Runtime als Fallback

Files:

  • Modify: app/src/routes/[...slug]/+page.svelte

  • Modify: app/src/lib/components/PostView.svelte

  • Modify: app/src/lib/components/LanguageAvailability.svelte

  • Step 1: +page.svelte umstellen

Ersetze app/src/routes/[...slug]/+page.svelte komplett durch:

<script lang="ts">
	import type { NostrEvent } from '$lib/nostr/loaders';
	import { loadPost } from '$lib/nostr/loaders';
	import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
	import { buildHablaLink } from '$lib/nostr/naddr';
	import PostView from '$lib/components/PostView.svelte';
	import LoadingOrError from '$lib/components/LoadingOrError.svelte';
	import { t } from '$lib/i18n';
	import { get } from 'svelte/store';

	let { data } = $props();
	const dtag = $derived(data.dtag);
	const snapshot = $derived(data.snapshot as PostSnapshot | null);

	interface PostSnapshot {
		slug: string;
		event_id: string;
		created_at: number;
		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 }[];
	}

	let post: NostrEvent | null = $state(null);
	let loading = $state(true);
	let error: string | null = $state(null);

	const hablaLink = $derived(
		buildHablaLink({
			pubkey: AUTHOR_PUBKEY_HEX,
			kind: 30023,
			identifier: dtag
		})
	);

	$effect(() => {
		const currentDtag = dtag;
		if (snapshot && snapshot.slug === currentDtag) {
			loading = false;
			error = null;
			return;
		}
		// Fallback-Pfad: Slug wurde zur Build-Zeit nicht prerendered
		// (Nostr-first-Post, zwischen Snapshot und Browse publiziert).
		post = null;
		loading = true;
		error = null;
		loadPost(currentDtag)
			.then((p) => {
				if (currentDtag !== dtag) return;
				if (!p) {
					error = get(t)('post.not_found', { values: { slug: currentDtag } });
				} else {
					post = p;
				}
			})
			.catch((e) => {
				if (currentDtag !== dtag) return;
				error = e instanceof Error ? e.message : get(t)('post.unknown_error');
			})
			.finally(() => {
				if (currentDtag === dtag) loading = false;
			});
	});
</script>

<svelte:head>
	{#if snapshot}
		<title>{snapshot.title}  Jörg Lohrer</title>
		<meta name="description" content={snapshot.summary} />
		<meta property="og:type" content="article" />
		<meta property="og:title" content={snapshot.title} />
		<meta property="og:description" content={snapshot.summary} />
		<meta property="og:url" content={`https://joerg-lohrer.de/${snapshot.slug}/`} />
		<meta property="og:locale" content={snapshot.lang === 'en' ? 'en_US' : 'de_DE'} />
		{#if snapshot.cover_image}
			<meta property="og:image" content={snapshot.cover_image.url} />
			{#if snapshot.cover_image.alt}
				<meta property="og:image:alt" content={snapshot.cover_image.alt} />
			{/if}
		{/if}
		<meta property="article:published_time" content={new Date(snapshot.published_at * 1000).toISOString()} />
		<meta name="twitter:card" content="summary_large_image" />
		<meta name="twitter:title" content={snapshot.title} />
		<meta name="twitter:description" content={snapshot.summary} />
		{#if snapshot.cover_image}
			<meta name="twitter:image" content={snapshot.cover_image.url} />
		{/if}
		<link rel="canonical" href={`https://joerg-lohrer.de/${snapshot.slug}/`} />
		{#each snapshot.translations as tr}
			<link rel="alternate" hreflang={tr.lang} href={`https://joerg-lohrer.de/${tr.slug}/`} />
		{/each}
		<link rel="alternate" hreflang={snapshot.lang} href={`https://joerg-lohrer.de/${snapshot.slug}/`} />
		{#if snapshot.lang !== 'de' && snapshot.translations.some((tr) => tr.lang === 'de')}
			<link rel="alternate" hreflang="x-default" href={`https://joerg-lohrer.de/${snapshot.translations.find((tr) => tr.lang === 'de')!.slug}/`} />
		{:else if snapshot.lang === 'de'}
			<link rel="alternate" hreflang="x-default" href={`https://joerg-lohrer.de/${snapshot.slug}/`} />
		{/if}
	{/if}
</svelte:head>

<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>

{#if snapshot}
	<PostView {snapshot} />
{:else}
	<LoadingOrError {loading} {error} {hablaLink} />
	{#if post}
		<PostView event={post} />
	{/if}
{/if}

<style>
	.breadcrumb {
		font-size: 0.9rem;
		margin-bottom: 1rem;
	}
	.breadcrumb a {
		color: var(--accent);
		text-decoration: none;
	}
	.breadcrumb a:hover {
		text-decoration: underline;
	}
</style>
  • Step 2: PostView.svelte akzeptiert Snapshot-Quelle

Öffne app/src/lib/components/PostView.svelte. Am Anfang des <script>-Blocks nach bestehenden Imports, ergänze die Prop-Definition und Snapshot-Unterstützung:

Ersetze den bestehenden Block:

interface Props {
	event: NostrEvent;
}
let { event }: Props = $props();

durch:

interface Snapshot {
	slug: string;
	title: string;
	summary: string;
	lang: string;
	published_at: number;
	cover_image: { url: string; fallback_url: string | null; alt: string | null } | null;
	content_markdown: string;
	tags: string[];
	translations: { lang: string; slug: string; title: string }[];
}

interface Props {
	event?: NostrEvent;
	snapshot?: Snapshot;
}
let { event, snapshot }: Props = $props();

function evTag(e: NostrEvent, name: string): string {
	return e.tags.find((t) => t[0] === name)?.[1] ?? '';
}
function evTagsAll(e: NostrEvent, name: string): string[] {
	return e.tags.filter((t) => t[0] === name).map((t) => t[1]);
}

Ersetze die bestehenden tagValue/tagsAll-Derivationen und Meta-Variablen durch einen Snapshot-zuerst-Pfad. Die bereits existierenden $derived-Zeilen (dtag, title, summary, image, publishedAt, date, tags, bodyHtml) werden ersetzt durch:

const dtag = $derived(snapshot?.slug ?? (event ? evTag(event, 'd') : ''));
const title = $derived(snapshot?.title || (event ? evTag(event, 'title') : '') || $t('post.untitled'));
const summary = $derived(snapshot?.summary ?? (event ? evTag(event, 'summary') : ''));
const image = $derived(snapshot?.cover_image?.url ?? (event ? evTag(event, 'image') : ''));
const imageAlt = $derived(snapshot?.cover_image?.alt ?? 'Cover-Bild');
const publishedAt = $derived(
	snapshot?.published_at ??
		(event ? parseInt(evTag(event, 'published_at') || `${event.created_at}`, 10) : 0)
);
const contentMd = $derived(snapshot?.content_markdown ?? event?.content ?? '');
const tags = $derived(snapshot?.tags ?? (event ? evTagsAll(event, 't') : []));
const translations = $derived(snapshot?.translations ?? null);
const bodyHtml = $derived(renderMarkdown(contentMd));

(date-Variable und currentLocale-Sync bleiben wie bisher. Das image-alt-Attribut im Template auf {imageAlt} umstellen, falls vorher hardgecodet.)

Und <Reactions {dtag} />, <ExternalClientLinks {dtag} />, <ReplyComposer …>, <ReplyList …> bleiben im unteren Template-Bereich — die brauchen zwar das rohe Event, funktionieren aber nur im Snapshot-Modus mit dtag. Daher: im Template den Block bedingen:

{#if dtag}
	<LanguageAvailability {translations} {snapshot} {event} />
	{#if event}
		<Reactions {dtag} />
		<ExternalClientLinks {dtag} />
		<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
		<ReplyList {dtag} optimistic={optimisticReplies} />
	{/if}
{/if}

(Reactions/Replies brauchen weiterhin die Event-ID; im reinen Snapshot-Modus haben wir die event_id im Snapshot, können sie später ergänzen — für jetzt YAGNI, Reply-UI erscheint nur wenn Runtime-Event geladen ist.)

  • Step 3: LanguageAvailability.svelte liest translations-Prop

Öffne app/src/lib/components/LanguageAvailability.svelte. Ersetze den Inhalt komplett durch:

<script lang="ts">
	import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
	import { loadTranslations } from '$lib/nostr/loaders';
	import { activeLocale } from '$lib/i18n';
	import type { SupportedLocale } from '$lib/i18n/activeLocale';

	interface Snapshot {
		slug: string;
		lang: string;
		translations: { lang: string; slug: string; title: string }[];
	}

	interface Props {
		event?: NostrEvent;
		snapshot?: Snapshot;
		translations?: { lang: string; slug: string; title: string }[] | null;
	}
	let { event, snapshot, translations: translationsProp }: Props = $props();

	let translations: TranslationInfo[] = $state([]);
	let loading = $state(true);

	$effect(() => {
		// Prop-basiert (Snapshot): keine Relay-Abfrage nötig
		if (translationsProp) {
			translations = translationsProp.map((t) => ({ lang: t.lang, slug: t.slug, title: t.title }));
			loading = false;
			return;
		}
		if (snapshot) {
			translations = snapshot.translations.map((t) => ({ lang: t.lang, slug: t.slug, title: t.title }));
			loading = false;
			return;
		}
		if (!event) {
			translations = [];
			loading = false;
			return;
		}
		const currentId = event.id;
		loading = true;
		translations = [];
		loadTranslations(event)
			.then((infos) => {
				if (event.id !== currentId) return;
				translations = infos;
			})
			.finally(() => {
				if (event.id === currentId) loading = false;
			});
	});

	function currentLang(): string {
		if (snapshot) return snapshot.lang;
		if (event) return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de';
		return 'de';
	}

	interface Option {
		code: string;
		href: string | null;
	}

	const options = $derived.by<Option[]>(() => {
		const self: Option = { code: currentLang(), href: null };
		const others: Option[] = translations.map((t) => ({ code: t.lang, href: `/${t.slug}/` }));
		return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))];
	});

	function selectOther(code: string, href: string) {
		activeLocale.set(code as SupportedLocale);
		window.location.href = href;
	}
</script>

{#if !loading && translations.length > 0}
	<p class="lang-switch" role="group" aria-label="Article language">
		<span class="icon" aria-hidden="true">📖</span>
		{#each options as opt, i}
			{#if opt.href === null}
				<span class="btn active" aria-current="true">{opt.code.toUpperCase()}</span>
			{:else}
				<button type="button" class="btn" onclick={() => selectOther(opt.code, opt.href!)}>{opt.code.toUpperCase()}</button>
			{/if}
			{#if i < options.length - 1}<span class="sep" aria-hidden="true">|</span>{/if}
		{/each}
	</p>
{/if}

<style>
	.lang-switch {
		display: inline-flex;
		align-items: center;
		gap: 0.35rem;
		font-size: 0.88rem;
		color: var(--muted);
		margin: 0.25rem 0 1rem;
	}
	.icon {
		font-size: 1rem;
		line-height: 1;
	}
	.btn {
		background: transparent;
		border: 1px solid var(--border);
		color: var(--muted);
		border-radius: 3px;
		padding: 1px 7px;
		font-size: 0.8rem;
		font-family: inherit;
		cursor: pointer;
	}
	.btn:hover:not(.active) {
		color: var(--fg);
	}
	.btn.active {
		color: var(--accent);
		border-color: var(--accent);
		cursor: default;
	}
	.sep {
		opacity: 0.4;
	}
</style>
  • Step 4: Typecheck + Tests + Build
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 <head> OG-Tags enthält.

Verify einer der Build-Outputs:

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
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 <meta property="og:...">.

Öffne http://localhost:5173/nicht-existiert/. Erwartet: Fallback-Pfad (Runtime-Fetch) läuft, zeigt „Post nicht gefunden".

Dev-Server stoppen.

  • Step 6: Commit
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 <svelte:head>-Block ergänzen

Im bestehenden <svelte:head>-Block (nach dem letzten <link rel="alternate">) ergänze:

		{@html `<script type="application/ld+json">${JSON.stringify({
			'@context': 'https://schema.org',
			'@type': 'Article',
			'headline': snapshot.title,
			'description': snapshot.summary,
			'datePublished': new Date(snapshot.published_at * 1000).toISOString(),
			'dateModified': new Date(snapshot.created_at * 1000).toISOString(),
			'author': {
				'@type': 'Person',
				'name': 'Jörg Lohrer',
				'url': 'https://joerg-lohrer.de/'
			},
			'image': snapshot.cover_image ? [snapshot.cover_image.url] : undefined,
			'inLanguage': snapshot.lang,
			'mainEntityOfPage': `https://joerg-lohrer.de/${snapshot.slug}/`
		})}</script>`}

Wichtig: Das +page.svelte-Snapshot-Interface braucht zusätzlich created_at. In der Interface-Definition PostSnapshot oben im Script:

	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
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
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?
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, <LoadingOrError>-Block).
  • Entferne den {#else}-Zweig mit Runtime-Logik.

Nach der Änderung ist die Datei:

<script lang="ts">
	import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
	import { buildHablaLink } from '$lib/nostr/naddr';
	import PostView from '$lib/components/PostView.svelte';
	import { t } from '$lib/i18n';

	let { data } = $props();
	const dtag = $derived(data.dtag);
	const snapshot = $derived(data.snapshot as PostSnapshot | null);

	interface PostSnapshot {
		slug: string;
		event_id: string;
		created_at: number;
		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 }[];
	}

	const hablaLink = $derived(
		buildHablaLink({
			pubkey: AUTHOR_PUBKEY_HEX,
			kind: 30023,
			identifier: dtag
		})
	);
</script>

<svelte:head>
	{#if snapshot}
		<!-- ... OG/Twitter/JSON-LD unverändert ... -->
	{/if}
</svelte:head>

<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>

{#if snapshot}
	<PostView {snapshot} />
{:else}
	<p>Post nicht gefunden.</p>
	<p><a href={hablaLink}>Auf Habla.news öffnen</a></p>
{/if}

(Den <svelte:head>-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

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
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
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
which lftp || echo "MISSING"

Falls missing:

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:

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
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
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
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
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
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
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:

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:

# 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):

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

| `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:

### 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
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 <img> mit onerror-Handler — bleibt YAGNI-Entscheidung für später.