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:
commit
48cfdf9aa3
|
|
@ -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
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -79,7 +79,17 @@ mit `../content/posts/...`. Git-Diff liefert aber repo-root-relative
|
|||
Pfade (`content/posts/...`). `changedPostDirs` normalisiert beides —
|
||||
wenn `posts=0` obwohl Änderungen vorliegen, ist das der Hotspot.
|
||||
|
||||
### 5. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/`
|
||||
### 5. Snapshot-Output muss vor `npm run build` da sein
|
||||
|
||||
SvelteKit prerendert `[...slug]/+page.{ts,svelte}` aus
|
||||
`snapshot/output/`-JSONs (`index.json` + `posts/<slug>.json`). Lokal
|
||||
buildst du nicht direkt mit `npm run build`, sondern via
|
||||
`./scripts/deploy-svelte.sh` — das ruft vorher `deno task snapshot`
|
||||
auf. Wer `cd app && npm run build` direkt nach dem Clone macht, ohne
|
||||
vorher `cd snapshot && deno task snapshot` auszuführen, scheitert
|
||||
mit `ENOENT snapshot/output/index.json`.
|
||||
|
||||
### 6. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/`
|
||||
|
||||
Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post
|
||||
versehentlich in `content/posts/<slug>/` (ohne Sprach-Ordner) anlegt,
|
||||
|
|
@ -96,6 +106,9 @@ wird von der Pipeline ignoriert.
|
|||
| `app/src/routes/` | SvelteKit-Routen (Layout, Home, Archiv, Post, Impressum) |
|
||||
| `publish/src/` | Deno-Publish-Pipeline (Deno-Tasks in `publish/deno.jsonc`) |
|
||||
| `publish/tests/` | Deno-Tests für die Pipeline |
|
||||
| `snapshot/src/` | Deno-Snapshot-Tool (Relays → JSON für Prerender) |
|
||||
| `snapshot/tests/` | Deno-Tests für den Snapshot |
|
||||
| `snapshot/output/` | (gitignored) build-zeit-JSON, wird vom SvelteKit-Prerender konsumiert |
|
||||
| `docs/superpowers/specs/` | Produktdesigns, Konventionen |
|
||||
| `docs/superpowers/plans/archive/` | Umgesetzte Implementierungspläne (Geschichte) |
|
||||
| `scripts/deploy-svelte.sh` | FTPS-Deploy |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,264 @@
|
|||
<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;
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (currentDtag !== dtag) return;
|
||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
||||
})
|
||||
.finally(() => {
|
||||
if (currentDtag === dtag) loading = false;
|
||||
});
|
||||
});
|
||||
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,
|
||||
})
|
||||
: '',
|
||||
)
|
||||
</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>
|
||||
.breadcrumb {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
if (legacyDtag) {
|
||||
throw redirect(301, canonicalPostPath(legacyDtag));
|
||||
}
|
||||
const legacyDtag = parseLegacyUrl(pathname)
|
||||
if (legacyDtag) {
|
||||
throw redirect(301, canonicalPostPath(legacyDtag))
|
||||
}
|
||||
|
||||
// Kanonisch: /<dtag>/ — 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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1707,8 +1707,8 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
|||
)
|
||||
</script>
|
||||
|
||||
{#if snapshot}
|
||||
<svelte:head>
|
||||
<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}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
output/
|
||||
.last-snapshot.json
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()]
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export interface SignedEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig: string
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
@ -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://')
|
||||
})
|
||||
|
|
@ -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 })
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
Loading…
Reference in New Issue