feat: prerender-snapshot — post-detailseiten zur build-zeit gerendered

Sechs entkoppelte etappen, je rollback-bar:

1. renderMarkdown auf isomorphic-dompurify (node-faehig fuer prerender).
2. Neues snapshot/-modul (Deno) mit 32 tests — liest events von relays,
   schreibt JSON-artefakte (NIP-09-aware mit zeitlicher reihenfolge,
   plausibilitaetschecks, cover-probe, last-known-good-cache).
3. GitHub-Action zieht snapshot nach jedem publish als artifact.
4. SvelteKit-detail-route auf prerender=true mit <svelte:head> fuer
   OG/Twitter/JSON-LD/hreflang. <html lang> + og:image-dimensionen
   pro post; x-default zeigt auf DE-slug.
5. Runtime-relay-fetch fuer detail-route entfernt — quelle der wahrheit
   ist jetzt der snapshot.
6. (Geskippt — lftp mirror in 3 phasen war optional.)

Plus toter code der pre-prerender-aera (PostView, LanguageAvailability,
loadPost, loadTranslations, translations.ts) entfernt; deploy-skript
zieht snapshot vor build; doku (CLAUDE/STATUS/HANDOFF) aktualisiert.

Live verifiziert auf svelte.joerg-lohrer.de — OG-tags, JSON-LD,
hreflang, multilingual-rendering korrekt.

Spec: docs/superpowers/specs/2026-04-21-prerender-snapshot-design.md
Plan: docs/superpowers/plans/2026-04-28-prerender-snapshot.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-28 09:35:12 +02:00
commit 48cfdf9aa3
43 changed files with 1650 additions and 601 deletions

View File

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

View File

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

View File

@ -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",

View File

@ -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" />
@ -10,6 +10,13 @@
<meta property="og:description" content="Jörg Lohrer Blog (Nostr-basiert)" />
<link rel="canonical" href="__SITE_URL__/" />
<meta name="robots" content="index, follow" />
<!--
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).
-->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />

View File

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

View File

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

View File

@ -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([]);
});
});

View File

@ -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
})
);
}

View File

@ -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([]);
});
});

View File

@ -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;
}

View File

@ -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('<h1');
expect(html).toContain('Hallo');
expect(html).toContain('<em>Kursiv</em>');
expect(html).toContain('href="https://example.com"');
});
it('sanitisiert XSS-Versuche', () => {
const html = renderMarkdown('<script>alert(1)</script>\n\nText');
expect(html).not.toContain('<script');
expect(html).toContain('Text');
});
it('hebt code-blocks mit highlight.js hervor', () => {
const html = renderMarkdown('```ts\nconst x: number = 1;\n```');
expect(html).toContain('class="hljs');
expect(html).toContain('language-ts');
});
});

View File

@ -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);
}

View File

