chore(spa): toter code aus pre-prerender-aera entfernt

Nach etappe 5 (runtime-fallback entfernt) sind diese files/exports
ohne aufrufer:

Files (ganz weg):
- app/src/lib/components/PostView.svelte
- app/src/lib/components/LanguageAvailability.svelte
- app/src/lib/nostr/translations.ts
- app/src/lib/nostr/translations.test.ts
- app/src/lib/nostr/loaders.loadTranslations.test.ts

Aus app/src/lib/nostr/loaders.ts entfernt:
- loadPost(), loadTranslations(), TranslationInfo
- resolveTranslationsFromRefs() (nur von loadTranslations.test.ts genutzt)
- TranslationRef-import von ./translations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-28 09:25:14 +02:00
parent 0ec72f9426
commit bb9d35076d
8 changed files with 28 additions and 510 deletions

View File

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="de"> <html lang="__HTML_LANG__">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@ -1,107 +0,0 @@
<script lang="ts">
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
import { loadTranslations } from '$lib/nostr/loaders';
import { activeLocale } from '$lib/i18n';
import type { SupportedLocale } from '$lib/i18n/activeLocale';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
let translations: TranslationInfo[] = $state([]);
let loading = $state(true);
$effect(() => {
const currentId = event.id;
loading = true;
translations = [];
loadTranslations(event)
.then((infos) => {
if (event.id !== currentId) return;
translations = infos;
})
.finally(() => {
if (event.id === currentId) loading = false;
});
});
function currentLang(): string {
return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de';
}
interface Option {
code: string;
href: string | null; // null = aktueller post, kein klick-ziel
}
const options = $derived.by<Option[]>(() => {
const self: Option = { code: currentLang(), href: null };
const others: Option[] = translations.map((t) => ({
code: t.lang,
href: `/${t.slug}/`
}));
// aktuelle sprache zuerst, dann rest sortiert nach code
return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))];
});
function selectOther(code: string, href: string) {
activeLocale.set(code as SupportedLocale);
// hartes location-setzen, damit svelte-kit-router den post-load triggert
window.location.href = href;
}
</script>
{#if !loading && translations.length > 0}
<p class="lang-switch" role="group" aria-label="Article language">
<span class="icon" aria-hidden="true">📖</span>
{#each options as opt, i}
{#if opt.href === null}
<span class="btn active" aria-current="true">{opt.code.toUpperCase()}</span>
{:else}
<button
type="button"
class="btn"
onclick={() => selectOther(opt.code, opt.href!)}
>{opt.code.toUpperCase()}</button>
{/if}
{#if i < options.length - 1}<span class="sep" aria-hidden="true">|</span>{/if}
{/each}
</p>
{/if}
<style>
.lang-switch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.88rem;
color: var(--muted);
margin: 0.25rem 0 1rem;
}
.icon {
font-size: 1rem;
line-height: 1;
}
.btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 3px;
padding: 1px 7px;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.btn:hover:not(.active) {
color: var(--fg);
}
.btn.active {
color: var(--accent);
border-color: var(--accent);
cursor: default;
}
.sep {
opacity: 0.4;
}
</style>

View File

@ -1,174 +0,0 @@
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import type { SignedEvent } from '$lib/nostr/signer';
import { renderMarkdown } from '$lib/render/markdown';
import Reactions from './Reactions.svelte';
import ReplyList from './ReplyList.svelte';
import ReplyComposer from './ReplyComposer.svelte';
import ExternalClientLinks from './ExternalClientLinks.svelte';
import LanguageAvailability from './LanguageAvailability.svelte';
import { t, activeLocale } from '$lib/i18n';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
function tagValue(e: NostrEvent, name: string): string {
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
}
function tagsAll(e: NostrEvent, name: string): string[] {
return e.tags.filter((t) => t[0] === name).map((t) => t[1]);
}
const dtag = $derived(tagValue(event, 'd'));
let currentLocale = $state('de');
activeLocale.subscribe((v) => (currentLocale = v));
const title = $derived(tagValue(event, 'title') || $t('post.untitled'));
const summary = $derived(tagValue(event, 'summary'));
const image = $derived(tagValue(event, 'image'));
const publishedAt = $derived(
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
);
const date = $derived(
new Date(publishedAt * 1000).toLocaleDateString(
currentLocale === 'en' ? 'en-US' : 'de-DE',
{ year: 'numeric', month: 'long', day: 'numeric' }
)
);
const tags = $derived(tagsAll(event, 't'));
const bodyHtml = $derived(renderMarkdown(event.content));
// Optimistisch gesendete Replies: der Composer pusht sie rein,
// ReplyList merged sie mit den vom Relay geladenen Replies (dedup per id).
let optimisticReplies: NostrEvent[] = $state([]);
function handlePublished(signed: SignedEvent) {
optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent];
}
$effect(() => {
document.title = `${title} Jörg Lohrer`;
});
</script>
<h1 class="post-title">{title}</h1>
<div class="meta">
{$t('post.published_on', { values: { date } })}
{#if tags.length > 0}
<div class="tags">
{#each tags as t}
<a class="tag" href="/tag/{encodeURIComponent(t)}/">{t}</a>
{/each}
</div>
{/if}
</div>
<LanguageAvailability {event} />
{#if image}
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
{/if}
{#if summary}
<p class="summary">{summary}</p>
{/if}
<article>{@html bodyHtml}</article>
{#if dtag}
<Reactions {dtag} />
<ExternalClientLinks {dtag} />
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
<ReplyList {dtag} optimistic={optimisticReplies} />
{/if}
<style>
.post-title {
font-size: 1.5rem;
line-height: 1.25;
margin: 0 0 0.4rem;
word-wrap: break-word;
}
@media (min-width: 640px) {
.post-title {
font-size: 2rem;
line-height: 1.2;
}
}
.meta {
color: var(--muted);
font-size: 0.92rem;
margin-bottom: 2rem;
}
.tags {
margin-top: 0.4rem;
}
.tag {
display: inline-block;
background: var(--code-bg);
border-radius: 3px;
padding: 1px 7px;
margin: 0 4px 4px 0;
font-size: 0.85em;
color: var(--fg);
text-decoration: none;
}
.tag:hover {
background: var(--border);
}
.cover {
max-width: 480px;
margin: 1rem auto 1.5rem;
}
.cover img {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
.summary {
font-style: italic;
color: var(--muted);
}
article :global(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
article :global(a) {
color: var(--accent);
word-break: break-word;
}
article :global(pre) {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
article :global(code) {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
article :global(pre code) {
padding: 0;
background: none;
word-break: normal;
}
article :global(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
article :global(blockquote) {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
margin: 1rem 0;
color: var(--muted);
}
</style>

View File

@ -1,74 +0,0 @@
import { describe, it, expect } from 'vitest';
import { resolveTranslationsFromRefs } from './loaders';
import type { NostrEvent } from './loaders';
import type { TranslationRef } from './translations';
function ev(tags: string[][]): NostrEvent {
return {
id: 'x',
pubkey: 'p',
created_at: 0,
kind: 30023,
tags,
content: '',
sig: 's'
} as unknown as NostrEvent;
}
describe('resolveTranslationsFromRefs', () => {
it('liefert lang/slug/title für jeden aufgelösten ref', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p1', dtag: 'hello' }
];
const fetcher = async () => [
ev([
['d', 'hello'],
['title', 'Hello World'],
['L', 'ISO-639-1'],
['l', 'en', 'ISO-639-1']
])
];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([
{ lang: 'en', slug: 'hello', title: 'Hello World' }
]);
});
it('ignoriert refs, zu denen kein event gefunden wird', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p1', dtag: 'hello' },
{ kind: 30023, pubkey: 'p1', dtag: 'missing' }
];
const fetcher = async (r: TranslationRef) =>
r.dtag === 'hello'
? [ev([
['d', 'hello'],
['title', 'Hi'],
['l', 'en', 'ISO-639-1']
])]
: [];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([{ lang: 'en', slug: 'hello', title: 'Hi' }]);
});
it('ignoriert events ohne l-tag (sprache unklar)', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p', dtag: 'x' }
];
const fetcher = async () => [
ev([
['d', 'x'],
['title', 'kein lang-tag']
])
];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([]);
});
it('leere ref-liste → leere ergebnis-liste', async () => {
const fetcher = async () => {
throw new Error('should not be called');
};
expect(await resolveTranslationsFromRefs([], fetcher)).toEqual([]);
});
});

View File

@ -6,7 +6,6 @@ import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter'
import { pool } from './pool'; import { pool } from './pool';
import { readRelays } from '$lib/stores/readRelays'; import { readRelays } from '$lib/stores/readRelays';
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config'; import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
import type { TranslationRef } from './translations';
/** Re-export als sprechenden Alias */ /** Re-export als sprechenden Alias */
export type { NostrEvent }; 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). * Profil-Event kind:0 (neueste Version).
* Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für * 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 })) .map(([content, count]) => ({ content, count }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
} }
export interface TranslationInfo {
lang: string;
slug: string;
title: string;
}
/**
* Pure Variante für Tests erhält die Events via Fetcher statt Relays.
*/
export async function resolveTranslationsFromRefs(
refs: TranslationRef[],
fetcher: (ref: TranslationRef) => Promise<NostrEvent[]>
): Promise<TranslationInfo[]> {
if (refs.length === 0) return [];
const results = await Promise.all(refs.map(fetcher));
const infos: TranslationInfo[] = [];
for (let i = 0; i < refs.length; i++) {
const evs = results[i];
if (evs.length === 0) continue;
const latest = evs.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best
);
const lang = latest.tags.find((t) => t[0] === 'l')?.[1];
if (!lang) continue;
const slug = latest.tags.find((t) => t[0] === 'd')?.[1] ?? refs[i].dtag;
const title = latest.tags.find((t) => t[0] === 'title')?.[1] ?? '';
infos.push({ lang, slug, title });
}
return infos;
}
/**
* Loader: findet die anderssprachigen Varianten eines Posts.
* Liefert leere Liste, wenn keine a-Tags mit marker "translation" vorhanden.
*/
export async function loadTranslations(
event: NostrEvent
): Promise<TranslationInfo[]> {
const { parseTranslationRefs } = await import('./translations');
const refs = parseTranslationRefs(event);
if (refs.length === 0) return [];
const relays = get(readRelays);
return resolveTranslationsFromRefs(refs, (ref) =>
collectEvents(relays, {
kinds: [ref.kind],
authors: [ref.pubkey],
'#d': [ref.dtag],
limit: 1
})
);
}

View File

@ -1,51 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseTranslationRefs } from './translations';
import type { NostrEvent } from './loaders';
function ev(tags: string[][]): NostrEvent {
return {
id: 'x',
pubkey: 'p',
created_at: 0,
kind: 30023,
tags,
content: '',
sig: 's'
} as unknown as NostrEvent;
}
describe('parseTranslationRefs', () => {
it('extrahiert a-tags mit marker "translation"', () => {
const e = ev([
['d', 'x'],
['a', '30023:abc:other-slug', '', 'translation'],
['a', '30023:abc:third-slug', '', 'translation']
]);
expect(parseTranslationRefs(e)).toEqual([
{ kind: 30023, pubkey: 'abc', dtag: 'other-slug' },
{ kind: 30023, pubkey: 'abc', dtag: 'third-slug' }
]);
});
it('ignoriert a-tags ohne marker "translation"', () => {
const e = ev([
['a', '30023:abc:root-thread', '', 'root'],
['a', '30023:abc:x', '', 'reply']
]);
expect(parseTranslationRefs(e)).toEqual([]);
});
it('ignoriert a-tags mit malformed coordinate', () => {
const e = ev([
['a', 'not-a-coord', '', 'translation'],
['a', '30023:abc:ok', '', 'translation']
]);
expect(parseTranslationRefs(e)).toEqual([
{ kind: 30023, pubkey: 'abc', dtag: 'ok' }
]);
});
it('leeres tag-array → leere liste', () => {
expect(parseTranslationRefs(ev([]))).toEqual([]);
});
});

