Compare commits

...

37 Commits
main ... spa

Author SHA1 Message Date
Jörg Lohrer 7ea29941a6 Merge branch 'main' into spa 2026-04-15 18:34:16 +02:00
Jörg Lohrer 51f0ae5067 spa(phase 6, tasks 33-35): robots, og-defaults, type-check, finaler deploy
- robots.txt: standard allow für alle Crawler.
- app.html <head>: og:title/type/url/description als Defaults für
  die Site. Per-Post OG-Tags erst mit Publish-Pipeline Phase 3
  (Meta-Stubs) möglich — aktuell out-of-scope.
- Final-Validierung:
  - svelte-check: 611 files, 0 errors, 0 warnings
  - Unit: 29/29 (markdown 14, naddr 4, legacy-url 11)
  - E2E (Playwright): 3/3
- Finaler Deploy nach svelte.joerg-lohrer.de.

35 Plan-Tasks + 2 Erweiterungen (Avatar/Name für Kommentatoren,
External-Client-Links) komplett.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:13:29 +02:00
Jörg Lohrer 9d41a68ef9 spa: edufeed-url ohne /a/-pfad
https://edufeed.org/<naddr> statt /a/<naddr>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:10:46 +02:00
Jörg Lohrer 32a39144bb spa: external-links — edufeed zuerst, njump nur noch auf kommentator-profilen
- externalClientLinks-Reihenfolge: EduFeed, Habla, Yakihonne (njump
  raus). EduFeed als OER/OEP-Community-Home an erster Stelle.
- njump bleibt für Kommentar-Autor-Profile (Klick auf Avatar/Name
  unter einem Kommentar) — dort ist es der bessere Profil-Viewer.
- EduFeed-URL-Schema: https://edufeed.org/a/<naddr> (falls sich das
  als falsch erweist, in zweitem Commit korrigieren).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:09:33 +02:00
Jörg Lohrer 3ad1a72d84 spa: kommentar-author klickbar (njump) + external-client-links am post
Zwei Erweiterungen, die Community-Interaktion an Nostr-native Clients
auslagern statt in der SPA nachzubauen:

1. ReplyItem-Header ist jetzt ein <a href=https://njump.me/<npub>
   target=_blank>. Klick auf Avatar/Name öffnet das vollständige
   Profil des Kommentar-Authors mit allen Events.
2. Neue ExternalClientLinks.svelte zwischen Reactions und Composer:
   dezente Box mit "In Nostr-Client öffnen" — drei Links (Habla,
   Yakihonne, njump) über naddr, damit Leser Thread-Replies,
   Reactions, Teilen dort nutzen können, wo die volle Nostr-Social-
   Layer läuft.

Nostr-Helper erweitert:
- buildNpub(hex) — npub1…-Bech32-Encoding
- buildNjumpProfileUrl(hex) — njump.me/<npub>
- externalClientLinks({pubkey, kind, identifier}) — Liste der drei
  etablierten Langform-Viewer mit naddr1…-URLs.

npm run check: 0 errors, 611 files. Deploy live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:04:23 +02:00
Jörg Lohrer eb400a8a6a spa: avatar + name für kommentar-authoren via kind:0-profil
- loadProfile(pubkey?) akzeptiert jetzt optional einen Pubkey, default
  weiterhin AUTHOR_PUBKEY_HEX.
- Neuer profileCache.ts: sessionsweiter Cache, Promise-Memoization —
  paralleles Nachladen derselben Pubkey teilt dieselbe Request.
- ReplyItem lädt das kind:0-Profil des Kommentar-Authors on mount,
  zeigt Avatar (32px rund) + display_name/name. Fallback bei fehlendem
  Profil: Pubkey-Hex-Prefix (wie bisher).
- Home-Page nutzt getProfile(AUTHOR_PUBKEY_HEX) statt loadProfile()
  direkt — gleicher Cache, kein doppeltes Fetchen.

npm run check: 0 errors. E2E 3/3 grün. Deploy live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:58:44 +02:00
Jörg Lohrer 22935d6737 spa(chore): test-results/ aus git und in .gitignore
Playwright schreibt Run-Artefakte in test-results/ — gehören nicht
ins Repo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:53:33 +02:00
Jörg Lohrer 3b0f059cea spa(phase 5, tasks 26-32): reactions, replies, nip-07 kommentare, e2e
Neue Komponenten unter $lib/components/:
- Reactions.svelte: lädt kind:7-Aggregation via loadReactions, rendert
  Chips mit Emoji + Count. +/- werden zu 👍/👎 gemappt.
- ReplyItem.svelte: einzelner Kommentar mit Author-Npub-Prefix + Datum.
- ReplyList.svelte: lädt kind:1-Replies, merged mit optimistic-Props
  (dedup per id), sortiert chronologisch.
- ReplyComposer.svelte: Textarea + Senden-Button. Nutzt NIP-07-Wrapper
  (getPublicKey, signEvent), baut kind:1-Event mit a/e/p-Tags, pusht
  via pool.publish() zu allen Read-Relays. Fehlertolerant: zeigt
  Hinweis, wenn NIP-07-Extension fehlt.

Integration in PostView: Reactions, Composer, ReplyList unterhalb des
Artikel-Bodys. Optimistisches Reply-Pattern: Composer.onPublished
pushed signed event in PostView-local $state, ReplyList merged mit
fetched events.

Playwright-E2E:
- playwright.config.ts mit Dev-Server-Auto-Start
- home.test.ts: Profil + Beitragsliste sichtbar
- post.test.ts: Titel + Body + Legacy-URL-Redirect

Alle 3 E2E-Tests grün. npm run check: 600 files, 0 errors.
Deploy live auf svelte.joerg-lohrer.de (Phase 5 inklusive).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:51:57 +02:00
Jörg Lohrer c089d9e429 spa(phase 4, tasks 23-25): tag-navigation
- loadPostsByTag(tagName): client-seitige Filterung der Post-Liste
  (case-insensitive). #t-Filter wird nicht von allen Relays zuverlässig
  unterstützt — wir laden alles und filtern lokal.
- /tag/[name]/+page.ts+svelte: neue Tag-Route, Breadcrumb zurück zur
  Übersicht, #tagName als H1, dieselbe PostCard-Darstellung wie Home.
- Tag-Chips in PostView sind bereits klickbar (aus Task 21).

npm run check: 0 errors. Deploy live auf svelte.joerg-lohrer.de.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:44:02 +02:00
Jörg Lohrer feb336fc5b spa(phase 3, tasks 15-22): routing, komponenten, home, postview
Phase 3 komplett:
- Task 15: LoadingOrError-Komponente (loading/error-states, Habla-Fallback)
- Task 16: app.html mit CSS-Variablen (light/dark), Base-Typography
- Task 17: +layout.svelte mit Container + bootstrapReadRelays onMount
- Task 18: ProfileCard-Komponente (Avatar, Name, About, NIP-05, Website)
- Task 19: PostCard-Komponente (Thumbnail + Titel/Summary/Datum), responsive
- Task 20: +page.svelte als Home (Profil + Liste, Promise.all für beides)
- Task 21: PostView-Komponente (Titel, Meta, Cover, Summary, Markdown-Body)
- Task 22: [...slug]/+page.ts+svelte — Catch-all-Route mit Legacy-301-Redirect