@ -1,58 +1,138 @@
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadPost } from '$lib/nostr/loaders';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
import { buildHablaLink } from '$lib/nostr/naddr';
import PostView from '$lib/components/PostView.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
import { t } from '$lib/i18n';
import { get } from 'svelte/store';
import type { NostrEvent } from '$lib/nostr/loaders'
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 type { SignedEvent } from '$lib/nostr/signer'
let { data } = $props();
const dtag = $derived(data.dtag);
let { data } = $props()
const dtag = $derived(data.dtag)
const snapshot = $derived(data.snapshot)
let post: NostrEvent | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
const siteUrl = '__SITE_URL__'
const hablaLink = $derived(
buildHablaLink({
pubkey: AUTHOR_PUBKEY_HEX,
kind: 30023,
identifier: dtag
})
);
// 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
$effect(() => {
const currentDtag = dtag;
post = null;
loading = true;
error = null;
loadPost(currentDtag)
.then((p) => {
if (currentDtag !== dtag) return;
if (!p) {
error = get(t)('post.not_found', { values: { slug: currentDtag } });
} else {
post = p;
const canonical = $derived(`${siteUrl}/${snapshot?.slug ?? dtag}/`)
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) : '',
)
let optimisticReplies: NostrEvent[] = $state([])
function handlePublished(signed: SignedEvent) {
optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent]
}
const jsonLd = $derived(
snapshot
? JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: snapshot.title,
description: snapshot.summary,
datePublished: new Date(snapshot.published_at * 1000).toISOString(),
dateModified: new Date(snapshot.created_at * 1000).toISOString(),
author: { '@type': 'Person', name: 'Jörg Lohrer' },
inLanguage: snapshot.lang,
image: ogImage,
mainEntityOfPage: canonical,
})
.catch((e) => {
if (currentDtag !== dtag) return;
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
})
.finally(() => {
if (currentDtag === dtag) loading = false;
});
});
: '',
)
</script>
<svelte:head>
{#if snapshot}
<title>{snapshot.title} Jörg Lohrer</title>
<meta name="description" content={snapshot.summary} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="article" />
<meta property="og:title" content={snapshot.title} />
<meta property="og:description" content={snapshot.summary} />
<meta property="og:url" content={canonical} />
<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 ogImageWidth}
<meta property="og:image:width" content={String(ogImageWidth)} />
{/if}
{#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" />
<meta name="twitter:title" content={snapshot.title} />
<meta name="twitter:description" content={snapshot.summary} />
<meta name="twitter:image" content={ogImage} />
{#each snapshot.translations as alt}
<link rel="alternate" hreflang={alt.lang} href={`${siteUrl}/${alt.slug}/`} />
{/each}
<link rel="alternate" hreflang="x-default" href={xDefaultHref} />
{@html `<script type="application/ld+json">${jsonLd.replace(/<\/script>/gi, '<\\/script>')}</script>`}
{/if}
</svelte:head>
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
<LoadingOrError {loading} {error} {hablaLink} />
{#if post}
<PostView event={post} />
{#if snapshot}
<article class="post">
<h1 class="post-title">{snapshot.title}</h1>
{#if snapshot.translations.length > 0}
<p class="lang-switch" role="group" aria-label="Article language">
<span class="icon" aria-hidden="true">📖</span>
<span class="btn active" aria-current="true">{snapshot.lang.toUpperCase()}</span>
{#each [...snapshot.translations].sort((a, b) => a.lang.localeCompare(b.lang)) as alt}
<span class="sep" aria-hidden="true">|</span>
<a class="btn" href={`/${alt.slug}/`}>{alt.lang.toUpperCase()}</a>
{/each}
</p>
{/if}
{#if snapshot.cover_image}
<p class="cover">
<img src={snapshot.cover_image.url} alt={snapshot.cover_image.alt ?? ''} />
</p>
{/if}
{#if snapshot.summary}
<p class="summary">{snapshot.summary}</p>
{/if}
<div class="body">{@html bodyHtmlPrerendered}</div>
{#if snapshot.tags.length > 0}
<div class="tags">
{#each snapshot.tags as tag}
<a class="tag" href={`/tag/${encodeURIComponent(tag)}/`}>{tag}</a>
{/each}
</div>
{/if}
<Reactions dtag={snapshot.slug} />
<ExternalClientLinks dtag={snapshot.slug} />
<ReplyComposer dtag={snapshot.slug} eventId={snapshot.event_id} onPublished={handlePublished} />
<ReplyList dtag={snapshot.slug} optimistic={optimisticReplies} />
</article>
{/if}
<style>
@ -67,4 +147,118 @@
.breadcrumb a:hover {
text-decoration: underline;
}
.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;
}
}
.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);
}
.body :global(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.body :global(a) {
color: var(--accent);
word-break: break-word;
}
.body :global(pre) {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
.body :global(code) {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
.body :global(pre code) {
padding: 0;
background: none;
word-break: normal;
}
.body :global(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
.body :global(blockquote) {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
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);
}
</style>

View File

@ -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<SnapshotIndex> {
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<PostJson | undefined> {
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/<dtag>.html/ → Redirect auf /<dtag>/
const legacyDtag = parseLegacyUrl(pathname);
const legacyDtag = parseLegacyUrl(pathname)
if (legacyDtag) {
throw redirect(301, canonicalPostPath(legacyDtag));
throw redirect(301, canonicalPostPath(legacyDtag))
}
// Kanonisch: /<dtag>/ — erster Segment des Pfades.
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/');
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/')
if (segments.length !== 1 || !segments[0]) {
throw error(404, 'Seite nicht gefunden');
throw error(404, 'Seite nicht gefunden')
}
const dtag = decodeURIComponent(segments[0])
if (!browser) {
const snapshot = await readPost(dtag)
if (!snapshot) throw error(404, 'Post nicht gefunden')
return { dtag, snapshot }
}
return { dtag: decodeURIComponent(segments[0]) };
};
throw error(404, 'Post nicht gefunden')
}

View File

@ -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 `/<slug>/__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 <img src="..."> und vom crawler verfolgte
// pseudo-routes. Die SPA selbst rendert die <img>-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'
}
}
};

View File

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

View File

@ -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).

View File

@ -1707,8 +1707,8 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
)
</script>
{#if snapshot}
<svelte:head>
{#if snapshot}
<title>{snapshot.title} Jörg Lohrer</title>
<meta name="description" content={snapshot.summary} />
<link rel="canonical" href={canonical} />
@ -1735,8 +1735,8 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
{/each}
<link rel="alternate" hreflang="x-default" href={canonical} />
<script type="application/ld+json">{jsonLd}</script>
</svelte:head>
{/if}
</svelte:head>
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>

View File

@ -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:
# /<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"

2
snapshot/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
output/
.last-snapshot.json

22
snapshot/README.md Normal file
View File

@ -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)

31
snapshot/deno.jsonc Normal file
View File

@ -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"]
}
}
}

172
snapshot/deno.lock Normal file
View File

@ -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"
]
}
}

120
snapshot/src/cli.ts Normal file
View File

@ -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<number> {
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<string, string>()
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)
}
}

View File

@ -0,0 +1,28 @@
export interface CacheState {
lastKnownGoodCount: number
deletedCoords: string[]
}
export async function readCache(path: string): Promise<CacheState | undefined> {
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<void> {
await Deno.writeTextFile(path, JSON.stringify(state, null, 2) + '\n')
}

View File

@ -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.`,
)
}
}
}

View File

@ -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 }
}

View File

@ -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<ProbeResult> {
try {
const r = await fetcher(url)
return { reachable: r.ok, status: r.status }
} catch {
return { reachable: false, status: 0 }
}
}

View File

@ -0,0 +1,16 @@
import type { SignedEvent } from './types.ts'
export function dedupByDtag(events: SignedEvent[]): SignedEvent[] {
const byDtag = new Map<string, SignedEvent>()
// 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()]
}

View File

@ -0,0 +1,27 @@
import type { SignedEvent } from './types.ts'
export function filterDeleted(
events: SignedEvent[],
deletions: SignedEvent[],
authorPubkey: string,
): SignedEvent[] {
const deletedAtByCoord = new Map<string, number>()
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
})
}

View File

@ -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<void> {
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',
)
}
}

View File

@ -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<string, string>,
): 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,
}
}

104
snapshot/src/core/relays.ts Normal file
View File

@ -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<SignedEvent | undefined>
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<string[]> {
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<SignedEvent[]>
export const defaultEventFetcher: EventFetcher = async (relay, pubkey) => {
const out: SignedEvent[] = []
const r = new Relay(relay)
return await new Promise<SignedEvent[]>((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<FetchEventsResult> {
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,
}
}

View File

@ -0,0 +1,9 @@
export interface SignedEvent {
id: string
pubkey: string
created_at: number
kind: number
tags: string[][]
content: string
sig: string
}

View File

@ -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')
})

View File

@ -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,
})
})

View File

@ -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://')
})

View File

@ -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 })
})

View File

@ -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')
})

View File

@ -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)
})

View File

@ -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/<slug>.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')
})

View File

@ -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'])
})

View File

@ -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'])
})