From f606748c3e894eae7ac87cfc6cfe14d22c551bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:00:13 +0200 Subject: [PATCH 01/35] test: failing node-test fuer renderMarkdown Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/lib/render/markdown.node.test.ts | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 app/src/lib/render/markdown.node.test.ts diff --git a/app/src/lib/render/markdown.node.test.ts b/app/src/lib/render/markdown.node.test.ts new file mode 100644 index 0000000..2b45889 --- /dev/null +++ b/app/src/lib/render/markdown.node.test.ts @@ -0,0 +1,26 @@ +// app/src/lib/render/markdown.node.test.ts +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { renderMarkdown } from './markdown'; + +describe('renderMarkdown (Node-Kontext)', () => { + it('rendert einfaches Markdown im Node-Build ohne window', () => { + const html = renderMarkdown('# Hallo\n\nWelt mit *Kursiv* und [Link](https://example.com)'); + expect(html).toContain('Kursiv'); + expect(html).toContain('href="https://example.com"'); + }); + + it('sanitisiert XSS-Versuche', () => { + const html = renderMarkdown('\n\nText'); + expect(html).not.toContain(' { + const html = renderMarkdown('```ts\nconst x: number = 1;\n```'); + expect(html).toContain('class="hljs'); + expect(html).toContain('language-ts'); + }); +}); From e0d723df147c2ffbd30c18fef49bed9361dfa65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:00:56 +0200 Subject: [PATCH 02/35] feat(render): renderMarkdown auf isomorphic-dompurify umgestellt Funktioniert jetzt sowohl in Browser/jsdom als auch in Node (SvelteKit-Build). Schritt 1 der prerender-snapshot-migration. Verhalten in der SPA unveraendert. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/package.json | 1 + app/src/lib/render/markdown.ts | 20 +------------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/app/package.json b/app/package.json index ddbc357..7a8b932 100644 --- a/app/package.json +++ b/app/package.json @@ -35,6 +35,7 @@ "applesauce-signers": "^5.2.0", "dompurify": "^3.4.0", "highlight.js": "^11.11.1", + "isomorphic-dompurify": "^3.10.0", "marked": "^18.0.0", "nostr-tools": "^2.23.3", "rxjs": "^7.8.2", diff --git a/app/src/lib/render/markdown.ts b/app/src/lib/render/markdown.ts index 0f6f2ff..383fdfe 100644 --- a/app/src/lib/render/markdown.ts +++ b/app/src/lib/render/markdown.ts @@ -1,5 +1,5 @@ import { Marked } from 'marked'; -import DOMPurify from 'dompurify'; +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'; @@ -14,11 +14,6 @@ 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. - * (Spec §3: lokale Ersetzbarkeit der Engine.) - */ const markedInstance = new Marked({ breaks: true, gfm: true, @@ -34,20 +29,7 @@ const markedInstance = new Marked({ } }); -/** - * Rendert einen Markdown-String zu sanitized HTML. - * Einziger Export des Moduls — so bleibt Austausch der Engine lokal. - * - * Nur im Browser/jsdom aufrufen: DOMPurify braucht ein DOM. Die SPA - * hat SSR global ausgeschaltet (`+layout.ts: ssr = false`), Vitest läuft - * in jsdom — beide Szenarien sind abgedeckt. Ein Aufruf in reiner - * Node-Umgebung würde hier laut fehlschlagen statt stumm unsicher - * durchzulaufen. - */ export function renderMarkdown(md: string): string { - if (typeof window === 'undefined') { - throw new Error('renderMarkdown: DOM-Kontext erforderlich (Browser oder jsdom).'); - } const raw = markedInstance.parse(md, { async: false }) as string; return DOMPurify.sanitize(raw); } From c391df0d55de7aca8c88ddb0e865f30ae9cda06c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:05:37 +0200 Subject: [PATCH 03/35] chore(render): alte dompurify-deps entfernt + design-rationale-kommentar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dead-code aus etappe 1 nachgezogen: - dompurify + @types/dompurify aus package.json (jetzt isomorphic-dompurify als einziger sanitizer, bringt eigene typen mit) - design-rationale-kommentar fuer markedInstance zurueckgebracht (Spec §3: lokale ersetzbarkeit der engine — nicht aus dem code ablesbar) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/package.json | 2 -- app/src/lib/render/markdown.ts | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/package.json b/app/package.json index 7a8b932..e75b00f 100644 --- a/app/package.json +++ b/app/package.json @@ -20,7 +20,6 @@ "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/svelte": "^5.3.1", - "@types/dompurify": "^3.0.5", "jsdom": "^29.0.2", "svelte": "^5.55.2", "svelte-check": "^4.4.6", @@ -33,7 +32,6 @@ "applesauce-loaders": "^5.1.0", "applesauce-relay": "^5.2.0", "applesauce-signers": "^5.2.0", - "dompurify": "^3.4.0", "highlight.js": "^11.11.1", "isomorphic-dompurify": "^3.10.0", "marked": "^18.0.0", diff --git a/app/src/lib/render/markdown.ts b/app/src/lib/render/markdown.ts index 383fdfe..6a29211 100644 --- a/app/src/lib/render/markdown.ts +++ b/app/src/lib/render/markdown.ts @@ -14,6 +14,11 @@ 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. + * (Spec §3: lokale Ersetzbarkeit der Engine.) + */ const markedInstance = new Marked({ breaks: true, gfm: true, From b6366ea1fe109759a9c8e8a4a2817f6c650c64b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:08:17 +0200 Subject: [PATCH 04/35] feat(snapshot): modul-skelett Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/.gitignore | 2 ++ snapshot/README.md | 22 ++++++++++++++++++++++ snapshot/deno.jsonc | 31 +++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 snapshot/.gitignore create mode 100644 snapshot/README.md create mode 100644 snapshot/deno.jsonc diff --git a/snapshot/.gitignore b/snapshot/.gitignore new file mode 100644 index 0000000..dbad61f --- /dev/null +++ b/snapshot/.gitignore @@ -0,0 +1,2 @@ +output/ +.last-snapshot.json diff --git a/snapshot/README.md b/snapshot/README.md new file mode 100644 index 0000000..53cb7f0 --- /dev/null +++ b/snapshot/README.md @@ -0,0 +1,22 @@ +# snapshot/ + +Liest die `kind:30023`-Events des Site-Autors von den Read-Relays und +schreibt sie als JSON-Artefakte für den SvelteKit-Prerender-Schritt. +Kein Live-Proxy: Relays werden nur zur Build-Zeit befragt. + +Spec: [`../docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md`](../docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md) + +## Nutzung + +```sh +cd snapshot +deno task snapshot # default +deno task snapshot --out ./output # alternatives Ziel +deno task snapshot --min-events 20 # Schwelle +deno task snapshot --allow-shrink # Drop-Check aus +``` + +Erwartet diese Env-Vars (aus `../.env.local`): + +- `AUTHOR_PUBKEY_HEX` (64 hex chars) +- `BOOTSTRAP_RELAY` (wss-URL) diff --git a/snapshot/deno.jsonc b/snapshot/deno.jsonc new file mode 100644 index 0000000..b8d9feb --- /dev/null +++ b/snapshot/deno.jsonc @@ -0,0 +1,31 @@ +{ + "tasks": { + "snapshot": "deno run --env-file=../.env.local --allow-env --allow-read --allow-write --allow-net src/cli.ts", + "test": "deno test --allow-env --allow-read --allow-write --allow-net", + "fmt": "deno fmt", + "lint": "deno lint" + }, + "imports": { + "@std/yaml": "jsr:@std/yaml@^1.0.5", + "@std/cli": "jsr:@std/cli@^1.0.6", + "@std/fs": "jsr:@std/fs@^1.0.4", + "@std/path": "jsr:@std/path@^1.0.6", + "@std/testing": "jsr:@std/testing@^1.0.3", + "@std/assert": "jsr:@std/assert@^1.0.6", + "@std/encoding": "jsr:@std/encoding@^1.0.5", + "nostr-tools": "npm:nostr-tools@^2.10.4", + "applesauce-relay": "npm:applesauce-relay@^2.0.0", + "rxjs": "npm:rxjs@^7.8.1" + }, + "fmt": { + "lineWidth": 100, + "indentWidth": 2, + "semiColons": false, + "singleQuote": true + }, + "lint": { + "rules": { + "tags": ["recommended"] + } + } +} From 45df54f2b3515b7c4dc8ab7de5649408f1a5fdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:08:46 +0200 Subject: [PATCH 05/35] feat(snapshot): config-loader mit env-validierung Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/config.ts | 15 +++++++++++++++ snapshot/tests/config.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 snapshot/src/core/config.ts create mode 100644 snapshot/tests/config.test.ts diff --git a/snapshot/src/core/config.ts b/snapshot/src/core/config.ts new file mode 100644 index 0000000..bf783ab --- /dev/null +++ b/snapshot/src/core/config.ts @@ -0,0 +1,15 @@ +export interface Config { + authorPubkeyHex: string + bootstrapRelay: string +} + +export function loadConfig(): Config { + const authorPubkeyHex = Deno.env.get('AUTHOR_PUBKEY_HEX') + const bootstrapRelay = Deno.env.get('BOOTSTRAP_RELAY') + if (!authorPubkeyHex) throw new Error('AUTHOR_PUBKEY_HEX fehlt in env') + if (!/^[0-9a-f]{64}$/i.test(authorPubkeyHex)) { + throw new Error('AUTHOR_PUBKEY_HEX muss 64 hex chars sein') + } + if (!bootstrapRelay) throw new Error('BOOTSTRAP_RELAY fehlt in env') + return { authorPubkeyHex, bootstrapRelay } +} diff --git a/snapshot/tests/config.test.ts b/snapshot/tests/config.test.ts new file mode 100644 index 0000000..f34d6bc --- /dev/null +++ b/snapshot/tests/config.test.ts @@ -0,0 +1,22 @@ +import { assertEquals, assertThrows } from '@std/assert' +import { loadConfig } from '../src/core/config.ts' + +Deno.test('loadConfig liest pubkey + bootstrap relay', () => { + Deno.env.set('AUTHOR_PUBKEY_HEX', '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41') + Deno.env.set('BOOTSTRAP_RELAY', 'wss://relay.primal.net') + const cfg = loadConfig() + assertEquals(cfg.authorPubkeyHex, '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41') + assertEquals(cfg.bootstrapRelay, 'wss://relay.primal.net') +}) + +Deno.test('loadConfig wirft bei fehlendem AUTHOR_PUBKEY_HEX', () => { + Deno.env.delete('AUTHOR_PUBKEY_HEX') + Deno.env.set('BOOTSTRAP_RELAY', 'wss://relay.primal.net') + assertThrows(() => loadConfig(), Error, 'AUTHOR_PUBKEY_HEX') +}) + +Deno.test('loadConfig wirft bei ungueltigem hex', () => { + Deno.env.set('AUTHOR_PUBKEY_HEX', 'nicht-hex') + Deno.env.set('BOOTSTRAP_RELAY', 'wss://relay.primal.net') + assertThrows(() => loadConfig(), Error, '64 hex') +}) From 300cd9bea91248881bc3b95cb40acc52380ebbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:09:14 +0200 Subject: [PATCH 06/35] feat(snapshot): dedup-by-d-tag Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/dedup.ts | 14 ++++++++++++++ snapshot/src/core/types.ts | 9 +++++++++ snapshot/tests/dedup.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 snapshot/src/core/dedup.ts create mode 100644 snapshot/src/core/types.ts create mode 100644 snapshot/tests/dedup.test.ts diff --git a/snapshot/src/core/dedup.ts b/snapshot/src/core/dedup.ts new file mode 100644 index 0000000..8c19983 --- /dev/null +++ b/snapshot/src/core/dedup.ts @@ -0,0 +1,14 @@ +import type { SignedEvent } from './types.ts' + +export function dedupByDtag(events: SignedEvent[]): SignedEvent[] { + const byDtag = new Map() + for (const ev of events) { + const d = ev.tags.find((t) => t[0] === 'd')?.[1] + if (!d) continue + const existing = byDtag.get(d) + if (!existing || ev.created_at > existing.created_at) { + byDtag.set(d, ev) + } + } + return [...byDtag.values()] +} diff --git a/snapshot/src/core/types.ts b/snapshot/src/core/types.ts new file mode 100644 index 0000000..7f338c7 --- /dev/null +++ b/snapshot/src/core/types.ts @@ -0,0 +1,9 @@ +export interface SignedEvent { + id: string + pubkey: string + created_at: number + kind: number + tags: string[][] + content: string + sig: string +} diff --git a/snapshot/tests/dedup.test.ts b/snapshot/tests/dedup.test.ts new file mode 100644 index 0000000..726cc4d --- /dev/null +++ b/snapshot/tests/dedup.test.ts @@ -0,0 +1,29 @@ +import { assertEquals } from '@std/assert' +import { dedupByDtag } from '../src/core/dedup.ts' +import type { SignedEvent } from '../src/core/types.ts' + +function ev(d: string, created_at: number, id: string): SignedEvent { + return { + id, pubkey: 'p', created_at, kind: 30023, sig: 's', content: '', + tags: [['d', d]], + } +} + +Deno.test('dedupByDtag behaelt das neueste event pro d-tag', () => { + const out = dedupByDtag([ + ev('a', 100, 'a-old'), + ev('a', 200, 'a-new'), + ev('b', 50, 'b-only'), + ]) + const ids = out.map((e) => e.id).sort() + assertEquals(ids, ['a-new', 'b-only']) +}) + +Deno.test('dedupByDtag laesst events ohne d-tag weg', () => { + const out = dedupByDtag([ + { id: 'x', pubkey: 'p', created_at: 1, kind: 30023, sig: 's', content: '', tags: [] }, + ev('a', 1, 'a'), + ]) + assertEquals(out.length, 1) + assertEquals(out[0].id, 'a') +}) From ccd7daf14dc79f571dedeb0cafc15f90fe894617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:09:37 +0200 Subject: [PATCH 07/35] feat(snapshot): NIP-09-filter Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/nip09-filter.ts | 22 +++++++++++++++++++++ snapshot/tests/nip09-filter.test.ts | 30 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 snapshot/src/core/nip09-filter.ts create mode 100644 snapshot/tests/nip09-filter.test.ts diff --git a/snapshot/src/core/nip09-filter.ts b/snapshot/src/core/nip09-filter.ts new file mode 100644 index 0000000..cd2d602 --- /dev/null +++ b/snapshot/src/core/nip09-filter.ts @@ -0,0 +1,22 @@ +import type { SignedEvent } from './types.ts' + +export function filterDeleted( + events: SignedEvent[], + deletions: SignedEvent[], + authorPubkey: string, +): SignedEvent[] { + const deletedCoords = new Set() + for (const del of deletions) { + if (del.kind !== 5) continue + if (del.pubkey !== authorPubkey) continue + for (const tag of del.tags) { + if (tag[0] === 'a' && tag[1]) deletedCoords.add(tag[1]) + } + } + return events.filter((ev) => { + const d = ev.tags.find((t) => t[0] === 'd')?.[1] + if (!d) return true + const coord = `${ev.kind}:${ev.pubkey}:${d}` + return !deletedCoords.has(coord) + }) +} diff --git a/snapshot/tests/nip09-filter.test.ts b/snapshot/tests/nip09-filter.test.ts new file mode 100644 index 0000000..0eabf6b --- /dev/null +++ b/snapshot/tests/nip09-filter.test.ts @@ -0,0 +1,30 @@ +import { assertEquals } from '@std/assert' +import { filterDeleted } from '../src/core/nip09-filter.ts' +import type { SignedEvent } from '../src/core/types.ts' + +function post(d: string, id: string): SignedEvent { + return { id, pubkey: 'P', created_at: 1, kind: 30023, sig: 's', content: '', tags: [['d', d]] } +} +function deletion(coords: string[]): SignedEvent { + return { + id: 'del', pubkey: 'P', created_at: 2, kind: 5, sig: 's', content: '', + tags: coords.map((c) => ['a', c]), + } +} + +Deno.test('filterDeleted entfernt events deren coord in einem kind:5 referenziert ist', () => { + const out = filterDeleted( + [post('alive', 'a'), post('dead', 'b')], + [deletion(['30023:P:dead'])], + 'P', + ) + assertEquals(out.map((e) => e.id), ['a']) +}) + +Deno.test('filterDeleted ignoriert kind:5 fremder pubkeys', () => { + const fremde: SignedEvent = { + ...deletion(['30023:P:alive']), pubkey: 'OTHER', + } + const out = filterDeleted([post('alive', 'a')], [fremde], 'P') + assertEquals(out.length, 1) +}) From 7e38b737851062d95e72c6a51717d9bb3db127ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:10:07 +0200 Subject: [PATCH 08/35] feat(snapshot): plausibilitaets-checks (relay-quorum, drop, min-events) Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/checks.ts | 36 +++++++++++++++++++++ snapshot/tests/checks.test.ts | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 snapshot/src/core/checks.ts create mode 100644 snapshot/tests/checks.test.ts diff --git a/snapshot/src/core/checks.ts b/snapshot/src/core/checks.ts new file mode 100644 index 0000000..d2fc97b --- /dev/null +++ b/snapshot/src/core/checks.ts @@ -0,0 +1,36 @@ +export interface CheckInput { + relaysQueried: number + relaysResponded: number + eventCount: number + minEvents: number + lastKnownGoodCount: number | undefined + newDeletionsCount: number + allowShrink: boolean +} + +export function runChecks(input: CheckInput): void { + const quorum = Math.ceil(input.relaysQueried * 0.6) + if (input.relaysResponded < quorum) { + throw new Error( + `Relay-Quorum nicht erreicht: ${input.relaysResponded}/${input.relaysQueried} ` + + `(brauche mindestens ${quorum})`, + ) + } + if (input.eventCount < input.minEvents) { + throw new Error( + `Event-Count ${input.eventCount} unter min-events ${input.minEvents}`, + ) + } + if (input.lastKnownGoodCount !== undefined && !input.allowShrink) { + const drop = input.lastKnownGoodCount - input.eventCount + const dropPct = drop / input.lastKnownGoodCount + if (dropPct > 0.2 && drop > input.newDeletionsCount) { + throw new Error( + `Event-Count-Drop ${drop} (${(dropPct * 100).toFixed(0)}%) gegenueber ` + + `last-known-good ${input.lastKnownGoodCount}, ` + + `nur ${input.newDeletionsCount} korrespondierende kind:5. ` + + `Override mit --allow-shrink falls bewusst.`, + ) + } + } +} diff --git a/snapshot/tests/checks.test.ts b/snapshot/tests/checks.test.ts new file mode 100644 index 0000000..65547c0 --- /dev/null +++ b/snapshot/tests/checks.test.ts @@ -0,0 +1,59 @@ +import { assertEquals, assertThrows } from '@std/assert' +import { runChecks } from '../src/core/checks.ts' + +Deno.test('runChecks: weniger als 60% relays geantwortet -> hard-fail', () => { + assertThrows( + () => runChecks({ + relaysQueried: 5, relaysResponded: 2, + eventCount: 27, minEvents: 1, lastKnownGoodCount: undefined, + newDeletionsCount: 0, allowShrink: false, + }), + Error, 'Relay-Quorum', + ) +}) + +Deno.test('runChecks: event-count unter min-events -> hard-fail', () => { + assertThrows( + () => runChecks({ + relaysQueried: 5, relaysResponded: 5, + eventCount: 0, minEvents: 1, lastKnownGoodCount: undefined, + newDeletionsCount: 0, allowShrink: false, + }), + Error, 'min-events', + ) +}) + +Deno.test('runChecks: drop > 20% ohne kind:5 -> hard-fail', () => { + assertThrows( + () => runChecks({ + relaysQueried: 5, relaysResponded: 5, + eventCount: 20, minEvents: 1, lastKnownGoodCount: 27, + newDeletionsCount: 0, allowShrink: false, + }), + Error, 'Event-Count-Drop', + ) +}) + +Deno.test('runChecks: drop > 20% mit korrespondierenden kind:5 -> ok', () => { + runChecks({ + relaysQueried: 5, relaysResponded: 5, + eventCount: 20, minEvents: 1, lastKnownGoodCount: 27, + newDeletionsCount: 7, allowShrink: false, + }) +}) + +Deno.test('runChecks: --allow-shrink umgeht drop-check', () => { + runChecks({ + relaysQueried: 5, relaysResponded: 5, + eventCount: 1, minEvents: 1, lastKnownGoodCount: 27, + newDeletionsCount: 0, allowShrink: true, + }) +}) + +Deno.test('runChecks: erstlauf ohne cache + min-events=1 -> ok', () => { + runChecks({ + relaysQueried: 5, relaysResponded: 5, + eventCount: 1, minEvents: 1, lastKnownGoodCount: undefined, + newDeletionsCount: 0, allowShrink: false, + }) +}) From 4b2c157938a5d2c3b73003f4e91c341b13ae4b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:10:55 +0200 Subject: [PATCH 09/35] feat(snapshot): post-json-builder mit fallback-summary Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/post-json.ts | 108 +++++++++++++++++++++++++++++++ snapshot/tests/post-json.test.ts | 83 ++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 snapshot/src/core/post-json.ts create mode 100644 snapshot/tests/post-json.test.ts diff --git a/snapshot/src/core/post-json.ts b/snapshot/src/core/post-json.ts new file mode 100644 index 0000000..b28e510 --- /dev/null +++ b/snapshot/src/core/post-json.ts @@ -0,0 +1,108 @@ +import { nip19 } from 'nostr-tools' +import type { SignedEvent } from './types.ts' + +export interface CoverImage { + url: string + width?: number + height?: number + alt?: string + mime?: string +} + +export interface TranslationRef { + lang: string + slug: string + title: string +} + +export interface PostJson { + slug: string + event_id: string + created_at: number + published_at: number + title: string + summary: string + lang: string + cover_image: CoverImage | null + content_markdown: string + tags: string[] + naddr: string + habla_url: string + translations: TranslationRef[] +} + +const SUMMARY_MAX = 200 + +function tagValue(ev: SignedEvent, name: string): string | undefined { + return ev.tags.find((t) => t[0] === name)?.[1] +} + +function tagsAll(ev: SignedEvent, name: string): string[] { + return ev.tags.filter((t) => t[0] === name).map((t) => t[1]) +} + +function deriveSummary(content: string): string { + const flat = content.replace(/\s+/g, ' ').trim() + if (flat.length <= SUMMARY_MAX) return flat + const cut = flat.slice(0, SUMMARY_MAX) + const lastSpace = cut.lastIndexOf(' ') + const trimmed = lastSpace > SUMMARY_MAX * 0.5 ? cut.slice(0, lastSpace) : cut + return trimmed + '…' +} + +export function buildPostJson( + ev: SignedEvent, + titleByDtag: Map, +): PostJson { + const slug = tagValue(ev, 'd') ?? '' + const title = tagValue(ev, 'title') ?? '' + const summaryTag = tagValue(ev, 'summary') + const summary = summaryTag && summaryTag.length > 0 ? summaryTag : deriveSummary(ev.content) + const image = tagValue(ev, 'image') + const publishedAtRaw = tagValue(ev, 'published_at') + const publishedAt = publishedAtRaw ? parseInt(publishedAtRaw, 10) : ev.created_at + const lang = ev.tags.find((t) => t[0] === 'l' && t[2] === 'ISO-639-1')?.[1] ?? 'de' + + const cover_image: CoverImage | null = image + ? { url: image, alt: title || undefined } + : null + + const naddr = nip19.naddrEncode({ + kind: ev.kind, + pubkey: ev.pubkey, + identifier: slug, + }) + + const translations: TranslationRef[] = [] + for (const tag of ev.tags) { + if (tag[0] !== 'a') continue + if (tag[3] !== 'translation') continue + const coord = tag[1] + if (!coord) continue + const parts = coord.split(':') + if (parts.length !== 3) continue + const otherSlug = parts[2] + const otherTitle = titleByDtag.get(otherSlug) ?? otherSlug + translations.push({ + lang: lang === 'de' ? 'en' : 'de', + slug: otherSlug, + title: otherTitle, + }) + } + + return { + slug, + event_id: ev.id, + created_at: ev.created_at, + published_at: publishedAt, + title, + summary, + lang, + cover_image, + content_markdown: ev.content, + tags: tagsAll(ev, 't'), + naddr, + habla_url: `https://habla.news/a/${naddr}`, + translations, + } +} diff --git a/snapshot/tests/post-json.test.ts b/snapshot/tests/post-json.test.ts new file mode 100644 index 0000000..1d2b89b --- /dev/null +++ b/snapshot/tests/post-json.test.ts @@ -0,0 +1,83 @@ +import { assertEquals } from '@std/assert' +import { buildPostJson } from '../src/core/post-json.ts' +import type { SignedEvent } from '../src/core/types.ts' + +const PUBKEY = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41' + +function buildEvent(opts: { + d: string + title: string + summary?: string + image?: string + publishedAt?: number + lang?: string + tags?: string[] + translationCoords?: string[] + content: string +}): SignedEvent { + const tags: string[][] = [['d', opts.d], ['title', opts.title]] + if (opts.summary) tags.push(['summary', opts.summary]) + if (opts.image) tags.push(['image', opts.image]) + if (opts.publishedAt) tags.push(['published_at', String(opts.publishedAt)]) + if (opts.lang) { + tags.push(['L', 'ISO-639-1']) + tags.push(['l', opts.lang, 'ISO-639-1']) + } + for (const t of opts.tags ?? []) tags.push(['t', t]) + for (const c of opts.translationCoords ?? []) tags.push(['a', c, '', 'translation']) + return { + id: 'event-' + opts.d, pubkey: PUBKEY, created_at: 1700000000, kind: 30023, + sig: 'sig', content: opts.content, tags, + } +} + +Deno.test('buildPostJson: vollstaendiges event', () => { + const ev = buildEvent({ + d: 'bibel-selfies', title: 'Bibel-Selfies', summary: 'Kurz', + image: 'https://blossom.edufeed.org/abc.jpg', + publishedAt: 1699000000, lang: 'de', tags: ['Bibel'], + translationCoords: [`30023:${PUBKEY}:bible-selfies`], + content: '# body', + }) + const titleByDtag = new Map([['bible-selfies', 'Bible-Selfies']]) + const json = buildPostJson(ev, titleByDtag) + assertEquals(json.slug, 'bibel-selfies') + assertEquals(json.title, 'Bibel-Selfies') + assertEquals(json.summary, 'Kurz') + assertEquals(json.lang, 'de') + assertEquals(json.tags, ['Bibel']) + assertEquals(json.published_at, 1699000000) + assertEquals(json.cover_image?.url, 'https://blossom.edufeed.org/abc.jpg') + assertEquals(json.translations, [ + { lang: 'en', slug: 'bible-selfies', title: 'Bible-Selfies' }, + ]) + assertEquals(json.content_markdown, '# body') +}) + +Deno.test('buildPostJson: fallback summary aus content', () => { + const ev = buildEvent({ + d: 'no-summary', title: 'X', content: 'Lorem ipsum dolor sit amet.'.repeat(20), + }) + const json = buildPostJson(ev, new Map()) + if (!json.summary) throw new Error('summary fehlt') + if (json.summary.length > 220) throw new Error('summary zu lang') + if (!json.summary.endsWith('…')) throw new Error('summary ohne ellipsis') +}) + +Deno.test('buildPostJson: fehlt published_at -> created_at', () => { + const ev = buildEvent({ d: 'no-pub', title: 'X', content: 'x' }) + const json = buildPostJson(ev, new Map()) + assertEquals(json.published_at, 1700000000) +}) + +Deno.test('buildPostJson: fehlt image -> cover_image null', () => { + const ev = buildEvent({ d: 'no-img', title: 'X', content: 'x' }) + const json = buildPostJson(ev, new Map()) + assertEquals(json.cover_image, null) +}) + +Deno.test('buildPostJson: lang default de wenn keine l-tags', () => { + const ev = buildEvent({ d: 'no-lang', title: 'X', content: 'x' }) + const json = buildPostJson(ev, new Map()) + assertEquals(json.lang, 'de') +}) From 2af44035b880391e0430cf8abc6df409d13f4dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:11:17 +0200 Subject: [PATCH 10/35] feat(snapshot): cover-image-HEAD-probe-modul Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/cover-probe.ts | 23 +++++++++++++++++++++++ snapshot/tests/cover-probe.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 snapshot/src/core/cover-probe.ts create mode 100644 snapshot/tests/cover-probe.test.ts diff --git a/snapshot/src/core/cover-probe.ts b/snapshot/src/core/cover-probe.ts new file mode 100644 index 0000000..10a47d3 --- /dev/null +++ b/snapshot/src/core/cover-probe.ts @@ -0,0 +1,23 @@ +export interface ProbeResult { + reachable: boolean + status: number +} + +export type HeadFetcher = (url: string) => Promise<{ ok: boolean; status: number }> + +export const defaultHeadFetcher: HeadFetcher = async (url) => { + const resp = await fetch(url, { method: 'HEAD' }) + return { ok: resp.ok, status: resp.status } +} + +export async function probeCover( + url: string, + fetcher: HeadFetcher = defaultHeadFetcher, +): Promise { + try { + const r = await fetcher(url) + return { reachable: r.ok, status: r.status } + } catch { + return { reachable: false, status: 0 } + } +} diff --git a/snapshot/tests/cover-probe.test.ts b/snapshot/tests/cover-probe.test.ts new file mode 100644 index 0000000..34499a1 --- /dev/null +++ b/snapshot/tests/cover-probe.test.ts @@ -0,0 +1,22 @@ +import { assertEquals } from '@std/assert' +import { probeCover, type HeadFetcher } from '../src/core/cover-probe.ts' + +Deno.test('probeCover: 200 -> reachable=true', async () => { + const fetcher: HeadFetcher = async () => ({ ok: true, status: 200 }) + const r = await probeCover('https://blossom.example/abc.jpg', fetcher) + assertEquals(r, { reachable: true, status: 200 }) +}) + +Deno.test('probeCover: 404 -> reachable=false', async () => { + const fetcher: HeadFetcher = async () => ({ ok: false, status: 404 }) + const r = await probeCover('https://blossom.example/abc.jpg', fetcher) + assertEquals(r, { reachable: false, status: 404 }) +}) + +Deno.test('probeCover: network error -> reachable=false', async () => { + const fetcher: HeadFetcher = async () => { + throw new Error('ECONNREFUSED') + } + const r = await probeCover('https://blossom.example/abc.jpg', fetcher) + assertEquals(r, { reachable: false, status: 0 }) +}) From a199f1daf104eee9146870dfcda27470c5c7dee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:11:36 +0200 Subject: [PATCH 11/35] feat(snapshot): cache-state fuer last-known-good Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/cache.ts | 18 ++++++++++++++++++ snapshot/tests/cache.test.ts | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 snapshot/src/core/cache.ts create mode 100644 snapshot/tests/cache.test.ts diff --git a/snapshot/src/core/cache.ts b/snapshot/src/core/cache.ts new file mode 100644 index 0000000..c3c315d --- /dev/null +++ b/snapshot/src/core/cache.ts @@ -0,0 +1,18 @@ +export interface CacheState { + lastKnownGoodCount: number + deletedCoords: string[] +} + +export async function readCache(path: string): Promise { + try { + const text = await Deno.readTextFile(path) + return JSON.parse(text) as CacheState + } catch (err) { + if (err instanceof Deno.errors.NotFound) return undefined + throw err + } +} + +export async function writeCache(path: string, state: CacheState): Promise { + await Deno.writeTextFile(path, JSON.stringify(state, null, 2) + '\n') +} diff --git a/snapshot/tests/cache.test.ts b/snapshot/tests/cache.test.ts new file mode 100644 index 0000000..4b66e17 --- /dev/null +++ b/snapshot/tests/cache.test.ts @@ -0,0 +1,19 @@ +import { assertEquals } from '@std/assert' +import { join } from '@std/path' +import { readCache, writeCache, type CacheState } from '../src/core/cache.ts' + +Deno.test('readCache: file fehlt -> undefined', async () => { + const dir = await Deno.makeTempDir() + const path = join(dir, 'cache.json') + const cache = await readCache(path) + assertEquals(cache, undefined) +}) + +Deno.test('writeCache + readCache: round-trip', async () => { + const dir = await Deno.makeTempDir() + const path = join(dir, 'cache.json') + const state: CacheState = { lastKnownGoodCount: 27, deletedCoords: ['30023:P:dead'] } + await writeCache(path, state) + const out = await readCache(path) + assertEquals(out, state) +}) From 075549893758ba41d628f073706a862e863e689f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:12:01 +0200 Subject: [PATCH 12/35] feat(snapshot): output-writer (index.json + posts/.json) Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/output.ts | 41 +++++++++++++++++++++++++++++++++++ snapshot/tests/output.test.ts | 36 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 snapshot/src/core/output.ts create mode 100644 snapshot/tests/output.test.ts diff --git a/snapshot/src/core/output.ts b/snapshot/src/core/output.ts new file mode 100644 index 0000000..77b68c3 --- /dev/null +++ b/snapshot/src/core/output.ts @@ -0,0 +1,41 @@ +import { ensureDir } from '@std/fs' +import { join } from '@std/path' +import type { PostJson } from './post-json.ts' + +export interface OutputInput { + generatedAt: string + authorPubkey: string + relaysQueried: string[] + relaysResponded: string[] + posts: PostJson[] +} + +export async function writeOutput(outDir: string, input: OutputInput): Promise { + await ensureDir(outDir) + await ensureDir(join(outDir, 'posts')) + + const index = { + generated_at: input.generatedAt, + author_pubkey: input.authorPubkey, + relays_queried: input.relaysQueried, + relays_responded: input.relaysResponded, + post_count: input.posts.length, + posts: input.posts.map((p) => ({ + slug: p.slug, + lang: p.lang, + created_at: p.created_at, + title: p.title, + })), + } + await Deno.writeTextFile( + join(outDir, 'index.json'), + JSON.stringify(index, null, 2) + '\n', + ) + + for (const post of input.posts) { + await Deno.writeTextFile( + join(outDir, 'posts', `${post.slug}.json`), + JSON.stringify(post, null, 2) + '\n', + ) + } +} diff --git a/snapshot/tests/output.test.ts b/snapshot/tests/output.test.ts new file mode 100644 index 0000000..862b7ee --- /dev/null +++ b/snapshot/tests/output.test.ts @@ -0,0 +1,36 @@ +import { assertEquals } from '@std/assert' +import { join } from '@std/path' +import { writeOutput } from '../src/core/output.ts' +import type { PostJson } from '../src/core/post-json.ts' + +const samplePost: PostJson = { + slug: 'a', event_id: 'e1', created_at: 1, published_at: 1, + title: 'A', summary: 's', lang: 'de', cover_image: null, + content_markdown: '# A', tags: [], naddr: 'naddr1', habla_url: 'https://habla.news/a/naddr1', + translations: [], +} + +Deno.test('writeOutput schreibt index.json + posts/.json', async () => { + const dir = await Deno.makeTempDir() + await writeOutput(dir, { + generatedAt: '2026-04-28T10:00:00Z', + authorPubkey: 'P', + relaysQueried: ['wss://r1', 'wss://r2'], + relaysResponded: ['wss://r1'], + posts: [samplePost], + }) + + const indexText = await Deno.readTextFile(join(dir, 'index.json')) + const index = JSON.parse(indexText) + assertEquals(index.author_pubkey, 'P') + assertEquals(index.post_count, 1) + assertEquals(index.posts.length, 1) + assertEquals(index.posts[0].slug, 'a') + assertEquals(index.posts[0].title, 'A') + assertEquals(index.posts[0].lang, 'de') + + const postText = await Deno.readTextFile(join(dir, 'posts', 'a.json')) + const post = JSON.parse(postText) + assertEquals(post.slug, 'a') + assertEquals(post.content_markdown, '# A') +}) From 848cdf763e34034df3315544915f914e1daa282d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:19:20 +0200 Subject: [PATCH 13/35] fix(snapshot): NIP-09-filter beachtet zeitliche reihenfolge Per NIP-09 darf ein deletion nur events mit created_at <= deletion.created_at loeschen. Vorher wurde ein re-publizierter post nach geloeschtem vorgaenger stumm wegfiltern. Code-review-feedback aus etappe 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/nip09-filter.ts | 11 ++++++++--- snapshot/tests/nip09-filter.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/snapshot/src/core/nip09-filter.ts b/snapshot/src/core/nip09-filter.ts index cd2d602..0caaf7e 100644 --- a/snapshot/src/core/nip09-filter.ts +++ b/snapshot/src/core/nip09-filter.ts @@ -5,18 +5,23 @@ export function filterDeleted( deletions: SignedEvent[], authorPubkey: string, ): SignedEvent[] { - const deletedCoords = new Set() + const deletedAtByCoord = new Map() for (const del of deletions) { if (del.kind !== 5) continue if (del.pubkey !== authorPubkey) continue for (const tag of del.tags) { - if (tag[0] === 'a' && tag[1]) deletedCoords.add(tag[1]) + if (tag[0] !== 'a' || !tag[1]) continue + const previous = deletedAtByCoord.get(tag[1]) + if (previous === undefined || del.created_at > previous) { + deletedAtByCoord.set(tag[1], del.created_at) + } } } return events.filter((ev) => { const d = ev.tags.find((t) => t[0] === 'd')?.[1] if (!d) return true const coord = `${ev.kind}:${ev.pubkey}:${d}` - return !deletedCoords.has(coord) + const deletedAt = deletedAtByCoord.get(coord) + return deletedAt === undefined || ev.created_at > deletedAt }) } diff --git a/snapshot/tests/nip09-filter.test.ts b/snapshot/tests/nip09-filter.test.ts index 0eabf6b..2112709 100644 --- a/snapshot/tests/nip09-filter.test.ts +++ b/snapshot/tests/nip09-filter.test.ts @@ -28,3 +28,30 @@ Deno.test('filterDeleted ignoriert kind:5 fremder pubkeys', () => { const out = filterDeleted([post('alive', 'a')], [fremde], 'P') assertEquals(out.length, 1) }) + +Deno.test('filterDeleted: re-publizierter post (post.created_at > deletion.created_at) bleibt erhalten', () => { + const oldDelete: SignedEvent = { + id: 'del', pubkey: 'P', created_at: 100, kind: 5, sig: 's', content: '', + tags: [['a', '30023:P:resurrected']], + } + const newPost: SignedEvent = { + id: 'new', pubkey: 'P', created_at: 200, kind: 30023, sig: 's', content: '', + tags: [['d', 'resurrected']], + } + const out = filterDeleted([newPost], [oldDelete], 'P') + assertEquals(out.length, 1) + assertEquals(out[0].id, 'new') +}) + +Deno.test('filterDeleted: post mit created_at <= deletion.created_at wird entfernt', () => { + const newDelete: SignedEvent = { + id: 'del', pubkey: 'P', created_at: 200, kind: 5, sig: 's', content: '', + tags: [['a', '30023:P:dead']], + } + const oldPost: SignedEvent = { + id: 'old', pubkey: 'P', created_at: 100, kind: 30023, sig: 's', content: '', + tags: [['d', 'dead']], + } + const out = filterDeleted([oldPost], [newDelete], 'P') + assertEquals(out.length, 0) +}) From 715c1f5e1e92b9c395ae508e89fd3772d8d16ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:19:51 +0200 Subject: [PATCH 14/35] fix(snapshot): tagsAll filtert tags ohne value Vorher konnten malformed tags wie ['t'] (ohne second element) undefined ins string[]-array werfen, das im JSON als null landete. Code-review-feedback aus etappe 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/post-json.ts | 4 +++- snapshot/tests/post-json.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/snapshot/src/core/post-json.ts b/snapshot/src/core/post-json.ts index b28e510..994bfb9 100644 --- a/snapshot/src/core/post-json.ts +++ b/snapshot/src/core/post-json.ts @@ -38,7 +38,9 @@ function tagValue(ev: SignedEvent, name: string): string | undefined { } function tagsAll(ev: SignedEvent, name: string): string[] { - return ev.tags.filter((t) => t[0] === name).map((t) => t[1]) + return ev.tags + .filter((t) => t[0] === name && typeof t[1] === 'string') + .map((t) => t[1] as string) } function deriveSummary(content: string): string { diff --git a/snapshot/tests/post-json.test.ts b/snapshot/tests/post-json.test.ts index 1d2b89b..e22043b 100644 --- a/snapshot/tests/post-json.test.ts +++ b/snapshot/tests/post-json.test.ts @@ -81,3 +81,19 @@ Deno.test('buildPostJson: lang default de wenn keine l-tags', () => { const json = buildPostJson(ev, new Map()) assertEquals(json.lang, 'de') }) + +Deno.test('buildPostJson: malformed t-tag ohne value wird ignoriert', () => { + const ev: SignedEvent = { + id: 'event-malformed', pubkey: PUBKEY, created_at: 1700000000, kind: 30023, + sig: 'sig', content: 'x', + tags: [ + ['d', 'malformed'], + ['title', 'X'], + ['t', 'gut'], + ['t'], // malformed: kein value + ['t', 'auch-gut'], + ], + } + const json = buildPostJson(ev, new Map()) + assertEquals(json.tags, ['gut', 'auch-gut']) +}) From 1827817ad514d48fab9f7cb17c68247b1621f600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:20:01 +0200 Subject: [PATCH 15/35] docs(snapshot): drop-check-semantik dokumentiert Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/checks.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/snapshot/src/core/checks.ts b/snapshot/src/core/checks.ts index d2fc97b..59ecf49 100644 --- a/snapshot/src/core/checks.ts +++ b/snapshot/src/core/checks.ts @@ -21,6 +21,13 @@ export function runChecks(input: CheckInput): void { `Event-Count ${input.eventCount} unter min-events ${input.minEvents}`, ) } + // Drop-Check: hard-fail bei jedem unerklaerten Event-Verlust > 20%. + // Bedingung "drop > newDeletionsCount" heisst: ein einziges nicht durch + // kind:5 abgedecktes verschwundenes event reicht zum fail. Bewusst strikt, + // weil ein versehentlich verschwundener post schlimmer ist als ein + // false-positive-failure (override mit --allow-shrink). Wer das tunen + // will, sollte die bedingung auf "drop - newDeletionsCount > schwelle" + // umstellen. if (input.lastKnownGoodCount !== undefined && !input.allowShrink) { const drop = input.lastKnownGoodCount - input.eventCount const dropPct = drop / input.lastKnownGoodCount From 998e08e073f730cb28d29cd2373ad30e143d74eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:20:29 +0200 Subject: [PATCH 16/35] feat(snapshot): config validiert BOOTSTRAP_RELAY-prefix Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/config.ts | 3 +++ snapshot/tests/config.test.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/snapshot/src/core/config.ts b/snapshot/src/core/config.ts index bf783ab..1a94d60 100644 --- a/snapshot/src/core/config.ts +++ b/snapshot/src/core/config.ts @@ -11,5 +11,8 @@ export function loadConfig(): Config { throw new Error('AUTHOR_PUBKEY_HEX muss 64 hex chars sein') } if (!bootstrapRelay) throw new Error('BOOTSTRAP_RELAY fehlt in env') + if (!bootstrapRelay.startsWith('wss://') && !bootstrapRelay.startsWith('ws://')) { + throw new Error('BOOTSTRAP_RELAY muss eine wss:// (oder ws://) URL sein') + } return { authorPubkeyHex, bootstrapRelay } } diff --git a/snapshot/tests/config.test.ts b/snapshot/tests/config.test.ts index f34d6bc..e1f5fcf 100644 --- a/snapshot/tests/config.test.ts +++ b/snapshot/tests/config.test.ts @@ -20,3 +20,9 @@ Deno.test('loadConfig wirft bei ungueltigem hex', () => { Deno.env.set('BOOTSTRAP_RELAY', 'wss://relay.primal.net') assertThrows(() => loadConfig(), Error, '64 hex') }) + +Deno.test('loadConfig wirft bei ungueltigem BOOTSTRAP_RELAY (kein wss://)', () => { + Deno.env.set('AUTHOR_PUBKEY_HEX', '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41') + Deno.env.set('BOOTSTRAP_RELAY', 'http://relay.example.com') + assertThrows(() => loadConfig(), Error, 'wss://') +}) From 63b68411e4e94900c66fe2929580d5cef77874af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:20:51 +0200 Subject: [PATCH 17/35] feat(snapshot): cache validiert format beim lesen Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/cache.ts | 14 ++++++++++++-- snapshot/tests/cache.test.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/snapshot/src/core/cache.ts b/snapshot/src/core/cache.ts index c3c315d..f5870fb 100644 --- a/snapshot/src/core/cache.ts +++ b/snapshot/src/core/cache.ts @@ -4,13 +4,23 @@ export interface CacheState { } export async function readCache(path: string): Promise { + let text: string try { - const text = await Deno.readTextFile(path) - return JSON.parse(text) as CacheState + text = await Deno.readTextFile(path) } catch (err) { if (err instanceof Deno.errors.NotFound) return undefined throw err } + const parsed = JSON.parse(text) as unknown + if ( + !parsed || + typeof parsed !== 'object' || + typeof (parsed as { lastKnownGoodCount?: unknown }).lastKnownGoodCount !== 'number' || + !Array.isArray((parsed as { deletedCoords?: unknown }).deletedCoords) + ) { + throw new Error('Cache-File hat unbekanntes Format — bitte loeschen und neu starten') + } + return parsed as CacheState } export async function writeCache(path: string, state: CacheState): Promise { diff --git a/snapshot/tests/cache.test.ts b/snapshot/tests/cache.test.ts index 4b66e17..2eb630c 100644 --- a/snapshot/tests/cache.test.ts +++ b/snapshot/tests/cache.test.ts @@ -17,3 +17,18 @@ Deno.test('writeCache + readCache: round-trip', async () => { const out = await readCache(path) assertEquals(out, state) }) + +Deno.test('readCache wirft bei korruptem cache-file', async () => { + const dir = await Deno.makeTempDir() + const path = join(dir, 'cache.json') + await Deno.writeTextFile(path, '{"unsinn": 42}') + let threw = false + try { + await readCache(path) + } catch (err) { + threw = true + if (!(err instanceof Error)) throw err + if (!err.message.includes('Cache-File')) throw err + } + if (!threw) throw new Error('readCache haette werfen sollen') +}) From 49c740d908eae8f9751b46938dfbf22af321d2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:21:00 +0200 Subject: [PATCH 18/35] docs(snapshot): dedup-tie-break dokumentiert Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/dedup.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/snapshot/src/core/dedup.ts b/snapshot/src/core/dedup.ts index 8c19983..fb6275c 100644 --- a/snapshot/src/core/dedup.ts +++ b/snapshot/src/core/dedup.ts @@ -2,6 +2,8 @@ import type { SignedEvent } from './types.ts' export function dedupByDtag(events: SignedEvent[]): SignedEvent[] { const byDtag = new Map() + // Bei gleicher created_at gewinnt das zuerst gesehene event (relay-delivery- + // reihenfolge ist nicht-deterministisch, equal-timestamp = aequivalent). for (const ev of events) { const d = ev.tags.find((t) => t[0] === 'd')?.[1] if (!d) continue From 10cb0d947def5121b45f55d6ce62b916e1b36e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:21:08 +0200 Subject: [PATCH 19/35] docs(snapshot): multi-lang-TODO fuer translation-inferenz Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/post-json.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/snapshot/src/core/post-json.ts b/snapshot/src/core/post-json.ts index 994bfb9..9427302 100644 --- a/snapshot/src/core/post-json.ts +++ b/snapshot/src/core/post-json.ts @@ -75,6 +75,10 @@ export function buildPostJson( identifier: slug, }) + // TODO multi-lang: aktuell ableitung "andere sprache = en wenn lang=de, sonst de" + // funktioniert nur fuer den 2-sprachen-fall. Bei 3+ sprachen muss die lang aus dem + // referenzierten event ausgelesen werden — dafuer braucht buildPostJson zugriff + // auf den event-pool, nicht nur auf titleByDtag. const translations: TranslationRef[] = [] for (const tag of ev.tags) { if (tag[0] !== 'a') continue From d8a29ca389b0b08697872bc57e98225e3419a417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:23:25 +0200 Subject: [PATCH 20/35] feat(snapshot): relay-loader (kind:10002 + event-fetch) Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/core/relays.ts | 99 +++++++++++++++++++++++++++++++++++ snapshot/tests/relays.test.ts | 33 ++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 snapshot/src/core/relays.ts create mode 100644 snapshot/tests/relays.test.ts diff --git a/snapshot/src/core/relays.ts b/snapshot/src/core/relays.ts new file mode 100644 index 0000000..348010d --- /dev/null +++ b/snapshot/src/core/relays.ts @@ -0,0 +1,99 @@ +import { Relay } from 'applesauce-relay' +import { firstValueFrom, timeout } from 'rxjs' +import type { SignedEvent } from './types.ts' + +export type RelayListLoader = ( + bootstrapRelay: string, + authorPubkey: string, +) => Promise + +export const FALLBACK_READ_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', + 'wss://relay.tchncs.de', + 'wss://relay.edufeed.org', +] + +export function extractReadRelays(kind10002: SignedEvent): string[] { + const out: string[] = [] + for (const tag of kind10002.tags) { + if (tag[0] !== 'r' || !tag[1]) continue + const marker = tag[2] + if (marker === 'write') continue + out.push(tag[1]) + } + return out +} + +export const defaultRelayListLoader: RelayListLoader = async (bootstrap, pubkey) => { + try { + const relay = new Relay(bootstrap) + const ev = await firstValueFrom( + relay.request({ kinds: [10002], authors: [pubkey], limit: 1 }) + .pipe(timeout({ first: 5_000 })), + ) + return ev as SignedEvent + } catch { + return undefined + } +} + +export async function loadReadRelays( + bootstrapRelay: string, + authorPubkey: string, + loader: RelayListLoader = defaultRelayListLoader, + fallback: string[] = FALLBACK_READ_RELAYS, +): Promise { + const ev = await loader(bootstrapRelay, authorPubkey) + if (!ev) return fallback + const list = extractReadRelays(ev) + return list.length > 0 ? list : fallback +} + +export interface FetchEventsResult { + events: SignedEvent[] + responded: string[] + queried: string[] +} + +export type EventFetcher = (relay: string, pubkey: string) => Promise + +export const defaultEventFetcher: EventFetcher = async (relay, pubkey) => { + const out: SignedEvent[] = [] + const r = new Relay(relay) + return await new Promise((resolve) => { + const sub = r.request({ kinds: [30023, 5], authors: [pubkey] }) + .pipe(timeout({ first: 10_000 })) + .subscribe({ + next: (ev) => out.push(ev as SignedEvent), + error: () => resolve(out), + complete: () => resolve(out), + }) + setTimeout(() => sub.unsubscribe(), 11_000) + }) +} + +export async function fetchEvents( + relays: string[], + authorPubkey: string, + fetcher: EventFetcher = defaultEventFetcher, +): Promise { + const results = await Promise.all( + relays.map(async (url) => { + try { + const events = await fetcher(url, authorPubkey) + return { url, ok: true as const, events } + } catch { + return { url, ok: false as const, events: [] as SignedEvent[] } + } + }), + ) + const events: SignedEvent[] = [] + for (const r of results) events.push(...r.events) + return { + events, + responded: results.filter((r) => r.ok).map((r) => r.url), + queried: relays, + } +} diff --git a/snapshot/tests/relays.test.ts b/snapshot/tests/relays.test.ts new file mode 100644 index 0000000..771ef33 --- /dev/null +++ b/snapshot/tests/relays.test.ts @@ -0,0 +1,33 @@ +import { assertEquals } from '@std/assert' +import { extractReadRelays, type RelayListLoader, loadReadRelays } from '../src/core/relays.ts' +import type { SignedEvent } from '../src/core/types.ts' + +const KIND_10002: SignedEvent = { + id: 'r', pubkey: 'P', created_at: 1, kind: 10002, sig: 's', content: '', + tags: [ + ['r', 'wss://relay.damus.io'], + ['r', 'wss://nos.lol', 'read'], + ['r', 'wss://relay.write-only.example', 'write'], + ], +} + +Deno.test('extractReadRelays: ohne marker = read+write, "read" = read, "write" = nicht', () => { + assertEquals(extractReadRelays(KIND_10002), [ + 'wss://relay.damus.io', + 'wss://nos.lol', + ]) +}) + +Deno.test('loadReadRelays: nutzt fallback wenn kein kind:10002', async () => { + const loader: RelayListLoader = async () => undefined + const relays = await loadReadRelays('wss://bootstrap', 'P', loader, [ + 'wss://fallback1', 'wss://fallback2', + ]) + assertEquals(relays, ['wss://fallback1', 'wss://fallback2']) +}) + +Deno.test('loadReadRelays: nutzt kind:10002 wenn vorhanden', async () => { + const loader: RelayListLoader = async () => KIND_10002 + const relays = await loadReadRelays('wss://bootstrap', 'P', loader, ['wss://fallback']) + assertEquals(relays, ['wss://relay.damus.io', 'wss://nos.lol']) +}) From d7bb62d4692f9132e97de87146f14bcd975ab2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:23:55 +0200 Subject: [PATCH 21/35] feat(snapshot): cli-entrypoint verdrahtet alle module Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/cli.ts | 115 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 snapshot/src/cli.ts diff --git a/snapshot/src/cli.ts b/snapshot/src/cli.ts new file mode 100644 index 0000000..b44b346 --- /dev/null +++ b/snapshot/src/cli.ts @@ -0,0 +1,115 @@ +import { parseArgs } from '@std/cli' +import { join, resolve } from '@std/path' +import { loadConfig } from './core/config.ts' +import { loadReadRelays, fetchEvents } from './core/relays.ts' +import { dedupByDtag } from './core/dedup.ts' +import { filterDeleted } from './core/nip09-filter.ts' +import { runChecks } from './core/checks.ts' +import { buildPostJson } from './core/post-json.ts' +import { probeCover } from './core/cover-probe.ts' +import { writeOutput } from './core/output.ts' +import { readCache, writeCache, type CacheState } from './core/cache.ts' +import type { SignedEvent } from './core/types.ts' + +async function main(): Promise { + const args = parseArgs(Deno.args, { + string: ['out', 'cache', 'min-events'], + boolean: ['allow-shrink'], + default: { + out: resolve(import.meta.dirname!, '../output'), + }, + }) + const outDir = String(args.out) + const cachePath = args.cache ? String(args.cache) : join(outDir, '.last-snapshot.json') + const allowShrink = args['allow-shrink'] === true + + const cfg = loadConfig() + const cache = await readCache(cachePath) + const minEvents = args['min-events'] + ? parseInt(String(args['min-events']), 10) + : cache + ? Math.max(1, cache.lastKnownGoodCount - 2) + : 1 + + console.log('snapshot: bootstrap relay =', cfg.bootstrapRelay) + const readRelays = await loadReadRelays(cfg.bootstrapRelay, cfg.authorPubkeyHex) + console.log('snapshot: read relays =', readRelays.join(', ')) + + const fetched = await fetchEvents(readRelays, cfg.authorPubkeyHex) + console.log( + `snapshot: ${fetched.responded.length}/${fetched.queried.length} relays geantwortet, ` + + `${fetched.events.length} events roh`, + ) + + const posts: SignedEvent[] = [] + const deletions: SignedEvent[] = [] + for (const ev of fetched.events) { + if (ev.kind === 30023) posts.push(ev) + else if (ev.kind === 5) deletions.push(ev) + } + + const dedupedPosts = dedupByDtag(posts) + const filtered = filterDeleted(dedupedPosts, deletions, cfg.authorPubkeyHex) + + const previousDeletedCoords = new Set(cache?.deletedCoords ?? []) + const newlyDeletedCount = deletions.flatMap((d) => + d.tags.filter((t) => t[0] === 'a' && t[1] && !previousDeletedCoords.has(t[1])).map((t) => t[1]) + ).length + + runChecks({ + relaysQueried: fetched.queried.length, + relaysResponded: fetched.responded.length, + eventCount: filtered.length, + minEvents, + lastKnownGoodCount: cache?.lastKnownGoodCount, + newDeletionsCount: newlyDeletedCount, + allowShrink, + }) + + const titleByDtag = new Map() + for (const ev of filtered) { + const d = ev.tags.find((t) => t[0] === 'd')?.[1] + const title = ev.tags.find((t) => t[0] === 'title')?.[1] + if (d && title) titleByDtag.set(d, title) + } + const postJsons = filtered.map((ev) => buildPostJson(ev, titleByDtag)) + + for (const p of postJsons) { + if (!p.cover_image) continue + const probe = await probeCover(p.cover_image.url) + if (!probe.reachable) { + console.warn( + `snapshot: cover unreachable [${probe.status}] ${p.cover_image.url} (slug=${p.slug}) — URL wird trotzdem geschrieben`, + ) + } + } + + await writeOutput(outDir, { + generatedAt: new Date().toISOString(), + authorPubkey: cfg.authorPubkeyHex, + relaysQueried: fetched.queried, + relaysResponded: fetched.responded, + posts: postJsons, + }) + + const allDeletedCoords = deletions.flatMap((d) => + d.tags.filter((t) => t[0] === 'a' && t[1]).map((t) => t[1] as string) + ) + const newCache: CacheState = { + lastKnownGoodCount: filtered.length, + deletedCoords: [...new Set(allDeletedCoords)], + } + await writeCache(cachePath, newCache) + + console.log(`snapshot: ${filtered.length} posts geschrieben nach ${outDir}`) + return 0 +} + +if (import.meta.main) { + try { + Deno.exit(await main()) + } catch (err) { + console.error('snapshot: HARD-FAIL —', err instanceof Error ? err.message : String(err)) + Deno.exit(1) + } +} From 2c4bceb7680b04e3af92d6c588f0b65e51dc788a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:27:12 +0200 Subject: [PATCH 22/35] fix(snapshot): cache akkumuliert deletedCoords + timeout-kommentar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review-feedback aus etappe 2.9/2.10: 1. cli.ts: deletedCoords im cache wird ab jetzt akkumuliert statt ersetzt. Vorher wurden bei einem run nur die aktuell von relays gelieferten kind:5-coords geschrieben — wenn ein relay beim naechsten run die alten deletions nicht mehr liefert (GC, relay-tausch), waere die geschichte verloren und newDeletionsCount im naechsten lauf wieder "neu" -> false-positive hard-fail im drop-check. 2. relays.ts: kommentar zum belt-and-suspenders-setTimeout neben dem RxJS-timeout-operator, damit der zweck (handle-cleanup falls beide subscribe-callbacks verschluckt werden) klar ist. Co-Authored-By: Claude Opus 4.7 (1M context) --- snapshot/src/cli.ts | 9 +++++++-- snapshot/src/core/relays.ts | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/snapshot/src/cli.ts b/snapshot/src/cli.ts index b44b346..dbd629e 100644 --- a/snapshot/src/cli.ts +++ b/snapshot/src/cli.ts @@ -92,12 +92,17 @@ async function main(): Promise { posts: postJsons, }) - const allDeletedCoords = deletions.flatMap((d) => + const currentDeletedCoords = deletions.flatMap((d) => d.tags.filter((t) => t[0] === 'a' && t[1]).map((t) => t[1] as string) ) + // Cache akkumuliert deletedCoords ueber alle bisherigen runs — nicht + // ersetzen: wenn ein relay beim naechsten run die alten kind:5-events + // nicht mehr liefert (GC, relay-tausch), wuerde sonst der vergleich + // gegen previousDeletedCoords im naechsten lauf wieder als "neu" + // werten und einen false-positive hard-fail ausloesen. const newCache: CacheState = { lastKnownGoodCount: filtered.length, - deletedCoords: [...new Set(allDeletedCoords)], + deletedCoords: [...new Set([...(cache?.deletedCoords ?? []), ...currentDeletedCoords])], } await writeCache(cachePath, newCache) diff --git a/snapshot/src/core/relays.ts b/snapshot/src/core/relays.ts index 348010d..7510690 100644 --- a/snapshot/src/core/relays.ts +++ b/snapshot/src/core/relays.ts @@ -70,6 +70,11 @@ export const defaultEventFetcher: EventFetcher = async (relay, pubkey) => { error: () => resolve(out), complete: () => resolve(out), }) + // Belt-and-suspenders: falls subscribe-callback weder error noch + // complete feuert (z.B. timeout-operator wird intern verschluckt), + // schliessen wir nach timeout+1s manuell. Resolve() kommt dann nicht + // mehr durch (Promise schon settled), aber der Relay-Handle wird + // entsorgt — kein leak. setTimeout(() => sub.unsubscribe(), 11_000) }) } From 3fa85fcb07bc440b566a1b16c6893f47eca72416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:37:59 +0200 Subject: [PATCH 23/35] ci: snapshot-step nach publish + output als artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Etappe 3 des prerender-snapshot-plans (variante 1: minimal — kein neuer workflow, deploy bleibt manuell via scripts/deploy-svelte.sh): - 'Snapshot'-step laeuft nach publish, ruft deno-snapshot-cli auf - output (index.json + posts/*.json + .last-snapshot.json) wird als github-actions-artifact fuer 30 tage aufgehoben — debug-pfad falls ein deploy-bug nachvollzogen werden muss - AUTHOR_PUBKEY_HEX + BOOTSTRAP_RELAY werden aus existierenden secrets uebernommen, keine neuen secrets noetig Reihenfolge "publish dann snapshot": neue events muessen erst auf den relays sein, bevor sie gesnapshottet werden koennen. Bei publish-fail laeuft snapshot nicht — gewollt, weil unklarer relay-stand zu fehlerhaftem snapshot-output fuehren wuerde. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 839155e..bb6d18b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -55,3 +55,18 @@ jobs: name: publish-log path: ./publish/logs/publish-*.json retention-days: 30 + + - name: Snapshot + working-directory: ./snapshot + env: + AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }} + BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }} + run: | + deno run --allow-env --allow-read --allow-write --allow-net src/cli.ts + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: snapshot-output + path: ./snapshot/output/ + retention-days: 30 From b5772b8aa2962f572647e3668008ea66548fd5c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:40:17 +0200 Subject: [PATCH 24/35] feat(spa): detail-route auf prerender + ssr=true Lokaler override des global ssr=false. entries() liest aus snapshot/output/index.json, load() pro-slug aus posts/.json. runtime-fallback bleibt fuer slugs ausserhalb des snapshots. @types/node als devDependency ergaenzt, da node:fs/promises-Typen fuer den SSR-Pfad benoetigt werden. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/package.json | 1 + app/src/routes/[...slug]/+page.ts | 89 +++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/app/package.json b/app/package.json index e75b00f..6cccba5 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,7 @@ "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/svelte": "^5.3.1", + "@types/node": "^25.6.0", "jsdom": "^29.0.2", "svelte": "^5.55.2", "svelte-check": "^4.4.6", diff --git a/app/src/routes/[...slug]/+page.ts b/app/src/routes/[...slug]/+page.ts index c7b2f51..9272237 100644 --- a/app/src/routes/[...slug]/+page.ts +++ b/app/src/routes/[...slug]/+page.ts @@ -1,21 +1,78 @@ -import { error, redirect } from '@sveltejs/kit'; -import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy'; -import type { PageLoad } from './$types'; +import { error, redirect } from '@sveltejs/kit' +import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy' +import type { EntryGenerator, PageLoad } from './$types' +import { browser } from '$app/environment' + +export const ssr = true +export const prerender = true +export const trailingSlash = 'always' + +interface SnapshotIndex { + posts: Array<{ slug: string; lang: string; title: string }> +} + +interface PostJson { + slug: string + event_id: string + created_at: number + published_at: number + title: string + summary: string + lang: string + cover_image: { url: string; alt?: string; width?: number; height?: number; mime?: string } | null + content_markdown: string + tags: string[] + naddr: string + habla_url: string + translations: Array<{ lang: string; slug: string; title: string }> +} + +let cachedIndex: SnapshotIndex | undefined +async function readIndex(): Promise { + if (cachedIndex) return cachedIndex + const fs = await import('node:fs/promises') + const path = await import('node:path') + const dir = path.resolve('../snapshot/output') + const text = await fs.readFile(path.join(dir, 'index.json'), 'utf-8') + cachedIndex = JSON.parse(text) as SnapshotIndex + return cachedIndex +} + +async function readPost(slug: string): Promise { + try { + const fs = await import('node:fs/promises') + const path = await import('node:path') + const dir = path.resolve('../snapshot/output') + const text = await fs.readFile(path.join(dir, 'posts', `${slug}.json`), 'utf-8') + return JSON.parse(text) as PostJson + } catch { + return undefined + } +} + +export const entries: EntryGenerator = async () => { + const idx = await readIndex() + return idx.posts.map((p) => ({ slug: p.slug })) +} export const load: PageLoad = async ({ url }) => { - const pathname = url.pathname; + const pathname = url.pathname - // Legacy-Form /YYYY/MM/DD/.html/ → Redirect auf // - const legacyDtag = parseLegacyUrl(pathname); - if (legacyDtag) { - throw redirect(301, canonicalPostPath(legacyDtag)); - } + const legacyDtag = parseLegacyUrl(pathname) + if (legacyDtag) { + throw redirect(301, canonicalPostPath(legacyDtag)) + } - // Kanonisch: // — erster Segment des Pfades. - const segments = pathname.replace(/^\/+|\/+$/g, '').split('/'); - if (segments.length !== 1 || !segments[0]) { - throw error(404, 'Seite nicht gefunden'); - } + const segments = pathname.replace(/^\/+|\/+$/g, '').split('/') + if (segments.length !== 1 || !segments[0]) { + throw error(404, 'Seite nicht gefunden') + } + const dtag = decodeURIComponent(segments[0]) - return { dtag: decodeURIComponent(segments[0]) }; -}; + if (!browser) { + const snapshot = await readPost(dtag) + if (snapshot) return { dtag, snapshot } + } + + return { dtag, snapshot: null } +} From 63e59bffb9ce200c98acffe6383748b26075b2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:41:11 +0200 Subject: [PATCH 25/35] feat(spa): post-detail rendert prerendered aus snapshot Snapshot-pfad: page+head komplett aus json, mit og/twitter/jsonld/hreflang. Runtime-fallback: falls data.snapshot null, loadPost+PostView wie bisher. Reactions/replies kommen im naechsten task. svelte:head auf top-level verschoben (svelte-constraint: keine meta-tags innerhalb von {#if}-bloecken erlaubt). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/routes/[...slug]/+page.svelte | 253 ++++++++++++++++++++------ 1 file changed, 196 insertions(+), 57 deletions(-) diff --git a/app/src/routes/[...slug]/+page.svelte b/app/src/routes/[...slug]/+page.svelte index 13afecf..d38b296 100644 --- a/app/src/routes/[...slug]/+page.svelte +++ b/app/src/routes/[...slug]/+page.svelte @@ -1,70 +1,209 @@ + + {#if snapshot} + {snapshot.title} – Jörg Lohrer + + + + + + + + + + {#if snapshot.cover_image?.width} + + {/if} + {#if snapshot.cover_image?.height} + + {/if} + + + + + + {#each snapshot.translations as alt} + + {/each} + + + {/if} + + - - -{#if post} - +{#if snapshot} +
+