Alle $props()-abhängigen Werte via $derived() (Svelte-5-Runes-Konformität).

npm run check: 0 errors, 0 warnings, 592 files. npm run build grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:39:24 +02:00
Jörg Lohrer dcef74e75c spa(task 14): nip-07-signer-wrapper
window.nostr-Proxy für Alby/nos2x/Flamingo-Extensions. Fehlertolerant:
bei fehlender Extension ODER User-Ablehnung returnen die Helper null,
damit UI klar "bitte Extension installieren"-Hinweise zeigen kann
statt zu crashen.

UnsignedEvent/SignedEvent als explizite Types — werden ab Task 29
(ReplyComposer) für NIP-07-signierte kind:1-Kommentare genutzt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:22:54 +02:00
Jörg Lohrer f470732c2c spa(task 13): reactions-loader mit aggregation
loadReactions(dtag) sammelt kind:7-Events mit #a-Filter auf den
Post, gruppiert nach content (emoji oder +/-), zählt und sortiert
nach Häufigkeit. Leerer content wird als + interpretiert (NIP-25-
Konvention für Like-Default).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:21:44 +02:00
Jörg Lohrer bab2895848 spa(task 12): replies-loader für kind:1 mit a-tag-filter
Fügt `loadReplies(dtag)` an loaders.ts an. Filter `#a` auf das
addressable-Event-Format "30023:<pubkey>:<dtag>" findet alle kind:1
Replies auf den Post. Sortiert aufsteigend (älteste zuerst).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:19:21 +02:00
Jörg Lohrer 09f2ce8b49 spa: loader für postlist, post, profile 2026-04-15 16:40:21 +02:00
Jörg Lohrer 078423a1b2 spa: read-relays-store mit bootstrap aus kind:10002 2026-04-15 16:37:41 +02:00
Jörg Lohrer 0bf9bf3bf2 spa: outbox-relay-loader für kind:10002 mit fallback 2026-04-15 16:33:27 +02:00
Jörg Lohrer 6f9f53c561 spa: relaypool-singleton via applesauce-relay 2026-04-15 16:10:06 +02:00
Jörg Lohrer ec9d361a13 spa(task 7 polish): scoped marked-instance, ssr-guard, erweiterte xss-tests
- Eigene `new Marked({...})`-Instanz statt globaler `marked.use()`-Mutation
  — schützt andere Module vor Konfigurationsleckage, schärft Spec §3
  ("lokale Ersetzbarkeit").
- SSR-Guard: `renderMarkdown` wirft in Non-DOM-Umgebungen eine
  Fehlermeldung statt stumm unsicher durchzulaufen. SPA hat `ssr=false`,
  Vitest läuft in jsdom — Guard ist Early-Fail für versehentliche
  Node-Aufrufe.
- `ADD_ATTR: ['target', 'rel']` entfernt — war ein No-Op, weil Marked
  diese Attribute nicht einfügt. Link-Attribut-Hardening kommt später,
  wenn externe Links tatsächlich `target="_blank"` bekommen sollen.
- Code-Block-Test prüft zusätzlich `class="hljs"` (Regression-Anker
  für Custom-Renderer).
- Erweiterte XSS-Matrix: onerror, onclick, iframe, data:text/html,
  vbscript:, svg+script — relevant für spätere Reply-Darstellung.

14/14 Tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:06:51 +02:00
Jörg Lohrer 2bcb2451b4 spa: markdown-renderer mit sanitize (tdd) 2026-04-15 16:03:04 +02:00
Jörg Lohrer 8af049a9ff spa: deploy-script und htaccess für svelte.joerg-lohrer.de
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:59:46 +02:00
Jörg Lohrer 1fb77669e6 spa(task 5 polish): jsdoc auf naddr-helpers, coverage-lücken geschlossen
- JSDoc zu NaddrArgs, buildNaddr, buildHablaLink (Stil konsistent mit config.ts).
- Neue Tests: ohne relays (Default-`?? []`-Pfad), unterschiedliche Inputs
  erzeugen unterschiedliche Links (Guard gegen konstanten Rückgabewert).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:21:36 +02:00
Jörg Lohrer c539c4fee3 spa: naddr/habla-link-helper (tdd) 2026-04-15 15:18:41 +02:00
Jörg Lohrer 36dd76a88f spa(task 4 polish): decodeURIComponent crash-safe, edge-case-tests
- decodeURIComponent in try/catch (malformed URI encoding crasht
  den SPA-Boot-Path nicht mehr, returned stattdessen null).
- JSDoc präzisiert: erwartet nur Pfad ohne Query/Fragment.
- Neue Tests: malformed %E0 → null, leerer dtag → null,
  round-trip Legacy → canonical.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:17:38 +02:00
Jörg Lohrer 47decd9b70 spa: url-parser für legacy-hugo-urls (tdd) 2026-04-15 15:14:35 +02:00
Jörg Lohrer bf3d82d266 spa(task 3 polish): config-konstanten immutable, klarere timeout-doku
- FALLBACK_READ_RELAYS als `as const` tuple (kein mutables Array).
- BOOTSTRAP_RELAY als erster Eintrag referenziert statt dupliziert.
- Präzisere JSDoc zu HABLA_BASE (klarmacht, dass /a/ baked-in ist).
- Timeout-Kommentare trennen soft (per-Relay) vs. hard (Page-Budget).

Code-Quality-Nitpicks aus Task 3 Review adressiert. npm run check grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:12:30 +02:00
Jörg Lohrer b5fbfb0e85 spa: nostr-konfigurations-modul mit pubkey, bootstrap-relay, fallbacks 2026-04-15 15:10:17 +02:00
Jörg Lohrer bc02a80e10 spa(task 2): runtime- und dev-dependencies installiert
Runtime: applesauce-core/relay/loaders/signers, nostr-tools, marked,
dompurify, highlight.js, rxjs.

Dev: vitest, @playwright/test, @testing-library/svelte, jsdom,
@types/dompurify.

