2717 lines
80 KiB
Markdown
2717 lines
80 KiB
Markdown
|
|
# 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.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 <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**
|
|||
|
|
|
|||
|
|
```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<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**
|
|||
|
|
|
|||
|
|
```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<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**
|
|||
|
|
|
|||
|
|
```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<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**
|
|||
|
|
|
|||
|
|
```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<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**
|
|||
|
|
|
|||
|
|
```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<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**
|
|||
|
|
|
|||
|
|
```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<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**
|
|||
|
|
|
|||
|
|
```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> = {}): 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/<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`:
|
|||
|
|
|
|||
|
|
```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<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**
|
|||
|
|
|
|||
|
|
```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 <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**
|
|||
|
|
|
|||
|
|
```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/<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**
|
|||
|
|
|
|||
|
|
```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 `<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**
|
|||
|
|
|
|||
|
|
```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('<h1>');
|
|||
|
|
expect(html).toContain('<strong>');
|
|||
|
|
});
|
|||
|
|
it('sanitized XSS', () => {
|
|||
|
|
const html = renderMarkdown('<script>alert(1)</script>');
|
|||
|
|
expect(html).not.toContain('<script>');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/joerglohrer/repositories/joerglohrerde/app && npm run test:unit 2>&1 | tail -5
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 44 passed (+2 neue).
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/joerglohrer/repositories/joerglohrerde/snapshot && deno task snapshot --out ./output
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Build**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```svelte
|
|||
|
|
<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:
|
|||
|
|
```svelte
|
|||
|
|
interface Props {
|
|||
|
|
event: NostrEvent;
|
|||
|
|
}
|
|||
|
|
let { event }: Props = $props();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
durch:
|
|||
|
|
|
|||
|
|
```svelte
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```svelte
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```svelte
|
|||
|
|
{#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:
|
|||
|
|
|
|||
|
|
```svelte
|
|||
|
|
<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**
|
|||
|
|
|
|||
|
|
```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 `<head>` 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 `<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**
|
|||
|
|
|
|||
|
|
```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 `<svelte:head>`-Block ergänzen**
|
|||
|
|
|
|||
|
|
Im bestehenden `<svelte:head>`-Block (nach dem letzten `<link rel="alternate">`) ergänze:
|
|||
|
|
|
|||
|
|
```svelte
|
|||
|
|
{@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:
|
|||
|
|
|
|||
|
|
```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`, `<LoadingOrError>`-Block).
|
|||
|
|
- Entferne den `{#else}`-Zweig mit Runtime-Logik.
|
|||
|
|
|
|||
|
|
Nach der Änderung ist die Datei:
|
|||
|
|
|
|||
|
|
```svelte
|
|||
|
|
<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**
|
|||
|
|
|
|||
|
|
```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 `<img>` mit `onerror`-Handler — bleibt YAGNI-Entscheidung für später.
|