joerglohrerde/docs/superpowers/plans/2026-04-15-spa-sveltekit.md

72 KiB
Raw Blame History

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


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

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.js auf 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.svelte fü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/.gitignore setzen

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: .htaccess fü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 714)

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('![alt](https://example.com/img.png)');
    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: loadReplies hinzufü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: loadReactions hinzufü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 1522)

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

Task 23: Tag-Filter-Loader

Files:

  • Modify: app/src/lib/nostr/loaders.ts (ergänzen)

  • Step 23.1: loadPostsByTag hinzufü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"

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

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

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:10002 und kind: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 check und npm run test:unit laufen grün.
  • npm run test:e2e (Playwright) bestätigt Happy-Path-Szenarien.
  • Lighthouse Accessibility ≥ 90.