vite.config.ts um vitest-Konfiguration erweitert (jsdom, globals,
tests/unit/**). package.json um test:unit, test:e2e, deploy:svelte
npm-Scripts ergänzt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:09:10 +02:00
Jörg Lohrer 5b9773ccd3 spa(task 1): sveltekit-skeleton mit adapter-static initialisiert
- sv create minimal template, TypeScript, ohne addons
- adapter-static statt adapter-auto (fallback: index.html)
- ssr=false, prerender=false, trailingSlash=always im layout.ts
- build produziert statisches build/ (getestet)
- .gitignore um package-lock.json und *.log ergänzt

Svelte 5 mit Runes, SvelteKit 2.57, TypeScript 6, Vite 8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:03:15 +02:00
Jörg Lohrer 64640a5eed Merge branch 'main' into spa 2026-04-15 14:57:27 +02:00
Jörg Lohrer 3bcc4a7170 Merge branch 'main' into spa 2026-04-15 14:46:34 +02:00
Jörg Lohrer 1147980f2a spike(spa-mini): legacy-hugo-urls auf kurze form normalisieren
/YYYY/MM/DD/<dtag>.html/ wird erkannt, via history.replaceState auf
die kanonische Form /<dtag>/ umgeschrieben, dann der Post geladen.
Externe Backlinks auf alte Hugo-URLs landen damit ohne Reload-Flash
auf der neuen kurzen Adresse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:44:25 +02:00
Jörg Lohrer fc6e0fecdb spike(spa-mini): profilkachel auf der startseite
Lädt kind:0-Metadata-Event des Autors parallel zur Beitragsliste und
zeigt Avatar, Anzeigename, About-Text, NIP-05 und Website oben auf
der Übersichtsseite. Einzelpost-Seiten bleiben fokussiert, ohne
Profil-Header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:37:32 +02:00
Jörg Lohrer 2e18e68907 Merge branch 'main' into spa 2026-04-15 14:31:21 +02:00
Jörg Lohrer 865e429c5a spike(spa-mini): tag-dedup + cover-bild-größe begrenzen
- tagsAll() dedupliziert Werte (Schutz gegen Clients, die doppelte
  t-Tags ins Event schreiben; real beobachtet bei einem existierenden
  Post mit zweimal "relilab").
- Cover-Bild in der Einzelansicht auf max 480px Breite + zentriert,
  damit es nicht die gesamte Viewportbreite füllt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:29:57 +02:00
Jörg Lohrer f070ea33c0 spike(spa-mini): url-routing, post-liste mit thumbnails, streaming-load
Liest dtag aus URL-Pfad (SPA-Navigation via History-API) und zeigt
Liste auf /, Einzelpost auf /<dtag>/. Interne Links ohne Reload,
Browser-Back funktioniert.

Streaming-Load via pool.subscribeMany: Events werden angezeigt,
sobald das erste Relay antwortet, statt auf alle 5 zu warten.
Deutlich bessere Reaktionszeit.

Liste mit Cover-Thumbnail links, Titel+Summary+Datum rechts.
Responsive: unter 480px stapelt sich Bild über Text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:21:34 +02:00
Jörg Lohrer 1ae6445c84 spike(spa-mini): responsive layout + erläuterungstext nach oben
- Erklärung zur Implementierungstechnik als Intro-Box direkt unter dem
  Tech-Spike-Banner (statt versteckt im Footer).
- Footer reduziert auf einen Link zum Quellcode.
- Mobile-Anpassungen: kleinerer Title auf < 640px, weniger Padding,
  Tags wrappen sauber, lange URLs/Code/Tabellen brechen ohne Overflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:03:10 +02:00
Jörg Lohrer 0679a335f4 spike(spa-mini): vanilla-html viewer für einen einzelnen nostr-post
Tech-Spike unter preview/spa-mini/ — eine index.html, lädt
nostr-tools/marked/DOMPurify von esm.sh, holt das kind:30023-Event
mit dtag dezentrale-oep-oer von 5 public-relays, rendert clientseitig.
Beweist, dass die SPA-Architektur in der Praxis funktioniert, ohne
SvelteKit-Build-Pipeline.

Inhalt:
- index.html mit Loader, Renderer, Fehler-Handling
- .htaccess mit SPA-Fallback (relevant sobald gehostet)
- README mit Anleitung lokal/Deploy

.gitignore um .env*, logs/ ergänzt (für späteren Pipeline-Bedarf).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:53:16 +02:00
52 changed files with 2881 additions and 1 deletions

3
.gitignore vendored
View File

@ -1 +1,4 @@
**/.DS_Store
.env
.env.local
logs/

28
app/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# npm
package-lock.json
*.log
test-results/

1
app/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

3
app/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
app/README.md Normal file
View File

@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.1 create --template minimal --types ts --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

42
app/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "app",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"deploy:svelte": "../scripts/deploy-svelte.sh"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/svelte": "^5.3.1",
"@types/dompurify": "^3.0.5",
"jsdom": "^29.0.2",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7",
"vitest": "^4.1.4"
},
"dependencies": {
"applesauce-core": "^5.2.0",
"applesauce-loaders": "^5.1.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
"dompurify": "^3.4.0",
"highlight.js": "^11.11.1",
"marked": "^18.0.0",
"nostr-tools": "^2.23.3",
"rxjs": "^7.8.2"
}
}

13
app/playwright.config.ts Normal file
View File

@ -0,0 +1,13 @@
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
});

13
app/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

59
app/src/app.html Normal file
View File