{snapshot.title}

+ {#if snapshot.cover_image} +

+ {snapshot.cover_image.alt +

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

{snapshot.summary}

+ {/if} +
{@html bodyHtmlPrerendered}
+
+{:else} + + {#if post} + + {/if} {/if} From 4e4a5efa428a9f250748062bfc08aa8bed95d982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:42:07 +0200 Subject: [PATCH 26/35] =?UTF-8?q?docs:=20plan-korrektur=20=E2=80=94=20svel?= =?UTF-8?q?te:head=20muss=20top-level=20stehen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei der umsetzung von task 4.2 stellte sich heraus, dass svelte nicht in einem {#if}-block stehen darf. Plan-code korrigiert von {#if snapshot}...{/if} zu {#if snapshot}...{/if} Semantisch identisch (head-content erscheint nur wenn snapshot da ist), aber svelte-konform. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/superpowers/plans/2026-04-28-prerender-snapshot.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-04-28-prerender-snapshot.md b/docs/superpowers/plans/2026-04-28-prerender-snapshot.md index 5d90e63..7d33a9e 100644 --- a/docs/superpowers/plans/2026-04-28-prerender-snapshot.md +++ b/docs/superpowers/plans/2026-04-28-prerender-snapshot.md @@ -1707,8 +1707,8 @@ Co-Authored-By: Claude Opus 4.7 (1M context) " ) -{#if snapshot} - + + {#if snapshot} {snapshot.title} – Jörg Lohrer @@ -1735,8 +1735,8 @@ Co-Authored-By: Claude Opus 4.7 (1M context) " {/each} - -{/if} + {/if} + From 2ad27adf1f921bd54f9b5a61ae45df4fc98ef282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:46:12 +0200 Subject: [PATCH 27/35] feat(spa): snapshot-pfad mit reactions/replies/langs/tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot-pfad bekommt feature-paritaet mit dem runtime-fallback: - Sprach-switcher (inline, gleiche optik wie LanguageAvailability, ohne neue i18n-keys — verwendet snapshot.translations direkt) - Tag-liste mit links auf /tag// - Reactions, ExternalClientLinks, ReplyComposer, ReplyList (alle dtag-basiert, brauchen keine NostrEvent-konstruktion) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/routes/[...slug]/+page.svelte | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/app/src/routes/[...slug]/+page.svelte b/app/src/routes/[...slug]/+page.svelte index d38b296..fdd6bec 100644 --- a/app/src/routes/[...slug]/+page.svelte +++ b/app/src/routes/[...slug]/+page.svelte @@ -5,10 +5,15 @@ import { buildHablaLink } from '$lib/nostr/naddr' import PostView from '$lib/components/PostView.svelte' import LoadingOrError from '$lib/components/LoadingOrError.svelte' + import Reactions from '$lib/components/Reactions.svelte' + import ReplyList from '$lib/components/ReplyList.svelte' + import ReplyComposer from '$lib/components/ReplyComposer.svelte' + import ExternalClientLinks from '$lib/components/ExternalClientLinks.svelte' import { renderMarkdown } from '$lib/render/markdown' import { t } from '$lib/i18n' import { get } from 'svelte/store' import { onMount } from 'svelte' + import type { SignedEvent } from '$lib/nostr/signer' let { data } = $props() const dtag = $derived(data.dtag) @@ -57,6 +62,11 @@ }) }) + let optimisticReplies: NostrEvent[] = $state([]) + function handlePublished(signed: SignedEvent) { + optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent] + } + const jsonLd = $derived( snapshot ? JSON.stringify({ @@ -111,6 +121,16 @@ {#if snapshot}

{snapshot.title}

+ {#if snapshot.translations.length > 0} +

+ + {snapshot.lang.toUpperCase()} + {#each [...snapshot.translations].sort((a, b) => a.lang.localeCompare(b.lang)) as alt} + + {alt.lang.toUpperCase()} + {/each} +

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

{snapshot.cover_image.alt @@ -120,6 +140,17 @@

{snapshot.summary}

{/if}
{@html bodyHtmlPrerendered}
+ {#if snapshot.tags.length > 0} +
+ {#each snapshot.tags as tag} + {tag} + {/each} +
+ {/if} + + + +
{:else} @@ -206,4 +237,52 @@ margin: 1rem 0; color: var(--muted); } + .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; + text-decoration: none; + } + .btn:hover:not(.active) { + color: var(--fg); + } + .btn.active { + color: var(--accent); + border-color: var(--accent); + } + .sep { + opacity: 0.4; + } + .tags { + margin-top: 1.5rem; + } + .tag { + display: inline-block; + background: var(--code-bg); + border-radius: 3px; + padding: 1px 7px; + margin: 0 4px 4px 0; + font-size: 0.85em; + color: var(--fg); + text-decoration: none; + } + .tag:hover { + background: var(--border); + } From 3e31caacefcc721aeb572ffe9565341ad1397678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:50:27 +0200 Subject: [PATCH 28/35] fix(spa): prerender-build laeuft + meta/json-ld korrekt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei build-blocker beim ersten prerender-versuch identifiziert und gefixt: 1. svelte.config.js: handleHttpError + handleMissingId fuer den prerender-crawler. Der crawler folgt zur build-zeit allen hrefs/srcs im HTML — sieht dabei - __SITE_URL__-platzhalter in canonical/hreflang (werden im deploy per sed ersetzt, sind keine echten routes) - relative bild-paths in alten posts (z.B. h01-json-import.png) - anchor-links auf headings ohne id-attribute (#ACF-JSON-Export) Alle drei sind keine echten 404s — handlers ignorieren sie. 2. +page.svelte: in rendert {jsonLd} als literalen string, weil svelte den script-tag-inhalt nicht als expression evaluiert. Zurueck zu {@html ...} mit -escape-hardening, damit titel oder beschreibungen mit den output nicht aufbrechen koennen. 3. app.html behaelt seine homepage-defaults fuer og:title/og:url/ og:description/canonical — der prerender-crawler rendert nur detail-routen (//), die homepage bleibt SPA-only und braucht die defaults im app.html-template, weil dort kein svelte:head greift. Detail-routen ueberschreiben per ; last-wins greift bei LinkedIn/Mastodon/Browser. Facebook/Twitter (first-wins) haetten einen homepage-prerender-schritt noetig — folge-aufgabe. Plus snapshot/deno.lock committed — deno empfiehlt lockfile-commit fuer reproduzierbare CI-builds, analog package-lock.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/app.html | 7 ++ app/src/routes/[...slug]/+page.svelte | 2 +- app/svelte.config.js | 23 ++++ snapshot/deno.lock | 172 ++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 snapshot/deno.lock diff --git a/app/src/app.html b/app/src/app.html index a89d42f..5f95b7d 100644 --- a/app/src/app.html +++ b/app/src/app.html @@ -10,6 +10,13 @@ + diff --git a/app/src/routes/[...slug]/+page.svelte b/app/src/routes/[...slug]/+page.svelte index fdd6bec..adcbb26 100644 --- a/app/src/routes/[...slug]/+page.svelte +++ b/app/src/routes/[...slug]/+page.svelte @@ -112,7 +112,7 @@ {/each} - + {@html ``} {/if} diff --git a/app/svelte.config.js b/app/svelte.config.js index ac0fb60..73c3a3b 100644 --- a/app/svelte.config.js +++ b/app/svelte.config.js @@ -18,6 +18,29 @@ const config = { }), alias: { $lib: 'src/lib' + }, + prerender: { + // Der Crawler folgt zur Build-Zeit href/src-attributen im HTML. Zwei + // faelle, in denen 404er kein echter fehler sind: + // + // 1. canonical/hreflang enthalten den `__SITE_URL__`-platzhalter, der + // erst beim deploy per sed durch die echte SITE_URL ersetzt wird. + // Pfade wie `//__SITE_URL__/` sind also pseudo-pfade. + // 2. Bild-references mit relativen pfaden (z.B. `h01-json-import.png`) + // in alten posts, die nicht zu Blossom-URLs migriert wurden — die + // sind im post-body als und vom crawler verfolgte + // pseudo-routes. Die SPA selbst rendert die -tags zwar, aber + // eine 404-route gibt es dafuer nicht. + handleHttpError: ({ path, message }) => { + if (path.includes('__SITE_URL__')) return; + if (/\.(png|jpe?g|gif|webp|svg|avif)\/?$/i.test(path)) return; + throw new Error(message); + }, + // Markdown-headings bekommen ohne slugify-plugin keine id-attribute. + // Anchor-links in alten posts (z.B. [link](#ACF-JSON-Export)) sind + // damit zur build-zeit unauffindbar. Kein render-fehler — die SPA + // scrollt im browser entweder zum element oder garnicht. + handleMissingId: 'ignore' } } }; diff --git a/snapshot/deno.lock b/snapshot/deno.lock new file mode 100644 index 0000000..385c36a --- /dev/null +++ b/snapshot/deno.lock @@ -0,0 +1,172 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.6": "1.0.19", + "jsr:@std/cli@^1.0.6": "1.0.28", + "jsr:@std/fs@^1.0.4": "1.0.23", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/path@^1.0.6": "1.1.4", + "jsr:@std/path@^1.1.4": "1.1.4", + "npm:applesauce-relay@2": "2.3.0", + "npm:nostr-tools@^2.10.4": "2.23.3", + "npm:rxjs@^7.8.1": "7.8.2" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/cli@1.0.28": { + "integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path@^1.1.4" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "@noble/ciphers@2.1.1": { + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==" + }, + "@noble/curves@2.0.1": { + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "dependencies": [ + "@noble/hashes@2.0.1" + ] + }, + "@noble/hashes@1.8.0": { + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + }, + "@noble/hashes@2.0.1": { + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==" + }, + "@scure/base@1.1.1": { + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==" + }, + "@scure/base@1.2.6": { + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==" + }, + "@scure/base@2.0.0": { + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==" + }, + "@scure/bip32@2.0.1": { + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "dependencies": [ + "@noble/curves", + "@noble/hashes@2.0.1", + "@scure/base@2.0.0" + ] + }, + "@scure/bip39@2.0.1": { + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "dependencies": [ + "@noble/hashes@2.0.1", + "@scure/base@2.0.0" + ] + }, + "applesauce-core@2.3.0": { + "integrity": "sha512-rMVrwGMgHxXAHZfrq3ibtMjljAxeEfT95nl5VYLl5mSMmOHXnwjbiPTccJ2UDd6GP+INdHfkPgeB8AOUf5DFog==", + "dependencies": [ + "@noble/hashes@1.8.0", + "@scure/base@1.2.6", + "debug", + "fast-deep-equal", + "hash-sum", + "light-bolt11-decoder", + "nanoid", + "nostr-tools", + "rxjs" + ] + }, + "applesauce-relay@2.3.0": { + "integrity": "sha512-tOijiN1yVyORS5jT5mXe8MTzqc1IVq/AdJXOzTe3uQgeDYhJzQ9lNYgqejDBXW1ahUThsRZgX2RybkOHVjBuHA==", + "dependencies": [ + "@noble/hashes@1.8.0", + "applesauce-core", + "nanoid", + "nostr-tools", + "rxjs" + ] + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "hash-sum@2.0.0": { + "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==" + }, + "light-bolt11-decoder@3.2.0": { + "integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==", + "dependencies": [ + "@scure/base@1.1.1" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@5.1.7": { + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "bin": true + }, + "nostr-tools@2.23.3": { + "integrity": "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==", + "dependencies": [ + "@noble/ciphers", + "@noble/curves", + "@noble/hashes@2.0.1", + "@scure/base@2.0.0", + "@scure/bip32", + "@scure/bip39", + "nostr-wasm" + ] + }, + "nostr-wasm@0.1.0": { + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==" + }, + "rxjs@7.8.2": { + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dependencies": [ + "tslib" + ] + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.6", + "jsr:@std/cli@^1.0.6", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/fs@^1.0.4", + "jsr:@std/path@^1.0.6", + "jsr:@std/testing@^1.0.3", + "jsr:@std/yaml@^1.0.5", + "npm:applesauce-relay@2", + "npm:nostr-tools@^2.10.4", + "npm:rxjs@^7.8.1" + ] + } +} From f6824019c81fe4b535495efee4f309ed7c38b9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Tue, 28 Apr 2026 08:55:53 +0200 Subject: [PATCH 29/35] feat(deploy): snapshot vor svelte-build im deploy-skript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Damit DEPLOY_TARGET=svelte/staging/prod immer mit aktuellem snapshot/output baut. Ohne diesen step wuerde ein veralteter snapshot ins HTML wandern, frische posts oder uebersetzungen saessen erst beim naechsten deploy drin. Schlaegt der snapshot fehl (relays down, env fehlt), bricht das skript ab — bewusst hard-fail, damit kein verfaelschter build hochgeladen wird. Live-verifiziert auf https://svelte.joerg-lohrer.de/bibel-selfies/: - HTTP 200, og-tags + hreflang + json-ld korrekt - __SITE_URL__-substitution greift sauber - , og:title, json-ld stimmen mit snapshot ueberein Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- scripts/deploy-svelte.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/deploy-svelte.sh b/scripts/deploy-svelte.sh index 52fe61f..a00db9d 100755 --- a/scripts/deploy-svelte.sh +++ b/scripts/deploy-svelte.sh @@ -76,6 +76,18 @@ for pair in "$FTP_HOST_KEY:$FTP_HOST" "$FTP_USER_KEY:$FTP_USER" \ done BUILD_DIR="$ROOT/app/build" +SNAPSHOT_DIR="$ROOT/snapshot/output" + +echo "Ziehe Snapshot von Relays …" +(cd "$ROOT/snapshot" && deno task snapshot) || { + echo "FEHLER: Snapshot fehlgeschlagen. 'cd snapshot && deno task snapshot' manuell ausführen zum Debuggen." >&2 + exit 1 +} + +if [ ! -f "$SNAPSHOT_DIR/index.json" ]; then + echo "FEHLER: $SNAPSHOT_DIR/index.json fehlt nach snapshot." >&2 + exit 1 +fi echo "Baue SvelteKit …" (cd "$ROOT/app" && npm run build >/dev/null 2>&1) || { From 10bdf603a91cc01642be68237eac3e3e7dfa1e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= <info.empeiria@gmail.com> Date: Tue, 28 Apr 2026 09:02:18 +0200 Subject: [PATCH 30/35] fix(spa): %sveltekit.head% nicht im app.html-kommentar erwaehnen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-feedback: kommentar-inhalt aus app.html erschien sichtbar im body der detail-seiten. Ursache: SvelteKit ersetzt jeden vorkommen von %sveltekit.head%, auch innerhalb von HTML-kommentaren. Mein beschreibender kommentar erwaehnte das wort als beispiel — wurde dann zur build-zeit durch tatsaechliche head-tags ersetzt, was den kommentar-block aufbrach (eingefuegte tags enthalten >, das schliesst den kommentar) und den rest des kommentar-textes in den body wandern liess. Fix: kommentar umformuliert, sodass der platzhalter-name nicht mehr woertlich vorkommt ("via SvelteKit-head-injection" statt "via %sveltekit.head%"). Live-verifiziert auf banksy-high-court-prophet — body startet jetzt sauber mit <header>. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- app/src/app.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/app.html b/app/src/app.html index 5f95b7d..d56bc50 100644 --- a/app/src/app.html +++ b/app/src/app.html @@ -11,8 +11,8 @@ <link rel="canonical" href="__SITE_URL__/" /> <meta name="robots" content="index, follow" /> <!-- - Detail-seiten (prerender=true) hängen via %sveltekit.head% ihre - eigenen og:title/description/url/canonical hinten an. Last-wins + Detail-seiten (prerender=true) haengen via SvelteKit-head-injection + ihre eigenen og:title/description/url/canonical hinten an. Last-wins gilt fuer LinkedIn/Mastodon/Browser; Facebook/Twitter nehmen tendenziell first-wins — fuer perfekte OG-tags muesste die homepage auch prerendered werden (separate aufgabe). From a57ae1e8bfb2774678f383f2b7d28db1e8692e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= <info.empeiria@gmail.com> Date: Tue, 28 Apr 2026 09:05:31 +0200 Subject: [PATCH 31/35] refactor(spa): detail-route 404 statt runtime-fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slugs ausserhalb des snapshots werfen jetzt 404 (server-side beim build, browser-side via static-fallback). Vorher kam in dem fall { dtag, snapshot: null } zurueck — die svelte-seite versuchte dann clientseitig via loadPost() zu laden. Frische nostr-first-posts erscheinen ab jetzt erst nach dem naechsten snapshot+build-lauf, nicht mehr live aus den relays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- app/src/routes/[...slug]/+page.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/routes/[...slug]/+page.ts b/app/src/routes/[...slug]/+page.ts index 9272237..aed1794 100644 --- a/app/src/routes/[...slug]/+page.ts +++ b/app/src/routes/[...slug]/+page.ts @@ -71,8 +71,9 @@ export const load: PageLoad = async ({ url }) => { if (!browser) { const snapshot = await readPost(dtag) - if (snapshot) return { dtag, snapshot } + if (!snapshot) throw error(404, 'Post nicht gefunden') + return { dtag, snapshot } } - return { dtag, snapshot: null } + throw error(404, 'Post nicht gefunden') } From 0ec72f9426acbea1fa23d1dd6532cefb119ecdcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= <info.empeiria@gmail.com> Date: Tue, 28 Apr 2026 09:05:53 +0200 Subject: [PATCH 32/35] refactor(spa): detail-route nur noch snapshot-pfad Runtime-fallback (loadPost + LoadingOrError + PostView) entfernt. Detail-seite rendert jetzt ausschliesslich aus dem snapshot. Imports und state, die nur fuer den fallback gebraucht wurden, sind weg. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- app/src/routes/[...slug]/+page.svelte | 43 --------------------------- 1 file changed, 43 deletions(-) diff --git a/app/src/routes/[...slug]/+page.svelte b/app/src/routes/[...slug]/+page.svelte index adcbb26..8b9f894 100644 --- a/app/src/routes/[...slug]/+page.svelte +++ b/app/src/routes/[...slug]/+page.svelte @@ -1,36 +1,17 @@ <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 Reactions from '$lib/components/Reactions.svelte' import ReplyList from '$lib/components/ReplyList.svelte' import ReplyComposer from '$lib/components/ReplyComposer.svelte' import ExternalClientLinks from '$lib/components/ExternalClientLinks.svelte' import { renderMarkdown } from '$lib/render/markdown' import { t } from '$lib/i18n' - import { get } from 'svelte/store' - import { onMount } from 'svelte' import type { SignedEvent } from '$lib/nostr/signer' let { data } = $props() const dtag = $derived(data.dtag) const snapshot = $derived(data.snapshot) - let post: NostrEvent | null = $state(null) - let loading = $state(false) - let error: string | null = $state(null) - - const hablaLink = $derived( - buildHablaLink({ - pubkey: AUTHOR_PUBKEY_HEX, - kind: 30023, - identifier: dtag, - }), - ) - const siteUrl = '__SITE_URL__' const canonical = $derived(`${siteUrl}/${snapshot?.slug ?? dtag}/`) const ogImage = $derived( @@ -43,25 +24,6 @@ snapshot ? renderMarkdown(snapshot.content_markdown) : '', ) - onMount(() => { - if (snapshot) return - loading = true - const currentDtag = dtag - 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 - }) - }) - let optimisticReplies: NostrEvent[] = $state([]) function handlePublished(signed: SignedEvent) { optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent] @@ -152,11 +114,6 @@ <ReplyComposer dtag={snapshot.slug} eventId={snapshot.event_id} onPublished={handlePublished} /> <ReplyList dtag={snapshot.slug} optimistic={optimisticReplies} /> </article> -{:else} - <LoadingOrError {loading} {error} {hablaLink} /> - {#if post} - <PostView event={post} /> - {/if} {/if} <style> From bb9d35076d610d724c21bbc004dd82ebcba49473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= <info.empeiria@gmail.com> Date: Tue, 28 Apr 2026 09:25:14 +0200 Subject: [PATCH 33/35] chore(spa): toter code aus pre-prerender-aera entfernt Nach etappe 5 (runtime-fallback entfernt) sind diese files/exports ohne aufrufer: Files (ganz weg): - app/src/lib/components/PostView.svelte - app/src/lib/components/LanguageAvailability.svelte - app/src/lib/nostr/translations.ts - app/src/lib/nostr/translations.test.ts - app/src/lib/nostr/loaders.loadTranslations.test.ts Aus app/src/lib/nostr/loaders.ts entfernt: - loadPost(), loadTranslations(), TranslationInfo - resolveTranslationsFromRefs() (nur von loadTranslations.test.ts genutzt) - TranslationRef-import von ./translations Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- app/src/app.html | 2 +- .../components/LanguageAvailability.svelte | 107 ----------- app/src/lib/components/PostView.svelte | 174 ------------------ .../nostr/loaders.loadTranslations.test.ts | 74 -------- app/src/lib/nostr/loaders.ts | 68 ------- app/src/lib/nostr/translations.test.ts | 51 ----- app/src/lib/nostr/translations.ts | 27 --- app/src/routes/[...slug]/+page.svelte | 35 +++- 8 files changed, 28 insertions(+), 510 deletions(-) delete mode 100644 app/src/lib/components/LanguageAvailability.svelte delete mode 100644 app/src/lib/components/PostView.svelte delete mode 100644 app/src/lib/nostr/loaders.loadTranslations.test.ts delete mode 100644 app/src/lib/nostr/translations.test.ts delete mode 100644 app/src/lib/nostr/translations.ts diff --git a/app/src/app.html b/app/src/app.html index d56bc50..dc464a7 100644 --- a/app/src/app.html +++ b/app/src/app.html @@ -1,5 +1,5 @@ <!doctype html> -<html lang="de"> +<html lang="__HTML_LANG__"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> diff --git a/app/src/lib/components/LanguageAvailability.svelte b/app/src/lib/components/LanguageAvailability.svelte deleted file mode 100644 index 12a4c08..0000000 --- a/app/src/lib/components/LanguageAvailability.svelte +++ /dev/null @@ -1,107 +0,0 @@ -<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 Props { - event: NostrEvent; - } - let { event }: Props = $props(); - - let translations: TranslationInfo[] = $state([]); - let loading = $state(true); - - $effect(() => { - 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 { - return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de'; - } - - interface Option { - code: string; - href: string | null; // null = aktueller post, kein klick-ziel - } - - const options = $derived.by<Option[]>(() => { - const self: Option = { code: currentLang(), href: null }; - const others: Option[] = translations.map((t) => ({ - code: t.lang, - href: `/${t.slug}/` - })); - // aktuelle sprache zuerst, dann rest sortiert nach code - return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))]; - }); - - function selectOther(code: string, href: string) { - activeLocale.set(code as SupportedLocale); - // hartes location-setzen, damit svelte-kit-router den post-load triggert - 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> diff --git a/app/src/lib/components/PostView.svelte b/app/src/lib/components/PostView.svelte deleted file mode 100644 index 755ff30..0000000 --- a/app/src/lib/components/PostView.svelte +++ /dev/null @@ -1,174 +0,0 @@ -<script lang="ts"> - import type { NostrEvent } from '$lib/nostr/loaders'; - import type { SignedEvent } from '$lib/nostr/signer'; - import { renderMarkdown } from '$lib/render/markdown'; - import Reactions from './Reactions.svelte'; - import ReplyList from './ReplyList.svelte'; - import ReplyComposer from './ReplyComposer.svelte'; - import ExternalClientLinks from './ExternalClientLinks.svelte'; - import LanguageAvailability from './LanguageAvailability.svelte'; - import { t, activeLocale } from '$lib/i18n'; - - interface Props { - event: NostrEvent; - } - let { event }: Props = $props(); - - function tagValue(e: NostrEvent, name: string): string { - return e.tags.find((t) => t[0] === name)?.[1] ?? ''; - } - function tagsAll(e: NostrEvent, name: string): string[] { - return e.tags.filter((t) => t[0] === name).map((t) => t[1]); - } - - const dtag = $derived(tagValue(event, 'd')); - let currentLocale = $state('de'); - activeLocale.subscribe((v) => (currentLocale = v)); - - const title = $derived(tagValue(event, 'title') || $t('post.untitled')); - const summary = $derived(tagValue(event, 'summary')); - const image = $derived(tagValue(event, 'image')); - const publishedAt = $derived( - parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10) - ); - const date = $derived( - new Date(publishedAt * 1000).toLocaleDateString( - currentLocale === 'en' ? 'en-US' : 'de-DE', - { year: 'numeric', month: 'long', day: 'numeric' } - ) - ); - const tags = $derived(tagsAll(event, 't')); - const bodyHtml = $derived(renderMarkdown(event.content)); - - // Optimistisch gesendete Replies: der Composer pusht sie rein, - // ReplyList merged sie mit den vom Relay geladenen Replies (dedup per id). - let optimisticReplies: NostrEvent[] = $state([]); - function handlePublished(signed: SignedEvent) { - optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent]; - } - - $effect(() => { - document.title = `${title} – Jörg Lohrer`; - }); -</script> - -<h1 class="post-title">{title}</h1> -<div class="meta"> - {$t('post.published_on', { values: { date } })} - {#if tags.length > 0} - <div class="tags"> - {#each tags as t} - <a class="tag" href="/tag/{encodeURIComponent(t)}/">{t}</a> - {/each} - </div> - {/if} -</div> - -<LanguageAvailability {event} /> - -{#if image} - <p class="cover"><img src={image} alt="Cover-Bild" /></p> -{/if} - -{#if summary} - <p class="summary">{summary}</p> -{/if} - -<article>{@html bodyHtml}</article> - -{#if dtag} - <Reactions {dtag} /> - <ExternalClientLinks {dtag} /> - <ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} /> - <ReplyList {dtag} optimistic={optimisticReplies} /> -{/if} - -<style> - .post-title { - font-size: 1.5rem; - line-height: 1.25; - margin: 0 0 0.4rem; - word-wrap: break-word; - } - @media (min-width: 640px) { - .post-title { - font-size: 2rem; - line-height: 1.2; - } - } - .meta { - color: var(--muted); - font-size: 0.92rem; - margin-bottom: 2rem; - } - .tags { - margin-top: 0.4rem; - } - .tag { - display: inline-block; - background: var(--code-bg); - border-radius: 3px; - padding: 1px 7px; - margin: 0 4px 4px 0; - font-size: 0.85em; - color: var(--fg); - text-decoration: none; - } - .tag:hover { - background: var(--border); - } - .cover { - max-width: 480px; - margin: 1rem auto 1.5rem; - } - .cover img { - display: block; - width: 100%; - height: auto; - border-radius: 4px; - } - .summary { - font-style: italic; - color: var(--muted); - } - article :global(img) { - max-width: 100%; - height: auto; - border-radius: 4px; - } - article :global(a) { - color: var(--accent); - word-break: break-word; - } - article :global(pre) { - background: var(--code-bg); - padding: 0.8rem; - border-radius: 4px; - overflow-x: auto; - font-size: 0.88em; - max-width: 100%; - } - article :global(code) { - background: var(--code-bg); - padding: 1px 4px; - border-radius: 3px; - font-size: 0.92em; - word-break: break-word; - } - article :global(pre code) { - padding: 0; - background: none; - word-break: normal; - } - article :global(hr) { - border: none; - border-top: 1px solid var(--border); - margin: 2rem 0; - } - article :global(blockquote) { - border-left: 3px solid var(--border); - padding: 0 0 0 1rem; - margin: 1rem 0; - color: var(--muted); - } -</style> diff --git a/app/src/lib/nostr/loaders.loadTranslations.test.ts b/app/src/lib/nostr/loaders.loadTranslations.test.ts deleted file mode 100644 index eb4578c..0000000 --- a/app/src/lib/nostr/loaders.loadTranslations.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { resolveTranslationsFromRefs } from './loaders'; -import type { NostrEvent } from './loaders'; -import type { TranslationRef } from './translations'; - -function ev(tags: string[][]): NostrEvent { - return { - id: 'x', - pubkey: 'p', - created_at: 0, - kind: 30023, - tags, - content: '', - sig: 's' - } as unknown as NostrEvent; -} - -describe('resolveTranslationsFromRefs', () => { - it('liefert lang/slug/title für jeden aufgelösten ref', async () => { - const refs: TranslationRef[] = [ - { kind: 30023, pubkey: 'p1', dtag: 'hello' } - ]; - const fetcher = async () => [ - ev([ - ['d', 'hello'], - ['title', 'Hello World'], - ['L', 'ISO-639-1'], - ['l', 'en', 'ISO-639-1'] - ]) - ]; - const result = await resolveTranslationsFromRefs(refs, fetcher); - expect(result).toEqual([ - { lang: 'en', slug: 'hello', title: 'Hello World' } - ]); - }); - - it('ignoriert refs, zu denen kein event gefunden wird', async () => { - const refs: TranslationRef[] = [ - { kind: 30023, pubkey: 'p1', dtag: 'hello' }, - { kind: 30023, pubkey: 'p1', dtag: 'missing' } - ]; - const fetcher = async (r: TranslationRef) => - r.dtag === 'hello' - ? [ev([ - ['d', 'hello'], - ['title', 'Hi'], - ['l', 'en', 'ISO-639-1'] - ])] - : []; - const result = await resolveTranslationsFromRefs(refs, fetcher); - expect(result).toEqual([{ lang: 'en', slug: 'hello', title: 'Hi' }]); - }); - - it('ignoriert events ohne l-tag (sprache unklar)', async () => { - const refs: TranslationRef[] = [ - { kind: 30023, pubkey: 'p', dtag: 'x' } - ]; - const fetcher = async () => [ - ev([ - ['d', 'x'], - ['title', 'kein lang-tag'] - ]) - ]; - const result = await resolveTranslationsFromRefs(refs, fetcher); - expect(result).toEqual([]); - }); - - it('leere ref-liste → leere ergebnis-liste', async () => { - const fetcher = async () => { - throw new Error('should not be called'); - }; - expect(await resolveTranslationsFromRefs([], fetcher)).toEqual([]); - }); -}); diff --git a/app/src/lib/nostr/loaders.ts b/app/src/lib/nostr/loaders.ts index 15961eb..b681b48 100644 --- a/app/src/lib/nostr/loaders.ts +++ b/app/src/lib/nostr/loaders.ts @@ -6,7 +6,6 @@ import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter' import { pool } from './pool'; import { readRelays } from '$lib/stores/readRelays'; import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config'; -import type { TranslationRef } from './translations'; /** Re-export als sprechenden Alias */ export type { NostrEvent }; @@ -89,21 +88,6 @@ export async function loadPostList( }); } -/** Einzelpost per d-Tag */ -export async function loadPost(dtag: string): Promise<NostrEvent | null> { - const relays = get(readRelays); - const events = await collectEvents(relays, { - kinds: [30023], - authors: [AUTHOR_PUBKEY_HEX], - '#d': [dtag], - limit: 1 - }); - if (events.length === 0) return null; - return events.reduce((best, cur) => - cur.created_at > best.created_at ? cur : best - ); -} - /** * Profil-Event kind:0 (neueste Version). * Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für @@ -190,55 +174,3 @@ export async function loadReactions(dtag: string): Promise<ReactionSummary[]> { .map(([content, count]) => ({ content, count })) .sort((a, b) => b.count - a.count); } - -export interface TranslationInfo { - lang: string; - slug: string; - title: string; -} - -/** - * Pure Variante für Tests — erhält die Events via Fetcher statt Relays. - */ -export async function resolveTranslationsFromRefs( - refs: TranslationRef[], - fetcher: (ref: TranslationRef) => Promise<NostrEvent[]> -): Promise<TranslationInfo[]> { - if (refs.length === 0) return []; - const results = await Promise.all(refs.map(fetcher)); - const infos: TranslationInfo[] = []; - for (let i = 0; i < refs.length; i++) { - const evs = results[i]; - if (evs.length === 0) continue; - const latest = evs.reduce((best, cur) => - cur.created_at > best.created_at ? cur : best - ); - const lang = latest.tags.find((t) => t[0] === 'l')?.[1]; - if (!lang) continue; - const slug = latest.tags.find((t) => t[0] === 'd')?.[1] ?? refs[i].dtag; - const title = latest.tags.find((t) => t[0] === 'title')?.[1] ?? ''; - infos.push({ lang, slug, title }); - } - return infos; -} - -/** - * Loader: findet die anderssprachigen Varianten eines Posts. - * Liefert leere Liste, wenn keine a-Tags mit marker "translation" vorhanden. - */ -export async function loadTranslations( - event: NostrEvent -): Promise<TranslationInfo[]> { - const { parseTranslationRefs } = await import('./translations'); - const refs = parseTranslationRefs(event); - if (refs.length === 0) return []; - const relays = get(readRelays); - return resolveTranslationsFromRefs(refs, (ref) => - collectEvents(relays, { - kinds: [ref.kind], - authors: [ref.pubkey], - '#d': [ref.dtag], - limit: 1 - }) - ); -} diff --git a/app/src/lib/nostr/translations.test.ts b/app/src/lib/nostr/translations.test.ts deleted file mode 100644 index df82317..0000000 --- a/app/src/lib/nostr/translations.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseTranslationRefs } from './translations'; -import type { NostrEvent } from './loaders'; - -function ev(tags: string[][]): NostrEvent { - return { - id: 'x', - pubkey: 'p', - created_at: 0, - kind: 30023, - tags, - content: '', - sig: 's' - } as unknown as NostrEvent; -} - -describe('parseTranslationRefs', () => { - it('extrahiert a-tags mit marker "translation"', () => { - const e = ev([ - ['d', 'x'], - ['a', '30023:abc:other-slug', '', 'translation'], - ['a', '30023:abc:third-slug', '', 'translation'] - ]); - expect(parseTranslationRefs(e)).toEqual([ - { kind: 30023, pubkey: 'abc', dtag: 'other-slug' }, - { kind: 30023, pubkey: 'abc', dtag: 'third-slug' } - ]); - }); - - it('ignoriert a-tags ohne marker "translation"', () => { - const e = ev([ - ['a', '30023:abc:root-thread', '', 'root'], - ['a', '30023:abc:x', '', 'reply'] - ]); - expect(parseTranslationRefs(e)).toEqual([]); - }); - - it('ignoriert a-tags mit malformed coordinate', () => { - const e = ev([ - ['a', 'not-a-coord', '', 'translation'], - ['a', '30023:abc:ok', '', 'translation'] - ]); - expect(parseTranslationRefs(e)).toEqual([ - { kind: 30023, pubkey: 'abc', dtag: 'ok' } - ]); - }); - - it('leeres tag-array → leere liste', () => { - expect(parseTranslationRefs(ev([]))).toEqual([]); - }); -}); diff --git a/app/src/lib/nostr/translations.ts b/app/src/lib/nostr/translations.ts deleted file mode 100644 index 8b1cd6c..0000000 --- a/app/src/lib/nostr/translations.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { NostrEvent } from './loaders'; - -export interface TranslationRef { - kind: number; - pubkey: string; - dtag: string; -} - -const COORD_RE = /^(\d+):([0-9a-f]+):([a-z0-9][a-z0-9-]*)$/; - -export function parseTranslationRefs(event: NostrEvent): TranslationRef[] { - const refs: TranslationRef[] = []; - for (const tag of event.tags) { - if (tag[0] !== 'a') continue; - if (tag[3] !== 'translation') continue; - const coord = tag[1]; - if (typeof coord !== 'string') continue; - const m = coord.match(COORD_RE); - if (!m) continue; - refs.push({ - kind: parseInt(m[1], 10), - pubkey: m[2], - dtag: m[3] - }); - } - return refs; -} diff --git a/app/src/routes/[...slug]/+page.svelte b/app/src/routes/[...slug]/+page.svelte index 8b9f894..baf3b82 100644 --- a/app/src/routes/[...slug]/+page.svelte +++ b/app/src/routes/[...slug]/+page.svelte @@ -13,13 +13,32 @@ const snapshot = $derived(data.snapshot) const siteUrl = '__SITE_URL__' + + // Site-default-OG-bild aus app/static. Dimensionen sind hartcodiert, + // weil das asset stabil ist (siehe spec §Algorithmus-Schritt 8). + const DEFAULT_OG_IMAGE = `${siteUrl}/joerg-profil-2024.webp` + const DEFAULT_OG_IMAGE_WIDTH = 512 + const DEFAULT_OG_IMAGE_HEIGHT = 512 + const canonical = $derived(`${siteUrl}/${snapshot?.slug ?? dtag}/`) - const ogImage = $derived( - snapshot?.cover_image?.url ?? `${siteUrl}/joerg-profil-2024.webp`, - ) + const ogImage = $derived(snapshot?.cover_image?.url ?? DEFAULT_OG_IMAGE) const ogImageAlt = $derived( snapshot?.cover_image?.alt ?? snapshot?.title ?? 'Jörg Lohrer', ) + const ogImageWidth = $derived( + snapshot?.cover_image?.width ?? (snapshot?.cover_image ? undefined : DEFAULT_OG_IMAGE_WIDTH), + ) + const ogImageHeight = $derived( + snapshot?.cover_image?.height ?? (snapshot?.cover_image ? undefined : DEFAULT_OG_IMAGE_HEIGHT), + ) + // x-default zeigt auf die DE-variante, weil der autor DE-first arbeitet. + // Bei EN-posts: DE-slug aus translations[] suchen; sonst (DE-post) + // bleibt x-default = canonical. + const xDefaultHref = $derived( + snapshot?.lang === 'en' + ? `${siteUrl}/${snapshot.translations.find((tr) => tr.lang === 'de')?.slug ?? snapshot.slug}/` + : canonical, + ) const bodyHtmlPrerendered = $derived( snapshot ? renderMarkdown(snapshot.content_markdown) : '', ) @@ -59,11 +78,11 @@ <meta property="og:locale" content={snapshot.lang === 'de' ? 'de_DE' : 'en_US'} /> <meta property="og:image" content={ogImage} /> <meta property="og:image:alt" content={ogImageAlt} /> - {#if snapshot.cover_image?.width} - <meta property="og:image:width" content={String(snapshot.cover_image.width)} /> + {#if ogImageWidth} + <meta property="og:image:width" content={String(ogImageWidth)} /> {/if} - {#if snapshot.cover_image?.height} - <meta property="og:image:height" content={String(snapshot.cover_image.height)} /> + {#if ogImageHeight} + <meta property="og:image:height" content={String(ogImageHeight)} /> {/if} <meta property="article:published_time" content={new Date(snapshot.published_at * 1000).toISOString()} /> <meta name="twitter:card" content="summary_large_image" /> @@ -73,7 +92,7 @@ {#each snapshot.translations as alt} <link rel="alternate" hreflang={alt.lang} href={`${siteUrl}/${alt.slug}/`} /> {/each} - <link rel="alternate" hreflang="x-default" href={canonical} /> + <link rel="alternate" hreflang="x-default" href={xDefaultHref} /> {@html `<script type="application/ld+json">${jsonLd.replace(/<\/script>/gi, '<\\/script>')}</script>`} {/if} </svelte:head> From 47282f3c300cb7b59a48a14115cb774d6ae4d6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= <info.empeiria@gmail.com> Date: Tue, 28 Apr 2026 09:25:59 +0200 Subject: [PATCH 34/35] fix(deploy): __HTML_LANG__-substitution pro detail-HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begleiter zu app.html-aenderung in bb9d350, die <html lang> auf __HTML_LANG__ umgestellt hat. Deploy-skript leitet den lang-wert pro <slug>/index.html aus snapshot/output/posts/<slug>.json ab (grep nach "lang"-feld im JSON), faellt sonst auf 'de' zurueck. Spec-§3.2: <html lang> aus snapshot.lang. Vorher hartcodiert "de", was englische posts (z.B. bible-selfies) crawlern als deutschsprachig auswies — google search console hat das als lang-mismatch geflagt, screen reader sprechen englischen body mit deutscher engine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- scripts/deploy-svelte.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/deploy-svelte.sh b/scripts/deploy-svelte.sh index a00db9d..90b5fdb 100755 --- a/scripts/deploy-svelte.sh +++ b/scripts/deploy-svelte.sh @@ -110,6 +110,23 @@ find "$BUILD_DIR" -type f -name "*.html" -print0 | while IFS= read -r -d '' html sed -i '' "s|__SITE_URL__|$SITE_URL|g" "$html_file" done +# __HTML_LANG__-Platzhalter pro detail-HTML aus dem snapshot-JSON ableiten: +# /<slug>/index.html → snapshot/output/posts/<slug>.json → .lang +# Alle anderen HTMLs (index, archiv/, impressum/, tag/) bekommen den +# default 'de' — die SPA setzt activeLocale clientseitig nach. +echo "Patche __HTML_LANG__ pro HTML aus snapshot/output …" +find "$BUILD_DIR" -type f -name "index.html" -print0 | while IFS= read -r -d '' html_file; do + rel="${html_file#$BUILD_DIR/}" + slug="${rel%/index.html}" + lang_file="$SNAPSHOT_DIR/posts/${slug}.json" + if [ -f "$lang_file" ]; then + lang=$(grep -o '"lang": *"[a-z][a-z]"' "$lang_file" | head -1 | sed 's/.*"\([a-z][a-z]\)".*/\1/') + else + lang="de" + fi + sed -i '' "s|__HTML_LANG__|${lang:-de}|g" "$html_file" +done + echo "Ziel: $TARGET ($PUBLIC_URL)" echo "Lade Build von $BUILD_DIR nach ftp://$FTP_HOST$FTP_REMOTE_PATH" From 3449042051e4fdcfd7f087c4486eac456d65edb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= <info.empeiria@gmail.com> Date: Tue, 28 Apr 2026 09:29:36 +0200 Subject: [PATCH 35/35] docs: top-level-doku auf prerender-snapshot-stand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus dem code-review der etappe (2026-04-28): - CLAUDE.md: snapshot/-verzeichnis in hauptarbeitsbereiche-tabelle ergaenzt; neue stolperfalle "snapshot-output muss vor npm run build da sein" eingefuegt. - STATUS.md: kurzfassung um den prerender-stand erweitert; repo-baum zeigt snapshot/; erledigt-eintrag mit den sechs etappen. - HANDOFF.md: stolperfalle "snapshot vor build" am anfang der liste — haeufigster fallstrick fuer naechste session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 15 ++++++++++++++- docs/HANDOFF.md | 8 ++++++++ docs/STATUS.md | 33 ++++++++++++++++++++++++++++----- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de57275..a291619 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,17 @@ mit `../content/posts/...`. Git-Diff liefert aber repo-root-relative Pfade (`content/posts/...`). `changedPostDirs` normalisiert beides — wenn `posts=0` obwohl Änderungen vorliegen, ist das der Hotspot. -### 5. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/` +### 5. Snapshot-Output muss vor `npm run build` da sein + +SvelteKit prerendert `[...slug]/+page.{ts,svelte}` aus +`snapshot/output/`-JSONs (`index.json` + `posts/<slug>.json`). Lokal +buildst du nicht direkt mit `npm run build`, sondern via +`./scripts/deploy-svelte.sh` — das ruft vorher `deno task snapshot` +auf. Wer `cd app && npm run build` direkt nach dem Clone macht, ohne +vorher `cd snapshot && deno task snapshot` auszuführen, scheitert +mit `ENOENT snapshot/output/index.json`. + +### 6. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/` Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post versehentlich in `content/posts/<slug>/` (ohne Sprach-Ordner) anlegt, @@ -96,6 +106,9 @@ wird von der Pipeline ignoriert. | `app/src/routes/` | SvelteKit-Routen (Layout, Home, Archiv, Post, Impressum) | | `publish/src/` | Deno-Publish-Pipeline (Deno-Tasks in `publish/deno.jsonc`) | | `publish/tests/` | Deno-Tests für die Pipeline | +| `snapshot/src/` | Deno-Snapshot-Tool (Relays → JSON für Prerender) | +| `snapshot/tests/` | Deno-Tests für den Snapshot | +| `snapshot/output/` | (gitignored) build-zeit-JSON, wird vom SvelteKit-Prerender konsumiert | | `docs/superpowers/specs/` | Produktdesigns, Konventionen | | `docs/superpowers/plans/archive/` | Umgesetzte Implementierungspläne (Geschichte) | | `scripts/deploy-svelte.sh` | FTPS-Deploy | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 8cb3d3a..a89e513 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -221,6 +221,14 @@ cd publish && deno task test # tests ## Bekannte Stolperfallen +- **Snapshot vor Build:** `app/build` braucht zur Build-Zeit + `snapshot/output/index.json` und `snapshot/output/posts/<slug>.json`. + `./scripts/deploy-svelte.sh` zieht den Snapshot automatisch vor dem + Build. Wer `cd app && npm run build` direkt aufruft, ohne vorher + `cd snapshot && deno task snapshot` auszuführen, scheitert mit + ENOENT auf `index.json`. Frische Posts erscheinen erst nach einem + Snapshot-Re-Run, weil die Detail-Route ausschließlich aus dem + Snapshot rendert (kein Runtime-Relay-Fetch mehr). - **Amber-Bunker:** bei neuer Bunker-URL müssen die zwei Permissions (`get_public_key`, `sign_event`) in Amber auf „Allow + Always" gesetzt werden, bevor Publish-Requests verarbeitet werden. Siehe diff --git a/docs/STATUS.md b/docs/STATUS.md index 71d2975..acc6f0a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,7 +3,7 @@ > **Rolle dieses Dokuments:** Logbuch — aktueller Stand und Erledigt-Chronologie. > Konventionen und Workflows stehen in [`HANDOFF.md`](HANDOFF.md). -**Stand:** 2026-04-21 (Mehrsprachigkeit live) +**Stand:** 2026-04-28 (Prerender-Snapshot live auf svelte-subdomain) ## Kurzfassung @@ -12,6 +12,14 @@ signierten Nostr-Events (NIP-23, `kind:30023`) auf 5 Public-Relays rendert. Bilder liegen content-addressed auf 2 Blossom-Servern. Die Hugo-basierte Altseite ist als `hugo-archive`-Branch eingefroren. +**Seit 2026-04-28 prerender-snapshot:** Post-Detailseiten werden zur +Build-Zeit prerendered, mit vollen OG-/Twitter-/JSON-LD-Tags. Ein Deno- +Tool (`snapshot/`) liest die Events von den Relays und schreibt sie als +JSON-Artefakte; SvelteKit baut daraus `<slug>/index.html` mit korrekten +Meta-Tags. Crawler und Social-Media-Vorschauen sehen jetzt echte Titel, +Beschreibungen, Cover-Bilder. Live verifiziert auf `svelte.joerg-lohrer.de`, +prod-merge ausstehend. + **Seit 2026-04-21 multilingual:** UI-Chrome (Menü, Footer, Post-Meta) in Deutsch und Englisch via `svelte-i18n`, mit Browser-Locale-Default, `localStorage`-Persistenz und Header-Sprachswitcher. Inhalte pro Sprache @@ -59,9 +67,10 @@ joerglohrerde/ ├── content/impressum.md # Statisches Impressum (wird von SPA geladen) ├── app/ │ ├── src/lib/i18n/ # svelte-i18n + activeLocale-Store + Messages -│ ├── src/lib/nostr/ # Relay-Loader, Translations-Resolving -│ └── src/lib/components/ # u. a. LanguageSwitcher, LanguageAvailability +│ ├── src/lib/nostr/ # Relay-Loader (Listen, Replies, Reactions, Profile) +│ └── src/lib/components/ # u. a. LanguageSwitcher, Reactions, ReplyComposer ├── publish/ # Deno-Publish-Pipeline (Blossom + Nostr) +├── snapshot/ # Deno-Snapshot-Tool (Relays → JSON für Prerender) ├── preview/spa-mini/ # Vanilla-HTML-Mini-Spike (historisch) ├── scripts/ │ └── deploy-svelte.sh # FTPS-Deploy, Targets: svelte/staging/prod @@ -73,9 +82,9 @@ joerglohrerde/ │ ├── wiki-draft-nostr-image-metadata.md │ ├── github-ci-setup.md │ └── superpowers/ -│ ├── specs/ # SPA, Publish-Pipeline, Bild-Metadaten, Multilingual, Prerender (Entwurf) +│ ├── specs/ # SPA, Publish-Pipeline, Bild-Metadaten, Multilingual, Prerender, Docs-Cleanup │ └── plans/ -│ └── archive/ # Umgesetzte Pläne (Geschichte) + eingefrorener Prerender-Plan +│ └── archive/ # Umgesetzte Pläne (Geschichte) + Prerender-Plan (durch 2026-04-28 ersetzt) ├── .github/workflows/ # publish.yml (Forgejo→GitHub Push-Mirror-Trigger) ├── .claude/ │ ├── skills/ # Repo-spezifischer Claude-Skill @@ -117,6 +126,20 @@ Nach Priorität: ## Erledigt (chronologisch seit 2026-04-15) +- ✅ **Prerender-Snapshot (2026-04-28)** — Post-Detailseiten werden zur + Build-Zeit prerendered, nicht mehr live aus Relays. Sechs Etappen: + - `renderMarkdown` auf `isomorphic-dompurify` (node-fähig). + - Neues `snapshot/`-Modul (Deno) mit 32 Tests, liest Events von + Relays und schreibt JSON-Artefakte (NIP-09-aware, Plausibilitäts- + Checks, Cover-Probe, Cache mit akkumulierten deletedCoords). + - GitHub-Action zieht Snapshot nach jedem Publish als Artifact. + - SvelteKit-Detail-Route auf `prerender=true` mit `<svelte:head>` für + OG/Twitter/JSON-LD/hreflang. `<html lang>` + `og:image:width/height` + pro Post korrekt gesetzt; `x-default` zeigt auf DE-Slug. + - Runtime-Relay-Fetch der Detail-Route entfernt. + - Deploy-Skript ruft Snapshot vor SvelteKit-Build auf. + - Toten Code aus Pre-Prerender-Ära entfernt (PostView, LanguageAvailability, + loadPost, loadTranslations, translations.ts). - ✅ Content-Migration: alle 18 Posts haben strukturierte `images:`-Liste im Frontmatter (91 Bilder, mit Alt-Text, Lizenz, Autor:innen, ggf. Caption und Modifications).