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 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///` +### 5. Snapshot-Output muss vor `npm run build` da sein + +SvelteKit prerendert `[...slug]/+page.{ts,svelte}` aus +`snapshot/output/`-JSONs (`index.json` + `posts/.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///` Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post versehentlich in `content/posts//` (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/app/package.json b/app/package.json index ddbc357..6cccba5 100644 --- a/app/package.json +++ b/app/package.json @@ -20,7 +20,7 @@ "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/svelte": "^5.3.1", - "@types/dompurify": "^3.0.5", + "@types/node": "^25.6.0", "jsdom": "^29.0.2", "svelte": "^5.55.2", "svelte-check": "^4.4.6", @@ -33,8 +33,8 @@ "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", "nostr-tools": "^2.23.3", "rxjs": "^7.8.2", diff --git a/app/src/app.html b/app/src/app.html index a89d42f..dc464a7 100644 --- a/app/src/app.html +++ b/app/src/app.html @@ -1,5 +1,5 @@ - + @@ -10,6 +10,13 @@ + 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 @@ - - -{#if !loading && translations.length > 0} -

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

-{/if} - - 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 @@ - - -

{title}

-
- {$t('post.published_on', { values: { date } })} - {#if tags.length > 0} -
- {#each tags as t} - {t} - {/each} -
- {/if} -
- - - -{#if image} -

Cover-Bild

-{/if} - -{#if summary} -

{summary}

-{/if} - -
{@html bodyHtml}
- -{#if dtag} - - - - -{/if} - - 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 { - 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 { .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 -): Promise { - 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 { - 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/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'); + }); +}); diff --git a/app/src/lib/render/markdown.ts b/app/src/lib/render/markdown.ts index 0f6f2ff..6a29211 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'; @@ -34,20 +34,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); } diff --git a/app/src/routes/[...slug]/+page.svelte b/app/src/routes/[...slug]/+page.svelte index 13afecf..baf3b82 100644 --- a/app/src/routes/[...slug]/+page.svelte +++ b/app/src/routes/[...slug]/+page.svelte @@ -1,70 +1,264 @@ + + {#if snapshot} + {snapshot.title} – Jörg Lohrer + + + + + + + + + + {#if ogImageWidth} + + {/if} + {#if ogImageHeight} + + {/if} + + + + + + {#each snapshot.translations as alt} + + {/each} + + {@html ``} + {/if} + + - - -{#if post} - +{#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 +

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

{snapshot.summary}

+ {/if} +
{@html bodyHtmlPrerendered}
+ {#if snapshot.tags.length > 0} +
+ {#each snapshot.tags as tag} + {tag} + {/each} +
+ {/if} + + + + +
{/if} diff --git a/app/src/routes/[...slug]/+page.ts b/app/src/routes/[...slug]/+page.ts index c7b2f51..aed1794 100644 --- a/app/src/routes/[...slug]/+page.ts +++ b/app/src/routes/[...slug]/+page.ts @@ -1,21 +1,79 @@ -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) throw error(404, 'Post nicht gefunden') + return { dtag, snapshot } + } + + throw error(404, 'Post nicht gefunden') +} 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/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/.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 `/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 `` für + OG/Twitter/JSON-LD/hreflang. `` + `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). 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} + diff --git a/scripts/deploy-svelte.sh b/scripts/deploy-svelte.sh index 52fe61f..90b5fdb 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) || { @@ -98,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: +# //index.html → snapshot/output/posts/.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" 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"] + } + } +} 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" + ] + } +} diff --git a/snapshot/src/cli.ts b/snapshot/src/cli.ts new file mode 100644 index 0000000..dbd629e --- /dev/null +++ b/snapshot/src/cli.ts @@ -0,0 +1,120 @@ +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 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([...(cache?.deletedCoords ?? []), ...currentDeletedCoords])], + } + 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) + } +} diff --git a/snapshot/src/core/cache.ts b/snapshot/src/core/cache.ts new file mode 100644 index 0000000..f5870fb --- /dev/null +++ b/snapshot/src/core/cache.ts @@ -0,0 +1,28 @@ +export interface CacheState { + lastKnownGoodCount: number + deletedCoords: string[] +} + +export async function readCache(path: string): Promise { + let text: string + try { + 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 { + await Deno.writeTextFile(path, JSON.stringify(state, null, 2) + '\n') +} diff --git a/snapshot/src/core/checks.ts b/snapshot/src/core/checks.ts new file mode 100644 index 0000000..59ecf49 --- /dev/null +++ b/snapshot/src/core/checks.ts @@ -0,0 +1,43 @@ +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}`, + ) + } + // 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 + 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/src/core/config.ts b/snapshot/src/core/config.ts new file mode 100644 index 0000000..1a94d60 --- /dev/null +++ b/snapshot/src/core/config.ts @@ -0,0 +1,18 @@ +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') + 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/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/src/core/dedup.ts b/snapshot/src/core/dedup.ts new file mode 100644 index 0000000..fb6275c --- /dev/null +++ b/snapshot/src/core/dedup.ts @@ -0,0 +1,16 @@ +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 + 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/nip09-filter.ts b/snapshot/src/core/nip09-filter.ts new file mode 100644 index 0000000..0caaf7e --- /dev/null +++ b/snapshot/src/core/nip09-filter.ts @@ -0,0 +1,27 @@ +import type { SignedEvent } from './types.ts' + +export function filterDeleted( + events: SignedEvent[], + deletions: SignedEvent[], + authorPubkey: string, +): SignedEvent[] { + 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]) 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}` + const deletedAt = deletedAtByCoord.get(coord) + return deletedAt === undefined || ev.created_at > deletedAt + }) +} 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/src/core/post-json.ts b/snapshot/src/core/post-json.ts new file mode 100644 index 0000000..9427302 --- /dev/null +++ b/snapshot/src/core/post-json.ts @@ -0,0 +1,114 @@ +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 && typeof t[1] === 'string') + .map((t) => t[1] as string) +} + +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, + }) + + // 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 + 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/src/core/relays.ts b/snapshot/src/core/relays.ts new file mode 100644 index 0000000..7510690 --- /dev/null +++ b/snapshot/src/core/relays.ts @@ -0,0 +1,104 @@ +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), + }) + // 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) + }) +} + +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/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/cache.test.ts b/snapshot/tests/cache.test.ts new file mode 100644 index 0000000..2eb630c --- /dev/null +++ b/snapshot/tests/cache.test.ts @@ -0,0 +1,34 @@ +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) +}) + +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') +}) 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, + }) +}) diff --git a/snapshot/tests/config.test.ts b/snapshot/tests/config.test.ts new file mode 100644 index 0000000..e1f5fcf --- /dev/null +++ b/snapshot/tests/config.test.ts @@ -0,0 +1,28 @@ +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') +}) + +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://') +}) 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 }) +}) 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') +}) diff --git a/snapshot/tests/nip09-filter.test.ts b/snapshot/tests/nip09-filter.test.ts new file mode 100644 index 0000000..2112709 --- /dev/null +++ b/snapshot/tests/nip09-filter.test.ts @@ -0,0 +1,57 @@ +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) +}) + +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) +}) 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') +}) diff --git a/snapshot/tests/post-json.test.ts b/snapshot/tests/post-json.test.ts new file mode 100644 index 0000000..e22043b --- /dev/null +++ b/snapshot/tests/post-json.test.ts @@ -0,0 +1,99 @@ +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') +}) + +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']) +}) 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']) +})