@ -0,0 +1,59 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Jörg Lohrer Blog (Nostr-basiert)" />
<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 property="og:description" content="Jörg Lohrer Blog (Nostr-basiert)" />
<meta name="robots" content="index, follow" />
<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>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { externalClientLinks } from '$lib/nostr/naddr';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
interface Props {
dtag: string;
}
let { dtag }: Props = $props();
const links = $derived(
externalClientLinks({
pubkey: AUTHOR_PUBKEY_HEX,
kind: 30023,
identifier: dtag
})
);
</script>
<section class="external">
<span class="label">In Nostr-Client öffnen (für Threads, Reactions, Teilen):</span>
<ul>
{#each links as l}
<li><a href={l.url} target="_blank" rel="noopener">{l.label}</a></li>
{/each}
</ul>
</section>
<style>
.external {
margin: 2rem 0 1rem;
padding: 0.8rem 1rem;
background: var(--code-bg);
border-radius: 4px;
font-size: 0.9rem;
}
.label {
display: block;
color: var(--muted);
margin-bottom: 0.4rem;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
}
li a {
color: var(--accent);
text-decoration: none;
}
li a:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,40 @@
<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>

View File

@ -0,0 +1,94 @@
<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 = $derived(tagValue(event, 'd'));
const title = $derived(tagValue(event, 'title') || '(ohne Titel)');
const summary = $derived(tagValue(event, 'summary'));
const image = $derived(tagValue(event, 'image'));
const publishedAt = $derived(
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
);
const date = $derived(
new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
);
const href = $derived(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>

View File

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

View File

@ -0,0 +1,82 @@
<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>

View File

@ -0,0 +1,58 @@
<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>

View File

@ -0,0 +1,148 @@
<script lang="ts">
import { get } from 'svelte/store';
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';
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);
const results = await pool.publish(relays, signed);
const okCount = results.filter((r) => r.ok).length;
if (okCount === 0) {
error = 'Kein Relay hat den Kommentar akzeptiert.';
return;
}
info = `Kommentar gesendet (${okCount}/${results.length} Relays).`;
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>

View File

@ -0,0 +1,100 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
import { getProfile } from '$lib/nostr/profileCache';
import { buildNjumpProfileUrl } from '$lib/nostr/naddr';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
const date = $derived(new Date(event.created_at * 1000).toLocaleString('de-DE'));
const npubPrefix = $derived(event.pubkey.slice(0, 12) + '…');
const profileUrl = $derived(buildNjumpProfileUrl(event.pubkey));
let profile = $state<Profile | null>(null);
onMount(async () => {
try {
profile = await getProfile(event.pubkey);
} catch {
profile = null;
}
});
const displayName = $derived(profile?.display_name || profile?.name || npubPrefix);
</script>
<li class="reply">
<a class="header" href={profileUrl} target="_blank" rel="noopener">
{#if profile?.picture}
<img class="avatar" src={profile.picture} alt={displayName} />
{:else}
<div class="avatar avatar-placeholder" aria-hidden="true"></div>
{/if}
<div class="meta">
<span class="name">{displayName}</span>
<span class="date">{date}</span>
</div>
</a>
<div class="content">{event.content}</div>
</li>
<style>
.reply {
list-style: none;
padding: 0.8rem 0;
border-bottom: 1px solid var(--border);
}
.header {
display: flex;
gap: 0.6rem;
align-items: center;
margin-bottom: 0.4rem;
color: inherit;
text-decoration: none;
border-radius: 4px;
padding: 2px;
margin-left: -2px;
}
.header:hover {
background: var(--code-bg);
}
.header:hover .name {
color: var(--accent);
}
.avatar {
flex: 0 0 32px;
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
background: var(--code-bg);
}
.avatar-placeholder {
display: block;
}
.meta {
font-size: 0.85rem;
color: var(--muted);
display: flex;
flex-direction: column;
line-height: 1.3;
}
.name {
color: var(--fg);
font-weight: 500;
word-break: break-word;
}
.content {
white-space: pre-wrap;
word-wrap: break-word;
margin-left: calc(32px + 0.6rem);
}
@media (max-width: 479px) {
.content {
margin-left: 0;
}
}
</style>

View File

@ -0,0 +1,68 @@
<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;
/**
* Optimistisch hinzugefügte Events (z. B. frisch gesendete Kommentare).
* Werden vor dem Rendern zur geladenen Liste gemerged, dedupliziert per id.
*/
optimistic?: NostrEvent[];
}
let { dtag, optimistic = [] }: Props = $props();
let fetched: NostrEvent[] = $state([]);
let loading = $state(true);
const merged = $derived.by(() => {
const byId = new Map<string, NostrEvent>();
for (const ev of fetched) byId.set(ev.id, ev);
for (const ev of optimistic) byId.set(ev.id, ev);
return [...byId.values()].sort((a, b) => a.created_at - b.created_at);
});
onMount(async () => {
try {
fetched = await loadReplies(dtag);
} finally {
loading = false;
}
});
</script>
<section class="replies">
<h3>Kommentare ({merged.length})</h3>
{#if loading}
<p class="hint">Lade Kommentare …</p>
{:else if merged.length === 0}
<p class="hint">Noch keine Kommentare.</p>
{:else}
<ul>
{#each merged 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>

1
app/src/lib/index.ts Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -0,0 +1,37 @@
/**
* 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.
* Bootstrap-Relay ist bewusst als erster Eintrag Teil der Liste ein Ort der Wahrheit.
*/
export const FALLBACK_READ_RELAYS = [
BOOTSTRAP_RELAY,
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.tchncs.de',
'wss://relay.edufeed.org',
] as const;
/**
* Habla.news-Route für Addressable Events URL endet auf `/a/`, der
* vollständige Deep-Link wird durch Anhängen des `naddr1…`-Bech32 gebildet.
*/
export const HABLA_BASE = 'https://habla.news/a/';
/** Soft-Timeout: einzelne Relay-Abfrage darf nicht länger als diese Dauer blockieren. */
export const RELAY_TIMEOUT_MS = 8000;
/** Hard-Timeout: Page-Budget, nach dem eine Route-Abfrage endgültig abbricht. */
export const RELAY_HARD_TIMEOUT_MS = 15000;

View File

@ -0,0 +1,191 @@
import { get } from 'svelte/store';
import { lastValueFrom, timeout, toArray, EMPTY, tap } from 'rxjs';
import { catchError } from 'rxjs/operators';
import type { NostrEvent } from 'applesauce-core/helpers/event';
import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter';
import { pool } from './pool';
import { readRelays } from '$lib/stores/readRelays';
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
/** Re-export als sprechenden Alias */
export type { NostrEvent };
/** Profile-Content (kind:0) */
export interface Profile {
name?: string;
display_name?: string;
picture?: string;
banner?: string;
about?: string;
website?: string;
nip05?: string;
lud16?: string;
}
type Filter = ApplesauceFilter;
interface CollectOpts {
onEvent?: (ev: NostrEvent) => void;
hardTimeoutMs?: number;
}
/**
* Startet eine Request-Subscription und sammelt alle gelieferten Events
* bis EOSE (pool.request completes nach EOSE) oder Hard-Timeout.
*/
async function collectEvents(
relays: string[],
filter: Filter,
opts: CollectOpts = {}
): Promise<NostrEvent[]> {
const events = await lastValueFrom(
pool.request(relays, filter).pipe(
tap((ev: NostrEvent) => opts.onEvent?.(ev)),
timeout(opts.hardTimeoutMs ?? RELAY_HARD_TIMEOUT_MS),
toArray(),
catchError(() => EMPTY)
),
{ defaultValue: [] as NostrEvent[] }
);
return events;
}
/** 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).
* Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für
* die Anzeige fremder Kommentar-Autoren.
*/
export async function loadProfile(pubkey: string = AUTHOR_PUBKEY_HEX): Promise<Profile | null> {
const relays = get(readRelays);
const events = await collectEvents(relays, {
kinds: [0],
authors: [pubkey],
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;
}
}
/** 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);
}
/**
* 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)
);
}
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);
}

View File

@ -0,0 +1,66 @@
import { nip19 } from 'nostr-tools';
import { HABLA_BASE } from './config';
/**
* Argumente für NIP-19 addressable-event-Pointer.
* Validierung (hex-Länge etc.) wird an `nip19.naddrEncode` delegiert.
*/
export interface NaddrArgs {
pubkey: string;
kind: number;
identifier: string;
relays?: string[];
}
/**
* Baut einen `naddr1…`-Bech32-String (NIP-19) für ein addressable Event.
* Wird u. a. für Habla.news-Deep-Links genutzt.
*/
export function buildNaddr(args: NaddrArgs): string {
return nip19.naddrEncode({
pubkey: args.pubkey,
kind: args.kind,
identifier: args.identifier,
relays: args.relays ?? []
});
}
/**
* Habla.news-Deep-Link auf ein addressable Event.
* Fallback für Post nicht gefunden" / JS-lose Clients.
*/
export function buildHablaLink(args: NaddrArgs): string {
return `${HABLA_BASE}${buildNaddr(args)}`;
}
/**
* `npub1…`-Bech32-String für einen Pubkey für Profil-Links außerhalb
* der SPA (z. B. njump.me).
*/
export function buildNpub(pubkeyHex: string): string {
return nip19.npubEncode(pubkeyHex);
}
/**
* njump.me-Profil-URL. Öffnet das Nostr-native Profil-Browser mit
* vollständiger Event-Historie.
*/
export function buildNjumpProfileUrl(pubkeyHex: string): string {
return `https://njump.me/${buildNpub(pubkeyHex)}`;
}
/**
* Liste externer Nostr-Clients für Post öffnen in "-Links.
* Nutzt naddr, damit jeder Client das addressable Event adressieren kann.
* EduFeed zuerst OER/OEP-Bildungscommunity, wichtig für Jörgs Zielgruppe.
*/
export function externalClientLinks(
args: NaddrArgs
): { label: string; url: string }[] {
const naddr = buildNaddr(args);
return [
{ label: 'EduFeed', url: `https://edufeed.org/${naddr}` },
{ label: 'Habla', url: `https://habla.news/a/${naddr}` },
{ label: 'Yakihonne', url: `https://yakihonne.com/article/${naddr}` }
];
}

View File

@ -0,0 +1,7 @@
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();

View File

@ -0,0 +1,17 @@
import type { Profile } from './loaders';
import { loadProfile } from './loaders';
/**
* Sessionsweiter Cache für kind:0-Profile.
* Jeder Pubkey wird maximal einmal angefragt; mehrfache parallele
* Aufrufe teilen sich dieselbe Promise.
*/
const cache = new Map<string, Promise<Profile | null>>();
export function getProfile(pubkey: string): Promise<Profile | null> {
const existing = cache.get(pubkey);
if (existing) return existing;
const pending = loadProfile(pubkey);
cache.set(pubkey, pending);
return pending;
}

View File

@ -0,0 +1,95 @@
import { lastValueFrom, timeout, toArray, EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import type { NostrEvent } from 'applesauce-core/helpers/event';
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 firstEvent();
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 --------------------------------------------------------
/**
* Fragt das neueste kind:10002-Event vom Bootstrap-Relay ab.
* Sammelt alle Events bis EOSE (`pool.request(...)` emittiert nur Events
* und completes bei EOSE), nimmt das neueste, oder null falls keines.
*/
async function firstEvent(): Promise<NostrEvent | null> {
try {
const events = await lastValueFrom(
pool
.request([BOOTSTRAP_RELAY], {
kinds: [10002],
authors: [AUTHOR_PUBKEY_HEX],
limit: 1
})
.pipe(
timeout(RELAY_TIMEOUT_MS),
toArray(),
catchError(() => EMPTY)
),
{ defaultValue: [] as NostrEvent[] }
);
if (events.length === 0) return null;
return events.reduce((best, cur) =>
cur.created_at > best.created_at ? cur : best
);
} catch {
return null;
}
}

View File

@ -0,0 +1,50 @@
/**
* NIP-07-Wrapper für Browser-Extension-Signer (Alby, nos2x, Flamingo).
*
* `window.nostr` ist optional wenn die Extension fehlt, liefern die Helper
* 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;
}
}

View File

@ -0,0 +1,53 @@
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);
/**
* Lokaler Marked-Instance, damit die globale `marked`-Singleton nicht
* mutiert wird andere Module können `marked` unbeeinflusst weiterverwenden.
* (Spec §3: lokale Ersetzbarkeit der Engine.)
*/
const markedInstance = new Marked({
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.
*
* Nur im Browser/jsdom aufrufen: DOMPurify braucht ein DOM. Die SPA
* hat SSR global ausgeschaltet (`+layout.ts: ssr = false`), Vitest läuft
* in jsdom beide Szenarien sind abgedeckt. Ein Aufruf in reiner
* Node-Umgebung würde hier laut fehlschlagen statt stumm unsicher
* durchzulaufen.
*/
export function renderMarkdown(md: string): string {
if (typeof window === 'undefined') {
throw new Error('renderMarkdown: DOM-Kontext erforderlich (Browser oder jsdom).');
}
const raw = markedInstance.parse(md, { async: false }) as string;
return DOMPurify.sanitize(raw);
}

View File

@ -0,0 +1,29 @@
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: bootstrapReadRelays() 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;

24
app/src/lib/url/legacy.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* 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.
*
* Erwartet nur den Pfad ohne Query/Fragment wenn vorhanden vom Aufrufer
* trennen. `decodeURIComponent` wird defensiv gekapselt, damit malformed
* Percent-Encoding die SPA beim Boot nicht crasht.
*/
export function parseLegacyUrl(path: string): string | null {
const match = path.match(/^\/\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
/**
* Erzeugt die kanonische kurze Post-URL /<dtag>/.
*/
export function canonicalPostPath(dtag: string): string {
return `/${encodeURIComponent(dtag)}/`;
}

View File

@ -0,0 +1,32 @@
<script lang="ts">
import { onMount } from 'svelte';
import favicon from '$lib/assets/favicon.svg';
import { bootstrapReadRelays } from '$lib/stores/readRelays';
let { children } = $props();
onMount(() => {
bootstrapReadRelays();
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<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>

View File

@ -0,0 +1,3 @@
export const prerender = false;
export const ssr = false;
export const trailingSlash = 'always';

View File

@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
import { loadPostList } from '$lib/nostr/loaders';
import { getProfile } from '$lib/nostr/profileCache';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
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([getProfile(AUTHOR_PUBKEY_HEX), 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>

View File

@ -0,0 +1,61 @@
<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 = $derived(data.dtag);
let post: NostrEvent | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
const hablaLink = $derived(
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>

View File

@ -0,0 +1,21 @@
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]) };
};

View File

@ -0,0 +1,59 @@
<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 = $derived(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>

View File

@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return { tagName: decodeURIComponent(params.name) };
};

13
app/static/.htaccess Normal file
View File

@ -0,0 +1,13 @@
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]

2
app/static/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /

25
app/svelte.config.js Normal file
View File

@ -0,0 +1,25 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false
}),
alias: {
$lib: 'src/lib'
}
}
};
export default config;

View File

@ -0,0 +1,8 @@
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();
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
});

View File

@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
test('Einzelpost rendert Titel und Markdown-Body', async ({ page }) => {
await page.goto('/dezentrale-oep-oer/');
// Titel steht einmal als .post-title (H1 außerhalb des Artikels),
// und nochmal im Markdown-Body des Events — wir prüfen den ersten.
await expect(page.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('h1.post-title')).toContainText('Gemeinsam die Bildungszukunft');
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.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
});

View File

@ -0,0 +1,61 @@
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',
);
});
it('gibt null zurück bei malformed percent-encoding (crash-sicher)', () => {
expect(parseLegacyUrl('/2024/01/26/%E0.html/')).toBeNull();
});
it('gibt null zurück für leeren dtag', () => {
expect(parseLegacyUrl('/2024/01/26/.html/')).toBeNull();
});
});
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/');
});
});
describe('round-trip parseLegacyUrl → canonicalPostPath', () => {
it('Legacy-URL wird zur kanonischen kurzen Form', () => {
const dtag = parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/');
expect(dtag).not.toBeNull();
expect(canonicalPostPath(dtag!)).toBe('/dezentrale-oep-oer/');
});
});