View File

@ -1,27 +0,0 @@
import type { NostrEvent } from './loaders';
export interface TranslationRef {
kind: number;
pubkey: string;
dtag: string;
}
const COORD_RE = /^(\d+):([0-9a-f]+):([a-z0-9][a-z0-9-]*)$/;
export function parseTranslationRefs(event: NostrEvent): TranslationRef[] {
const refs: TranslationRef[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'a') continue;
if (tag[3] !== 'translation') continue;
const coord = tag[1];
if (typeof coord !== 'string') continue;
const m = coord.match(COORD_RE);
if (!m) continue;
refs.push({
kind: parseInt(m[1], 10),
pubkey: m[2],
dtag: m[3]
});
}
return refs;
}

View File

@ -13,13 +13,32 @@
const snapshot = $derived(data.snapshot) const snapshot = $derived(data.snapshot)
const siteUrl = '__SITE_URL__' const siteUrl = '__SITE_URL__'
// Site-default-OG-bild aus app/static. Dimensionen sind hartcodiert,
// weil das asset stabil ist (siehe spec §Algorithmus-Schritt 8).
const DEFAULT_OG_IMAGE = `${siteUrl}/joerg-profil-2024.webp`
const DEFAULT_OG_IMAGE_WIDTH = 512
const DEFAULT_OG_IMAGE_HEIGHT = 512
const canonical = $derived(`${siteUrl}/${snapshot?.slug ?? dtag}/`) const canonical = $derived(`${siteUrl}/${snapshot?.slug ?? dtag}/`)
const ogImage = $derived( const ogImage = $derived(snapshot?.cover_image?.url ?? DEFAULT_OG_IMAGE)
snapshot?.cover_image?.url ?? `${siteUrl}/joerg-profil-2024.webp`,
)
const ogImageAlt = $derived( const ogImageAlt = $derived(
snapshot?.cover_image?.alt ?? snapshot?.title ?? 'Jörg Lohrer', 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( const bodyHtmlPrerendered = $derived(
snapshot ? renderMarkdown(snapshot.content_markdown) : '', snapshot ? renderMarkdown(snapshot.content_markdown) : '',
) )
@ -59,11 +78,11 @@
<meta property="og:locale" content={snapshot.lang === 'de' ? 'de_DE' : 'en_US'} /> <meta property="og:locale" content={snapshot.lang === 'de' ? 'de_DE' : 'en_US'} />
<meta property="og:image" content={ogImage} /> <meta property="og:image" content={ogImage} />
<meta property="og:image:alt" content={ogImageAlt} /> <meta property="og:image:alt" content={ogImageAlt} />
{#if snapshot.cover_image?.width} {#if ogImageWidth}
<meta property="og:image:width" content={String(snapshot.cover_image.width)} /> <meta property="og:image:width" content={String(ogImageWidth)} />
{/if} {/if}
{#if snapshot.cover_image?.height} {#if ogImageHeight}
<meta property="og:image:height" content={String(snapshot.cover_image.height)} /> <meta property="og:image:height" content={String(ogImageHeight)} />
{/if} {/if}
<meta property="article:published_time" content={new Date(snapshot.published_at * 1000).toISOString()} /> <meta property="article:published_time" content={new Date(snapshot.published_at * 1000).toISOString()} />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
@ -73,7 +92,7 @@
{#each snapshot.translations as alt} {#each snapshot.translations as alt}
<link rel="alternate" hreflang={alt.lang} href={`${siteUrl}/${alt.slug}/`} /> <link rel="alternate" hreflang={alt.lang} href={`${siteUrl}/${alt.slug}/`} />
{/each} {/each}
<link rel="alternate" hreflang="x-default" href={canonical} /> <link rel="alternate" hreflang="x-default" href={xDefaultHref} />
{@html `<script type="application/ld+json">${jsonLd.replace(/<\/script>/gi, '<\\/script>')}</script>`} {@html `<script type="application/ld+json">${jsonLd.replace(/<\/script>/gi, '<\\/script>')}</script>`}
{/if} {/if}
</svelte:head> </svelte:head>