72 KiB
SvelteKit-SPA Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Eine produktive SvelteKit-SPA bauen, die Jörgs Nostr-Posts (kind:30023) live von Public-Relays rendert, auf svelte.joerg-lohrer.de deployed wird und später joerg-lohrer.de ablösen soll.
Architecture: SvelteKit mit adapter-static (SSR aus, Fallback index.html), applesauce-relay + applesauce-loaders + applesauce-signers für Nostr, marked + DOMPurify + highlight.js für Markdown. Konfiguration zur Laufzeit aus kind:10002 (Relays) und kind:10063 (Blossom). Legacy-URLs werden via history.replaceState auf kanonische kurze Form normalisiert.
Tech Stack: SvelteKit 2.x · Svelte 5 · TypeScript · Vite · applesauce-relay · applesauce-loaders · applesauce-signers · nostr-tools (nip19 Encoding) · marked · DOMPurify · highlight.js · Vitest · Playwright · adapter-static
Scope: Diese Implementation deckt die vollständige SPA-Spec ab — Home mit Liste + Profil, Einzelpost, Tag-Filter, Reactions und NIP-07-Kommentare. Die Publish-Pipeline (Markdown → Events) ist nicht Teil dieses Plans — sie hat eine eigene Spec und einen separaten Plan.
Ausführungsort: Branch spa, Unterordner app/ des Repos.
Referenzen
- Spec SPA:
docs/superpowers/specs/2026-04-15-nostr-page-design.md - Spec Publish-Pipeline:
docs/superpowers/specs/2026-04-15-publish-pipeline-design.md - Mini-Spike als funktionierendes Referenz-Verhalten:
preview/spa-mini/index.html— live unterhttps://spa.joerg-lohrer.de/ - Autoren-Pubkey (npub):
npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9 - Autoren-Pubkey (hex):
4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 - Bootstrap-Relay:
wss://relay.damus.io - Beispiel-Event zum Testen:
kind:30023,d:dezentrale-oep-oer(existiert bereits auf allen 5 Relays)
File-Structure
app/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── tsconfig.json
├── .gitignore
├── README.md
├── static/
│ ├── favicon.ico
│ └── robots.txt
├── src/
│ ├── app.html # HTML-Shell, <head>-Defaults
│ ├── app.d.ts # TypeScript-Ambient-Deklarationen
│ ├── hooks.client.ts # Globaler Client-Hook (Fehler-Reporting)
│ ├── lib/
│ │ ├── nostr/
│ │ │ ├── config.ts # BOOTSTRAP_RELAY, AUTHOR_PUBKEY_HEX, FALLBACK_READ_RELAYS
│ │ │ ├── pool.ts # RelayPool Singleton
│ │ │ ├── relays.ts # loadOutboxRelays (kind:10002)
│ │ │ ├── blossom.ts # loadBlossomServers (kind:10063) — read-only, für später
│ │ │ ├── loaders.ts # loadProfile, loadPostList, loadPost, loadReplies, loadReactions
│ │ │ ├── signer.ts # NIP-07-Wrapper
│ │ │ └── naddr.ts # nip19.naddrEncode Helper
│ │ ├── render/
│ │ │ └── markdown.ts # renderMarkdown(md: string): string
│ │ ├── url/
│ │ │ └── legacy.ts # parseLegacyUrl, canonicalPostPath
│ │ ├── stores/
│ │ │ └── readRelays.ts # derived Store: aktuelle Read-Relay-Liste
│ │ └── components/
│ │ ├── ProfileCard.svelte
│ │ ├── PostCard.svelte
│ │ ├── PostView.svelte
│ │ ├── TagChip.svelte
│ │ ├── ReplyList.svelte
│ │ ├── ReplyItem.svelte
│ │ ├── ReplyComposer.svelte
│ │ ├── Reactions.svelte
│ │ └── LoadingOrError.svelte
│ └── routes/
│ ├── +layout.svelte # Shell (Header nur auf /, Breadcrumb ansonsten)
│ ├── +layout.ts # export const prerender = false; ssr = false
│ ├── +page.svelte # Home: Profil + Beitragsliste
│ ├── +error.svelte # Fehlerseite
│ ├── [...slug]/+page.svelte # Catch-all: Legacy-Check, dann Einzelpost
│ └── tag/
│ └── [name]/+page.svelte # Tag-Filter
├── tests/
│ ├── unit/
│ │ ├── markdown.test.ts
│ │ ├── legacy-url.test.ts
│ │ ├── naddr.test.ts
│ │ └── loaders.test.ts # gegen Mock-Relay
│ └── e2e/
│ ├── home.test.ts
│ ├── post.test.ts
│ ├── legacy-redirect.test.ts
│ └── tag.test.ts
└── playwright.config.ts
Phase 1 — Setup (Tasks 1–6)
Task 1: SvelteKit-Projekt initialisieren
Files:
-
Create:
app/package.json -
Create:
app/svelte.config.js -
Create:
app/vite.config.ts -
Create:
app/tsconfig.json -
Create:
app/.gitignore -
Create:
app/src/app.html -
Create:
app/src/app.d.ts -
Create:
app/src/routes/+page.svelte -
Create:
app/static/favicon.ico -
Create:
app/static/robots.txt -
Step 1.1: Arbeitsverzeichnis vorbereiten und SvelteKit-Skeleton erzeugen
cd /Users/joerglohrer/repositories/joerglohrerde
mkdir -p app
cd app
# Nicht-interaktiv: minimal TypeScript-Skeleton anlegen
npx --yes sv create . --template minimal --types ts --no-add-ons --install npm
Expected: Verzeichnis app/ hat package.json, svelte.config.js, vite.config.ts, src/, static/, node_modules/ installiert.
- Step 1.2:
svelte.config.jsauf adapter-static umstellen
Ersetze den Inhalt von app/svelte.config.js durch:
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
alias: {
$lib: 'src/lib',
},
},
};
export default config;
- Step 1.3: adapter-static als Dependency hinzufügen
cd app
npm install --save-dev @sveltejs/adapter-static
Expected: package.json hat "@sveltejs/adapter-static" in devDependencies.
- Step 1.4: Globales SSR deaktivieren via
+layout.ts
Create app/src/routes/+layout.ts:
export const prerender = false;
export const ssr = false;
export const trailingSlash = 'always';
- Step 1.5: Minimale
+page.sveltefür erste Build-Verifikation
Ersetze Inhalt von app/src/routes/+page.svelte durch:
<h1>SvelteKit-SPA bootet</h1>
<p>Wird Stück für Stück mit Nostr-Funktionalität gefüllt.</p>
- Step 1.6: Build testen
cd app
npm run build
Expected: build/index.html existiert, keine Fehler.
- Step 1.7:
app/.gitignoresetzen
Create oder überschreibe app/.gitignore:
node_modules/
build/
.svelte-kit/
package-lock.json
.env
.env.local
*.log
Hinweis: package-lock.json wird bewusst nicht committed, weil die Repo-weite Policy das so handhabt. Wenn die Policy später ändert, diese Zeile entfernen.
- Step 1.8: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/
git commit -m "spa: sveltekit-skeleton mit adapter-static initialisiert"
Task 2: Dependencies installieren und Aliases konfigurieren
Files:
-
Modify:
app/package.json -
Modify:
app/tsconfig.json -
Step 2.1: Runtime-Dependencies installieren
cd app
npm install \
applesauce-core \
applesauce-relay \
applesauce-loaders \
applesauce-signers \
nostr-tools \
marked \
dompurify \
highlight.js \
rxjs
- Step 2.2: Dev-Dependencies installieren (Tests)
cd app
npm install --save-dev vitest @playwright/test @testing-library/svelte jsdom
- Step 2.3: Type-Definitionen für DOMPurify
cd app
npm install --save-dev @types/dompurify
- Step 2.4: Vitest-Konfiguration erweitern in
vite.config.ts
Ersetze Inhalt von app/vite.config.ts durch:
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['tests/unit/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
globals: true,
},
});
- Step 2.5: npm-Scripts ergänzen in
package.json
Öffne app/package.json, ergänze im "scripts"-Objekt:
"test:unit": "vitest run",
"test:e2e": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"deploy:svelte": "../scripts/deploy-svelte.sh"
(Die build, preview, dev Scripts erzeugt SvelteKit initial — nur ergänzen.)
- Step 2.6: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/
git commit -m "spa: runtime- und dev-dependencies installiert"
Task 3: Konfigurations-Modul config.ts
Files:
-
Create:
app/src/lib/nostr/config.ts -
Step 3.1: Config-Konstanten anlegen
Create app/src/lib/nostr/config.ts:
/**
* Nostr-Konfiguration der SPA.
*
* Wichtig: Der AUTHOR_PUBKEY_HEX muss synchron zum tatsächlichen
* Autorenkonto sein (siehe docs/superpowers/specs/2026-04-15-nostr-page-design.md).
*/
/** npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9 in hex */
export const AUTHOR_PUBKEY_HEX =
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
/** Bootstrap-Relay für das initiale Lesen von kind:10002 */
export const BOOTSTRAP_RELAY = 'wss://relay.damus.io';
/** Fallback, falls kind:10002 nicht geladen werden kann */
export const FALLBACK_READ_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.tchncs.de',
'wss://relay.edufeed.org',
];
/** Habla.news-Deep-Link-Basis (für Nutzer ohne JS oder wenn Events fehlen) */
export const HABLA_BASE = 'https://habla.news/a/';
/** Timeout-Werte in ms */
export const RELAY_TIMEOUT_MS = 8000;
export const RELAY_HARD_TIMEOUT_MS = 15000;
- Step 3.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/config.ts
git commit -m "spa: nostr-konfigurations-modul mit pubkey, bootstrap-relay, fallbacks"
Task 4: URL-Parsing-Modul mit TDD
Files:
-
Create:
app/src/lib/url/legacy.ts -
Create:
app/tests/unit/legacy-url.test.ts -
Step 4.1: Failing test für
parseLegacyUrl
Create app/tests/unit/legacy-url.test.ts:
import { describe, expect, it } from 'vitest';
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
describe('parseLegacyUrl', () => {
it('extrahiert dtag aus der Hugo-URL-Form mit Trailing-Slash', () => {
expect(parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/')).toBe(
'dezentrale-oep-oer',
);
});
it('extrahiert dtag aus der Hugo-URL-Form ohne Trailing-Slash', () => {
expect(parseLegacyUrl('/2024/01/26/offenheit-das-wesentliche.html')).toBe(
'offenheit-das-wesentliche',
);
});
it('returned null für die kanonische kurze Form', () => {
expect(parseLegacyUrl('/dezentrale-oep-oer/')).toBeNull();
});
it('returned null für leeren Pfad', () => {
expect(parseLegacyUrl('/')).toBeNull();
});
it('returned null für andere Strukturen', () => {
expect(parseLegacyUrl('/tag/OER/')).toBeNull();
expect(parseLegacyUrl('/some/random/path/')).toBeNull();
});
it('decodiert percent-encoded dtags', () => {
expect(parseLegacyUrl('/2024/05/12/mit%20leerzeichen.html/')).toBe(
'mit leerzeichen',
);
});
});
describe('canonicalPostPath', () => {
it('erzeugt /<dtag>/ mit encodeURIComponent', () => {
expect(canonicalPostPath('dezentrale-oep-oer')).toBe('/dezentrale-oep-oer/');
});
it('kodiert Sonderzeichen', () => {
expect(canonicalPostPath('mit leerzeichen')).toBe('/mit%20leerzeichen/');
});
});
- Step 4.2: Test ausführen, erwarte FAIL
cd app
npm run test:unit -- legacy-url
Expected: Import-Fehler, weil $lib/url/legacy nicht existiert.
- Step 4.3: Implementation
Create app/src/lib/url/legacy.ts:
/**
* Erkennt Legacy-Hugo-URLs der Form /YYYY/MM/DD/<dtag>.html oder .../<dtag>.html/
* und gibt den dtag-Teil zurück. Für alle anderen Pfade: null.
*/
export function parseLegacyUrl(path: string): string | null {
const match = path.match(/^\/\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
if (!match) return null;
return decodeURIComponent(match[1]);
}
/**
* Erzeugt die kanonische kurze Post-URL /<dtag>/.
*/
export function canonicalPostPath(dtag: string): string {
return `/${encodeURIComponent(dtag)}/`;
}
- Step 4.4: Test ausführen, erwarte PASS
cd app
npm run test:unit -- legacy-url
Expected: alle 8 Tests grün.
- Step 4.5: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/url/legacy.ts app/tests/unit/legacy-url.test.ts
git commit -m "spa: url-parser für legacy-hugo-urls (tdd)"
Task 5: naddr-Encoder-Modul mit TDD
Files:
-
Create:
app/src/lib/nostr/naddr.ts -
Create:
app/tests/unit/naddr.test.ts -
Step 5.1: Failing test
Create app/tests/unit/naddr.test.ts:
import { describe, expect, it } from 'vitest';
import { buildHablaLink } from '$lib/nostr/naddr';
describe('buildHablaLink', () => {
it('erzeugt einen habla.news/a/-Link mit naddr1-Bech32', () => {
const link = buildHablaLink({
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
kind: 30023,
identifier: 'dezentrale-oep-oer',
relays: ['wss://relay.damus.io'],
});
expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/);
});
it('ist deterministisch für gleiche Inputs', () => {
const args = {
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
kind: 30023,
identifier: 'foo',
relays: ['wss://relay.damus.io'],
};
expect(buildHablaLink(args)).toBe(buildHablaLink(args));
});
});
- Step 5.2: Test ausführen (FAIL)
cd app
npm run test:unit -- naddr
- Step 5.3: Implementation
Create app/src/lib/nostr/naddr.ts:
import { nip19 } from 'nostr-tools';
import { HABLA_BASE } from './config';
export interface NaddrArgs {
pubkey: string;
kind: number;
identifier: string;
relays?: string[];
}
export function buildNaddr(args: NaddrArgs): string {
return nip19.naddrEncode({
pubkey: args.pubkey,
kind: args.kind,
identifier: args.identifier,
relays: args.relays ?? [],
});
}
export function buildHablaLink(args: NaddrArgs): string {
return `${HABLA_BASE}${buildNaddr(args)}`;
}
- Step 5.4: Test ausführen (PASS)
cd app
npm run test:unit -- naddr
- Step 5.5: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/naddr.ts app/tests/unit/naddr.test.ts
git commit -m "spa: naddr/habla-link-helper (tdd)"
Task 6: Deploy-Script für svelte.joerg-lohrer.de
Files:
-
Create:
scripts/deploy-svelte.sh -
Create:
scripts/README.md -
Step 6.1: Deploy-Script anlegen
Create scripts/deploy-svelte.sh:
#!/usr/bin/env bash
# Deploy: SvelteKit-Build nach svelte.joerg-lohrer.de per FTPS.
# Credentials kommen aus ./.env.local (gitignored), Variablen-Prefix SVELTE_FTP_.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [ ! -f .env.local ]; then
echo "FEHLER: .env.local fehlt — Credentials ergänzen (siehe .env.example)." >&2
exit 1
fi
# nur SVELTE_FTP_* exportieren
set -a
# shellcheck disable=SC1090
. <(grep -E '^SVELTE_FTP_' .env.local)
set +a
for v in SVELTE_FTP_HOST SVELTE_FTP_USER SVELTE_FTP_PASS SVELTE_FTP_REMOTE_PATH; do
if [ -z "${!v:-}" ]; then
echo "FEHLER: $v fehlt in .env.local." >&2
exit 1
fi
done
BUILD_DIR="$ROOT/app/build"
if [ ! -d "$BUILD_DIR" ]; then
echo "FEHLER: app/build nicht vorhanden. Bitte vorher 'npm run build' in app/ ausführen." >&2
exit 1
fi
echo "Lade Build von $BUILD_DIR nach ftp://$SVELTE_FTP_HOST$SVELTE_FTP_REMOTE_PATH"
# pro Datei ein curl-Upload (zuverlässig auf macOS ohne lftp)
find "$BUILD_DIR" -type f -print0 | while IFS= read -r -d '' local_file; do
rel="${local_file#$BUILD_DIR/}"
remote="ftp://$SVELTE_FTP_HOST${SVELTE_FTP_REMOTE_PATH%/}/$rel"
echo " → $rel"
curl -sSf --ssl-reqd --ftp-create-dirs \
--user "$SVELTE_FTP_USER:$SVELTE_FTP_PASS" \
-T "$local_file" "$remote"
done
echo "Upload fertig. Live-Check:"
curl -sIL "https://svelte.joerg-lohrer.de/" | head -5
- Step 6.2: Script ausführbar machen
chmod +x scripts/deploy-svelte.sh
- Step 6.3: Scripts-README anlegen
Create scripts/README.md:
# Scripts
- **`deploy-svelte.sh`** — deployed den SvelteKit-Build aus `app/build/` nach
`svelte.joerg-lohrer.de` via FTPS. Benötigt `.env.local` im Repo-Root mit
den Variablen `SVELTE_FTP_HOST`, `SVELTE_FTP_USER`, `SVELTE_FTP_PASS`,
`SVELTE_FTP_REMOTE_PATH`. Aufruf:
```sh
cd app && npm run build && cd .. && ./scripts/deploy-svelte.sh
- [ ] **Step 6.4: Test-Deploy mit dem Minimal-Build aus Task 1**
Voraussetzung: `SVELTE_FTP_*` in `.env.local` ist ausgefüllt und `svelte.joerg-lohrer.de` hat SSL aktiv.
```sh
cd app && npm run build && cd ..
./scripts/deploy-svelte.sh
Expected: Upload läuft durch, curl -sI https://svelte.joerg-lohrer.de/ liefert HTTP 200 und Inhalt zeigt „SvelteKit-SPA bootet".
- Step 6.5:
.htaccessfür SPA-Fallback anlegen (analog zu Mini-Spike)
Create app/static/.htaccess:
RewriteEngine On
# HTTPS forcieren
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Alles andere → SPA-Fallback (SvelteKit mit adapter-static)
RewriteRule ^ /index.html [L]
Rebuild und redeploy:
cd app && npm run build && cd ..
./scripts/deploy-svelte.sh
- Step 6.6: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add scripts/ app/static/.htaccess
git commit -m "spa: deploy-script und htaccess für svelte.joerg-lohrer.de"
Phase 2 — Datenebene (Tasks 7–14)
Task 7: Markdown-Renderer mit TDD
Files:
-
Create:
app/src/lib/render/markdown.ts -
Create:
app/tests/unit/markdown.test.ts -
Step 7.1: Failing test
Create app/tests/unit/markdown.test.ts:
import { describe, expect, it } from 'vitest';
import { renderMarkdown } from '$lib/render/markdown';
describe('renderMarkdown', () => {
it('rendert einfachen Markdown-Text zu HTML', () => {
const html = renderMarkdown('**bold** and *italic*');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<em>italic</em>');
});
it('entfernt <script>-Tags (DOMPurify)', () => {
const html = renderMarkdown('hello <script>alert("x")</script> world');
expect(html).not.toContain('<script>');
});
it('entfernt javascript:-URLs', () => {
const html = renderMarkdown('[click](javascript:alert(1))');
expect(html).not.toMatch(/javascript:/i);
});
it('rendert Links mit http:// und erhält das href', () => {
const html = renderMarkdown('[nostr](https://nostr.com)');
expect(html).toContain('href="https://nostr.com"');
});
it('rendert horizontale Linie aus ---', () => {
const html = renderMarkdown('oben\n\n---\n\nunten');
expect(html).toContain('<hr>');
});
it('rendert fenced code blocks', () => {
const html = renderMarkdown('```js\nconst x = 1;\n```');
expect(html).toContain('<pre>');
expect(html).toContain('<code');
});
it('rendert GFM tables', () => {
const md = '| a | b |\n|---|---|\n| 1 | 2 |';
const html = renderMarkdown(md);
expect(html).toContain('<table');
expect(html).toContain('<td>1</td>');
});
it('rendert Bilder', () => {
const html = renderMarkdown('');
expect(html).toContain('<img');
expect(html).toContain('src="https://example.com/img.png"');
});
});
- Step 7.2: Test ausführen (FAIL)
cd app
npm run test:unit -- markdown
- Step 7.3: Implementation
Create app/src/lib/render/markdown.ts:
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import bash from 'highlight.js/lib/languages/bash';
import typescript from 'highlight.js/lib/languages/typescript';
import json from 'highlight.js/lib/languages/json';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('js', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('ts', typescript);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('sh', bash);
hljs.registerLanguage('json', json);
marked.use({
breaks: true,
gfm: true,
renderer: {
code({ text, lang }) {
const language = lang && hljs.getLanguage(lang) ? lang : undefined;
const highlighted = language
? hljs.highlight(text, { language }).value
: hljs.highlightAuto(text).value;
const cls = language ? ` language-${language}` : '';
return `<pre><code class="hljs${cls}">${highlighted}</code></pre>`;
},
},
});
/**
* Rendert einen Markdown-String zu sanitized HTML.
* Einziger Export des Moduls — so bleibt Austausch der Engine lokal.
*/
export function renderMarkdown(md: string): string {
const raw = marked.parse(md, { async: false }) as string;
return DOMPurify.sanitize(raw, {
ADD_ATTR: ['target', 'rel'],
});
}
- Step 7.4: Test ausführen (PASS)
cd app
npm run test:unit -- markdown
Expected: alle 8 Tests grün.
- Step 7.5: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/render/markdown.ts app/tests/unit/markdown.test.ts
git commit -m "spa: markdown-renderer mit sanitize (tdd)"
Task 8: RelayPool-Singleton
Files:
-
Create:
app/src/lib/nostr/pool.ts -
Step 8.1: RelayPool anlegen
Create app/src/lib/nostr/pool.ts:
import { RelayPool } from 'applesauce-relay';
/**
* Singleton-Pool für alle Nostr-Requests der SPA.
* applesauce-relay verwaltet Reconnects, Subscriptions, deduping intern.
*/
export const pool = new RelayPool();
- Step 8.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/pool.ts
git commit -m "spa: relaypool-singleton via applesauce-relay"
Task 9: Outbox-Relays laden (kind:10002)
Files:
-
Create:
app/src/lib/nostr/relays.ts -
Step 9.1: Helper anlegen
Create app/src/lib/nostr/relays.ts:
import { pool } from './pool';
import {
AUTHOR_PUBKEY_HEX,
BOOTSTRAP_RELAY,
FALLBACK_READ_RELAYS,
RELAY_TIMEOUT_MS,
} from './config';
export interface OutboxRelay {
url: string;
/** true = zum Lesen zu nutzen (kein dritter Tag-Wert oder "read") */
read: boolean;
/** true = zum Schreiben zu nutzen (kein dritter Tag-Wert oder "write") */
write: boolean;
}
/**
* Lädt die NIP-65-Relay-Liste (kind:10002) des Autors vom Bootstrap-Relay.
* Fallback auf FALLBACK_READ_RELAYS, wenn das Event nicht innerhalb von
* RELAY_TIMEOUT_MS gefunden wird.
*
* Interpretation des dritten Tag-Werts:
* - nicht gesetzt → read + write
* - "read" → nur read
* - "write" → nur write
*/
export async function loadOutboxRelays(): Promise<OutboxRelay[]> {
const event = await Promise.race([
firstEvent({ kinds: [10002], authors: [AUTHOR_PUBKEY_HEX], limit: 1 }),
timeout(RELAY_TIMEOUT_MS),
]).catch(() => null);
if (!event) {
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
}
const relays: OutboxRelay[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'r' || !tag[1]) continue;
const mode = tag[2];
relays.push({
url: tag[1],
read: mode !== 'write',
write: mode !== 'read',
});
}
if (relays.length === 0) {
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
}
return relays;
}
/** Nur die Read-URLs aus OutboxRelay[] */
export function readUrls(relays: OutboxRelay[]): string[] {
return relays.filter((r) => r.read).map((r) => r.url);
}
/** Nur die Write-URLs aus OutboxRelay[] */
export function writeUrls(relays: OutboxRelay[]): string[] {
return relays.filter((r) => r.write).map((r) => r.url);
}
// ---------- Internes --------------------------------------------------------
interface NostrEventShape {
tags: string[][];
[k: string]: unknown;
}
function firstEvent(filter: {
kinds: number[];
authors?: string[];
limit?: number;
}): Promise<NostrEventShape | null> {
return new Promise((resolve) => {
let done = false;
const sub = pool.subscription([BOOTSTRAP_RELAY], filter).subscribe({
next(msg) {
if (done) return;
// applesauce-relay liefert Tupel ["EVENT", ..., event] / ["EOSE"] etc.
if (Array.isArray(msg) && msg[0] === 'EVENT') {
done = true;
sub.unsubscribe();
resolve(msg[2] as NostrEventShape);
} else if (Array.isArray(msg) && msg[0] === 'EOSE') {
if (!done) {
done = true;
sub.unsubscribe();
resolve(null);
}
}
},
error() {
if (!done) {
done = true;
resolve(null);
}
},
complete() {
if (!done) {
done = true;
resolve(null);
}
},
});
});
}
function timeout(ms: number): Promise<null> {
return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms));
}
- Step 9.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/relays.ts
git commit -m "spa: outbox-relay-loader für kind:10002 mit fallback"
Task 10: Read-Relays-Store
Files:
-
Create:
app/src/lib/stores/readRelays.ts -
Step 10.1: Store anlegen
Create app/src/lib/stores/readRelays.ts:
import { writable, type Readable } from 'svelte/store';
import { loadOutboxRelays, readUrls } from '$lib/nostr/relays';
import { FALLBACK_READ_RELAYS } from '$lib/nostr/config';
/**
* Store mit der aktuellen Read-Relay-Liste.
* Initial = FALLBACK_READ_RELAYS, damit die SPA sofort abfragen kann;
* sobald loadOutboxRelays() fertig ist, wird der Store aktualisiert.
*
* Singleton-Initialisierung: bootstrap() wird genau einmal beim ersten
* Import aufgerufen.
*/
const store = writable<string[]>([...FALLBACK_READ_RELAYS]);
let bootstrapped = false;
export function bootstrapReadRelays(): void {
if (bootstrapped) return;
bootstrapped = true;
loadOutboxRelays()
.then((relays) => {
const urls = readUrls(relays);
if (urls.length > 0) store.set(urls);
})
.catch(() => {
// Store behält seinen initialen FALLBACK-Zustand
});
}
export const readRelays: Readable<string[]> = store;
- Step 10.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/stores/readRelays.ts
git commit -m "spa: read-relays-store mit bootstrap aus kind:10002"
Task 11: Loader-Modul — Post-Liste und Einzelpost
Files:
-
Create:
app/src/lib/nostr/loaders.ts -
Step 11.1: Loader-API definieren
Create app/src/lib/nostr/loaders.ts:
import { get } from 'svelte/store';
import { pool } from './pool';
import { readRelays } from '$lib/stores/readRelays';
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
/** Minimales Event-Interface für unsere Zwecke */
export interface NostrEvent {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
}
/** Profile-Content (kind:0) */
export interface Profile {
name?: string;
display_name?: string;
picture?: string;
banner?: string;
about?: string;
website?: string;
nip05?: string;
lud16?: string;
}
/**
* Startet eine Subscription und liefert alle gesammelten Events,
* sobald EOSE empfangen wird ODER hard-Timeout eintritt.
*/
function collectEvents(
relays: string[],
filter: { kinds: number[]; authors?: string[]; '#d'?: string[]; '#a'?: string[]; '#e'?: string[]; limit?: number },
opts?: { onEvent?: (ev: NostrEvent) => void; hardTimeoutMs?: number },
): Promise<NostrEvent[]> {
return new Promise((resolve) => {
const collected: NostrEvent[] = [];
let done = false;
const finish = () => {
if (done) return;
done = true;
sub.unsubscribe();
resolve(collected);
};
const sub = pool.subscription(relays, filter).subscribe({
next(msg) {
if (done) return;
if (Array.isArray(msg) && msg[0] === 'EVENT') {
const ev = msg[2] as NostrEvent;
collected.push(ev);
opts?.onEvent?.(ev);
} else if (Array.isArray(msg) && msg[0] === 'EOSE') {
finish();
}
},
error: finish,
complete: finish,
});
setTimeout(finish, opts?.hardTimeoutMs ?? RELAY_HARD_TIMEOUT_MS);
});
}
/** Dedup per d-Tag: neueste (created_at) wins */
function dedupByDtag(events: NostrEvent[]): NostrEvent[] {
const byDtag = new Map<string, NostrEvent>();
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()];
}
/** Alle kind:30023-Posts des Autors, neueste zuerst */
export async function loadPostList(
onEvent?: (ev: NostrEvent) => void,
): Promise<NostrEvent[]> {
const relays = get(readRelays);
const events = await collectEvents(
relays,
{ kinds: [30023], authors: [AUTHOR_PUBKEY_HEX], limit: 200 },
{ onEvent },
);
const deduped = dedupByDtag(events);
return deduped.sort((a, b) => {
const ap = parseInt(a.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${a.created_at}`, 10);
const bp = parseInt(b.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${b.created_at}`, 10);
return bp - ap;
});
}
/** 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) */
export async function loadProfile(): Promise<Profile | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [0],
authors: [AUTHOR_PUBKEY_HEX],
limit: 1,
});
if (events.length === 0) return null;
const latest = events.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best,
);
try {
return JSON.parse(latest.content) as Profile;
} catch {
return null;
}
}
- Step 11.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: loader für postlist, post, profile"
Task 12: Replies-Loader (kind:1 mit #a-Tag auf Post-Adresse)
Files:
-
Modify:
app/src/lib/nostr/loaders.ts(ergänzen) -
Step 12.1:
loadReplieshinzufügen
Am Ende von app/src/lib/nostr/loaders.ts anhängen:
/** Post-Adresse im `a`-Tag-Format: "30023:<pubkey>:<dtag>" */
function eventAddress(pubkey: string, dtag: string): string {
return `30023:${pubkey}:${dtag}`;
}
/**
* Alle kind:1-Replies auf einen Post, chronologisch aufsteigend (älteste zuerst).
* Streamt via onEvent, wenn angegeben.
*/
export async function loadReplies(
dtag: string,
onEvent?: (ev: NostrEvent) => void,
): Promise<NostrEvent[]> {
const relays = get(readRelays);
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
const events = await collectEvents(
relays,
{ kinds: [1], '#a': [address], limit: 500 },
{ onEvent },
);
return events.sort((a, b) => a.created_at - b.created_at);
}
- Step 12.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: replies-loader für kind:1 mit a-tag-filter"
Task 13: Reactions-Loader (kind:7)
Files:
-
Modify:
app/src/lib/nostr/loaders.ts(ergänzen) -
Step 13.1:
loadReactionshinzufügen
Am Ende von app/src/lib/nostr/loaders.ts anhängen:
export interface ReactionSummary {
/** Emoji oder "+"/"-" */
content: string;
count: number;
}
/**
* Aggregiert kind:7-Reactions auf einen Post.
* Gruppiert nach content, zählt Anzahl.
*/
export async function loadReactions(dtag: string): Promise<ReactionSummary[]> {
const relays = get(readRelays);
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
const events = await collectEvents(relays, {
kinds: [7],
'#a': [address],
limit: 500,
});
const counts = new Map<string, number>();
for (const ev of events) {
const key = ev.content || '+';
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return [...counts.entries()]
.map(([content, count]) => ({ content, count }))
.sort((a, b) => b.count - a.count);
}
- Step 13.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: reactions-loader mit aggregation"
Task 14: NIP-07-Signer-Wrapper
Files:
-
Create:
app/src/lib/nostr/signer.ts -
Step 14.1: Signer-Wrapper anlegen
Create app/src/lib/nostr/signer.ts:
/**
* NIP-07-Wrapper für Browser-Extension-Signer (Alby, nos2x, Flamingo).
*
* window.nostr ist optional — wenn die Extension fehlt, liefern wir null zurück
* und der Aufrufer zeigt einen Hinweis an.
*/
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
};
}
}
export interface UnsignedEvent {
kind: number;
tags: string[][];
content: string;
created_at: number;
pubkey: string;
}
export interface SignedEvent extends UnsignedEvent {
id: string;
sig: string;
}
export function hasNip07(): boolean {
return typeof window !== 'undefined' && !!window.nostr;
}
export async function getPublicKey(): Promise<string | null> {
if (!hasNip07()) return null;
try {
return await window.nostr!.getPublicKey();
} catch {
return null;
}
}
export async function signEvent(event: UnsignedEvent): Promise<SignedEvent | null> {
if (!hasNip07()) return null;
try {
return await window.nostr!.signEvent(event);
} catch {
return null;
}
}
- Step 14.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/signer.ts
git commit -m "spa: nip-07-signer-wrapper"
Phase 3 — Routing & Pages (Tasks 15–22)
Task 15: Gemeinsame Komponente LoadingOrError.svelte
Files:
-
Create:
app/src/lib/components/LoadingOrError.svelte -
Step 15.1: Komponente anlegen
Create app/src/lib/components/LoadingOrError.svelte:
<script lang="ts">
interface Props {
loading: boolean;
error: string | null;
hablaLink?: string;
}
let { loading, error, hablaLink }: Props = $props();
</script>
{#if loading && !error}
<p class="status">Lade von Nostr-Relays …</p>
{:else if error}
<p class="status status-error">
{error}
{#if hablaLink}
<br />
<a href={hablaLink} target="_blank" rel="noopener">
In Habla.news öffnen
</a>
{/if}
</p>
{/if}
<style>
.status {
padding: 1rem;
border-radius: 4px;
background: var(--code-bg);
color: var(--muted);
text-align: center;
}
.status-error {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.status-error {
background: #450a0a;
color: #fca5a5;
}
}
</style>
- Step 15.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/LoadingOrError.svelte
git commit -m "spa: loading-or-error-komponente"
Task 16: Globales Styling im app.html
Files:
-
Modify:
app/src/app.html -
Step 16.1: app.html mit CSS-Variablen und Base-Layout
Ersetze Inhalt von app/src/app.html durch:
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
<title>Jörg Lohrer</title>
<style>
:root {
--fg: #1f2937;
--muted: #6b7280;
--bg: #fafaf9;
--accent: #2563eb;
--code-bg: #f3f4f6;
--border: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e5e7eb;
--muted: #9ca3af;
--bg: #18181b;
--accent: #60a5fa;
--code-bg: #27272a;
--border: #3f3f46;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 17px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--fg);
background: var(--bg);
}
a { color: var(--accent); }
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
- Step 16.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/app.html
git commit -m "spa: globales styling mit css-variablen im app.html"
Task 17: +layout.svelte mit Container und Bootstrap
Files:
-
Create:
app/src/routes/+layout.svelte -
Step 17.1: Layout-Komponente mit Relay-Bootstrap
Create app/src/routes/+layout.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import { bootstrapReadRelays } from '$lib/stores/readRelays';
let { children } = $props();
onMount(() => {
bootstrapReadRelays();
});
</script>
<main>
{@render children()}
</main>
<style>
main {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
@media (min-width: 640px) {
main {
padding: 1.5rem;
}
}
</style>
- Step 17.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/+layout.svelte
git commit -m "spa: layout mit container und relay-bootstrap"
Task 18: ProfileCard.svelte
Files:
-
Create:
app/src/lib/components/ProfileCard.svelte -
Step 18.1: Komponente anlegen
Create app/src/lib/components/ProfileCard.svelte:
<script lang="ts">
import type { Profile } from '$lib/nostr/loaders';
interface Props { profile: Profile | null }
let { profile }: Props = $props();
</script>
{#if profile}
<div class="profile">
{#if profile.picture}
<img class="avatar" src={profile.picture} alt={profile.display_name ?? profile.name ?? ''} />
{:else}
<div class="avatar"></div>
{/if}
<div class="info">
<div class="name">{profile.display_name ?? profile.name ?? ''}</div>
{#if profile.about}
<div class="about">{profile.about}</div>
{/if}
{#if profile.nip05 || profile.website}
<div class="meta-line">
{#if profile.nip05}<span>{profile.nip05}</span>{/if}
{#if profile.nip05 && profile.website}<span class="sep">·</span>{/if}
{#if profile.website}
<a href={profile.website} target="_blank" rel="noopener">
{profile.website.replace(/^https?:\/\//, '')}
</a>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<style>
.profile {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.avatar {
flex: 0 0 80px;
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background: var(--code-bg);
}
.info { flex: 1; min-width: 0; }
.name { font-size: 1.3rem; font-weight: 600; margin: 0 0 0.2rem; }
.about { color: var(--muted); font-size: 0.95rem; margin: 0 0 0.3rem; }
.meta-line { font-size: 0.85rem; color: var(--muted); }
.meta-line a { color: var(--accent); text-decoration: none; }
.meta-line a:hover { text-decoration: underline; }
.sep { margin: 0 0.4rem; opacity: 0.5; }
</style>
- Step 18.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ProfileCard.svelte
git commit -m "spa: profile-card komponente"
Task 19: PostCard.svelte (Listenelement)
Files:
-
Create:
app/src/lib/components/PostCard.svelte -
Step 19.1: Komponente anlegen
Create app/src/lib/components/PostCard.svelte:
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import { canonicalPostPath } from '$lib/url/legacy';
interface Props { event: NostrEvent }
let { event }: Props = $props();
function tagValue(e: NostrEvent, name: string): string {
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
}
const dtag = tagValue(event, 'd');
const title = tagValue(event, 'title') || '(ohne Titel)';
const summary = tagValue(event, 'summary');
const image = tagValue(event, 'image');
const publishedAt = parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10);
const date = new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
});
const href = canonicalPostPath(dtag);
</script>
<a class="card" {href}>
<div class="thumb" style:background-image={image ? `url('${image}')` : undefined} aria-hidden="true"></div>
<div class="text">
<div class="meta">{date}</div>
<h2>{title}</h2>
{#if summary}<p class="excerpt">{summary}</p>{/if}
</div>
</a>
<style>
.card {
display: flex;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
color: inherit;
text-decoration: none;
align-items: flex-start;
}
.card:hover { background: var(--code-bg); }
.thumb {
flex: 0 0 120px;
aspect-ratio: 1 / 1;
border-radius: 4px;
background: var(--code-bg) center/cover no-repeat;
}
.text { flex: 1; min-width: 0; }
h2 { margin: 0 0 0.3rem; font-size: 1.2rem; color: var(--fg); word-wrap: break-word; }
.excerpt { color: var(--muted); font-size: 0.95rem; margin: 0; }
.meta { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.2rem; }
@media (max-width: 479px) {
.card { flex-direction: column; gap: 0.5rem; }
.thumb { flex: 0 0 auto; width: 100%; aspect-ratio: 2 / 1; }
}
</style>
- Step 19.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/PostCard.svelte
git commit -m "spa: post-card listenelement"
Task 20: Home-Page +page.svelte
Files:
-
Modify:
app/src/routes/+page.svelte -
Step 20.1: Home-Page mit Profil + Liste
Ersetze Inhalt von app/src/routes/+page.svelte durch:
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
import { loadPostList, loadProfile } from '$lib/nostr/loaders';
import ProfileCard from '$lib/components/ProfileCard.svelte';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
let profile: Profile | null = $state(null);
let posts: NostrEvent[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(async () => {
try {
const [p, list] = await Promise.all([loadProfile(), loadPostList()]);
profile = p;
posts = list;
loading = false;
if (list.length === 0) {
error = 'Keine Posts gefunden auf den abgefragten Relays.';
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
}
});
$effect(() => {
const name = profile?.display_name ?? profile?.name ?? 'Jörg Lohrer';
document.title = `${name} – Blog`;
});
</script>
<ProfileCard {profile} />
<h1 class="list-title">Beiträge</h1>
<LoadingOrError {loading} {error} />
{#each posts as post (post.id)}
<PostCard event={post} />
{/each}
<style>
.list-title { margin: 0 0 1rem; font-size: 1.4rem; }
</style>
- Step 20.2: Lokal testen
cd app
npm run dev
Öffne http://localhost:5173/. Erwartung: Profilkachel oben, „Beiträge"-Überschrift, Liste der publizierten Posts mit Thumbnails.
- Step 20.3: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/+page.svelte
git commit -m "spa: home-page mit profil und beitragsliste"
Task 21: PostView.svelte Komponente
Files:
-
Create:
app/src/lib/components/PostView.svelte -
Step 21.1: Komponente anlegen
Create app/src/lib/components/PostView.svelte:
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
import { renderMarkdown } from '$lib/render/markdown';
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 title = tagValue(event, 'title') || '(ohne Titel)';
const summary = tagValue(event, 'summary');
const image = tagValue(event, 'image');
const publishedAt = parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10);
const date = new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
});
const tags = tagsAll(event, 't');
const bodyHtml = renderMarkdown(event.content);
$effect(() => {
document.title = `${title} – Jörg Lohrer`;
});
</script>
<h1 class="post-title">{title}</h1>
<div class="meta">
Veröffentlicht am {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>
{#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>
<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>
- Step 21.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/PostView.svelte
git commit -m "spa: post-view komponente mit markdown-rendering"
Task 22: Catch-all-Route [...slug]/+page.svelte mit Legacy-Normalisierung
Files:
-
Create:
app/src/routes/[...slug]/+page.svelte -
Create:
app/src/routes/[...slug]/+page.ts -
Step 22.1: Route-Load-Funktion
Create app/src/routes/[...slug]/+page.ts:
import { error, redirect } from '@sveltejs/kit';
import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
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));
}
// Kanonisch: /<dtag>/ — erster Segment des Pfades.
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/');
if (segments.length !== 1 || !segments[0]) {
throw error(404, 'Seite nicht gefunden');
}
return { dtag: decodeURIComponent(segments[0]) };
};
- Step 22.2: PostView-Seite
Create app/src/routes/[...slug]/+page.svelte:
<script lang="ts">
import { onMount } from 'svelte';
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';
let { data } = $props();
const dtag = data.dtag;
let post: NostrEvent | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
const hablaLink = buildHablaLink({
pubkey: AUTHOR_PUBKEY_HEX,
kind: 30023,
identifier: dtag,
});
onMount(async () => {
try {
const p = await loadPost(dtag);
loading = false;
if (!p) {
error = `Post "${dtag}" nicht gefunden.`;
} else {
post = p;
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
}
});
</script>
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
<LoadingOrError {loading} {error} {hablaLink} />
{#if post}
<PostView event={post} />
{/if}
<style>
.breadcrumb {
font-size: 0.9rem;
margin-bottom: 1rem;
}
.breadcrumb a { color: var(--accent); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
</style>
- Step 22.3: Lokal testen
cd app
npm run dev
Öffne http://localhost:5173/dezentrale-oep-oer/. Erwartung: Post rendert. Breadcrumb „← Zurück zur Übersicht" funktioniert. Teste auch http://localhost:5173/2025/03/04/dezentrale-oep-oer.html/ — Browser sollte auf /dezentrale-oep-oer/ umleiten und Post zeigen.
- Step 22.4: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/
git commit -m "spa: catch-all-route mit legacy-redirect und postview"
Phase 4 — Tag-Navigation (Tasks 23–25)
Task 23: Tag-Filter-Loader
Files:
-
Modify:
app/src/lib/nostr/loaders.ts(ergänzen) -
Step 23.1:
loadPostsByTaghinzufügen
Am Ende von app/src/lib/nostr/loaders.ts anhängen:
/**
* Filtert Post-Liste clientseitig nach Tag-Name.
* (Relay-seitige #t-Filter werden nicht von allen Relays unterstützt — safer
* ist es, die ganze Liste zu laden und lokal zu filtern.)
*/
export async function loadPostsByTag(tagName: string): Promise<NostrEvent[]> {
const all = await loadPostList();
const norm = tagName.toLowerCase();
return all.filter((ev) =>
ev.tags.some((t) => t[0] === 't' && t[1]?.toLowerCase() === norm),
);
}
- Step 23.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/nostr/loaders.ts
git commit -m "spa: tag-filter-loader (case-insensitive, client-side)"
Task 24: Tag-Seite
Files:
-
Create:
app/src/routes/tag/[name]/+page.ts -
Create:
app/src/routes/tag/[name]/+page.svelte -
Step 24.1: Load-Funktion
Create app/src/routes/tag/[name]/+page.ts:
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return { tagName: decodeURIComponent(params.name) };
};
- Step 24.2: Seite
Create app/src/routes/tag/[name]/+page.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadPostsByTag } from '$lib/nostr/loaders';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
let { data } = $props();
const tagName = data.tagName;
let posts: NostrEvent[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(async () => {
try {
posts = await loadPostsByTag(tagName);
loading = false;
if (posts.length === 0) {
error = `Keine Posts mit Tag "${tagName}" gefunden.`;
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
}
});
$effect(() => {
document.title = `#${tagName} – Jörg Lohrer`;
});
</script>
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
<h1 class="tag-title">#{tagName}</h1>
<LoadingOrError {loading} {error} />
{#each posts as post (post.id)}
<PostCard event={post} />
{/each}
<style>
.breadcrumb { font-size: 0.9rem; margin-bottom: 1rem; }
.breadcrumb a { color: var(--accent); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.tag-title { margin: 0 0 1.5rem; font-size: 1.6rem; }
</style>
- Step 24.3: Lokal testen
cd app
npm run dev
Öffne http://localhost:5173/tag/OER/. Erwartung: Liste aller Posts mit t-Tag „OER".
- Step 24.4: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/routes/tag/
git commit -m "spa: tag-filter-seite"
Task 25: Tag-Link-Sicherheit fixen
Files:
- Modify:
app/src/lib/components/PostView.svelte:63-65
Hintergrund: in Task 21 wurde href="/tag/{encodeURIComponent(t)}/" schon gesetzt — sollte passen. Verifizieren.
- Step 25.1: Verifizieren
Öffne http://localhost:5173/dezentrale-oep-oer/. Klick auf Tag „OER". Erwartung: Navigation zu /tag/OER/, Liste wird gefiltert.
- Step 25.2: Keine Änderung nötig, Verifikations-Step. Commit wird übersprungen, wenn nichts geändert wurde.
Phase 5 — Reactions & NIP-07-Kommentare (Tasks 26–32)
Task 26: Reactions.svelte
Files:
-
Create:
app/src/lib/components/Reactions.svelte -
Step 26.1: Komponente anlegen
Create app/src/lib/components/Reactions.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import type { ReactionSummary } from '$lib/nostr/loaders';
import { loadReactions } from '$lib/nostr/loaders';
interface Props { dtag: string }
let { dtag }: Props = $props();
let reactions: ReactionSummary[] = $state([]);
onMount(async () => {
try {
reactions = await loadReactions(dtag);
} catch {
reactions = [];
}
});
function displayChar(c: string): string {
if (c === '+' || c === '') return '👍';
if (c === '-') return '👎';
return c;
}
</script>
{#if reactions.length > 0}
<div class="reactions">
{#each reactions as r}
<span class="reaction">
<span class="emoji">{displayChar(r.content)}</span>
<span class="count">{r.count}</span>
</span>
{/each}
</div>
{/if}
<style>
.reactions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1.5rem 0;
}
.reaction {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.6rem;
background: var(--code-bg);
border-radius: 999px;
font-size: 0.9rem;
}
.count { color: var(--muted); }
</style>
- Step 26.2: In PostView einbinden
Modify app/src/lib/components/PostView.svelte — am Anfang des <script>:
import Reactions from './Reactions.svelte';
Am Ende, vor dem schließenden </style>-Block (also im Markup-Bereich), nach <article>{@html bodyHtml}</article> ergänzen:
{#if dtag}<Reactions {dtag} />{/if}
Und oben in den Variablen:
const dtag = tagValue(event, 'd');
- Step 26.3: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/Reactions.svelte app/src/lib/components/PostView.svelte
git commit -m "spa: reactions-anzeige unter posts"
Task 27: ReplyItem.svelte
Files:
-
Create:
app/src/lib/components/ReplyItem.svelte -
Step 27.1: Komponente anlegen
Create app/src/lib/components/ReplyItem.svelte:
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
interface Props { event: NostrEvent }
let { event }: Props = $props();
const date = new Date(event.created_at * 1000).toLocaleString('de-DE');
const authorNpub = event.pubkey.slice(0, 12) + '…';
</script>
<li class="reply">
<div class="meta">
<span class="author">{authorNpub}</span>
<span class="sep">·</span>
<span class="date">{date}</span>
</div>
<div class="content">{event.content}</div>
</li>
<style>
.reply {
list-style: none;
padding: 0.8rem 0;
border-bottom: 1px solid var(--border);
}
.meta { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.3rem; }
.author { font-family: monospace; }
.sep { margin: 0 0.4rem; opacity: 0.5; }
.content { white-space: pre-wrap; word-wrap: break-word; }
</style>
- Step 27.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ReplyItem.svelte
git commit -m "spa: reply-item komponente"
Task 28: ReplyList.svelte
Files:
-
Create:
app/src/lib/components/ReplyList.svelte -
Step 28.1: Komponente anlegen
Create app/src/lib/components/ReplyList.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadReplies } from '$lib/nostr/loaders';
import ReplyItem from './ReplyItem.svelte';
interface Props { dtag: string }
let { dtag }: Props = $props();
let replies: NostrEvent[] = $state([]);
let loading = $state(true);
onMount(async () => {
try {
replies = await loadReplies(dtag);
} finally {
loading = false;
}
});
export function addOptimistic(event: NostrEvent): void {
replies = [...replies, event];
}
</script>
<section class="replies">
<h3>Kommentare ({replies.length})</h3>
{#if loading}
<p class="hint">Lade Kommentare …</p>
{:else if replies.length === 0}
<p class="hint">Noch keine Kommentare.</p>
{:else}
<ul>
{#each replies as reply (reply.id)}
<ReplyItem event={reply} />
{/each}
</ul>
{/if}
</section>
<style>
.replies { margin: 2rem 0; }
h3 { font-size: 1.1rem; margin: 0 0 0.8rem; }
ul { list-style: none; padding: 0; margin: 0; }
.hint { color: var(--muted); font-size: 0.9rem; }
</style>
- Step 28.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ReplyList.svelte
git commit -m "spa: reply-list komponente"
Task 29: ReplyComposer.svelte
Files:
-
Create:
app/src/lib/components/ReplyComposer.svelte -
Step 29.1: Komponente anlegen
Create app/src/lib/components/ReplyComposer.svelte:
<script lang="ts">
import { hasNip07, getPublicKey, signEvent, type SignedEvent, type UnsignedEvent } from '$lib/nostr/signer';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
import { pool } from '$lib/nostr/pool';
import { readRelays } from '$lib/stores/readRelays';
import { get } from 'svelte/store';
interface Props {
/** d-Tag des Posts, auf den geantwortet wird */
dtag: string;
/** Event-ID des ursprünglichen Posts (für e-Tag) */
eventId: string;
/** Callback, wenn ein Reply erfolgreich publiziert wurde */
onPublished?: (ev: SignedEvent) => void;
}
let { dtag, eventId, onPublished }: Props = $props();
let text = $state('');
let publishing = $state(false);
let error: string | null = $state(null);
let info: string | null = $state(null);
const nip07 = hasNip07();
async function submit() {
error = null;
info = null;
if (!text.trim()) {
error = 'Leeres Kommentar — nichts zu senden.';
return;
}
publishing = true;
try {
const pubkey = await getPublicKey();
if (!pubkey) {
error = 'Nostr-Extension (z. B. Alby) hat den Pubkey nicht geliefert.';
return;
}
const unsigned: UnsignedEvent = {
kind: 1,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', `30023:${AUTHOR_PUBKEY_HEX}:${dtag}`],
['e', eventId, '', 'root'],
['p', AUTHOR_PUBKEY_HEX],
],
content: text.trim(),
};
const signed = await signEvent(unsigned);
if (!signed) {
error = 'Signatur wurde abgelehnt oder ist fehlgeschlagen.';
return;
}
const relays = get(readRelays);
pool.publish(relays, signed);
info = 'Kommentar gesendet.';
text = '';
onPublished?.(signed);
} catch (e) {
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
} finally {
publishing = false;
}
}
</script>
<div class="composer">
{#if !nip07}
<p class="hint">
Um zu kommentieren, benötigst du eine Nostr-Extension
(<a href="https://getalby.com" target="_blank" rel="noopener">Alby</a>,
<a href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener">nos2x</a>),
oder kommentiere direkt in einem Nostr-Client.
</p>
{:else}
<textarea
bind:value={text}
placeholder="Dein Kommentar …"
rows="4"
disabled={publishing}
></textarea>
<div class="actions">
<button type="button" onclick={submit} disabled={publishing || !text.trim()}>
{publishing ? 'Sende …' : 'Kommentar senden'}
</button>
</div>
{#if error}<p class="error">{error}</p>{/if}
{#if info}<p class="info">{info}</p>{/if}
{/if}
</div>
<style>
.composer { margin: 1.5rem 0; }
textarea {
width: 100%;
padding: 0.6rem;
font: inherit;
color: inherit;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 4px;
resize: vertical;
}
.actions { margin-top: 0.5rem; display: flex; justify-content: flex-end; }
button {
padding: 0.4rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.hint { font-size: 0.9rem; color: var(--muted); }
.error { color: #991b1b; font-size: 0.9rem; }
.info { color: #065f46; font-size: 0.9rem; }
</style>
- Step 29.2: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/ReplyComposer.svelte
git commit -m "spa: reply-composer mit nip-07 signing"
Task 30: Replies und Composer in PostView integrieren
Files:
-
Modify:
app/src/lib/components/PostView.svelte -
Modify:
app/src/routes/[...slug]/+page.svelte -
Step 30.1: PostView um Replies + Composer erweitern
Modify app/src/lib/components/PostView.svelte — im <script>-Block zusätzlich:
import ReplyList from './ReplyList.svelte';
import ReplyComposer from './ReplyComposer.svelte';
import type { SignedEvent } from '$lib/nostr/signer';
let replyList: ReplyList | null = $state(null);
function handlePublished(ev: SignedEvent) {
replyList?.addOptimistic(ev as unknown as NostrEvent);
}
Im Markup-Bereich, nach <Reactions {dtag} />:
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
<ReplyList bind:this={replyList} {dtag} />
- Step 30.2: Lokal testen
cd app
npm run dev
Öffne einen Post. Erwartung: unter dem Fließtext Reactions-Chips (falls vorhanden), dann Composer (Textarea + Button), dann Kommentar-Liste.
- Step 30.3: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/src/lib/components/PostView.svelte
git commit -m "spa: replies und composer in postview integriert"
Task 31: E2E-Test für Home und Post (Playwright)
Files:
-
Create:
app/playwright.config.ts -
Create:
app/tests/e2e/home.test.ts -
Create:
app/tests/e2e/post.test.ts -
Step 31.1: Playwright-Config
Create app/playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
use: { baseURL: 'http://localhost:5173' },
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 120_000,
},
timeout: 60_000,
});
- Step 31.2: Playwright-Browser installieren
cd app
npx playwright install chromium
- Step 31.3: Home-E2E
Create app/tests/e2e/home.test.ts:
import { expect, test } from '@playwright/test';
test('Home zeigt Profil und mindestens einen Post', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1, name: /Beiträge/ })).toBeVisible();
// Profil: mindestens Name-Element
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
// Mindestens eine Post-Card
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
});
- Step 31.4: Post-E2E
Create app/tests/e2e/post.test.ts:
import { expect, test } from '@playwright/test';
test('Einzelpost rendert Titel und Markdown-Body', async ({ page }) => {
await page.goto('/dezentrale-oep-oer/');
await expect(
page.getByRole('heading', { level: 1, name: /Gemeinsam die Bildungszukunft/ }),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator('article')).toContainText('Open Educational');
});
test('Legacy-URL wird auf kurze Form umgeleitet', async ({ page }) => {
await page.goto('/2025/03/04/dezentrale-oep-oer.html/');
await expect(page).toHaveURL(/\/dezentrale-oep-oer\/$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 15_000 });
});
- Step 31.5: E2E laufen lassen
cd app
npm run test:e2e
Expected: beide Tests grün. Setzt live-Relays und publizierte Events voraus (beides gegeben).
- Step 31.6: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/playwright.config.ts app/tests/e2e/
git commit -m "spa: playwright e2e-tests für home, post, legacy-redirect"
Task 32: Production-Build und Deploy
Files:
-
(keine Code-Änderung, nur Ausführung)
-
Step 32.1: Production-Build
cd app
npm run build
ls build/
Expected: index.html, _app/, .htaccess, favicon, robots.txt.
- Step 32.2: Deploy ausführen
Vorher: SVELTE_FTP_*-Variablen in .env.local müssen gefüllt sein, svelte.joerg-lohrer.de muss mit SSL aktiv sein.
cd /Users/joerglohrer/repositories/joerglohrerde
./scripts/deploy-svelte.sh
Expected: Upload-Log zeigt alle Dateien, abschließend HTTP/2 200 von https://svelte.joerg-lohrer.de/.
- Step 32.3: Live-Smoke-Test
Öffne in deinem Browser:
https://svelte.joerg-lohrer.de/→ Liste.https://svelte.joerg-lohrer.de/dezentrale-oep-oer/→ Post.https://svelte.joerg-lohrer.de/2025/03/04/dezentrale-oep-oer.html/→ Post, URL-Leiste springt auf kurze Form.https://svelte.joerg-lohrer.de/tag/OER/→ gefilterte Liste.- Im Post: Tag-Klick → Tag-Seite.
- Kommentar-Komposer: wenn Alby installiert, Test-Kommentar schreiben und in der Liste sehen.
Kein Code-Commit nötig.
Phase 6 — Abschlusspolish (Tasks 33–35)
Task 33: robots.txt und Basic-SEO-Meta
Files:
-
Modify:
app/static/robots.txt -
Modify:
app/src/app.html -
Step 33.1: robots.txt
Create oder überschreibe app/static/robots.txt:
User-agent: *
Allow: /
- Step 33.2: OG-Tag-Defaults in app.html
Modify app/src/app.html — im <head> vor %sveltekit.head% ergänzen:
<meta property="og:title" content="Jörg Lohrer – Blog" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://svelte.joerg-lohrer.de/" />
<meta name="robots" content="index, follow" />
Hinweis: per-Post OG-Tags sind erst mit Server-Side-Rendering oder Meta-Stubs möglich — aktuell Out-of-Scope (siehe Spec §5 „Phase 3 — Stubs nachrüsten").
- Step 33.3: Commit
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/static/robots.txt app/src/app.html
git commit -m "spa: robots.txt und default og-tags"
Task 34: svelte-check laufen lassen und Type-Fehler beheben
Files:
-
Alle modifizierten (Fehlerbehebungen ergeben sich aus der Ausgabe)
-
Step 34.1: Type-Check
cd app
npm run check
- Step 34.2: Falls Fehler gemeldet werden
Jede svelte-check-Warnung/Error einzeln prüfen:
- Fehlender Type-Import: den Import ergänzen.
- Svelte-5-Rune-Warnings: auf die neue API umstellen (
$state,$derived,$effect). window.nostr-Zugriff außerhalb Browser:if (typeof window !== 'undefined')absichern.
Iterativ fixen, bis npm run check sauber durchläuft.
- Step 34.3: Commit (falls Änderungen)
cd /Users/joerglohrer/repositories/joerglohrerde
git add app/
git commit -m "spa: type-check-fehler behoben"
Task 35: Redeploy nach Polish
Files:
-
(keine Code-Änderung)
-
Step 35.1: Build + Deploy
cd /Users/joerglohrer/repositories/joerglohrerde/app
npm run build
cd ..
./scripts/deploy-svelte.sh
- Step 35.2: Live abschließen
Prüfe nochmal:
https://svelte.joerg-lohrer.de/→ aktuell- View-Source auf Homepage: OG-Tags im HTML sichtbar
- Lighthouse (in Browser-DevTools): Performance + Accessibility ≥ 90
Fertig.
Phase 7 — Optionale Erweiterungen (zukünftig, außerhalb dieses Plans)
Diese Items stehen in der Spec, sind aber bewusst nicht in diesem Plan:
- Impressum-Seite (
/impressum/) mit rechtlichem Text — wird als statische HTML-Datei außerhalb der SPA-Routen ergänzt, wenn der rechtliche Inhalt formuliert ist. - Meta-Stubs pro Post für Social-Previews und SEO auf Post-Ebene — kommt über die Publish-Pipeline (siehe Publish-Spec §7).
- Eigener Relay und Blossom-Server — Infrastruktur-Erweiterung, SPA-Code unverändert, nur Einträge in
kind:10002undkind:10063. - NIP-05-Identifier-Verifikation im Profil (grüner Haken) — Nice-to-have.
- Service-Worker für Offline-Caching — bei Bedarf.
Erfolgskriterien (Plan vollständig ausgeführt)
https://svelte.joerg-lohrer.de/zeigt Profilkachel und Post-Liste live aus Relays.- Post-Einzelansicht rendert Markdown mit Cover-Bild, Tags (klickbar), Reactions, Kommentare, Composer.
- Legacy-Hugo-URLs werden auf kurze Form normalisiert.
- Tag-Klick filtert zur Tag-Liste.
- NIP-07-Kommentar kann mit Alby gesendet werden und erscheint optimistisch.
npm run checkundnpm run test:unitlaufen grün.npm run test:e2e(Playwright) bestätigt Happy-Path-Szenarien.- Lighthouse Accessibility ≥ 90.