View File

@ -0,0 +1,81 @@
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 mit hljs-klasse', () => {
const html = renderMarkdown('```js\nconst x = 1;\n```');
expect(html).toContain('<pre>');
expect(html).toContain('<code');
expect(html).toContain('class="hljs');
});
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"');
});
// Erweiterte XSS-Matrix — relevant ab Reply-Komponenten (3rd-party Content).
it('entfernt onerror-Attribute auf inline-HTML-img', () => {
const html = renderMarkdown('<img src="x" onerror="alert(1)">');
expect(html.toLowerCase()).not.toContain('onerror');
});
it('entfernt onclick-Attribute auf inline-HTML', () => {
const html = renderMarkdown('<a href="#" onclick="alert(1)">x</a>');
expect(html.toLowerCase()).not.toContain('onclick');
});
it('entfernt iframe-Tags', () => {
const html = renderMarkdown('<iframe src="https://evil.com"></iframe>');
expect(html.toLowerCase()).not.toContain('<iframe');
});
it('entfernt data:text/html-URLs in Links', () => {
const html = renderMarkdown('[x](data:text/html,<script>alert(1)</script>)');
expect(html.toLowerCase()).not.toMatch(/href="data:text\/html/);
});
it('entfernt vbscript:-URLs', () => {
const html = renderMarkdown('<a href="vbscript:msgbox(1)">x</a>');
expect(html.toLowerCase()).not.toContain('vbscript:');
});
it('entfernt script-Tag innerhalb svg', () => {
const html = renderMarkdown('<svg><script>alert(1)</script></svg>');
expect(html.toLowerCase()).not.toContain('<script');
});
});

View File

@ -0,0 +1,44 @@
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));
});
it('funktioniert ohne relays (optional)', () => {
const link = buildHablaLink({
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
kind: 30023,
identifier: 'foo',
});
expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/);
});
it('erzeugt unterschiedliche Links für unterschiedliche Inputs', () => {
const base = {
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
kind: 30023,
relays: [],
};
const a = buildHablaLink({ ...base, identifier: 'foo' });
const b = buildHablaLink({ ...base, identifier: 'bar' });
expect(a).not.toBe(b);
});
});

20
app/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

11
app/vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
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
}
});

View File

@ -0,0 +1,13 @@
RewriteEngine On
# HTTPS forcieren (relevant sobald Zertifikat aktiv)
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 (für die Mini-Seite optional, aber harmlos).
RewriteRule ^ /index.html [L]

View File

@ -0,0 +1,58 @@
# SPA Mini-Preview
**Tech-Spike, kein Produkt.**
Eine einzige `index.html`, die im Browser einen einzelnen Nostr-Post (`kind:30023`)
live von Public-Relays lädt und rendert. Beweist, dass die SPA-Architektur
aus [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](../../docs/superpowers/specs/2026-04-15-nostr-page-design.md)
in der Praxis funktioniert — ohne SvelteKit-Build, ohne Routing, ohne Backend.
## Was sie macht
- Lädt `nostr-tools`, `marked` und `DOMPurify` zur Laufzeit von esm.sh.
- Verbindet sich zu fünf Public-Relays.
- Holt das `kind:30023`-Event mit `d`-Tag `dezentrale-oep-oer` für den hartcodierten Pubkey.
- Rendert Markdown via `marked`, sanitized via `DOMPurify`.
- Cover-Bild wird vom Blossom-Server geladen (URL aus dem Event-Tag `image`).
## Was sie nicht macht
- Kein Routing, keine Post-Liste, keine Tags-Navigation, keine Reactions, keine Kommentare.
- Kein NIP-65-Outbox-Resolution (Relays sind hartcodiert).
- Kein NIP-07-Login.
- Kein Code-Splitting, keine Service-Worker, keine Optimierung.
Für all das wartet die echte SvelteKit-SPA — das hier ist nur das „Hello World".
## Lokal ausprobieren
Die Datei kann nicht per `file://` geöffnet werden (CORS für CDN-Imports).
Stattdessen ein lokaler HTTP-Server:
```sh
cd preview/spa-mini
python3 -m http.server 8000
# Browser: http://localhost:8000/
```
Oder mit Deno:
```sh
deno run --allow-net --allow-read jsr:@std/http/file-server preview/spa-mini
```
## Auf die Subdomain `spa.joerg-lohrer.de` deployen
Voraussetzung: Subdomain im All-Inkl-KAS angelegt, eigener DocumentRoot eingerichtet,
SSL-Zertifikat aktiviert.
Inhalt von `preview/spa-mini/` (also `index.html` und `.htaccess`) per FTP
in den DocumentRoot der Subdomain hochladen.
Erwartetes Ergebnis: `https://spa.joerg-lohrer.de/` zeigt den Post.
## Spätere Ablösung
Sobald die SvelteKit-SPA fertig ist, wird ihr `build/`-Output denselben Webroot
ablösen. Diese Mini-Seite kann dann gelöscht oder als historisches Artefakt
im Repo bleiben.

641
preview/spa-mini/index.html Normal file
View File

@ -0,0 +1,641 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jörg Lohrer — Nostr SPA Mini-Preview</title>
<meta name="robots" content="noindex">
<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);
padding: 1.5rem 1rem;
}
@media (min-width: 640px) {
body { padding: 1.5rem; }
}
main {
max-width: 720px;
margin: 0 auto;
}
header.banner {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.7rem 1rem;
margin-bottom: 1.5rem;
background: var(--code-bg);
font-size: 0.85rem;
color: var(--muted);
}
header.banner strong { color: var(--fg); }
nav#breadcrumb {
font-size: 0.9rem;
margin-bottom: 1rem;
}
nav#breadcrumb a { color: var(--accent); text-decoration: none; }
nav#breadcrumb a:hover { text-decoration: underline; }
.post-list-item {
display: flex;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
color: inherit;
text-decoration: none;
align-items: flex-start;
}
.post-list-item:hover { background: var(--code-bg); }
.post-list-item .thumb {
flex: 0 0 120px;
aspect-ratio: 1 / 1;
border-radius: 4px;
background: var(--code-bg) center/cover no-repeat;
}
.post-list-item .text { flex: 1; min-width: 0; }
.post-list-item h2 {
margin: 0 0 0.3rem;
font-size: 1.2rem;
color: var(--fg);
word-wrap: break-word;
}
.post-list-item .excerpt {
color: var(--muted);
font-size: 0.95rem;
margin: 0;
}
.post-list-item .list-meta {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.2rem;
}
@media (max-width: 479px) {
.post-list-item { flex-direction: column; gap: 0.5rem; }
.post-list-item .thumb { flex: 0 0 auto; width: 100%; aspect-ratio: 2 / 1; }
}
.list-title {
margin: 0 0 1rem;
font-size: 1.4rem;
}
.profile {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.profile .avatar {
flex: 0 0 80px;
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background: var(--code-bg);
}
.profile .info { flex: 1; min-width: 0; }
.profile .name {
font-size: 1.3rem;
font-weight: 600;
margin: 0 0 0.2rem;
}
.profile .about {
color: var(--muted);
font-size: 0.95rem;
margin: 0 0 0.3rem;
}
.profile .meta-line {
font-size: 0.85rem;
color: var(--muted);
}
.profile .meta-line a {
color: var(--accent);
text-decoration: none;
}
.profile .meta-line a:hover { text-decoration: underline; }
.profile .meta-line .sep { margin: 0 0.4rem; opacity: 0.5; }
h1.post-title {
font-size: 1.5rem;
line-height: 1.25;
margin: 0 0 0.4rem;
word-wrap: break-word;
}
@media (min-width: 640px) {
h1.post-title { font-size: 2rem; line-height: 1.2; }
}
.meta {
color: var(--muted);
font-size: 0.92rem;
margin-bottom: 2rem;
}
.meta .tags { margin-top: 0.4rem; }
.meta .tag {
display: inline-block;
background: var(--code-bg);
border-radius: 3px;
padding: 1px 7px;
margin: 0 4px 4px 0;
font-size: 0.85em;
}
article {
word-wrap: break-word;
overflow-wrap: break-word;
}
article img,
#content > p > img {
max-width: 100%;
height: auto;
border-radius: 4px;
display: block;
margin: 0 auto;
}
/* Cover-Bild (direktes <p> als Sibling unter .meta) auf vernünftige Größe begrenzen */
#content > p:has(> img) {
max-width: 480px;
margin: 1rem auto 1.5rem;
}
article a {
color: var(--accent);
word-break: break-word;
}
article pre {
background: var(--code-bg);
padding: 0.8rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.88em;
max-width: 100%;
}
article code {
background: var(--code-bg);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.92em;
word-break: break-word;
}
article pre code { padding: 0; background: none; word-break: normal; }
article hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
article blockquote {
border-left: 3px solid var(--border);
padding: 0 0 0 1rem;
margin: 1rem 0;
color: var(--muted);
}
article h1, article h2, article h3, article h4 {
line-height: 1.3;
word-wrap: break-word;
}
article ul, article ol { padding-left: 1.5rem; }
article table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.status {
padding: 1rem;
border-radius: 4px;
background: var(--code-bg);
color: var(--muted);
text-align: center;
}
.error {
background: #fee2e2;
color: #991b1b;
}
@media (prefers-color-scheme: dark) {
.error { background: #450a0a; color: #fca5a5; }
}
footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
footer a { color: var(--accent); }
</style>
</head>
<body>
<main>
<header class="banner">
<strong>Tech-Spike:</strong> Diese Seite ist ein Machbarkeitsbeweis,
keine produktive Webseite. Sie lädt Nostr-Events
(<code>kind:30023</code>, NIP-23) live von Public-Relays und rendert
sie im Browser. Kein Server-Backend, nur statisches HTML plus
JavaScript. Routing via URL-Pfad: <code>/</code> zeigt die Liste,
<code>/&lt;slug&gt;/</code> zeigt einen einzelnen Post.
</header>
<nav id="breadcrumb" hidden>
<a href="/" data-link>← Zurück zur Übersicht</a>
</nav>
<div id="content">
<p class="status">Lade von Nostr-Relays …</p>
</div>
<footer>
<a href="https://forgejo.joerglohrer.synology.me/joerglohrer/joerglohrerde">Quellcode &amp; Spezifikation</a>
</footer>
</main>
<script type="module">
import { SimplePool } from 'https://esm.sh/nostr-tools@2.10.4/pool';
import { marked } from 'https://esm.sh/marked@14.1.3';
import DOMPurify from 'https://esm.sh/dompurify@3.1.7';
const PUBKEY = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.tchncs.de',
'wss://relay.edufeed.org',
];
const TIMEOUT_MS = 8000;
const $content = document.getElementById('content');
const $breadcrumb = document.getElementById('breadcrumb');
const pool = new SimplePool();
// Profil-Cache: einmal laden, session-weit wiederverwenden
let profilePromise = null;
function loadProfile() {
if (profilePromise) return profilePromise;
profilePromise = new Promise(resolve => {
let done = false;
const timeout = setTimeout(() => {
if (!done) { done = true; try { sub.close(); } catch {} resolve(null); }
}, TIMEOUT_MS);
const sub = pool.subscribeMany(RELAYS, [
{ kinds: [0], authors: [PUBKEY], limit: 1 }
], {
onevent(ev) {
if (done) return;
done = true;
clearTimeout(timeout);
try { sub.close(); } catch {}
try {
resolve(JSON.parse(ev.content));
} catch {
resolve(null);
}
},
oneose() {
if (done) return;
done = true;
clearTimeout(timeout);
resolve(null);
},
});
});
return profilePromise;
}
function profileCardHtml(profile) {
if (!profile) return '';
const name = profile.display_name || profile.name || '';
const avatar = profile.picture || '';
const about = profile.about || '';
const nip05 = profile.nip05 || '';
const website = profile.website || '';
const metaBits = [];
if (nip05) metaBits.push(escapeHtml(nip05));
if (website) metaBits.push(`<a href="${escapeHtml(website)}" target="_blank" rel="noopener">${escapeHtml(website.replace(/^https?:\/\//, ''))}</a>`);
const metaHtml = metaBits.length
? `<div class="meta-line">${metaBits.join('<span class="sep">·</span>')}</div>`
: '';
const avatarHtml = avatar
? `<img class="avatar" src="${escapeHtml(avatar)}" alt="${escapeHtml(name)}">`
: `<div class="avatar"></div>`;
return `
<div class="profile">
${avatarHtml}
<div class="info">
<div class="name">${escapeHtml(name)}</div>
${about ? `<div class="about">${escapeHtml(about)}</div>` : ''}
${metaHtml}
</div>
</div>
`;
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#39;');
}
function tagValue(event, name) {
const t = event.tags.find(t => t[0] === name);
return t ? t[1] : '';
}
function tagsAll(event, name) {
// Dedup, falls ein Client doppelte Tags geschrieben hat
return [...new Set(event.tags.filter(t => t[0] === name).map(t => t[1]))];
}
function fmtDate(unixSeconds) {
const d = new Date(unixSeconds * 1000);
return d.toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
});
}
function renderPost(event) {
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 tags = tagsAll(event, 't');
const bodyHtml = DOMPurify.sanitize(marked.parse(event.content || ''), {
ADD_ATTR: ['target', 'rel'],
});
const tagsHtml = tags.length
? `<div class="tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>`
: '';
const coverHtml = image
? `<p><img src="${escapeHtml(image)}" alt="Cover-Bild"></p>`
: '';
const summaryHtml = summary
? `<p style="font-style: italic; color: var(--muted);">${escapeHtml(summary)}</p>`
: '';
document.title = `${title} Jörg Lohrer`;
$breadcrumb.hidden = false;
$content.innerHTML = `
<h1 class="post-title">${escapeHtml(title)}</h1>
<div class="meta">
Veröffentlicht am ${fmtDate(publishedAt)}
${tagsHtml}
</div>
${coverHtml}
${summaryHtml}
<article>${bodyHtml}</article>
`;
// Externe Links automatisch in neuen Tabs öffnen
for (const a of $content.querySelectorAll('article a[href^="http"]')) {
a.target = '_blank';
a.rel = 'noopener';
}
}
let cachedProfile = null;
loadProfile().then(p => {
cachedProfile = p;
// Falls Liste schon gerendert ist (ohne Profil), nachziehen
const placeholder = $content.querySelector('[data-profile-placeholder]');
if (placeholder) placeholder.outerHTML = profileCardHtml(p);
});
function renderList(events) {
const name = cachedProfile?.display_name || cachedProfile?.name || 'Jörg Lohrer';
document.title = `${name} Blog`;
$breadcrumb.hidden = true;
if (!events.length) {
$content.innerHTML = '<p class="status">Keine Posts gefunden.</p>';
return;
}
// Dedup per d-Tag: neueste Version pro d wins (replaceable-Semantik)
const byDtag = new Map();
for (const ev of events) {
const d = tagValue(ev, 'd');
if (!d) continue;
const existing = byDtag.get(d);
if (!existing || ev.created_at > existing.created_at) {
byDtag.set(d, ev);
}
}
const sorted = [...byDtag.values()].sort((a, b) => {
const aP = parseInt(tagValue(a, 'published_at') || a.created_at, 10);
const bP = parseInt(tagValue(b, 'published_at') || b.created_at, 10);
return bP - aP;
});
const itemsHtml = sorted.map(ev => {
const dtag = tagValue(ev, 'd');
const title = tagValue(ev, 'title') || '(ohne Titel)';
const summary = tagValue(ev, 'summary');
const image = tagValue(ev, 'image');
const publishedAt = parseInt(tagValue(ev, 'published_at') || ev.created_at, 10);
const thumbStyle = image ? `style="background-image:url('${escapeHtml(image)}')"` : '';
return `
<a class="post-list-item" href="/${encodeURIComponent(dtag)}/" data-link>
<div class="thumb" ${thumbStyle} aria-hidden="true"></div>
<div class="text">
<div class="list-meta">${fmtDate(publishedAt)}</div>
<h2>${escapeHtml(title)}</h2>
${summary ? `<p class="excerpt">${escapeHtml(summary)}</p>` : ''}
</div>
</a>
`;
}).join('');
const profileHtml = cachedProfile
? profileCardHtml(cachedProfile)
: '<div data-profile-placeholder></div>';
$content.innerHTML = `
${profileHtml}
<h1 class="list-title">Beiträge</h1>
${itemsHtml}
`;
}
function showError(msg) {
$breadcrumb.hidden = true;
$content.innerHTML = `<p class="status error">${escapeHtml(msg)}</p>`;
}
function showLoading(msg) {
$content.innerHTML = `<p class="status">${escapeHtml(msg)}</p>`;
}
function withTimeout(promise, ms, errMsg) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(errMsg)), ms)
),
]);
}
let activeSub = null;
function cancelActiveSub() {
if (activeSub) {
try { activeSub.close(); } catch {}
activeSub = null;
}
}
async function loadPost(dtag) {
cancelActiveSub();
showLoading('Lade Post …');
let rendered = false;
let bestEvent = null;
const timeout = setTimeout(() => {
if (!rendered) {
cancelActiveSub();
showError('Timeout — kein Relay hat geantwortet.');
}
}, TIMEOUT_MS);
activeSub = pool.subscribeMany(RELAYS, [
{ kinds: [30023], authors: [PUBKEY], '#d': [dtag], limit: 1 }
], {
onevent(ev) {
// Replaceable: neueste Version wins
if (!bestEvent || ev.created_at > bestEvent.created_at) {
bestEvent = ev;
rendered = true;
renderPost(ev);
}
},
oneose() {
clearTimeout(timeout);
if (!rendered) {
cancelActiveSub();
showError('Post nicht gefunden auf den abgefragten Relays.');
}
},
});
}
function loadList() {
cancelActiveSub();
showLoading('Lade Beitragsliste …');
const byDtag = new Map();
let renderTimer = null;
let done = false;
const scheduleRender = () => {
if (renderTimer) return;
renderTimer = setTimeout(() => {
renderTimer = null;
renderList([...byDtag.values()]);
}, 100); // coalesce rapid inflow
};
const timeout = setTimeout(() => {
if (!done) {
done = true;
cancelActiveSub();
if (!byDtag.size) {
showError('Timeout — kein Relay hat geantwortet.');
} else {
renderList([...byDtag.values()]);
}
}
}, TIMEOUT_MS);
activeSub = pool.subscribeMany(RELAYS, [
{ kinds: [30023], authors: [PUBKEY], limit: 200 }
], {
onevent(ev) {
const d = ev.tags.find(t => t[0] === 'd')?.[1];
if (!d) return;
const existing = byDtag.get(d);
if (!existing || ev.created_at > existing.created_at) {
byDtag.set(d, ev);
scheduleRender();
}
},
oneose() {
if (done) return;
done = true;
clearTimeout(timeout);
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
renderList([...byDtag.values()]);
},
});
}
// Erkennt Legacy-Hugo-URL /YYYY/MM/DD/<dtag>.html oder .../<dtag>.html/
// Returns <dtag> oder null.
function parseLegacyUrl(path) {
const m = path.match(/^\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
return m ? decodeURIComponent(m[1]) : null;
}
function route() {
// Pfad normalisieren: Slashes, "index.html"
const path = location.pathname.replace(/^\/+|\/+$/g, '');
// Leer → Liste
if (path === '' || path === 'index.html') {
loadList();
window.scrollTo(0, 0);
return;
}
// Legacy-Form YYYY/MM/DD/<dtag>.html/ → auf kurze Form umschreiben
const legacyDtag = parseLegacyUrl(path);
if (legacyDtag) {
history.replaceState(null, '', `/${encodeURIComponent(legacyDtag)}/`);
loadPost(legacyDtag);
window.scrollTo(0, 0);
return;
}
// Kanonische kurze Form /<dtag>/ → Post laden
const dtag = decodeURIComponent(path.split('/')[0]);
loadPost(dtag);
window.scrollTo(0, 0);
}
// SPA-Navigation: interne Links (data-link) ohne Page-Reload
document.addEventListener('click', ev => {
const link = ev.target.closest('a[data-link]');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('#')) return;
ev.preventDefault();
if (location.pathname !== href) {
history.pushState(null, '', href);
route();
}
});
window.addEventListener('popstate', route);
route();
</script>
</body>
</html>

10
scripts/README.md Normal file
View File

@ -0,0 +1,10 @@
# 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
```

55
scripts/deploy-svelte.sh Executable file
View File

@ -0,0 +1,55 @@
#!/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 (via Tempfile — process substitution ist nicht
# überall verfügbar, je nach Shell/Sandbox).
_ENV_TMP="$(mktemp)"
trap 'rm -f "$_ENV_TMP"' EXIT
grep -E '^SVELTE_FTP_' .env.local > "$_ENV_TMP" || true
set -a
# shellcheck disable=SC1090
. "$_ENV_TMP"
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"
# --tls-max 1.2: All-Inkl/Kasserver FTPS schließt bei TLS 1.3 die Data-
# Connection mit "426 Transfer aborted" — mit 1.2 läuft es sauber durch.
curl -sSf --ssl-reqd --tls-max 1.2 --ftp-create-dirs \
--retry 3 --retry-delay 2 --retry-all-errors \
--connect-timeout 15 \
--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